From 64749139edb998bd8ad0dd48c5911d76709ddd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Boh=C3=A1=C4=8Dek?= Date: Sat, 23 Sep 2023 13:14:28 +0200 Subject: [PATCH] feat: add qtile config --- hosts/home.nix | 3 +- modules/desktop/qtile/config/autostart.sh | 10 + modules/desktop/qtile/config/bluetooth.py | 188 ++++++ modules/desktop/qtile/config/config.py | 605 ++++++++++++++++++ modules/desktop/qtile/config/mpris2widget.py | 448 ++++++++++++++ modules/desktop/qtile/config/tasklist.py | 618 +++++++++++++++++++ modules/desktop/qtile/default.nix | 20 +- modules/desktop/qtile/home.nix | 18 +- modules/desktop/qtile/python-overlay.nix | 113 ++++ modules/editors/nvim/home.nix | 2 + modules/services/flameshot.nix | 22 +- modules/services/home.nix | 2 - modules/services/picom.nix | 130 ++-- modules/services/redshift.nix | 16 +- scripts/notifications/clear-popups.sh | 5 + scripts/notifications/pause.sh | 7 + scripts/notifications/show-center.sh | 6 + scripts/notifications/unpause.sh | 6 + 18 files changed, 2118 insertions(+), 101 deletions(-) create mode 100644 modules/desktop/qtile/config/autostart.sh create mode 100644 modules/desktop/qtile/config/bluetooth.py create mode 100644 modules/desktop/qtile/config/config.py create mode 100644 modules/desktop/qtile/config/mpris2widget.py create mode 100644 modules/desktop/qtile/config/tasklist.py create mode 100644 modules/desktop/qtile/python-overlay.nix create mode 100644 scripts/notifications/clear-popups.sh create mode 100644 scripts/notifications/pause.sh create mode 100644 scripts/notifications/show-center.sh create mode 100644 scripts/notifications/unpause.sh diff --git a/hosts/home.nix b/hosts/home.nix index 659e64a..fbf09fb 100644 --- a/hosts/home.nix +++ b/hosts/home.nix @@ -47,6 +47,7 @@ # File Management zathura # PDF Viewer evince # PDF Viewer + foliate # Ebook viewer rsync # Syncer - $ rsync -r dir1/ dir2/ unzip # Zip Files unrar # Rar Files @@ -71,8 +72,6 @@ #xorg.xrandr # Screen Settings # # Xorg home-manager - flameshot # Screenshot - picom # Compositer # # Desktop discord # Chat diff --git a/modules/desktop/qtile/config/autostart.sh b/modules/desktop/qtile/config/autostart.sh new file mode 100644 index 0000000..640ef48 --- /dev/null +++ b/modules/desktop/qtile/config/autostart.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +# Browser +firefox & + +# Comms +discord & +telegram-desktop & + +# aw-qt & diff --git a/modules/desktop/qtile/config/bluetooth.py b/modules/desktop/qtile/config/bluetooth.py new file mode 100644 index 0000000..b81bccc --- /dev/null +++ b/modules/desktop/qtile/config/bluetooth.py @@ -0,0 +1,188 @@ +# Copyright (c) 2021 Graeme Holliday +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from dbus_next.aio import MessageBus +from dbus_next.constants import BusType + +from libqtile.widget import base +from libqtile.log_utils import logger + +import asyncio + +BLUEZ = "org.bluez" +BLUEZ_PATH = "/org/bluez/hci0" +BLUEZ_ADAPTER = "org.bluez.Adapter1" +BLUEZ_DEVICE = "org.bluez.Device1" +BLUEZ_BATTERY = "org.bluez.Battery1" +BLUEZ_PROPERTIES = "org.freedesktop.DBus.Properties" + +def synchronize_async_helper(to_await): + async_response = [] + + async def run_and_capture_result(): + r = await to_await + async_response.append(r) + + loop = asyncio.get_event_loop() + coroutine = run_and_capture_result() + loop.run_until_complete(coroutine) + return async_response[0] + +class Bluetooth(base._TextBox): + """ + Displays bluetooth status for a particular connected device. + + (For example your bluetooth headphones.) + + Uses dbus-next to communicate with the system bus. + + Widget requirements: dbus-next_. + + .. _dbus-next: https://pypi.org/project/dbus-next/ + """ + + defaults = [ + ( + "hci", + "/dev_XX_XX_XX_XX_XX_XX", + "hci0 device path, can be found with d-feet or similar dbus explorer.", + ), + ( + "format_connected", + "{name}: {battery}%", + "format of the string to show" + ), + ( + "format_disconnected", + "not connected", + "what to show when not connected" + ), + ( + "format_unpowered", + "adapter off", + "what to show when the adapter is off" + ), + ] + + def __init__(self, **config): + base._TextBox.__init__(self, "", **config) + self._update_battery_task = None + self.add_defaults(Bluetooth.defaults) + + async def _config_async(self): + # set initial values + self.powered = await self._init_adapter() + self.connected, self.device, self.battery = await self._init_device() + self.update_text() + + async def _init_adapter(self): + # set up interface to adapter properties using high-level api + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + introspect = await bus.introspect(BLUEZ, BLUEZ_PATH) + obj = bus.get_proxy_object(BLUEZ, BLUEZ_PATH, introspect) + iface = obj.get_interface(BLUEZ_ADAPTER) + props = obj.get_interface(BLUEZ_PROPERTIES) + + powered = await iface.get_powered() + # subscribe receiver to property changed + props.on_properties_changed(self._adapter_signal_received) + return powered + + async def _init_device(self): + # set up interface to device properties using high-level api + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + introspect = await bus.introspect(BLUEZ, BLUEZ_PATH + self.hci) + obj = bus.get_proxy_object(BLUEZ, BLUEZ_PATH + self.hci, introspect) + device_iface = obj.get_interface(BLUEZ_DEVICE) + props = obj.get_interface(BLUEZ_PROPERTIES) + + battery = None + try: + battery_iface = obj.get_interface(BLUEZ_BATTERY) + battery = await battery_iface.get_percentage() + except: + pass + + connected = await device_iface.get_connected() + name = await device_iface.get_name() + # subscribe receiver to property changed + props.on_properties_changed(self._device_signal_received) + return connected, name, battery + + def _adapter_signal_received( + self, interface_name, changed_properties, _invalidated_properties + ): + powered = changed_properties.get("Powered", None) + if powered is not None: + self.powered = powered.value + self.update_text() + + def _device_signal_received( + self, interface_name, changed_properties, _invalidated_properties + ): + logger.warning(f"{changed_properties.keys()}") + connected = changed_properties.get("Connected", None) + if connected is not None: + self.connected = connected.value + self.update_text() + if self.connected == True: + self.on_connected() + + device = changed_properties.get("Name", None) + if device is not None: + self.device = device.value + self.update_text() + + battery = changed_properties.get("Percentage", None) + if battery is not None: + self.battery = battery.value + self.update_text() + + def on_connected(self): + if self._update_battery_task != None: + self._update_battery_task.cancel() + self._update_battery_task = self.timeout_add(1, self.update_battery_percentage) + + async def update_battery_percentage(self): + self._update_battery_task = None + battery = None + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + try: + introspect = await bus.introspect(BLUEZ, BLUEZ_PATH + self.hci) + obj = bus.get_proxy_object(BLUEZ, BLUEZ_PATH + self.hci, introspect) + battery_iface = obj.get_interface(BLUEZ_BATTERY) + self.battery = await battery_iface.get_percentage() + bus.disconnect() + self.update_text() + logger.warning(f"Updating text with battery") + except Exception as e: + logger.warning(e) + bus.disconnect() + + def update_text(self): + text = "" + if not self.powered: + text = self.format_unpowered + else: + if not self.connected: + text = self.format_disconnected + else: + text = self.format_connected.format(battery = self.battery, name = self.device) + self.update(text) diff --git a/modules/desktop/qtile/config/config.py b/modules/desktop/qtile/config/config.py new file mode 100644 index 0000000..5404c10 --- /dev/null +++ b/modules/desktop/qtile/config/config.py @@ -0,0 +1,605 @@ +import re +import os +import subprocess +import psutil +from libqtile import layout, bar, qtile +from libqtile.backend.base import Window +from libqtile.core.manager import Qtile +from libqtile.config import Click, Drag, Group, KeyChord, EzKey, EzKeyChord, Match, Screen, ScratchPad, DropDown, Key +from libqtile.lazy import lazy +from libqtile.utils import guess_terminal +from libqtile import hook +from libqtile.log_utils import logger +from libqtile.backend.wayland import InputConfig +import qtile_extras.widget as widget +from qtile_extras.widget.decorations import BorderDecoration, PowerLineDecoration, RectDecoration +from tasklist import TaskList +from mpris2widget import Mpris2 +from bluetooth import Bluetooth +from nixenvironment import setupLocation, configLocation + +colors = { + 'primary': '51afef', + 'active': '8babf0', + 'inactive': '555e60', + 'secondary': '55eddc', + 'background_primary': '222223', + 'background_secondary': '444444', + 'urgent': 'c45500', + 'white': 'd5d5d5', + 'grey': '737373', + 'black': '121212', +} + +# ##################################### +# Utility functions +@lazy.function +def focus_window_by_class(qtile: Qtile, wmclass: str): + match = Match(wm_class=wmclass) + windows = [w for w in qtile.windows_map.values() if isinstance(w, Window) and match.compare(w)] + if len(windows) == 0: + return + + window = windows[0] + group = window.group + group.toscreen() + group.focus(window) + +@lazy.function +def go_to_group(qtile: Qtile, group_name: str, switch_monitor: bool = False): + found = False + current_group = qtile.current_group + if group_name == current_group.name: + return + + if switch_monitor: + for screen in qtile.screens: + if screen.group.name == group_name: + qtile.focus_screen(screen.index) + found = True + break + + qtile.groups_map[group_name].toscreen() + + for window in current_group.windows: + if window.fullscreen: + window.toggle_fullscreen() + + if not switch_monitor or not found: + window: Window + for window in qtile.groups_map[group_name].windows: + if window.fullscreen: + window.toggle_fullscreen() + +# expands list of keys with the rest of regular keys, +# mainly usable for KeyChords, where you want any other key +# to exit the key chord instead. +def expand_with_rest_keys(keys: list[EzKey], global_prefix: str) -> list[EzKey]: + all_keys = ['', '', ''] + [chr(c) for c in range(ord('a'), ord('z') + 1)] + prefixes = ['', 'M-', 'M-S-', 'M-C-', 'C-', 'S-'] + + for prefix in prefixes: + for potentially_add_key in all_keys: + potentially_add_key = prefix + potentially_add_key + if potentially_add_key == global_prefix: + continue + + found = False + for existing_key in keys: + if existing_key.key.lower() == potentially_add_key: + found = True + break + + if not found: + keys.append(EzKey(potentially_add_key, lazy.spawn(f'notify-send "Not registered key {global_prefix} {potentially_add_key}"'))) + + return keys + + +# ##################################### +# Environment +mod = 'mod4' +terminal = 'alacritty' + +layout_theme = { + 'border_focus': colors['active'], + 'border_normal': colors['inactive'], + 'border_width': 1, + 'margin': 3, +} + +layouts = [ + layout.MonadTall(**layout_theme), + layout.Max(**layout_theme), + layout.MonadWide(**layout_theme), +] + +widget_defaults = dict( + font = 'Roboto Bold', + fontsize = 13, + padding = 3, + background = colors['background_primary'], + foreground = colors['white'], +) +extension_defaults = widget_defaults.copy() + +powerline = { + 'decorations': [ + PowerLineDecoration(path = 'forward_slash') + ] +} + +def create_top_bar(systray = False): + widgets = [ + widget.Sep(padding = 5, size_percent = 0, background = colors['background_secondary']), + widget.CurrentScreen( + active_text = 'I', + active_color = colors['active'], + padding = 3, + fontsize = 16, + background = colors['background_secondary'], + ), + widget.GroupBox( + markup = False, + highlight_method = 'line', + rounded = False, + margin_x = 2, + disable_drag = True, + use_mouse_wheel = True, + active = colors['white'], + inactive = colors['grey'], + urgent_alert_method = 'line', + urgent_border = colors['urgent'], + this_current_screen_border = colors['active'], + this_screen_border = colors['secondary'], + other_screen_border = colors['inactive'], + other_current_screen_border = '6989c0', + background = colors['background_secondary'], + ), + widget.CurrentScreen( + active_text = 'I', + active_color = colors['active'], + padding = 3, + fontsize = 16, + background = colors['background_secondary'], + decorations = [ + PowerLineDecoration(path = 'forward_slash'), + ], + ), + widget.Sep( + linewidth=2, + size_percent=0, + padding=5, + ), + widget.Prompt(), + widget.WindowName( + foreground = colors['primary'], + width = bar.CALCULATED, + padding = 10, + empty_group_string = 'Desktop', + max_chars = 160, + decorations = [ + RectDecoration( + colour = colors['black'], + radius = 0, + padding_y = 4, + padding_x = 0, + filled = True, + clip = True, + ), + ], + ), + widget.Spacer(), + widget.Chord( + padding = 15, + decorations = [ + RectDecoration( + colour = colors['black'], + radius = 0, + padding_y = 4, + padding_x = 6, + filled = True, + clip = True, + ), + ] + ), + widget.Net( + interface = 'enp24s0', + prefix='M', + format = '{down:6.2f} {down_suffix:<2}↓↑{up:6.2f} {up_suffix:<2}', + background = colors['background_secondary'], + **powerline, + ), + widget.Memory( + format = '{MemFree: .0f}{mm}', + fmt = '{} free', + **powerline, + ), + widget.CPU( + format = '{load_percent} %', + fmt = ' {}', + background = colors['background_secondary'], + **powerline, + ), + widget.DF( + update_interval = 60, + partition = '/', + #format = '[{p}] {uf}{m} ({r:.0f}%)', + format = '{uf}{m} free', + fmt = ' {}', + visible_on_warn = False, + **powerline, + ), + widget.GenPollText( + func = lambda: subprocess.check_output(['xkblayout-state', 'print', '%s']).decode('utf-8').upper(), + fmt = '⌨ {}', + update_interval = 0.5, + **powerline, + ), + widget.Clock( + timezone='Europe/Prague', + foreground = colors['primary'], + format='%A, %B %d - %H:%M:%S', + background = colors['background_secondary'], + **powerline + ), + widget.Volume( + fmt = '🕫 {}', + ), + widget.Sep( + foreground = colors['background_secondary'], + size_percent = 70, + linewidth = 3, + ), + Bluetooth( + hci = '/dev_88_C9_E8_49_93_16', + format_connected = ' {battery} %', + format_disconnected = ' Disconnected', + format_unpowered = '' + ), + ] + + if systray: + widgets.append(widget.Sep( + foreground = colors['background_secondary'], + size_percent = 70, + linewidth = 2, + )) + widgets.append(widget.Systray()) + widgets.append(widget.Sep(padding = 5, size_percent = 0)) + + return bar.Bar(widgets, 30) + +def create_bottom_bar(): + return bar.Bar([ + TaskList( + parse_text = lambda text : re.split(' [–—-] ', text)[-1], + highlight_method = 'line', + txt_floating = '🗗 ', + txt_maximized = '🗖 ', + txt_minimized = '🗕 ', + borderwidth = 3, + ), + widget.Spacer(), + Mpris2( + display_metadata = ['xesam:title'], + playerfilter = '.*Firefox.*', + paused_text = '', #'  {track}', + playing_text = ' {track}', + padding = 10, + decorations = [ + RectDecoration( + colour = colors['black'], + radius = 0, + padding_y = 4, + padding_x = 5, + filled = True, + clip = True, + ), + ], + ), + Mpris2( + display_metadata = ['xesam:title', 'xesam:artist'], + objname = 'org.mpris.MediaPlayer2.spotify', + paused_text = '', #'  {track}', + playing_text = ' {track}', # '  {track}', + padding = 10, + decorations = [ + RectDecoration( + colour = colors['black'], + radius = 0, + padding_y = 4, + padding_x = 5, + filled = True, + clip = True, + ), + ], + ), + widget.Sep( + size_percent = 0, + padding = 5, + **powerline, + ), + widget.Wttr( + location = {'Odolena_Voda': ''}, + format = '%t %c', + background = colors['background_secondary'], + **powerline, + ), + ], 30) + +def init_screen(top_bar, wallpaper, index): + return Screen(top=top_bar, bottom=create_bottom_bar(), wallpaper=wallpaper, x=1920*index, y=0, width=1920, height=1080) + +screens = [ + init_screen(create_top_bar(), '/usr/share/backgrounds/dark futuristic city 3.png', 0), + init_screen(create_top_bar(systray = True), '/usr/share/backgrounds/dark futuristic city č.png', 1), + init_screen(create_top_bar(), '/usr/share/backgrounds/synthwave futuristic city.png', 2), +] + +# Keys +keys = [] + +# up, down, main navigation +keys.extend([ + EzKey('M-j', lazy.layout.down(), desc = 'Move focus down'), + EzKey('M-k', lazy.layout.up(), desc = 'Move focus up'), + EzKey('M-S-j', lazy.layout.shuffle_down(), desc = 'Move focus down'), + EzKey('M-S-k', lazy.layout.shuffle_up(), desc = 'Move focus up'), + EzKey('M-C-h', lazy.layout.shrink_main(), desc = 'Move focus down'), + EzKey('M-C-l', lazy.layout.grow_main(), desc = 'Move focus up'), + EzKey('M-m', lazy.layout.focus_first(), desc = 'Focus main window'), + EzKey('M-u', lazy.next_urgent(), desc = 'Focus urgent window'), +]) + +keys.extend([ + EzKey('M-n', lazy.layout.normalize()), + EzKey('M-t', lazy.window.disable_floating()), + EzKey('M-f', lazy.window.toggle_fullscreen()), + EzKey('M-', lazy.layout.swap_main()), + EzKey('M-', lazy.next_layout()), + EzKey('M-S-', lazy.to_layout_index(0), desc = 'Default layout'), +]) + +# Spwning programs +keys.extend([ + EzKey('M-', lazy.spawn('rofi -show drun')), + EzKey('A-', lazy.spawn('rofi -show windowcd -modi window,windowcd')), + EzKey('M-S-', lazy.spawn('rofi -show window -modi window,windowcd')), + EzKey('M-S-', lazy.spawn(terminal)), +]) + +keys.extend([ + # social navigation + EzKeyChord('M-a', expand_with_rest_keys([ + EzKey('b', focus_window_by_class('discord')), + EzKey('n', focus_window_by_class('Cinny')), + EzKey('m', focus_window_by_class('Cinny')), + + # notifications + EzKey('l', lazy.spawn(f'{setupLocation}/scripts/notifications/clear-popups.sh')), + EzKey('p', lazy.spawn(f'{setupLocation}/scripts/notifications/pause.sh')), + EzKey('u', lazy.spawn(f'{setupLocation}/scripts/notifications/unpause.sh')), + EzKey('t', lazy.spawn(f'{setupLocation}/scripts/notifications/show-center.sh')), + + EzKey('e', lazy.spawn('emacsclient -c')), + ], 'M-a'), name = 'a') +]) + +keys.extend([ + EzKeyChord('M-s', expand_with_rest_keys([ + EzKey('e', lazy.spawn('emacsclient -c')), + EzKey('c', lazy.group['scratchpad'].dropdown_toggle('ipcam')), + EzKey('s', lazy.group['scratchpad'].dropdown_toggle('spotify')), + EzKey('b', lazy.group['scratchpad'].dropdown_toggle('bluetooth')), + EzKey('a', lazy.group['scratchpad'].dropdown_toggle('audio')), + EzKey('m', lazy.group['scratchpad'].dropdown_toggle('mail')), + EzKey('p', lazy.group['scratchpad'].dropdown_toggle('proton')), + ], 'M-s'), name = 's') +]) + +# bars +keys.extend([ + EzKey('M-b', lazy.hide_show_bar('all')), + EzKey('M-v', lazy.hide_show_bar('bottom')), +]) + +# media keys +keys.extend([ + EzKey('', lazy.spawn('playerctl play')), + EzKey('', lazy.spawn('playerctl pause')), + EzKey('', lazy.spawn('playerctl stop')), + EzKey('', lazy.spawn('playerctl next')), + EzKey('', lazy.spawn('playerctl previous')), + EzKey('', lazy.spawn('amixer -D pulse set Master 1+ toggle')), + EzKey('', lazy.spawn('xbacklight -inc 5')), + EzKey('', lazy.spawn('xbacklight -dec 5')), +]) + +# Printscreen +keys.extend([ + EzKey('', lazy.spawn('flameshot gui')), +]) + +# Qtile control +keys.extend([ + EzKey('M-S-c', lazy.window.kill()), + EzKey('M-C-r', lazy.reload_config()), + EzKey('M-C-q', lazy.shutdown()), +]) + +# Monitor navigation +monitor_navigation_keys = ['w', 'e', 'r'] +monitor_index_map = [ 1, 2, 0 ] +for i, key in enumerate(monitor_navigation_keys): + keys.extend([ + EzKey(f'M-{key}', lazy.to_screen(monitor_index_map[i]), desc = f'Move focus to screen {i}'), + EzKey(f'M-S-{key}', lazy.window.toscreen(monitor_index_map[i]), desc = f'Move focus to screen {i}'), + ]) + +if qtile.core.name == 'x11': + keys.append(EzKey('M-S-z', lazy.spawn('clipmenu'))) +elif qtile.core.name == 'wayland': + keys.append( + EzKey( + 'M-S-z', + lazy.spawn('sh -c "cliphist list | dmenu -l 10 | cliphist decode | wl-copy"') + ) + ) + +group_defaults = { + '9': { + 'layout': 'max' + } +} +logger.info(group_defaults.get('9')) +groups = [Group(i) if not (i in group_defaults.keys()) else Group(i, **group_defaults.get(i)) for i in '123456789'] + +for i in groups: + keys.extend( + [ + EzKey( + f'M-{i.name}', + go_to_group(i.name), + desc='Switch to group {}'.format(i.name), + ), + Key( + [mod, 'shift'], + i.name, + lazy.window.togroup(i.name, switch_group=False), + desc='Switch to & move focused window to group {}'.format(i.name), + ), + ] + ) + +scratch_pad_middle = { + 'x': 0.2, 'y': 0.2, + 'height': 0.6, 'width': 0.6, +} + +groups.append( + ScratchPad('scratchpad', [ + DropDown( + 'spotify', + ['spotify'], + on_focus_lost_hide = True, + **scratch_pad_middle + ), + DropDown( + 'bluetooth', + ['blueman-manager'], + on_focus_lost_hide = True, + **scratch_pad_middle + ), + DropDown( + 'audio', + ['pavucontrol'], + on_focus_lost_hide = True, + **scratch_pad_middle + ), + DropDown( + 'mail', + ['thunderbird'], + on_focus_lost_hide = True, + x = 0.025, y = 0.025, + width = 0.95, height = 0.95, + opacity = 1, + ), + ]) +) + +# Drag floating layouts. +mouse = [ + Drag([mod], 'Button1', lazy.window.set_position_floating(), start=lazy.window.get_position()), + Drag([mod], 'Button3', lazy.window.set_size_floating(), start=lazy.window.get_size()), + Click([mod], 'Button2', lazy.window.bring_to_front()), +] + +dgroups_key_binder = None +dgroups_app_rules = [] # type: list +follow_mouse_focus = False +cursor_warp = True +floating_layout = layout.Floating( + float_rules=[ + *layout.Floating.default_float_rules, + ] +) +auto_fullscreen = True +focus_on_window_activation = 'urgent' +reconfigure_screens = False +auto_minimize = True +bring_front_click = False + +wl_input_rules = {} +wmname = 'LG3D' + +# Swallow windows, +# when a process with window spawns +# another process with a window as a child, minimize the first +# winddow. Turn off the minimization after the child process +# is done. +@hook.subscribe.client_new +def _swallow(window): + pid = window.window.get_net_wm_pid() + ppid = psutil.Process(pid).ppid() + cpids = {c.window.get_net_wm_pid(): wid for wid, c in window.qtile.windows_map.items()} + for i in range(5): + if not ppid: + return + if ppid in cpids: + parent = window.qtile.windows_map.get(cpids[ppid]) + parent.minimized = True + window.parent = parent + return + ppid = psutil.Process(ppid).ppid() + +@hook.subscribe.client_killed +def _unswallow(window): + if hasattr(window, 'parent'): + window.parent.minimized = False + +# Startup setup, +# windows to correct workspaces, +# start autostart.sh +@hook.subscribe.startup_once +def autostart(): + subprocess.call([f'{configLocation}/autostart.sh']) + +firefoxInstance = 0 +@hook.subscribe.client_new +def startup_applications(client: Window): + global firefoxInstance + + if not isinstance(client, Window): + return + + if client.match(Match(wm_class = 'firefox')) and firefoxInstance <= 1: + client.togroup(groups[firefoxInstance].name) + firefoxInstance += 1 + elif client.match(Match(wm_class = 'discord')) or client.match(Match(wm_class = 'cinny')): + client.togroup(groups[8].name) + +# Turn off fullscreen on unfocus +@hook.subscribe.client_focus +def exit_fullscreen_on_focus_changed(client: Window): + windows = client.group.windows + window: Window + for window in windows: + if window != client and window.fullscreen: + window.toggle_fullscreen() + +# Start scratchpads +@hook.subscribe.startup_complete +def scratchpad_startup(): + scratchpad: ScratchPad = qtile.groups_map['scratchpad'] + for dropdown_name, dropdown_config in scratchpad._dropdownconfig.items(): + scratchpad._spawn(dropdown_config) + def wrapper(name): + def hide_dropdown(_): + dropdown = scratchpad.dropdowns.get(name) + if dropdown: + dropdown.hide() + hook.unsubscribe.client_managed(hide_dropdown) + return hide_dropdown + + hook.subscribe.client_managed(wrapper(dropdown_name)) diff --git a/modules/desktop/qtile/config/mpris2widget.py b/modules/desktop/qtile/config/mpris2widget.py new file mode 100644 index 0000000..bb628fd --- /dev/null +++ b/modules/desktop/qtile/config/mpris2widget.py @@ -0,0 +1,448 @@ +# Copyright (c) 2014 Sebastian Kricner +# Copyright (c) 2014 Sean Vig +# Copyright (c) 2014 Adi Sieker +# Copyright (c) 2014 Tycho Andersen +# Copyright (c) 2020 elParaguayo +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from __future__ import annotations + +import asyncio +import re +import string +from typing import TYPE_CHECKING + +from dbus_next import Message, Variant +from dbus_next.constants import MessageType + +from libqtile.command.base import expose_command +from libqtile.log_utils import logger +from libqtile.utils import _send_dbus_message, add_signal_receiver, create_task +from libqtile.widget import base + +if TYPE_CHECKING: + from typing import Any + +MPRIS_PATH = "/org/mpris/MediaPlayer2" +MPRIS_OBJECT = "org.mpris.MediaPlayer2" +MPRIS_PLAYER = "org.mpris.MediaPlayer2.Player" +PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties" +MPRIS_REGEX = re.compile(r"(\{(.*?):(.*?)(:.*?)?\})") + + +class Mpris2Formatter(string.Formatter): + """ + Custom string formatter for MPRIS2 metadata. + + Keys have a colon (e.g. "xesam:title") which causes issues with python's string + formatting as the colon splits the identifier from the format specification. + + This formatter handles this issue by changing the first colon to an underscore and + then formatting the incoming kwargs to match. + + Additionally, a default value is returned when an identifier is not provided by the + kwarg data. + """ + + def __init__(self, default=""): + string.Formatter.__init__(self) + self._default = default + + def get_value(self, key, args, kwargs): + """ + Replaces colon in kwarg keys with an underscore before getting value. + + Missing identifiers are replaced with the default value. + """ + kwargs = {k.replace(":", "_"): v for k, v in kwargs.items()} + try: + return string.Formatter.get_value(self, key, args, kwargs) + except (IndexError, KeyError): + return self._default + + def parse(self, format_string): + """ + Replaces first colon in format string with an underscore. + + This will cause issues if any identifier is provided that does not + contain a colon. This should not happen according to the MPRIS2 + specification! + """ + format_string = MPRIS_REGEX.sub(r"{\2_\3\4}", format_string) + return string.Formatter.parse(self, format_string) + + +class Mpris2(base._TextBox): + """An MPRIS 2 widget + + A widget which displays the current track/artist of your favorite MPRIS + player. This widget scrolls the text if neccessary and information that + is displayed is configurable. + + The widget relies on players broadcasting signals when the metadata or playback + status changes. If you are getting inconsistent results then you can enable background + polling of the player by setting the `poll_interval` parameter. This is disabled by + default. + + Basic mouse controls are also available: button 1 = play/pause, + scroll up = next track, scroll down = previous track. + + Widget requirements: dbus-next_. + + .. _dbus-next: https://pypi.org/project/dbus-next/ + """ + + defaults = [ + ("name", "audacious", "Name of the MPRIS widget."), + ( + "objname", + None, + "DBUS MPRIS 2 compatible player identifier" + "- Find it out with dbus-monitor - " + "Also see: http://specifications.freedesktop.org/" + "mpris-spec/latest/#Bus-Name-Policy. " + "``None`` will listen for notifications from all MPRIS2 compatible players.", + ), + + ( + "playerfilter", + None, + "Filter the player identifier based on this regex", + "usable with objname = None" + ), + ( + "format", + "{xesam:title} - {xesam:album} - {xesam:artist}", + "Format string for displaying metadata. " + "See http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/#index5h3 " + "for available values", + ), + ("separator", ", ", "Separator for metadata fields that are a list."), + ( + "display_metadata", + ["xesam:title", "xesam:album", "xesam:artist"], + "(Deprecated) Which metadata identifiers to display. ", + ), + ("scroll", True, "Whether text should scroll."), + ("playing_text", "{track}", "Text to show when playing"), + ("paused_text", "Paused: {track}", "Text to show when paused"), + ("stopped_text", "", "Text to show when stopped"), + ( + "stop_pause_text", + None, + "(Deprecated) Optional text to display when in the stopped/paused state", + ), + ( + "no_metadata_text", + "No metadata for current track", + "Text to show when track has no metadata", + ), + ( + "poll_interval", + 0, + "Periodic background polling interval of player (0 to disable polling).", + ), + ] + + def __init__(self, **config): + base._TextBox.__init__(self, "", **config) + self.add_defaults(Mpris2.defaults) + self.is_playing = False + self.count = 0 + self.displaytext = "" + self.track_info = "" + self.status = "{track}" + self.add_callbacks( + { + "Button1": self.play_pause, + "Button4": self.next, + "Button5": self.previous, + } + ) + paused = "" + stopped = "" + if "stop_pause_text" in config: + logger.warning( + "The use of 'stop_pause_text' is deprecated. Please use 'paused_text' and 'stopped_text' instead." + ) + if "paused_text" not in config: + paused = self.stop_pause_text + + if "stopped_text" not in config: + stopped = self.stop_pause_text + + if "display_metadata" in config: + logger.warning( + "The use of `display_metadata is deprecated. Please use `format` instead." + ) + self.format = " - ".join(f"{{{s}}}" for s in config["display_metadata"]) + + self._formatter = Mpris2Formatter() + + self.prefixes = { + "Playing": self.playing_text, + "Paused": paused or self.paused_text, + "Stopped": stopped or self.stopped_text, + } + + self._current_player: str | None = None + self.player_names: dict[str, str] = {} + self._background_poll: asyncio.TimerHandle | None = None + + @property + def player(self) -> str: + if self._current_player is None: + return "None" + else: + return self.player_names.get(self._current_player, "Unknown") + + async def _config_async(self): + # Set up a listener for NameOwner changes so we can remove players when they close + await add_signal_receiver( + self._name_owner_changed, + session_bus=True, + signal_name="NameOwnerChanged", + dbus_interface="org.freedesktop.DBus", + ) + + # Listen out for signals from any Mpris2 compatible player + subscribe = await add_signal_receiver( + self.message, + session_bus=True, + signal_name="PropertiesChanged", + bus_name=self.objname, + path="/org/mpris/MediaPlayer2", + dbus_interface="org.freedesktop.DBus.Properties", + ) + + if not subscribe: + logger.warning("Unable to add signal receiver for Mpris2 players") + + # If the user has specified a player to be monitored, we can poll it now. + if self.objname is not None: + await self._check_player() + + def _name_owner_changed(self, message): + # We need to track when an interface has been removed from the bus + # We use the NameOwnerChanged signal and check if the new owner is + # empty. + name, _, new_owner = message.body + + # Check if the current player has closed + if new_owner == "" and name == self._current_player: + self._current_player = None + self.update("") + + # Cancel any scheduled background poll + self._set_background_poll(False) + + def message(self, message): + if message.message_type != MessageType.SIGNAL: + return + + create_task(self.process_message(message)) + + async def process_message(self, message): + current_player = message.sender + + name = await self.get_player_name(current_player) + if current_player not in self.player_names: + self.player_names[current_player] = name + + if self.playerfilter != None and not re.match(self.playerfilter, name): + return + + self._current_player = current_player + + self.parse_message(*message.body) + + async def _check_player(self): + """Check for player at startup and retrieve metadata.""" + if not (self.objname or self._current_player): + return + + bus, message = await _send_dbus_message( + True, + MessageType.METHOD_CALL, + self.objname if self.objname else self._current_player, + PROPERTIES_INTERFACE, + MPRIS_PATH, + "GetAll", + "s", + [MPRIS_PLAYER], + ) + + if bus: + bus.disconnect() + + # If we get an error here it will be because the player object doesn't exist + if message.message_type != MessageType.METHOD_RETURN: + self._current_player = None + self.update("") + return + + if message.body: + self._current_player = message.sender + self.parse_message(self.objname, message.body[0], []) + + def _set_background_poll(self, poll=True): + if self._background_poll is not None: + self._background_poll.cancel() + + if poll: + self._background_poll = self.timeout_add(self.poll_interval, self._check_player) + + async def get_player_name(self, player): + bus, message = await _send_dbus_message( + True, + MessageType.METHOD_CALL, + player, + PROPERTIES_INTERFACE, + MPRIS_PATH, + "Get", + "ss", + [MPRIS_OBJECT, "Identity"], + ) + + if bus: + bus.disconnect() + + if message.message_type != MessageType.METHOD_RETURN: + logger.warning("Could not retrieve identity of player on %s.", player) + return "" + + return message.body[0].value + + def parse_message( + self, + _interface_name: str, + changed_properties: dict[str, Any], + _invalidated_properties: list[str], + ) -> None: + """ + http://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html#Mapping:Metadata_Map + """ + if not self.configured: + return + + if "Metadata" not in changed_properties and "PlaybackStatus" not in changed_properties: + return + + self.displaytext = "" + + metadata = changed_properties.get("Metadata") + if metadata: + self.track_info = self.get_track_info(metadata.value) + + playbackstatus = getattr(changed_properties.get("PlaybackStatus"), "value", None) + if playbackstatus: + self.is_playing = playbackstatus == "Playing" + self.status = self.prefixes.get(playbackstatus, "{track}") + + if not self.track_info: + self.track_info = self.no_metadata_text + + self.displaytext = self.status.format(track=self.track_info) + + if self.text != self.displaytext: + self.update(self.displaytext) + + if self.poll_interval: + self._set_background_poll() + + def get_track_info(self, metadata: dict[str, Variant]) -> str: + self.metadata = {} + for key in metadata: + new_key = key + val = getattr(metadata.get(key), "value", None) + if isinstance(val, str): + self.metadata[new_key] = val + elif isinstance(val, list): + self.metadata[new_key] = self.separator.join( + (y for y in val if isinstance(y, str)) + ) + + return self._formatter.format(self.format, **self.metadata).replace("\n", "") + + def _player_cmd(self, cmd: str) -> None: + if self._current_player is None: + return + + task = create_task(self._send_player_cmd(cmd)) + assert task + task.add_done_callback(self._task_callback) + + async def _send_player_cmd(self, cmd: str) -> Message | None: + bus, message = await _send_dbus_message( + True, + MessageType.METHOD_CALL, + self._current_player, + MPRIS_PLAYER, + MPRIS_PATH, + cmd, + "", + [], + ) + + if bus: + bus.disconnect() + + return message + + def _task_callback(self, task: asyncio.Task) -> None: + message = task.result() + + # This happens if we can't connect to dbus. Logger call is made + # elsewhere so we don't need to do any more here. + if message is None: + return + + if message.message_type != MessageType.METHOD_RETURN: + logger.warning("Unable to send command to player.") + + @expose_command() + def play_pause(self) -> None: + """Toggle the playback status.""" + self._player_cmd("PlayPause") + + + @expose_command() + def next(self) -> None: + """Play the next track.""" + self._player_cmd("Next") + + + @expose_command() + def previous(self) -> None: + """Play the previous track.""" + self._player_cmd("Previous") + + + @expose_command() + def stop(self) -> None: + """Stop playback.""" + self._player_cmd("Stop") + + + @expose_command() + def info(self): + """What's the current state of the widget?""" + d = base._TextBox.info(self) + d.update(dict(isplaying=self.is_playing, player=self.player)) + return d diff --git a/modules/desktop/qtile/config/tasklist.py b/modules/desktop/qtile/config/tasklist.py new file mode 100644 index 0000000..ab264bb --- /dev/null +++ b/modules/desktop/qtile/config/tasklist.py @@ -0,0 +1,618 @@ +# Copyright (c) 2012-2014 roger +# Copyright (c) 2012-2015 Tycho Andersen +# Copyright (c) 2013 dequis +# Copyright (c) 2013 Tao Sauvage +# Copyright (c) 2013 Craig Barnes +# Copyright (c) 2014 Sean Vig +# Copyright (c) 2018 Piotr Przymus +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import re + +import cairocffi + +try: + from xdg.IconTheme import getIconPath + + has_xdg = True +except ImportError: + has_xdg = False + +from libqtile import bar, hook, pangocffi +from libqtile.images import Img +from libqtile.log_utils import logger +from libqtile.widget import base + + +class TaskList(base._Widget, base.PaddingMixin, base.MarginMixin): + """Displays the icon and name of each window in the current group + + Contrary to WindowTabs this is an interactive widget. The window that + currently has focus is highlighted. + + Optional requirements: `pyxdg `__ is needed + to use theme icons and to display icons on Wayland. + """ + + orientations = base.ORIENTATION_HORIZONTAL + defaults = [ + ("font", "sans", "Default font"), + ("fontsize", None, "Font size. Calculated if None."), + ("foreground", "ffffff", "Foreground colour"), + ("fontshadow", None, "font shadow color, default is None(no shadow)"), + ("borderwidth", 2, "Current group border width"), + ("border", "215578", "Border colour"), + ("rounded", True, "To round or not to round borders"), + ( + "highlight_method", + "border", + "Method of highlighting (one of 'border' or 'block') " + "Uses `*_border` color settings", + ), + ("urgent_border", "FF0000", "Urgent border color"), + ( + "urgent_alert_method", + "border", + "Method for alerting you of WM urgent " "hints (one of 'border' or 'text')", + ), + ("highlight_color", "000000", "The color to highlight with in line mode"), + ( + "unfocused_border", + None, + "Border color for unfocused windows. " + "Affects only hightlight_method 'border' and 'block'. " + "Defaults to None, which means no special color.", + ), + ( + "max_title_width", + None, + "Max size in pixels of task title." "(if set to None, as much as available.)", + ), + ( + "title_width_method", + None, + "Method to compute the width of task title. (None, 'uniform'.)" + "Defaults to None, the normal behaviour.", + ), + ( + "parse_text", + None, + "Function to parse and modify window names. " + "e.g. function in config that removes excess " + "strings from window name: " + "def my_func(text)" + ' for string in [" - Chromium", " - Firefox"]:' + ' text = text.replace(string, "")' + " return text" + "then set option parse_text=my_func", + ), + ("spacing", None, "Spacing between tasks." "(if set to None, will be equal to margin_x)"), + ( + "txt_minimized", + "_ ", + "Text representation of the minimized window state. " 'e.g., "_ " or "\U0001F5D5 "', + ), + ( + "txt_maximized", + "[] ", + "Text representation of the maximized window state. " 'e.g., "[] " or "\U0001F5D6 "', + ), + ( + "txt_floating", + "V ", + "Text representation of the floating window state. " 'e.g., "V " or "\U0001F5D7 "', + ), + ( + "markup_normal", + None, + "Text markup of the normal window state. Supports pangomarkup with markup=True." + 'e.g., "{}" or "{}"', + ), + ( + "markup_minimized", + None, + "Text markup of the minimized window state. Supports pangomarkup with markup=True." + 'e.g., "{}" or "{}"', + ), + ( + "markup_maximized", + None, + "Text markup of the maximized window state. Supports pangomarkup with markup=True." + 'e.g., "{}" or "{}"', + ), + ( + "markup_floating", + None, + "Text markup of the floating window state. Supports pangomarkup with markup=True." + 'e.g., "{}" or "{}"', + ), + ( + "markup_focused", + None, + "Text markup of the focused window state. Supports pangomarkup with markup=True." + 'e.g., "{}" or "{}"', + ), + ( + "icon_size", + None, + "Icon size. " "(Calculated if set to None. Icons are hidden if set to 0.)", + ), + ( + "theme_mode", + None, + "When to use theme icons. `None` = never, `preferred` = use if available, " + "`fallback` = use if app does not provide icon directly. " + "`preferred` and `fallback` have identical behaviour on Wayland.", + ), + ( + "theme_path", + None, + "Path to icon theme to be used by pyxdg for icons. ``None`` will use default icon theme.", + ), + ( + "window_name_location", + False, + "Whether to show the location of the window in the title.", + ), + ( + "window_name_location_offset", + 0, + "The offset given to the window location", + ), + ] + + def __init__(self, **config): + base._Widget.__init__(self, bar.STRETCH, **config) + self.add_defaults(TaskList.defaults) + self.add_defaults(base.PaddingMixin.defaults) + self.add_defaults(base.MarginMixin.defaults) + self._icons_cache = {} + self._box_end_positions = [] + self.markup = False + self.clicked = None + if self.spacing is None: + self.spacing = self.margin_x + + self.add_callbacks({"Button1": self.select_window}) + + def box_width(self, text): + """ + calculate box width for given text. + If max_title_width is given, the returned width is limited to it. + """ + if self.markup: + text = re.sub("<[^<]+?>", "", text) + width, _ = self.drawer.max_layout_size([text], self.font, self.fontsize) + width = width + 2 * (self.padding_x + self.borderwidth) + return width + + def get_taskname(self, window): + """ + Get display name for given window. + Depending on its state minimized, maximized and floating + appropriate characters are prepended. + """ + state = "" + markup_str = self.markup_normal + + # Enforce markup and new string format behaviour when + # at least one markup_* option is used. + # Mixing non markup and markup may cause problems. + if ( + self.markup_minimized + or self.markup_maximized + or self.markup_floating + or self.markup_focused + ): + enforce_markup = True + else: + enforce_markup = False + + if window is None: + pass + elif window.minimized: + state = self.txt_minimized + markup_str = self.markup_minimized + elif window.maximized: + state = self.txt_maximized + markup_str = self.markup_maximized + elif window.floating: + state = self.txt_floating + markup_str = self.markup_floating + elif window is window.group.current_window: + markup_str = self.markup_focused + + window_location = ( + f"[{window.group.windows.index(window) + self.window_name_location_offset}] " + if self.window_name_location + else "" + ) + window_name = window_location + window.name if window and window.name else "?" + + if callable(self.parse_text): + try: + window_name = self.parse_text(window_name) + except: # noqa: E722 + logger.exception("parse_text function failed:") + + # Emulate default widget behavior if markup_str is None + if enforce_markup and markup_str is None: + markup_str = "%s{}" % (state) + + if markup_str is not None: + self.markup = True + window_name = pangocffi.markup_escape_text(window_name) + return markup_str.format(window_name) + + return "%s%s" % (state, window_name) + + @property + def windows(self): + if self.qtile.core.name == "x11": + return [ + w + for w in self.bar.screen.group.windows + if w.window.get_wm_type() in ("normal", None) + ] + return self.bar.screen.group.windows + + def calc_box_widths(self): + """ + Calculate box width for each window in current group. + If the available space is less than overall size of boxes, + the boxes are shrunk by percentage if greater than average. + """ + windows = self.windows + window_count = len(windows) + + # if no windows present for current group just return empty list + if not window_count: + return [] + + # Determine available and max average width for task name boxes. + width_total = self.width - 2 * self.margin_x - (window_count - 1) * self.spacing + width_avg = width_total / window_count + + names = [self.get_taskname(w) for w in windows] + + if self.icon_size == 0: + icons = len(windows) * [None] + else: + icons = [self.get_window_icon(w) for w in windows] + + # Obey title_width_method if specified + if self.title_width_method == "uniform": + width_uniform = width_total // window_count + width_boxes = [width_uniform for w in range(window_count)] + else: + # Default behaviour: calculated width for each task according to + # icon and task name consisting + # of state abbreviation and window name + width_boxes = [ + ( + self.box_width(names[idx]) + + ((self.icon_size + self.padding_x) if icons[idx] else 0) + ) + for idx in range(window_count) + ] + + # Obey max_title_width if specified + if self.max_title_width: + width_boxes = [min(w, self.max_title_width) for w in width_boxes] + + width_sum = sum(width_boxes) + + # calculated box width are to wide for available widget space: + if width_sum > width_total: + # sum the width of tasks shorter than calculated average + # and calculate a ratio to shrink boxes greater than width_avg + width_shorter_sum = sum([w for w in width_boxes if w < width_avg]) + + ratio = (width_total - width_shorter_sum) / (width_sum - width_shorter_sum) + # determine new box widths by shrinking boxes greater than avg + width_boxes = [(w if w < width_avg else w * ratio) for w in width_boxes] + + return zip(windows, icons, names, width_boxes) + + def _configure(self, qtile, bar): + base._Widget._configure(self, qtile, bar) + + if not has_xdg and self.theme_mode is not None: + logger.warning("You must install pyxdg to use theme icons.") + self.theme_mode = None + + if self.theme_mode and self.theme_mode not in ["preferred", "fallback"]: + logger.warning( + "Unexpected theme_mode (%s). Theme icons will be disabled.", self.theme_mode + ) + self.theme_mode = None + + if qtile.core.name == "wayland" and self.theme_mode is None and self.icon_size != 0: + # Disable icons + self.icon_size = 0 + + if self.icon_size is None: + self.icon_size = self.bar.height - 2 * (self.borderwidth + self.margin_y) + + if self.fontsize is None: + calc = self.bar.height - self.margin_y * 2 - self.borderwidth * 2 - self.padding_y * 2 + self.fontsize = max(calc, 1) + self.layout = self.drawer.textlayout( + "", "ffffff", self.font, self.fontsize, self.fontshadow, wrap=False + ) + self.setup_hooks() + + def update(self, window=None): + if not window or window in self.windows: + self.bar.draw() + + def remove_icon_cache(self, window): + wid = window.wid + if wid in self._icons_cache: + self._icons_cache.pop(wid) + + def invalidate_cache(self, window): + self.remove_icon_cache(window) + self.update(window) + + def setup_hooks(self): + hook.subscribe.client_name_updated(self.update) + hook.subscribe.focus_change(self.update) + hook.subscribe.float_change(self.update) + hook.subscribe.client_urgent_hint_changed(self.update) + + hook.subscribe.net_wm_icon_change(self.invalidate_cache) + hook.subscribe.client_killed(self.remove_icon_cache) + + def drawtext(self, text, textcolor, width): + if self.markup: + self.layout.markup = self.markup + + self.layout.text = text + + self.layout.font_family = self.font + self.layout.font_size = self.fontsize + self.layout.colour = textcolor + if width is not None: + self.layout.width = width + + def drawbox( + self, + offset, + text, + bordercolor, + textcolor, + width=None, + rounded=False, + block=False, + line=False, + icon=None, + highlight_color=None, + ): + self.drawtext(text, textcolor, width) + + icon_padding = (self.icon_size + self.padding_x) if icon else 0 + padding_x = [self.padding_x + icon_padding, self.padding_x] + + if bordercolor is None: + # border colour is set to None when we don't want to draw a border at all + # Rather than dealing with alpha blending issues, we just set border width + # to 0. + border_width = 0 + framecolor = self.background or self.bar.background + else: + border_width = self.borderwidth + framecolor = bordercolor + + framed = self.layout.framed(border_width, framecolor, padding_x, self.padding_y) + if block and bordercolor is not None: + framed.draw_fill(offset, self.margin_y, rounded) + elif line: + pad_y = [ + (self.bar.height - self.layout.height - self.borderwidth) / 2, + (self.bar.height - self.layout.height + self.borderwidth) / 2, + ] + framed = self.layout.framed(border_width, framecolor, padding_x, pad_y, highlight_color) + + framed.drawer.set_source_rgb(framed.border_color) + + opts = [ + offset, + self.margin_y, + framed.layout.width + framed.pad_left + framed.pad_right, + framed.layout.height + framed.pad_top + framed.pad_bottom, + framed.border_width, + ] + + if bordercolor != None: + framed.drawer.set_source_rgb(framed.highlight_color) + framed.drawer.fillrect(*opts) + framed.drawer.set_source_rgb(framed.border_color) + + opts = [ + offset, + 0,# framed.height - framed.border_width, + framed.layout.width + framed.pad_left + framed.pad_right, + framed.border_width, + framed.border_width, + ] + + framed.drawer.fillrect(*opts) + framed.drawer.ctx.stroke() + framed.layout.draw(offset + framed.pad_left, 0 + framed.pad_top) + else: + framed.draw(offset, self.margin_y, rounded) + + if icon: + self.draw_icon(icon, offset) + + def get_clicked(self, x, y): + box_start = self.margin_x + for box_end, win in zip(self._box_end_positions, self.windows): + if box_start <= x <= box_end: + return win + else: + box_start = box_end + self.spacing + # not found any , return None + return None + + def button_press(self, x, y, button): + self.clicked = self.get_clicked(x, y) + base._Widget.button_press(self, x, y, button) + + def select_window(self): + if self.clicked: + current_win = self.bar.screen.group.current_window + window = self.clicked + if window is not current_win: + window.group.focus(window, False) + if window.floating: + window.bring_to_front() + else: + window.toggle_minimize() + + def _get_class_icon(self, window): + if not getattr(window, "icons", False): + return None + + icons = sorted( + iter(window.icons.items()), + key=lambda x: abs(self.icon_size - int(x[0].split("x")[0])), + ) + icon = icons[0] + width, height = map(int, icon[0].split("x")) + + img = cairocffi.ImageSurface.create_for_data( + icon[1], cairocffi.FORMAT_ARGB32, width, height + ) + + return img + + def _get_theme_icon(self, window): + classes = window.get_wm_class() + + if not classes: + return None + + icon = None + + for cl in classes: + for app in set([cl, cl.lower()]): + icon = getIconPath(app, theme=self.theme_path) + if icon is not None: + break + else: + continue + break + + if not icon: + return None + + img = Img.from_path(icon) + + return img.surface + + def get_window_icon(self, window): + if not getattr(window, "icons", False) and self.theme_mode is None: + return None + + cache = self._icons_cache.get(window.wid) + if cache: + return cache + + surface = None + img = None + + if self.qtile.core.name == "x11": + img = self._get_class_icon(window) + + if self.theme_mode == "preferred" or (self.theme_mode == "fallback" and img is None): + xdg_img = self._get_theme_icon(window) + if xdg_img: + img = xdg_img + + if img is not None: + surface = cairocffi.SurfacePattern(img) + height = img.get_height() + width = img.get_width() + scaler = cairocffi.Matrix() + if height != self.icon_size: + sp = height / self.icon_size + height = self.icon_size + width /= sp + scaler.scale(sp, sp) + surface.set_matrix(scaler) + + self._icons_cache[window.wid] = surface + return surface + + def draw_icon(self, surface, offset): + if not surface: + return + + x = offset + self.borderwidth + self.padding_x + y = (self.height - self.icon_size) // 2 + + self.drawer.ctx.save() + self.drawer.ctx.translate(x, y) + self.drawer.ctx.set_source(surface) + self.drawer.ctx.paint() + self.drawer.ctx.restore() + + def draw(self): + self.drawer.clear(self.background or self.bar.background) + offset = self.margin_x + + self._box_end_positions = [] + for w, icon, task, bw in self.calc_box_widths(): + self._box_end_positions.append(offset + bw) + + if w.urgent: + border = self.urgent_border + text_color = border + elif w is w.group.current_window: + border = self.border + text_color = border + else: + border = self.unfocused_border or None + text_color = self.foreground + + if self.highlight_method == "text": + border = None + else: + text_color = self.foreground + + textwidth = ( + bw - 2 * self.padding_x - ((self.icon_size + self.padding_x) if icon else 0) + ) + self.drawbox( + offset, + task, + border, + text_color, + rounded=self.rounded, + block=(self.highlight_method == "block"), + line=(self.highlight_method == "line"), + width=textwidth, + highlight_color=self.highlight_color, + icon=icon, + ) + offset += bw + self.spacing + + self.drawer.draw(offsetx=self.offset, offsety=self.offsety, width=self.width) diff --git a/modules/desktop/qtile/default.nix b/modules/desktop/qtile/default.nix index 55f94ef..f4b46d0 100644 --- a/modules/desktop/qtile/default.nix +++ b/modules/desktop/qtile/default.nix @@ -1,17 +1,21 @@ -{ config, lib, pkgs, ... }: +{ config, lib, pkgs, nixpkgs, ... }: { - environment = { - systemPackages = with pkgs; [ - qtile - python310Packages.qtile-extras - ]; - }; + imports = [(import ./python-overlay.nix)]; + + environment.systemPackages = with pkgs; [ + playerctl + ]; services = { xserver = { enable = true; - windowManager.qtile.enable = true; + windowManager.qtile = { + enable = true; + extraPackages = ppkgs: [ + ppkgs.qtile-extras + ]; + }; }; }; } diff --git a/modules/desktop/qtile/home.nix b/modules/desktop/qtile/home.nix index 842a165..73e04b3 100644 --- a/modules/desktop/qtile/home.nix +++ b/modules/desktop/qtile/home.nix @@ -1,5 +1,19 @@ -{ config, lib, pkgs, ... }: +{ config, lib, pkgs, location, ... }: { - xdg.configFile."qtile".source = ./qtile; + xdg.configFile."qtile/config.py".source = ./config/config.py; + xdg.configFile."qtile/bluetooth.py".source = ./config/bluetooth.py; + xdg.configFile."qtile/mpris2widget.py".source = ./config/mpris2widget.py; + xdg.configFile."qtile/tasklist.py".source = ./config/tasklist.py; + + xdg.configFile."qtile/nixenvironment.py".text = '' +from string import Template +import os + +setupLocationRef = Template("${location}") +configLocationRef = Template("${location}/modules/desktop/qtile/config") + +setupLocation = setupLocationRef.substitute(os.environ) +configLocation = configLocationRef.substitute(os.environ) + ''; } diff --git a/modules/desktop/qtile/python-overlay.nix b/modules/desktop/qtile/python-overlay.nix new file mode 100644 index 0000000..43179ee --- /dev/null +++ b/modules/desktop/qtile/python-overlay.nix @@ -0,0 +1,113 @@ +{nigpkgs, ...}: + +{ + + nixpkgs = { + overlays = [ + (final: super: { + pythonPackagesOverlays = + (super.pythonPackagesOverlays or []) + ++ [ + (_: pprev: { + cairocffi = pprev.cairocffi.overridePythonAttrs (_: rec { + pname = "cairocffi"; + version = "1.6.1"; + src = super.fetchPypi { + inherit pname version; + hash = "sha256-eOa75HNXZAxFPQvpKfpJzQXM4uEobz0qHKnL2n79uLc="; + }; + format = "pyproject"; + postPatch = ""; + propagatedNativeBuildInputs = with super.python3Packages; [cffi flit-core]; + }); + pywlroots = pprev.pywlroots.overridePythonAttrs (_: rec { + version = "0.16.4"; + src = super.fetchPypi { + inherit version; + pname = "pywlroots"; + hash = "sha256-+1PILk14XoA/dINfoOQeeMSGBrfYX3pLA6eNdwtJkZE="; + }; + buildInputs = with super; [libinput libxkbcommon pixman xorg.libxcb xorg.xcbutilwm udev wayland wlroots_0_16]; + }); + xcffib = pprev.xcffib.overridePythonAttrs (_: rec { + pname = "xcffib"; + version = "1.4.0"; + src = super.fetchPypi { + inherit pname version; + hash = "sha256-uXfADf7TjHWuGIq8EkLvmDwCJSqGd6gMSwKVMz9StiQ="; + }; + patches = []; + }); + qtile = pprev.qtile.overridePythonAttrs (_: { + version = ''2023.08.23''; # qtile + src = super.fetchFromGitHub { + owner = "qtile"; + repo = "qtile"; + rev = ''9b2aff3b3d4607f3e782afda2ec2a061d7eba9f1''; # qtile + hash = ''sha256-20MO9eo2itF4zGLe9efEtE6c5UtAyQWKJBgwOSWBqAM=''; # qtile + }; + prePatch = '' + substituteInPlace libqtile/backend/wayland/cffi/build.py \ + --replace /usr/include/pixman-1 ${super.pixman.outPath}/include \ + --replace /usr/include/libdrm ${super.libdrm.dev.outPath}/include/libdrm + ''; + buildInputs = with super; + [ + libinput + wayland + libxkbcommon + xorg.xcbutilwm + wlroots_0_16 + ]; + }); + qtile-extras = pprev.qtile-extras.overridePythonAttrs (old: { + version = ''2023.08.14''; # extras + src = super.fetchFromGitHub { + owner = "elParaguayo"; + repo = "qtile-extras"; + rev = ''ed01fd8b94997b2a87eecb9bf48e424be91baf76''; # extras + hash = ''sha256-pIfaFIzM+skT/vZir7+fWWNvYcVnUnfXT3mzctqYvUs=''; # extras + }; + checkInputs = (old.checkInputs or []) ++ [super.python3Packages.pytest-lazy-fixture]; + pytestFlagsArray = ["--disable-pytest-warnings"]; + disabledTests = + (old.disabledTests or []) + ++ [ + "1-x11-currentlayout_manager1-55" + "test_githubnotifications_colours" + "test_githubnotifications_logging" + "test_githubnotifications_icon" + "test_githubnotifications_reload_token" + "test_image_size_horizontal" + "test_image_size_vertical" + "test_image_size_mask" + "test_widget_init_config" + "test_mpris2_popup" + "test_snapcast_icon" + "test_snapcast_icon_colour" + "test_snapcast_http_error" + "test_syncthing_not_syncing" + "test_syncthing_is_syncing" + "test_syncthing_http_error" + "test_syncthing_no_api_key" + "test_visualiser" + "test_no_icons" + "test_currentlayouticon_missing_icon" + "test_no_filename" + "test_no_image" + ]; + }); + }) + ]; + python3 = let + self = super.python3.override { + inherit self; + packageOverrides = super.lib.composeManyExtensions final.pythonPackagesOverlays; + }; + in + self; + python3Packages = final.python3.pkgs; + }) + ]; + }; +} diff --git a/modules/editors/nvim/home.nix b/modules/editors/nvim/home.nix index 1cfbbe3..f1e94cd 100644 --- a/modules/editors/nvim/home.nix +++ b/modules/editors/nvim/home.nix @@ -30,6 +30,8 @@ nvim-surround vim-easymotion vim-sneak + + vim-commentary ]; extraConfig = '' diff --git a/modules/services/flameshot.nix b/modules/services/flameshot.nix index 26952c1..939008e 100644 --- a/modules/services/flameshot.nix +++ b/modules/services/flameshot.nix @@ -5,18 +5,16 @@ { config, lib, pkgs, user, ... }: { - config = lib.mkIf (config.xsession.enable) { # Only evaluate code if using X11 - services = { # sxhkd shortcut = Printscreen button (Print) - flameshot = { - enable = true; - settings = { - General = { # Settings - savePath = "/home/${user}/screens"; - saveAsFileExtension = ".png"; - uiColor = "#2d0096"; - showHelp = "false"; - disabledTrayIcon = "true"; # Hide from systray - }; + services = { # sxhkd shortcut = Printscreen button (Print) + flameshot = { + enable = true; + settings = { + General = { # Settings + savePath = "/home/${user}/screens"; + saveAsFileExtension = ".png"; + uiColor = "#2d0096"; + showHelp = "false"; + disabledTrayIcon = "true"; # Hide from systray }; }; }; diff --git a/modules/services/home.nix b/modules/services/home.nix index 6f8fccb..e907e0a 100644 --- a/modules/services/home.nix +++ b/modules/services/home.nix @@ -17,5 +17,3 @@ ./udiskie.nix ./redshift.nix ] - -# redshift and media temporarely disables diff --git a/modules/services/picom.nix b/modules/services/picom.nix index 2f33567..438f65d 100644 --- a/modules/services/picom.nix +++ b/modules/services/picom.nix @@ -5,78 +5,76 @@ { config, lib, pkgs, ... }: { - config = lib.mkIf (config.xsession.enable) { # Only evaluate code if using X11 - services.picom = { - enable = true; - package = pkgs.picom.overrideAttrs(o: { - src = pkgs.fetchFromGitHub { - #repo = "picom"; - #owner = "pijulius"; - #rev = "982bb43e5d4116f1a37a0bde01c9bda0b88705b9"; - #sha256 = "YiuLScDV9UfgI1MiYRtjgRkJ0VuA1TExATA2nJSJMhM="; - repo = "picom"; - owner = "jonaburg"; - rev = "e3c19cd7d1108d114552267f302548c113278d45"; - sha256 = "4voCAYd0fzJHQjJo4x3RoWz5l3JJbRvgIXn1Kg6nz6Y="; - }; - }); # Override picom to use pijulius' version + services.picom = { + enable = true; + package = pkgs.picom.overrideAttrs(o: { + src = pkgs.fetchFromGitHub { + #repo = "picom"; + #owner = "pijulius"; + #rev = "982bb43e5d4116f1a37a0bde01c9bda0b88705b9"; + #sha256 = "YiuLScDV9UfgI1MiYRtjgRkJ0VuA1TExATA2nJSJMhM="; + repo = "picom"; + owner = "jonaburg"; + rev = "e3c19cd7d1108d114552267f302548c113278d45"; + sha256 = "4voCAYd0fzJHQjJo4x3RoWz5l3JJbRvgIXn1Kg6nz6Y="; + }; + }); # Override picom to use pijulius' version - backend = "glx"; # Rendering either with glx or xrender. You'll know if you need to switch this. - vSync = true; # Should fix screen tearing + backend = "glx"; # Rendering either with glx or xrender. You'll know if you need to switch this. + vSync = true; # Should fix screen tearing - #activeOpacity = 0.93; # Node transparency - #inactiveOpacity = 0.93; - #menuOpacity = 0.93; + #activeOpacity = 0.93; # Node transparency + #inactiveOpacity = 0.93; + #menuOpacity = 0.93; - shadow = false; # Shadows - shadowOpacity = 0.75; - fade = true; # Fade - fadeDelta = 10; - opacityRules = [ # Opacity rules if transparency is prefered - # "100:name = 'Picture in picture'" - # "100:name = 'Picture-in-Picture'" - # "85:class_i ?= 'rofi'" - "80:class_i *= 'discord'" - "80:class_i *= 'emacs'" - "80:class_i *= 'Alacritty'" - # "100:fullscreen" - ]; # Find with $ xprop | grep "WM_CLASS" + shadow = false; # Shadows + shadowOpacity = 0.75; + fade = true; # Fade + fadeDelta = 10; + opacityRules = [ # Opacity rules if transparency is prefered + # "100:name = 'Picture in picture'" + # "100:name = 'Picture-in-Picture'" + # "85:class_i ?= 'rofi'" + "80:class_i *= 'discord'" + "80:class_i *= 'emacs'" + "80:class_i *= 'Alacritty'" + # "100:fullscreen" + ]; # Find with $ xprop | grep "WM_CLASS" - settings = { - daemon = true; - use-damage = false; # Fixes flickering and visual bugs with borders - resize-damage = 1; - refresh-rate = 0; - corner-radius = 5; # Corners - round-borders = 5; + settings = { + daemon = true; + use-damage = false; # Fixes flickering and visual bugs with borders + resize-damage = 1; + refresh-rate = 0; + corner-radius = 5; # Corners + round-borders = 5; - # Animations Pijulius - #animations = true; # All Animations - #animation-window-mass = 0.5; - #animation-for-open-window = "zoom"; - #animation-stiffness = 350; - #animation-clamping = false; - #fade-out-step = 1; # Will fix random border dots from not disappearing + # Animations Pijulius + #animations = true; # All Animations + #animation-window-mass = 0.5; + #animation-for-open-window = "zoom"; + #animation-stiffness = 350; + #animation-clamping = false; + #fade-out-step = 1; # Will fix random border dots from not disappearing - # Animations Jonaburg - transition-length = 300; - transition-pow-x = 0.5; - transition-pow-y = 0.5; - transition-pow-w = 0.5; - transition-pow-h = 0.5; - size-transition = true; + # Animations Jonaburg + transition-length = 300; + transition-pow-x = 0.5; + transition-pow-y = 0.5; + transition-pow-w = 0.5; + transition-pow-h = 0.5; + size-transition = true; - # Extras - detect-rounded-corners = true; # Below should fix multiple issues - detect-client-opacity = false; - detect-transient = true; - detect-client-leader = false; - mark-wmwim-focused = true; - mark-ovredir-focues = true; - unredir-if-possible = true; - glx-no-stencil = true; - glx-no-rebind-pixmap = true; - }; # Extra options for picom.conf (mostly for pijulius fork) - }; + # Extras + detect-rounded-corners = true; # Below should fix multiple issues + detect-client-opacity = false; + detect-transient = true; + detect-client-leader = false; + mark-wmwim-focused = true; + mark-ovredir-focues = true; + unredir-if-possible = true; + glx-no-stencil = true; + glx-no-rebind-pixmap = true; + }; # Extra options for picom.conf (mostly for pijulius fork) }; } diff --git a/modules/services/redshift.nix b/modules/services/redshift.nix index 89ee23e..a11789a 100644 --- a/modules/services/redshift.nix +++ b/modules/services/redshift.nix @@ -4,14 +4,12 @@ { config, lib, pkgs, ...}: { - config = lib.mkIf (config.xsession.enable) { # Only evaluate code if using X11 - services = { - redshift = { - enable = true; - temperature.night = 3000; - latitude = 50.2332933; - longitude = 14.3225926; - }; + services = { + redshift = { + enable = true; + temperature.night = 3000; + latitude = 50.2332933; + longitude = 14.3225926; }; - }; + }; } diff --git a/scripts/notifications/clear-popups.sh b/scripts/notifications/clear-popups.sh new file mode 100644 index 0000000..419a330 --- /dev/null +++ b/scripts/notifications/clear-popups.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +dunstctl close-all +notify-send.py a --hint boolean:deadd-notification-center:true \ + string:type:clearPopups diff --git a/scripts/notifications/pause.sh b/scripts/notifications/pause.sh new file mode 100644 index 0000000..0227fdd --- /dev/null +++ b/scripts/notifications/pause.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +notify-send "Pausing notifications" +notify-send.py a --hint boolean:deadd-notification-center:true \ + string:type:pausePopups +sleep 3 +dunstctl set-paused true diff --git a/scripts/notifications/show-center.sh b/scripts/notifications/show-center.sh new file mode 100644 index 0000000..2132fa3 --- /dev/null +++ b/scripts/notifications/show-center.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +dunstctl history-pop +dunstctl history-pop +dunstctl history-pop +dunstctl history-pop diff --git a/scripts/notifications/unpause.sh b/scripts/notifications/unpause.sh new file mode 100644 index 0000000..9bf1f48 --- /dev/null +++ b/scripts/notifications/unpause.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +dunstctl set-paused false +notify-send.py a --hint boolean:deadd-notification-center:true \ + string:type:unpausePopups +notify-send "Unpaused notifications" -- 2.48.1