diff --git a/config.py b/config.py index e4e63a5..2959508 100644 --- a/config.py +++ b/config.py @@ -27,31 +27,33 @@ # Import Theme from libqtile import hook from libqtile.log_utils import logger -from kuro.utils import load_config_class -import traceback try: from kuro.theme import Kuro Theme = Kuro() except ImportError as e: - logger.error(traceback.format_exc()) logger.error("Could not load Kuro Theme. Trying to load BaseTheme. Error: {}".format(e)) try: from kuro.base import BaseTheme as Kuro Theme = Kuro() except ImportError as e: Kuro = None - logger.error(traceback.format_exc()) raise ImportError("Could not load theme Config or BaseTheme! Error: {}".format(e)) # Import theme configuration -Config = load_config_class() -if Config is None: - raise ImportError("Could not load theme Config or BaseConfig! Error: {}".format(e)) +try: + from kuro.config import Config +except ImportError as e: + logger.error("Could not load Kuro Config. Trying to load BaseConfig. Error: {}".format(e)) + try: + from kuro.base import BaseConfig as Config + except ImportError as e: + Config = None + raise ImportError("Could not load theme Config or BaseConfig! Error: {}".format(e)) + try: logger.warning("Initializing theme...") - logger.warning(f"Using config variables for '{Config.get('config_name', '????')}'") # Initialize the Theme Theme.initialize() logger.warning("Initialize done") @@ -108,13 +110,10 @@ try: except Exception as e: Theme = None Config = None - logger.error(traceback.format_exc()) raise AttributeError("Could not configure theme! Error: {}".format(e)) def main(qtile): - Config.initialize(qtile) - # set logging level if Config.get('debug', False): if Config.get('verbose', False): diff --git a/kuro/base.py b/kuro/base.py index 8f02d65..15ce41d 100644 --- a/kuro/base.py +++ b/kuro/base.py @@ -1,5 +1,3 @@ -import time - from libqtile import layout as libqtile_layout, layout, bar, widget from libqtile.lazy import lazy from libqtile.config import Key, Group, Screen, Drag, Click, Match @@ -9,21 +7,13 @@ from libqtile.log_utils import logger class BaseConfig: - config_name = "KuroBase" - @classmethod def get(cls, key, default): if hasattr(cls, key): - return getattr(cls, key) - #return cls.__dict__[key] + return cls.__dict__[key] else: return default - @classmethod - def initialize(cls, qtile): - # Can do extra initialization based on qtile instance here - pass - class BaseTheme: # Changing variables initialized by function @@ -55,7 +45,7 @@ class BaseTheme: auto_fullscreen = True focus_on_window_activation = "smart" extensions = [] - reconfigure_screens = False + reconfigure_screens = True # 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 @@ -70,9 +60,6 @@ class BaseTheme: # 'export _JAVA_AWT_WM_NONREPARENTING=1' wmname = "LG3D" - def __init__(self): - self.startup_time = time.time() - def initialize(self): logger.info("Initializing widget defaults...") self.widget_defaults = self.init_widget_defaults() diff --git a/kuro/config/__init__.py b/kuro/config.py similarity index 79% rename from kuro/config/__init__.py rename to kuro/config.py index 00ff7ea..ecfb2a4 100644 --- a/kuro/config/__init__.py +++ b/kuro/config.py @@ -1,11 +1,8 @@ from kuro.base import BaseConfig -from libqtile.log_utils import logger # Config variables used in the main configuration class Config(BaseConfig): - config_name = "KuroGeneral" - # Show debug bar and messages debug = False verbose = False @@ -22,16 +19,13 @@ class Config(BaseConfig): cmd_brightness_down = "sudo /usr/bin/xbacklight -dec 10" cmd_screenshot = "/home/kevin/bin/screenshot.sh" cmd_alt_screenshot = "/home/kevin/bin/screenshot.sh" + app_terminal = "ghostty" + web_browser = "firefox" + file_manager = "thunar" + app_launcher = "wofi --show drun,run" lock_command = "bash /home/kevin/bin/lock.sh" cliphistory_command = "/home/kevin/bin/cliphistory.sh" - # Default Applications - app_terminal = "ghostty" - app_launcher = "wofi --show run,drun" - file_manager = "thunar" - visualizer_app = "glava" - web_browser = "firefox" - # Autostart applications apps_autostart_group = [ {'group': "", 'command': ["firefox"]}, @@ -41,24 +35,15 @@ class Config(BaseConfig): {'group': "", 'command': ["thunderbird"]}, {'group': "", 'command': ["spotify"]}, ] - apps_autostart = { - 'common': [ - ["/usr/lib/kdeconnectd"], # KDE Connect daemon - ["kdeconnect-indicator"], # KDE Connect tray - ["vorta"], # Vorta backup scheduler - ], - 'x11': [ - ["dunst"], # Notification daemon - ["picom", "-b"], # Compositor - ["xfce4-clipman"], # Clipboard manager - ["xiccd"], # Color profile manager - ], - 'wayland': [ - ["mako"], # Notification daemon - ["wl-paste", "--watch", "cliphist", "store"], # Clipboard manager - ["kanshi"], # Display hotplugging - ] - } + apps_autostart = [ + # ["ulauncher", "--hide-window", "--no-window-shadow"], # App launcher background daemon + ["mako"], # Notification daemon + ["kanshi"], # Display hotplug + ["wl-paste", "--watch", "cliphist", "store"], # Clipboard manager + ["/usr/lib/kdeconnectd"], # KDE Connect daemon + ["kdeconnect-indicator"], # KDE Connect tray + ["vorta"], # Vorta backup scheduler + ] # Keyboard commands cmd_media_play = "playerctl -i kdeconnect play-pause" @@ -76,12 +61,11 @@ class Config(BaseConfig): cmd_reconfigure_screens = "kanshictl reload" # Commands - wallpaper_config_command = "/bin/true" # TODO: Remove + wallpaper_config_command = "/bin/true" # Images desktop_bg = "/home/kevin/Pictures/wallpapers/desktop.png" - desktop_bg_folder = "/home/kevin/Pictures/wallpapers/desktop_rotation/day" - desktop_bg_night_folder = "/home/kevin/Pictures/wallpapers/desktop_rotation/night" + desktop_bg_folder = "/home/kevin/Pictures/wallpapers/desktop_rotation" # desktop_bg_override = "/home/kevin/Pictures/safe_wallpaper.jpg" applauncher_image = "/home/kevin/.config/qtile/kuro/resources/arch.png" custom_layout_icon_paths = ['/home/kevin/.config/qtile/kuro/resources/layout_icons/'] @@ -158,10 +142,10 @@ class Config(BaseConfig): battery_update_delay = 5 # Network variables - wifi_interface = "wifi0" + wifi_interface = "wlp3s0" wifi_theme_path = "/home/kevin/.config/qtile/kuro/resources/wifi" wifi_update_interval = 5 - wired_interface = "eth0" + wired_interface = "enp4s0" # GPU variables gpu_theme_path = "/home/kevin/.config/qtile/kuro/resources/gpu" @@ -170,7 +154,9 @@ class Config(BaseConfig): volume_font = "Noto Sans" volume_fontsize = 11 volume_theme_path = "/home/kevin/.config/qtile/kuro/resources/volume" - volume_pulse_sinks = [] + volume_pulse_sink = "alsa_output.usb-CSCTEK_USB_Audio_and_HID_A34004801402-00.analog-stereo" + volume_pulse_sink2 = None + volume_is_bluetooth_icon = False volume_update_interval = 0.2 @@ -209,13 +195,3 @@ class Config(BaseConfig): # Comma-separated list of ignored players in the media widget media_ignore_players = "kdeconnect" - - @classmethod - def initialize(cls, qtile): - # Can do extra initialization based on qtile instance here - super(Config, cls).initialize(qtile=qtile) - - # Replace some apps if launched in X11 mode - if qtile.core.name == "x11": - logger.warning("Launched in X11 mode, overriding some apps in Config to xorg-variants.") - cls.app_launcher = "/home/kevin/bin/dmenu_wal.sh" diff --git a/kuro/config/aria.py b/kuro/config/aria.py deleted file mode 100644 index ebb87bf..0000000 --- a/kuro/config/aria.py +++ /dev/null @@ -1,45 +0,0 @@ -from kuro.config import Config as GeneralConfig - - -class Config(GeneralConfig): - """ - Kuro QTile configuration overrides for Aria - """ - config_name = "Aria" - - # Default Applications - app_terminal = "terminator" - app_launcher = "/home/kevin/bin/dmenu_wal.sh" - cmd_brightness_up = "true" - cmd_brightness_down = "true" - cmd_screenshot = "xfce4-screenshooter -r -c -d 1" - cmd_alt_screenshot = "xfce4-screenshooter -w -c -d 0" - lock_command = "bash /home/kevin/bin/lock.sh" - cliphistory_command = "true" - - # Autostart applications - apps_autostart_group = [ - {'group': "", 'command': ["firefox"]}, - {'group': "", 'command': ["terminator"]}, - {'group': "", 'command': ["/usr/bin/rambox"]}, - {'group': "", 'command': ["thunar"]}, - {'group': "", 'command': ["thunderbird"]}, - {'group': "", 'command': ["spotify"]}, - ] - - # Thermal indicator variables - thermal_sensor = "Package id 0" - thermal_chip = "coretemp-isa-0000" - - # Network variables - wifi_interface = "wifi0" - wired_interface = "enp7s0" - - # Volume widget variables - volume_pulse_sinks = [ - "alsa_output.pci-0000_00_1f.3.analog-stereo", - ] - - # Screen organization - laptop_screen_nvidia = "eDP-1-1" - laptop_screen_intel = "eDP1" diff --git a/kuro/config/meconopsis.py b/kuro/config/meconopsis.py deleted file mode 100644 index e580c92..0000000 --- a/kuro/config/meconopsis.py +++ /dev/null @@ -1,22 +0,0 @@ -from kuro.config import Config as GeneralConfig - - -class Config(GeneralConfig): - """ - Kuro QTile configuration overrides for Meconopsis - """ - config_name = "Meconopsis" - - # Thermal indicator variables - thermal_sensor = "Package id 0" - thermal_chip = "coretemp-isa-0000" - - # Network variables - wifi_interface = "wlp3s0" - wired_interface = "enp4s0" - - # Volume widget variables - volume_pulse_sinks = [ - # Analog jack - "alsa_output.usb-CSCTEK_USB_Audio_and_HID_A34004801402-00.analog-stereo", - ] diff --git a/kuro/config/violet.py b/kuro/config/violet.py deleted file mode 100644 index e6dd5a6..0000000 --- a/kuro/config/violet.py +++ /dev/null @@ -1,26 +0,0 @@ -from kuro.config import Config as GeneralConfig - - -class Config(GeneralConfig): - """ - Kuro QTile configuration overrides for Violet - """ - config_name = "Violet" - - # Thermal indicator variables - thermal_sensor = "Tdie" - thermal_chip = "zenpower-pci-00c3" - - # Network variables - wifi_interface = None - wired_interface = "br1" - - # Volume widget variables - volume_pulse_sinks = [ - # Behringer USB mixer - "alsa_output.usb-Burr-Brown_from_TI_USB_Audio_CODEC-00.analog-stereo-output", - # Motherboard output (Starship/Matisse) - "alsa_output.pci-0000_0e_00.4.iec958-stereo", - # PCIe card output (CMI8738/CMI8768 PCI Audio) - "alsa_output.pci-0000_08_00.0.analog-stereo", - ] diff --git a/kuro/theme.py b/kuro/theme.py index 7311976..88a0dca 100644 --- a/kuro/theme.py +++ b/kuro/theme.py @@ -1,8 +1,6 @@ import json import os import random -import time -import datetime import socket import subprocess from typing import Optional @@ -40,11 +38,14 @@ from kuro.utils import layouts as kuro_layouts logger.warning("Importing configuration...") -from kuro.utils import load_config_class -Config = load_config_class() -if Config is None: - raise ImportError("Could not load theme Config or BaseConfig! Error: {}".format(e)) -Config.initialize(qtile) +try: + from kuro.config import Config +except ImportError: + try: + from kuro.baseconfig import BaseConfig as Config + except ImportError: + Config = None + raise ImportError("Could not load theme Config or BaseConfig!") logger.warning("Imports done") @@ -56,6 +57,9 @@ class Kuro(BaseTheme): # Screen count num_screens = 0 + # Top bars + topbars = [] + # Static windows static_windows = [] @@ -305,26 +309,25 @@ class Kuro(BaseTheme): Config.bar_background = colors['color1'] def reinit_screens(self): - # TODO: Move backend check into utils method + # Re-initalize bars + self.topbars.clear() + if qtile.core.name == "x11": self.num_screens = max(1, utils.get_screen_count()) else: self.num_screens = max(1, len(qtile.core.get_screen_info())) logger.warning(f"Detected {self.num_screens} screens.") + screens = [] for x in range(self.num_screens): - logger.warning("Reconfiguring bars for screen {}".format(x)) + logger.warning("Initializing bars for screen {}".format(x)) + topbar = self.build_bar_for_screen(x) + self.topbars.append(topbar) + screens.append(Screen(top=topbar)) - try: - screen = self.screens[x] - except IndexError: - screen = Screen() - - if screen.top is None: - screen.top = self.build_bar_for_screen(x) - topbar = screen.top - - self.screens.append(Screen(top=topbar)) + self.screens.clear() + for s in screens: + self.screens.append(s) def update_keys(self): logger.warning("Updating keys") @@ -496,10 +499,19 @@ class Kuro(BaseTheme): ]) # Volume widget(s) - for sink_name in Config.get('volume_pulse_sinks', []): + widgets.append( + kuro.utils.widgets.VolumeInfoWidget( + pulse_sink=Config.get('volume_pulse_sink', None), + fontsize_left=18, + fontsize_right=11, + font_left=Config.get('font_groupbox', None), + ) + ) + # Violet has multiple volume widgets + if socket.gethostname() in ["Violet"]: widgets.append( kuro.utils.widgets.VolumeInfoWidget( - pulse_sink=sink_name, + pulse_sink=Config.get('volume_pulse_sink2', None), fontsize_left=18, fontsize_right=11, font_left=Config.get('font_groupbox', None), @@ -599,6 +611,7 @@ class Kuro(BaseTheme): # Update color scheme self.update_colorscheme() + self.startup_completed = True def callback_startup_complete(self, *args, **kwargs): logger.warning("Callback Startup Complete") @@ -609,20 +622,14 @@ class Kuro(BaseTheme): # Update color scheme self.update_colorscheme() - # Setup XDG Desktop Portal on Wayland - if qtile.core.name == "wayland": - self.setup_xdg_desktop_portal() + # Setup XDG Desktop Portal + self.setup_xdg_desktop_portal() # After first startup is complete, autostart configured apps logger.warning("Autostarting apps...") - for category in Config.get("apps_autostart", {}).keys(): - if qtile.core.name == category or category == "common": - logger.warning(f"Autostarting apps for {category}...") - for app in Config.get("apps_autostart", {}).get(category, []): - logger.warning(f"Starting '{app}'...") - utils.execute_once(app) - else: - logger.warning(f"Skipping autostart apps for {category}, because core is {qtile.core.name}...") + for app in Config.get("apps_autostart", []): + logger.warning(f"Starting '{app}'...") + utils.execute_once(app) for app in Config.get("apps_autostart_group", []): if all(x in app.keys() for x in ["group", "command"]): @@ -632,14 +639,10 @@ class Kuro(BaseTheme): logger.warning(f"Invalid app in 'apps_autostart_group', " f"must have 'group' and 'command' keys: {app}...") logger.warning("Autostart complete") - cur_time = time.time() - logger.warning(f"QTile startup completed! Started up in {(cur_time - self.startup_time):.1f} seconds!") - self.startup_completed = True def callback_client_managed(self, *args, **kwargs): client: Optional[Window] = args[0] if len(args) > 0 else None - # TODO: Move get_pid to an utility function w_pid = None try: w_pid = client.get_pid() @@ -668,14 +671,9 @@ class Kuro(BaseTheme): del client.is_static_window self.static_windows.remove(client) - def callback_screen_change(self, *args, **kwargs): - logger.warning(f"Screen configuration changed, reinitializing screens") + def callback_screens_reconfigured(self, *args, **kwargs): + logger.warning(f"Re-configuring screens!") self.reinit_screens() - qtile.reconfigure_screens() - #qtile.reconfigure_screens() # Twice, see: https://github.com/qtile/qtile/issues/4673#issuecomment-2196459114 - - #def callback_screens_reconfigured(self, *args, **kwargs): - logger.warning(f"Screens were reconfgured, updating wallpapers and color scheme") self.set_wallpaper(self.current_wallpaper) self.update_colorscheme() @@ -714,14 +712,6 @@ class Kuro(BaseTheme): def set_random_wallpaper(self, *args, **kwargs): wallpapers = [] wallpaper_dir = Config.get("desktop_bg_folder", "") - - # Use a wallpaper from the night folder after 9PM and before 6AM - wallpaper_night_dir = Config.get("desktop_bg_night_folder", "") - if wallpaper_night_dir and os.path.isdir(wallpaper_night_dir): - cur_time = datetime.datetime.now() - if cur_time.hour > 21 or cur_time.hour < 6: - wallpaper_dir = wallpaper_night_dir - try: wallpapers = [x for x in os.listdir(wallpaper_dir) if ".vertical." not in x] except os.error as e: @@ -732,7 +722,6 @@ class Kuro(BaseTheme): wallpaper_file = Config.get("desktop_bg_override", "") else: wallpaper_file = os.path.join(wallpaper_dir, random.choice(wallpapers)) - logger.warning(f"Selected new wallpaper: {wallpaper_file}") self.set_wallpaper(wallpaper_file) else: logger.warning("Random wallpaper requested but no wallpapers are available.") diff --git a/kuro/utils/__init__.py b/kuro/utils/__init__.py index 7d8a838..e69de29 100644 --- a/kuro/utils/__init__.py +++ b/kuro/utils/__init__.py @@ -1,31 +0,0 @@ -import importlib -import socket -import traceback -from libqtile.log_utils import logger - -def load_config_class(): - # Try to import host-specific configuration first - hostname = socket.gethostname().lower() - if hostname: - try: - host_module = importlib.import_module(f"kuro.config.{hostname}") - return getattr(host_module, "Config") - except ImportError: - pass - logger.warning(f"No host-specific configuration available for {hostname}. Loading general config...") - - # If no config yet, load general Kuro Config object - try: - conf_module = importlib.import_module("kuro.config") - return getattr(conf_module, "Config") - except ImportError as e: - logger.error(traceback.format_exc()) - logger.error("Could not load Kuro Config. Trying to load BaseConfig. Error: {}".format(e)) - - # If no config yet, load fallback BaseConfig - try: - base_module = importlib.import_module("kuro.base") - return getattr(base_module, "BaseConfig") - except ImportError as e: - logger.error(traceback.format_exc()) - return None diff --git a/kuro/utils/widgets.py b/kuro/utils/widgets.py index aff7f56..f1b51fd 100644 --- a/kuro/utils/widgets.py +++ b/kuro/utils/widgets.py @@ -201,18 +201,18 @@ class DualPaneTextboxBase(base._Widget): self.changed = False @expose_command() - def set_font(self, font=None, fontsize_left=None, fontsize_right=None, fontshadow=None): + def set_font(self, font=None, fontsize_left=0, fontsize_right=0, fontshadow=""): """ Change the font used by this widget. If font is None, the current font is used. """ if font is not None: self.font = font - if fontsize_left is not None: + if fontsize_left != 0: self.fontsize_left = fontsize_left - if fontsize_right is not None: + if fontsize_right != 0: self.fontsize_right = fontsize_right - if fontshadow is not None: + if fontshadow != "": self.fontshadow = fontshadow self.bar.draw() @@ -649,70 +649,62 @@ class NetworkInfoWidget(DualPaneTextboxBase): def _update_values(self): # Wifi - if self.wireless_interface: - try: - essid, quality = get_status(self.wireless_interface) - status = iwlib.get_iwconfig(self.wireless_interface) - self.wireless_ips = netifaces.ifaddresses(self.wireless_interface) - disconnected = essid is None - percent = math.ceil(((quality or 0) / 70) * 100) - self.wireless_quality = quality - self.wireless_signal = percent - self.wireless_name = essid - self.wireless_connected = not disconnected - self.wireless_accesspoint = status.get('Access Point', b'Unknown').decode() - self.wireless_frequency = status.get('Frequency', b'Unknown').decode() - self.wireless_ipv4 = self.wireless_ips.get(netifaces.AF_INET, [{'addr': ""}])[0]['addr'] - self.wireless_ipv6 = self.wireless_ips.get(netifaces.AF_INET6, [{'addr': ""}])[0]['addr'] - self.wireless_mac = self.wireless_ips.get(netifaces.AF_LINK, [{'addr': ""}])[0]['addr'] - except EnvironmentError: - pass + try: + essid, quality = get_status(self.wireless_interface) + status = iwlib.get_iwconfig(self.wireless_interface) + self.wireless_ips = netifaces.ifaddresses(self.wireless_interface) + disconnected = essid is None + percent = math.ceil(((quality or 0) / 70) * 100) + self.wireless_quality = quality + self.wireless_signal = percent + self.wireless_name = essid + self.wireless_connected = not disconnected + self.wireless_accesspoint = status.get('Access Point', b'Unknown').decode() + self.wireless_frequency = status.get('Frequency', b'Unknown').decode() + self.wireless_ipv4 = self.wireless_ips.get(netifaces.AF_INET, [{'addr': ""}])[0]['addr'] + self.wireless_ipv6 = self.wireless_ips.get(netifaces.AF_INET6, [{'addr': ""}])[0]['addr'] + self.wireless_mac = self.wireless_ips.get(netifaces.AF_LINK, [{'addr': ""}])[0]['addr'] + except EnvironmentError: + pass # Wired - if self.wired_interface: + try: + self.wired_ips = netifaces.ifaddresses(self.wired_interface) + self.wired_ipv4 = self.wired_ips.get(netifaces.AF_INET, [{'addr': ""}])[0]['addr'] + self.wired_ipv6 = self.wired_ips.get(netifaces.AF_INET6, [{'addr': ""}])[0]['addr'] + self.wired_mac = self.wired_ips.get(netifaces.AF_LINK, [{'addr': ""}])[0]['addr'] + command = ["ip", "link", "show", "{}".format(self.wired_interface)] try: - self.wired_ips = netifaces.ifaddresses(self.wired_interface) - self.wired_ipv4 = self.wired_ips.get(netifaces.AF_INET, [{'addr': ""}])[0]['addr'] - self.wired_ipv6 = self.wired_ips.get(netifaces.AF_INET6, [{'addr': ""}])[0]['addr'] - self.wired_mac = self.wired_ips.get(netifaces.AF_LINK, [{'addr': ""}])[0]['addr'] - command = ["ip", "link", "show", "{}".format(self.wired_interface)] - try: - eth_status = call_process(command) - except subprocess.CalledProcessError as e: - logger.error(f"Error while calling {command} - {e}") - return - m = self.wired_up_regex.search(eth_status) - if m: - self.wired_connected = "UP" in m.group(1) - else: - self.wired_connected = False + eth_status = call_process(command) + except subprocess.CalledProcessError as e: + logger.error(f"Error while calling {command} - {e}") + return + m = self.wired_up_regex.search(eth_status) + if m: + self.wired_connected = "UP" in m.group(1) + else: + self.wired_connected = False - except (EnvironmentError, ValueError): - pass + except (EnvironmentError, ValueError): + pass def update(self): self._update_values() self.draw() def draw(self): - if self.wireless_interface: - if self.wireless_connected: - strength = "" - if self.wireless_signal < 66: - strength = "" - if self.wireless_signal < 33: - strength = "" - self.text_left = strength - else: - self.text_left = "" + if self.wireless_connected: + strength = "" + if self.wireless_signal < 66: + strength = "" + if self.wireless_signal < 33: + strength = "" + self.text_left = strength else: - self.text_left = "" + self.text_left = "" - if self.wired_interface: - if self.wired_connected: - self.text_right = "" - else: - self.text_right = "" + if self.wired_connected: + self.text_right = "" else: self.text_right = "" diff --git a/required_packages.txt b/required_packages.txt index 3462d93..ff3bd88 100644 --- a/required_packages.txt +++ b/required_packages.txt @@ -4,34 +4,9 @@ notification-daemon otf-font-awesome python-osc -qtile-extras - # /optional/ playerctl xfce4-screenshooter xfce4-clipman-plugin wireless_tools - -# Utilities -kdeconnect # KDE Connect -vorta # Backup scheduler - -# Xorg-only -picom # Compositor -xfce4-clipman # Clipboard manager -dunst # Notification daemon -xiccd # Color profile manager - -# Wayland-only -xorg-xwayland -python-pywlroots -wofi # dmenu replacement -grim # Screenshot utility -swappy # Screenshot editor -slurp # Region selector -cliphist # Clipboard history -mako # Notifications daemon -kanshi # Display hotplugging -wallutils # Display/wallpaper utilities (lsmon) -papirus-icon-theme # Icon theme