Last active
August 30, 2021 14:36
-
-
Save neumond/511a75fd2ac63f46cbbee3fd84c0bdf5 to your computer and use it in GitHub Desktop.
My qtile config
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import pickle | |
import weakref | |
from contextlib import contextmanager, suppress | |
from datetime import datetime | |
from itertools import islice | |
from logging import getLogger | |
from pathlib import Path | |
from libqtile import bar, hook, layout, widget | |
from libqtile.command import lazy | |
from libqtile.config import Click, Drag, Group, Key, Screen | |
from libqtile.widget.base import ORIENTATION_HORIZONTAL | |
from libqtile.widget.base import _TextBox as BaseTextBox | |
logger = getLogger('qtile.config') | |
# TODO: multiple micro indicators will poll pulseaudio independently | |
# TODO: microphone icon rendered | |
# TODO: volume indicator | |
# TODO: separate indicators for several microphones and outputs | |
# TODO: background images for each group | |
# TODO: backgrounds and proper drawing for bar | |
# TODO: keyboard indicator/switcher | |
# TODO: caps lock & group switching bug | |
# TODO: screenshots without stealing focus | |
# TODO: click on notification should reveat freshly made screenshot | |
# TODO: automatically add additional groups ?? | |
# TODO: automatically clean clipboard after some time | |
# TODO: schematic window layouts under group letters | |
class PulseContext: | |
VOLUME_STEP = 0.05 | |
def __init__(self, pulse, ctl): | |
self._pulse = weakref.ref(pulse) | |
self._ctl = ctl | |
@property | |
def pulse(self): | |
return self._pulse() | |
def _change_volume(self, inc): | |
for obj in self.pulse.sink_list(): | |
obj.volume.values = [min(1, max(0, v + inc)) for v in obj.volume.values] | |
self.pulse.volume_set(obj, obj.volume) | |
def mute(self): | |
for obj in self.pulse.sink_list(): | |
self.pulse.mute(obj, mute=not obj.mute) | |
def raise_volume(self): | |
self._change_volume(self.VOLUME_STEP) | |
def lower_volume(self): | |
self._change_volume(-self.VOLUME_STEP) | |
def get_micro(self): | |
mute = True | |
for obj in self.pulse.source_list(): | |
mute &= obj.mute | |
return mute | |
def toggle_micro(self): | |
mute = not self.get_micro() | |
for obj in self.pulse.source_list(): | |
self.pulse.mute(obj, mute=mute) | |
for cb in self._ctl._micro_callbacks: | |
cb(mute) | |
return mute | |
class PulseControl: | |
def __init__(self): | |
self.Pulse = None | |
try: | |
from pulsectl import Pulse | |
except ImportError: | |
logger.error('You have to pip install pulsectl') | |
else: | |
self.Pulse = Pulse | |
self._micro_callbacks = [] | |
@property | |
def valid(self): | |
return self.Pulse is not None | |
@contextmanager | |
def context(self): | |
with self.Pulse('qtile-config') as pulse: | |
yield PulseContext(pulse, self) | |
def make_cmd(self, func): | |
def wrap(qtile): | |
if self.valid: | |
with self.context() as pctx: | |
func(pctx) | |
return lazy.function(wrap) | |
def register_micro_indicator(self, callback): | |
self._micro_callbacks.append(callback) | |
with self.context() as pctx: | |
callback(pctx.get_micro()) | |
class Timer: | |
def __init__(self): | |
self.reset() | |
@property | |
def current_gap(self): | |
if self._current is None: | |
return 0 | |
return (datetime.now() - self._current).total_seconds() | |
@property | |
def active(self): | |
return self._current is not None | |
@active.setter | |
def active(self, value): | |
if value == self.active: | |
return | |
if value: | |
self._current = datetime.now() | |
else: | |
self._summed += self.current_gap | |
self._current = None | |
def reset(self): | |
self._summed = 0 | |
self._current = None | |
@property | |
def seconds(self): | |
return self._summed + self.current_gap | |
def __getstate__(self): | |
# dict prevents value to be false | |
return {'seconds': self.seconds} | |
def __setstate__(self, d): | |
self._summed = d['seconds'] | |
self._current = None | |
class MulticlickDetector: | |
def __init__(self, clicks=3, time_period=2.0): | |
assert clicks > 1 | |
assert time_period > 0 | |
self.clicks = clicks | |
self.time_period = time_period | |
self.counter = 0 | |
self.last = datetime.now() | |
def click(self): | |
now = datetime.now() | |
if (now - self.last).total_seconds() > self.time_period: | |
self.counter = 0 | |
self.last = now | |
self.counter += 1 | |
if self.counter < self.clicks: | |
return False | |
self.counter = 0 | |
return True | |
class PersistenceFilter: | |
def __init__(self, timer: Timer): | |
self.olddata = pickle.dumps(timer) | |
def update(self, timer: Timer): | |
newdata = pickle.dumps(timer) | |
if newdata == self.olddata: | |
return False, None | |
self.olddata = newdata | |
return True, newdata | |
class TimerPersistence: | |
def __init__(self, file_path: Path): | |
self.file_path = file_path | |
def load(self): | |
timer = None | |
with suppress(FileNotFoundError, EOFError, pickle.UnpicklingError): | |
# TODO: check writable | |
timer = pickle.loads(self.file_path.read_bytes()) | |
if timer is None: | |
timer = Timer() | |
self.pfilter = PersistenceFilter(timer) | |
return timer | |
def flush(self, timer): | |
should, bindata = self.pfilter.update(timer) | |
if not should: | |
return | |
# TODO: check writable | |
self.file_path.write_bytes(bindata) | |
def format_timer(timer: Timer, spinner=True) -> str: | |
spinner_chars = '⣾⣽⣻⢿⡿⣟⣯⣷' | |
def ft(s: int) -> str: | |
too_small = s < 5 * 60 | |
result = [] | |
if s >= 3600: | |
result.append(str(s // 3600) + 'h') | |
s %= 3600 | |
if s >= 60: | |
result.append(str(s // 60) + 'm') | |
s %= 60 | |
if too_small: | |
result.append(str(s) + 's') | |
return ' '.join(result) | |
if not timer.active and timer.seconds <= 0: | |
return '⌚' | |
s = int(timer.seconds) | |
result = ft(s) | |
if spinner: | |
result += spinner_chars[s % len(spinner_chars)] | |
return result | |
class CustomBaseTextBox(BaseTextBox): | |
defaults = [ | |
("text_shift", 0, "Shift text vertically"), | |
] | |
def __init__(self, text=" ", width=bar.CALCULATED, **config): | |
super().__init__(text, width, **config) | |
self.add_defaults(CustomBaseTextBox.defaults) | |
# exact copy of original code, with Y adjustment | |
def draw(self): | |
# if the bar hasn't placed us yet | |
if self.offsetx is None: | |
return | |
self.drawer.clear(self.background or self.bar.background) | |
self.layout.draw( | |
self.actual_padding or 0, | |
int(self.bar.height / 2.0 - self.layout.height / 2.0) + 1 + self.text_shift, # here | |
) | |
self.drawer.draw(offsetx=self.offsetx, width=self.width) | |
class CustomCurrentLayout(CustomBaseTextBox): | |
orientations = ORIENTATION_HORIZONTAL | |
def __init__(self, width=bar.CALCULATED, **config): | |
super().__init__(text='', width=width, **config) | |
@staticmethod | |
def get_layout_name(name): | |
return ({ | |
'max': '▣', | |
'columns': '▥', | |
'bsp': '◫', | |
}).get(name, '[{}]'.format(name)) | |
def _configure(self, qtile, bar): | |
BaseTextBox._configure(self, qtile, bar) | |
self.text = self.get_layout_name(self.bar.screen.group.layouts[0].name) | |
self.setup_hooks() | |
def setup_hooks(self): | |
def hook_response(layout, group): | |
if group.screen is not None and group.screen == self.bar.screen: | |
self.text = self.get_layout_name(layout.name) | |
self.bar.draw() | |
hook.subscribe.layout_change(hook_response) | |
def button_press(self, x, y, button): | |
if button == 1: | |
self.qtile.cmd_next_layout() | |
elif button == 2: | |
self.qtile.cmd_prev_layout() | |
class MicroIndicator(CustomBaseTextBox): | |
orientations = ORIENTATION_HORIZONTAL | |
defaults = [ | |
('active_color', 'ff0000', 'Color of active indicator'), | |
('inactive_color', '888888', 'Color of inactive indicator'), | |
] | |
def get_color(self, mute): | |
return self.inactive_color if mute else self.active_color | |
def __init__(self, *, pulse_ctl, width=bar.CALCULATED, **config): | |
super().__init__(text='⚫', width=width, **config) | |
self.add_defaults(MicroIndicator.defaults) | |
self.pulse_ctl = pulse_ctl | |
self.pulse_ctl.register_micro_indicator(self.update_indicator) | |
def update_indicator(self, mute): | |
new_color = self.get_color(mute) | |
if self.foreground == new_color: | |
return | |
self.foreground = new_color | |
if self.configured: | |
# TODO: don't redraw whole bar | |
self.bar.draw() | |
def cmd_toggle(self): | |
"Toggle microphone." | |
with self.pulse_ctl.context() as pctx: | |
pctx.toggle_micro() | |
def button_press(self, x, y, button): | |
if button == 1: | |
self.cmd_toggle() | |
def timer_setup(self): | |
self.timeout_add(3, self._auto_update) | |
def _auto_update(self): | |
with self.pulse_ctl.context() as pctx: | |
mute = pctx.get_micro() | |
self.update_indicator(mute) | |
self.timeout_add(2, self._auto_update) | |
class TimeTracker(CustomBaseTextBox): | |
orientations = ORIENTATION_HORIZONTAL | |
defaults = [ | |
('active_color', 'ff4000', 'Color of active indicator'), | |
('inactive_color', '888888', 'Color of inactive indicator'), | |
('update_interval', 1, 'Update interval in seconds'), | |
('save_interval', 300, 'Interval in seconds for persistence'), | |
] | |
def __init__(self, **config): | |
super().__init__(text='', **config) | |
self.add_defaults(TimeTracker.defaults) | |
self.persist = TimerPersistence(Path.home() / '.tracker') | |
self.timer = self.persist.load() | |
self.resblocker = MulticlickDetector() | |
self.formatter = format_timer | |
self.update() | |
if self.padding is None: | |
self.padding = 4 | |
def cmd_toggle(self): | |
"Toggles timer (pause/unpause)." | |
self.timer.active = not self.timer.active | |
self.update() | |
def cmd_reset(self): | |
"Resets timer." | |
self.timer.reset() | |
self.update() | |
def cmd_read(self, humanize=True): | |
"Current amount of seconds." | |
if humanize: | |
return self.formatter(self.timer, spinner=False) | |
return self.timer.seconds | |
def button_press(self, x, y, button): | |
if button == 1: | |
self.cmd_toggle() | |
elif button == 3: | |
if self.resblocker.click(): | |
self.cmd_reset() | |
def update(self): | |
new_text = self.formatter(self.timer) | |
new_color = self.active_color if self.timer.active else self.inactive_color | |
redraw = False | |
redraw_bar = False | |
old_width = None | |
if self.layout: | |
old_width = self.layout.width | |
if new_color != self.foreground: | |
redraw = True | |
self.foreground = new_color | |
if new_text != self.text: | |
redraw = True | |
self.text = new_text | |
if not self.configured: | |
return | |
if self.layout.width != old_width: | |
redraw_bar = True | |
if redraw_bar: | |
self.bar.draw() | |
elif redraw: | |
self.draw() | |
def timer_setup(self): | |
self.timeout_add(self.update_interval, self._auto_update) | |
self.timeout_add(self.save_interval, self._auto_persist) | |
def _auto_update(self): | |
self.update() | |
self.timeout_add(self.update_interval, self._auto_update) | |
def _auto_persist(self): | |
self.persist.flush(self.timer) | |
self.timeout_add(self.save_interval, self._auto_persist) | |
hook.subscribe.hooks.add('prompt_focus') | |
hook.subscribe.hooks.add('prompt_unfocus') | |
class CustomPrompt(widget.Prompt): | |
def startInput(self, *a, **kw): # noqa: N802 | |
hook.fire('prompt_focus') | |
return super().startInput(*a, **kw) | |
def _unfocus(self): | |
hook.fire('prompt_unfocus') | |
return super()._unfocus() | |
class CustomWindowName(widget.WindowName): | |
def __init__(self, *a, **kw): | |
super().__init__(*a, **kw) | |
self.visible = True | |
def _configure(self, qtile, bar): | |
super()._configure(qtile, bar) | |
hook.subscribe._subscribe('prompt_focus', self.hide) | |
hook.subscribe._subscribe('prompt_unfocus', self.show) | |
def show(self): | |
self.visible = True | |
self.update() | |
def hide(self): | |
self.visible = False | |
self.update() | |
def update(self, *args): | |
if self.visible: | |
super().update(*args) | |
else: | |
self.text = '' | |
self.bar.draw() | |
pulse_ctl = PulseControl() | |
groups = [] | |
for gname in 'asdfghjkl': | |
groups.append(Group(gname, label=gname.upper())) | |
def user_keymap(mod, shift, control, alt): | |
for g in groups: | |
yield mod + g.name, lazy.group[g.name].toscreen() | |
yield mod + shift + g.name, lazy.window.togroup(g.name) | |
yield mod + 'Down', lazy.layout.down() | |
yield mod + 'Up', lazy.layout.up() | |
yield mod + 'Left', lazy.layout.left() | |
yield mod + 'Right', lazy.layout.right() | |
yield mod + shift + 'Down', lazy.layout.shuffle_down() | |
yield mod + shift + 'Up', lazy.layout.shuffle_up() | |
yield mod + shift + 'Left', lazy.layout.shuffle_left() | |
yield mod + shift + 'Right', lazy.layout.shuffle_right() | |
yield mod + control + 'Down', lazy.layout.grow_down() | |
yield mod + control + 'Up', lazy.layout.grow_up() | |
yield mod + control + 'Left', lazy.layout.grow_left() | |
yield mod + control + 'Right', lazy.layout.grow_right() | |
yield mod + alt + 'Down', lazy.layout.flip_down() | |
yield mod + alt + 'Up', lazy.layout.flip_up() | |
yield mod + alt + 'Left', lazy.layout.flip_left() | |
yield mod + alt + 'Right', lazy.layout.flip_right() | |
yield mod + 'n', lazy.layout.normalize() | |
yield mod + 'Tab', lazy.next_layout() | |
yield mod + 'F4', lazy.window.kill() | |
yield mod + control + 'r', lazy.restart() | |
yield mod + control + 'q', lazy.shutdown() | |
yield mod + 'r', lazy.spawncmd() | |
yield mod + 'F10', lazy.window.toggle_floating() | |
yield mod + 'F11', lazy.window.toggle_fullscreen() | |
yield 'XF86AudioMute', pulse_ctl.make_cmd(lambda pctx: pctx.mute()) | |
yield 'XF86AudioRaiseVolume', pulse_ctl.make_cmd(lambda pctx: pctx.raise_volume()) | |
yield 'XF86AudioLowerVolume', pulse_ctl.make_cmd(lambda pctx: pctx.lower_volume()) | |
yield mod + 'Return', lazy.spawn('gnome-terminal') | |
yield 'XF86HomePage', lazy.spawn('firefox') | |
yield 'XF86Tools', lazy.spawn('audacious') | |
yield 'XF86Mail', lazy.spawn('goldendict') | |
yield 'XF86Explorer', lazy.spawn('nautilus') | |
shutter = 'shutter --exit_after_capture --no_session --disable_systray ' | |
yield mod + 'Print', lazy.spawn(shutter + '--active') | |
yield mod + control + 'Print', lazy.spawn(shutter + '--full') | |
def make_keymap(user_map): | |
result = [] | |
class KeyCombo: | |
def __init__(self, mods, key): | |
self._mods = mods | |
self._key = key | |
class KeyMods: | |
def __init__(self, mods): | |
self._mods = set(mods) | |
def __add__(self, other): | |
if isinstance(other, KeyMods): | |
return KeyMods(self._mods | other._mods) | |
else: | |
return KeyCombo(self._mods, other) | |
for k, cmd in user_map( | |
KeyMods({'mod4'}), | |
KeyMods({'shift'}), | |
KeyMods({'control'}), | |
KeyMods({'mod1'}), | |
): | |
if isinstance(k, str): | |
mods = set() | |
elif isinstance(k, KeyCombo): | |
mods = k._mods | |
k = k._key | |
else: | |
logger.error('Bad key %s', k) | |
continue | |
if 'lock' in mods: | |
logger.error('You must not use "lock" modifier yourself') | |
continue | |
result.append(Key(list(mods), k, cmd)) | |
return result | |
keys = make_keymap(user_keymap) | |
class DarkWallpaperColorBox: | |
text = '000000' | |
inactive_text = '9b8976' | |
bg = 'e4ceb1' | |
highlight_bg = 'd0aa78' | |
urgent_bg = 'b81111' | |
border = '7a6e5e' | |
border_focus = urgent_bg | |
highlight_text = urgent_bg | |
class GentooColorBox: | |
bg = '54487A' | |
highlight_bg = '6e56af' | |
urgent_bg = '73d216' | |
text = 'ffffff' | |
inactive_text = '776a9c' | |
border = '61538d' | |
border_focus = urgent_bg | |
highlight_text = urgent_bg | |
class LightWallpaperColorBox: | |
bg = '666666' | |
highlight_bg = '888888' | |
urgent_bg = 'fe8964' | |
text = 'ffffff' | |
inactive_text = '333333' | |
border = '333333' | |
border_focus = urgent_bg | |
highlight_text = urgent_bg | |
ColorBox = LightWallpaperColorBox | |
class ScreenProxy: | |
def __init__(self, real_screen, margin): | |
self._s = real_screen | |
self._margin = margin | |
@property | |
def x(self): | |
return self._s.x + self._margin | |
@property | |
def y(self): | |
return self._s.y + self._margin | |
@property | |
def width(self): | |
return self._s.width - self._margin * 2 | |
@property | |
def height(self): | |
return self._s.height - self._margin * 2 | |
class FixedBsp(layout.Bsp): | |
def configure(self, client, screen): | |
amount = sum(1 for c in islice(self.root.clients(), 0, 2)) | |
super().configure( | |
client, | |
ScreenProxy(screen, self.margin if amount > 1 else -self.margin)) | |
layouts = [ | |
FixedBsp( | |
border_width=3, | |
border_normal=ColorBox.border, | |
border_focus=ColorBox.border_focus, | |
margin=5, | |
name='bsp', | |
), | |
layout.Max(), | |
] | |
floating_layout = layout.Floating( | |
border_width=3, | |
border_normal=ColorBox.border, | |
border_focus=ColorBox.border_focus, | |
float_rules=[ | |
{'wmclass': 'confirm'}, | |
{'wmclass': 'dialog'}, | |
{'wmclass': 'download'}, | |
{'wmclass': 'error'}, | |
{'wmclass': 'file_progress'}, | |
{'wmclass': 'notification'}, | |
{'wmclass': 'splash'}, | |
{'wmclass': 'toolbar'}, | |
{'wmclass': 'confirmreset'}, # gitk | |
{'wmclass': 'makebranch'}, # gitk | |
{'wmclass': 'maketag'}, # gitk | |
{'wname': 'branchdialog'}, # gitk | |
{'wname': 'pinentry'}, # GPG key password entry | |
{'wmclass': 'ssh-askpass'}, # ssh-askpass | |
], | |
) | |
widget_defaults = dict( | |
font='PT Sans', | |
fontsize=16, | |
padding=0, | |
padding_x=0, | |
padding_y=0, | |
margin=0, | |
margin_x=0, | |
margin_y=0, | |
foreground=ColorBox.text, | |
center_aligned=True, | |
markup=False, | |
) | |
def create_widgets(): | |
yield widget.GroupBox( | |
disable_drag=True, | |
use_mouse_wheel=False, | |
padding_x=4, | |
padding_y=0, | |
margin_y=4, | |
spacing=0, | |
borderwidth=0, | |
highlight_method='block', | |
urgent_alert_method='block', | |
rounded=False, | |
active=ColorBox.text, | |
inactive=ColorBox.inactive_text, | |
urgent_border=ColorBox.urgent_bg, | |
this_current_screen_border=ColorBox.highlight_bg, | |
fontsize=32, | |
font='Old-Town', | |
) | |
yield CustomPrompt( | |
padding=10, | |
foreground=ColorBox.highlight_text, | |
cursor_color=ColorBox.highlight_text, | |
) | |
yield CustomWindowName(padding=10) | |
yield widget.Systray() | |
yield TimeTracker( | |
active_color=ColorBox.highlight_text, | |
inactive_color=ColorBox.inactive_text, | |
) | |
yield widget.Clock( | |
format='%e %a', | |
foreground=ColorBox.inactive_text, | |
font='PT Serif', | |
update_interval=60, | |
padding=2, | |
) | |
yield widget.Clock( | |
format='%H:%M', | |
foreground=ColorBox.text, | |
fontsize=32, | |
font='Old-Town', | |
padding=2, | |
) | |
if pulse_ctl.valid: | |
yield MicroIndicator( | |
pulse_ctl=pulse_ctl, | |
active_color=ColorBox.highlight_text, | |
inactive_color=ColorBox.inactive_text, | |
fontsize=24, | |
text_shift=-1, | |
) | |
yield CustomCurrentLayout( | |
padding=0, | |
fontsize=26, | |
foreground=ColorBox.inactive_text, | |
text_shift=-3, | |
) | |
screens = [ | |
Screen( | |
bottom=bar.Bar( | |
list(create_widgets()), | |
22, | |
background=ColorBox.bg, | |
), | |
), | |
] | |
mouse = [ | |
Drag(['control'], 'Button9', lazy.window.set_position_floating(), start=lazy.window.get_position()), | |
Drag(['mod4'], 'Button9', lazy.window.set_size_floating(), start=lazy.window.get_size()), | |
Click([], 'Button9', lazy.widget['microindicator'].toggle(), focus=None), | |
] | |
dgroups_key_binder = None | |
dgroups_app_rules = [] | |
main = None | |
follow_mouse_focus = False | |
bring_front_click = True | |
cursor_warp = False | |
auto_fullscreen = True | |
focus_on_window_activation = 'urgent' | |
# XXX: Gasp! We're lying here. In fact, nobody really uses or cares about this | |
# string besides java UI toolkits; you can see several discussions on the | |
# mailing lists, github issues, and other WM documentation that suggest setting | |
# this string if your java app doesn't work correctly. We may as well just lie | |
# and say that we're a working one by default. | |
# | |
# We choose LG3D to maximize irony: it is a 3D non-reparenting WM written in | |
# java that happens to be on java's whitelist. | |
wmname = 'LG3D' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment