From ae9a80c1f012686c1f65fce7ce76fdd1b77b0b71 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Wed, 23 Sep 2026 21:08:35 +0200 Subject: ... --- accounts/gkleen@skadhi/default.nix | 206 +++- accounts/gkleen@skadhi/emacs/default.nix | 67 ++ accounts/gkleen@skadhi/emacs/emacs.el | 262 +++++ accounts/gkleen@skadhi/greetd.nix | 92 ++ accounts/gkleen@skadhi/niri.nix | 1005 ++++++++++++++++++ accounts/gkleen@skadhi/shell/default.nix | 123 +++ .../shell/quickshell-plugins/CMakeLists.txt | 190 ++++ .../shell/quickshell-plugins/Chrono.cpp | 83 ++ .../shell/quickshell-plugins/Chrono.hpp | 55 + .../shell/quickshell-plugins/FileSelector.cpp | 102 ++ .../shell/quickshell-plugins/FileSelector.hpp | 52 + .../shell/quickshell-plugins/KeePassXC.cpp | 18 + .../shell/quickshell-plugins/KeePassXC.hpp | 21 + .../shell/quickshell-plugins/ListUnitsEntry.cpp | 41 + .../shell/quickshell-plugins/ListUnitsEntry.hpp | 26 + .../shell/quickshell-plugins/Systemd.cpp | 288 +++++ .../shell/quickshell-plugins/Systemd.hpp | 193 ++++ .../shell/quickshell-plugins/customplugin.h | 7 + .../shell/quickshell-plugins/default.nix | 32 + .../org.freedesktop.DBus.Properties.xml | 28 + .../org.freedesktop.login1.Manager.xml | 445 ++++++++ .../org.freedesktop.login1.Session.xml | 146 +++ .../org.freedesktop.systemd1.Manager.xml | 821 +++++++++++++++ .../org.freedesktop.systemd1.Service.xml | 1100 ++++++++++++++++++++ .../org.freedesktop.systemd1.Unit.xml | 359 +++++++ .../shell/quickshell/ActiveWindowDisplay.qml | 172 +++ accounts/gkleen@skadhi/shell/quickshell/Bar.qml | 142 +++ .../shell/quickshell/BatteryWidget.qml | 139 +++ .../shell/quickshell/BrightnessOSD.qml | 117 +++ .../shell/quickshell/BrightnessWidget.qml | 84 ++ accounts/gkleen@skadhi/shell/quickshell/Clock.qml | 295 ++++++ .../shell/quickshell/KeyboardLayout.qml | 112 ++ .../shell/quickshell/LidSwitchInhibitorWidget.qml | 47 + .../gkleen@skadhi/shell/quickshell/LockSurface.qml | 227 ++++ .../gkleen@skadhi/shell/quickshell/Lockscreen.qml | 128 +++ .../shell/quickshell/LockscreenInhibitorWidget.qml | 48 + .../shell/quickshell/MaterialDesignIcon.qml | 35 + .../gkleen@skadhi/shell/quickshell/NiriIdle.qml | 30 + .../shell/quickshell/NotificationDisplay.qml | 340 ++++++ .../quickshell/NotificationInhibitorWidget.qml | 266 +++++ .../shell/quickshell/PipewireWidget.qml | 483 +++++++++ .../shell/quickshell/PrivacyWidget.qml | 49 + .../shell/quickshell/Services/Brightness.qml | 107 ++ .../shell/quickshell/Services/GpgAgent.qml | 18 + .../shell/quickshell/Services/InhibitorState.qml | 23 + .../shell/quickshell/Services/MprisProxy.qml | 8 + .../shell/quickshell/Services/NiriService.qml | 217 ++++ .../quickshell/Services/NotificationManager.qml | 162 +++ .../shell/quickshell/Services/Privacy.qml | 63 ++ .../shell/quickshell/Services/ScreenRecord.qml | 63 ++ .../quickshell/Services/WallpaperSelector.qml | 8 + .../shell/quickshell/Services/Worktime.qml | 84 ++ .../gkleen@skadhi/shell/quickshell/SystemTray.qml | 201 ++++ .../gkleen@skadhi/shell/quickshell/UnixIPC.qml | 106 ++ .../gkleen@skadhi/shell/quickshell/VolumeOSD.qml | 163 +++ .../shell/quickshell/WallpaperBackground.qml | 89 ++ .../shell/quickshell/WaylandInhibitorWidget.qml | 56 + .../shell/quickshell/WorkspaceSwitcher.qml | 204 ++++ .../shell/quickshell/WorktimeWidget.qml | 212 ++++ .../gkleen@skadhi/shell/quickshell/YtDlpWidget.qml | 190 ++++ .../shell/quickshell/displaymanager.qml | 115 ++ accounts/gkleen@skadhi/shell/quickshell/shell.qml | 53 + accounts/gkleen@skadhi/zsh/default.nix | 106 ++ accounts/gkleen@skadhi/zsh/zshrc | 298 ++++++ 64 files changed, 11001 insertions(+), 21 deletions(-) create mode 100644 accounts/gkleen@skadhi/emacs/default.nix create mode 100644 accounts/gkleen@skadhi/emacs/emacs.el create mode 100644 accounts/gkleen@skadhi/greetd.nix create mode 100644 accounts/gkleen@skadhi/niri.nix create mode 100644 accounts/gkleen@skadhi/shell/default.nix create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/CMakeLists.txt create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.cpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.hpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.cpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.hpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.cpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.hpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.cpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.hpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.cpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.hpp create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/customplugin.h create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/default.nix create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.DBus.Properties.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Manager.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Session.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Manager.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Service.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Unit.xml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/ActiveWindowDisplay.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Bar.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/BatteryWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/BrightnessOSD.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/BrightnessWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Clock.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/KeyboardLayout.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/LidSwitchInhibitorWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/LockSurface.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Lockscreen.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/LockscreenInhibitorWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/MaterialDesignIcon.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/NiriIdle.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/NotificationDisplay.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/NotificationInhibitorWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/PipewireWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/PrivacyWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/SystemTray.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/UnixIPC.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/VolumeOSD.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/WallpaperBackground.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/WaylandInhibitorWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/WorkspaceSwitcher.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/WorktimeWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/YtDlpWidget.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/displaymanager.qml create mode 100644 accounts/gkleen@skadhi/shell/quickshell/shell.qml create mode 100644 accounts/gkleen@skadhi/zsh/default.nix create mode 100644 accounts/gkleen@skadhi/zsh/zshrc (limited to 'accounts') diff --git a/accounts/gkleen@skadhi/default.nix b/accounts/gkleen@skadhi/default.nix index 4c0c4028..fc969348 100644 --- a/accounts/gkleen@skadhi/default.nix +++ b/accounts/gkleen@skadhi/default.nix @@ -1,10 +1,13 @@ -{ flake, flakeInputs, userName, config, lib, ... }: +{ flake, flakeInputs, userName, config, pkgs, lib, sources, ... }: let user = config.users.users.${userName}; in { - imports = with flake.nixosModules.userProfiles.${userName}; [ - zsh - ]; + imports = (with flake.nixosModules.userProfiles.${userName}; [ + zsh mpv yt-dlp ./greetd.nix + ]) ++ (with flake.nixosModules.systemProfiles; [ + niri-unstable + flakeInputs.nixVirt.nixosModules.default + ]); config = { users.users.gkleen.group = "gkleen"; @@ -17,9 +20,32 @@ in { }; }; - home-manager.users.${userName} = let sysConfig = config; in { config, ... }: { + programs = { + dconf.enable = true; + niri.enable = true; + }; + environment.sessionVariables."GTK_USE_PORTAL" = "1"; + services.niri-autoselect-portal.enable = true; + xdg.portal = { + enable = true; + extraPortals = with pkgs; [ xdg-desktop-portal-gtk ]; + config.niri = { + default = lib.mkForce ["gnome" "gtk"]; + "org.freedesktop.impl.portal.FileChooser" = ["gtk"]; + "org.freedesktop.impl.portal.OpenFile" = ["gtk"]; + "org.freedesktop.impl.portal.Access" = ["gtk"]; + "org.freedesktop.impl.portal.Notification" = ["gtk"]; + "org.freedesktop.impl.portal.Secret" = ["none"]; + "org.freedesktop.impl.portal.Inhibit" = ["none"]; + }; + }; + + security.pam.services.quickshell = {}; + + home-manager.users.${userName} = { sysConfig, config, ... }: { imports = [ flakeInputs.nix-index-database.homeModules.nix-index + ./niri.nix ./shell ./emacs ./zsh ]; home.stateVersion = "26.05"; @@ -30,26 +56,164 @@ in { ]; }; - programs.zsh.dirHashes = let - flakeHashes = lib.mapAttrs' (n: v: lib.nameValuePair (inputNames.${n} or n) (toString v)) flakeInputs; - inputNames = { - self = "nixos"; + programs.nix-index.enable = true; + + programs.alacritty = { + enable = true; + theme = "kitty"; + settings = { + font = { + normal = { + family = "Fira Mono"; + style = "Medium"; + }; + size = 10; + }; + hints = { + alphabet = "aoeuhtns"; + enabled = [ + { + command = lib.getExe' pkgs.xdg-utils "xdg-open"; + hyperlinks = true; + post_processing = true; + persist = false; + mouse.enabled = true; + binding = { key = "O"; mods = "Control|Shift"; }; + regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\\u0000-\\u001F\\u007F-\\u009F<>\"\\\\s{-}\\\\^⟨⟩`\\\\\\\\]+"; + } + { + command = lib.getExe' pkgs.wl-clipboard-rs "wl-copy"; + hyperlinks = true; + post_processing = true; + persist = false; + mouse.enabled = true; + binding = { key = "Y"; mods = "Control|Shift"; }; + regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\\u0000-\\u001F\\u007F-\\u009F<>\"\\\\s{-}\\\\^⟨⟩`\\\\\\\\]+"; + } + ]; + }; + terminal.shell.program = lib.getExe config.programs.zsh.package; }; - in flakeHashes // { - docs = "$HOME/documents"; - dl = "$HOME/Downloads"; - scrot = "$HOME/screenshots"; - media = "$HOME/media"; + }; + programs.fuzzel = { + enable = true; + settings = { + main = { + terminal = "${lib.getExe config.programs.alacritty.package} msg --socket /run/user/1000/alacritty.sock create-window --command ${lib.getExe' sysConfig.systemd.package "systemd-run"} --user --pty --same-dir --wait --collect --quiet --service-type=exec --expand-environment=no {cmd}"; + layer = "overlay"; + icon-theme = "Paper"; + font = "Fira Sans"; + }; + colors = { + background = "000000cc"; + text = "cdd6f4ff"; + match = "94e2d5ff"; + selection = "585b70ff"; + selection-match = "94e2d5ff"; + selection-text = "cdd6f4ff"; + border = "b4befeff"; + }; + dmenu = { + exit-immediately-if-empty = true; + }; }; - programs.starship.settings = { - directory.substitutions = lib.mapAttrs' (name: value: let - value' = builtins.unsafeDiscardStringContext value; - value'' = if lib.hasPrefix "$HOME/" value' then "~" + lib.removePrefix "$HOME" value' else value'; - in lib.nameValuePair value'' "~${name}" - ) config.programs.zsh.dirHashes; }; - programs.nix-index.enable = true; + services.xembed-sni-proxy = { + enable = true; + package = pkgs.kdePackages.plasma-workspace; + }; + services.network-manager-applet.enable = true; + services.blueman-applet.enable = true; + + home.pointerCursor = { + package = pkgs.vanilla-dmz; + name = "Vanilla-DMZ-AA"; + size = 16; + + x11 = { + enable = true; + defaultCursor = "left_ptr"; + }; + gtk.enable = true; + }; + + gtk = { + enable = true; + font = { + package = pkgs.fira; + name = "Fira Sans"; + size = 10; + }; + theme = { + package = pkgs.kdePackages.breeze-gtk; + name = "Breeze-Dark"; + }; + iconTheme = { + package = pkgs.adwaita-icon-theme; + name = "Adwaita"; + }; + }; + qt.enable = true; + qt.platformTheme.name = "gtk"; + + home.sessionVariables = { + QT_QPA_PLATFORMTHEME = lib.mkForce "gtk3"; + }; + home.extraProfileCommands = '' + export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}''${XDG_DATA_DIRS:+:''${XDG_DATA_DIRS}}" + ''; + + xdg.configFile."systemd/user/home-manager.service.d/before-graphical-session.conf".text = '' + [Unit] + Before=graphical-session-pre.target + ''; + + fonts = { + fontconfig.enable = true; + }; + + i18n.inputMethod = { + enable = true; + type = "fcitx5"; + fcitx5 = { + waylandFrontend = true; + settings = { + globalOptions."Behavior/DisabledAddons" = { + "0" = "clipboard"; + }; + inputMethod = { + "Groups/0" = { + Name = "Default"; + "Default Layout" = "us"; + DefaultIM = "keyboard-us"; + }; + "Groups/0/Items/0" = { + Name = "keyboard-us"; + Layout = ""; + }; + "GroupOrder"."0" = "Default"; + }; + addons = { + classicui.globalSection = { + "Vertical Candidate List" = "True"; + Font = "Fira Sans 10"; + MenuFont = "Fira Sans 10"; + TrayFont = "Fira Sans Demi-Bold 10"; + UseDarkTheme = "True"; + }; + quickphrase = { + globalSection = { + "Choose Modifier" = "None"; + Spell = "True"; + FallbackSpellLanguage = "en"; + }; + sections.TriggerKey."0" = "Super+E"; + }; + }; + }; + }; + }; }; }; } diff --git a/accounts/gkleen@skadhi/emacs/default.nix b/accounts/gkleen@skadhi/emacs/default.nix new file mode 100644 index 00000000..4cf6b0b9 --- /dev/null +++ b/accounts/gkleen@skadhi/emacs/default.nix @@ -0,0 +1,67 @@ +{ pkgs, lib, config, sources, ... }: +let + editor = pkgs.symlinkJoin { + name = "${config.services.emacs.package.name}-editor"; + buildInputs = with pkgs; [ makeWrapper ]; + paths = [ config.services.emacs.package ]; + postBuild = '' + wrapProgram $out/bin/emacsclient \ + --inherit-argv0 \ + --add-flags ${lib.escapeShellArg (lib.escapeShellArgs config.services.emacs.client.arguments)} + ''; + }; +in { + config = { + home.persistence."/persistent".directories = [ + ".local/state/emacs/saves" ".local/state/emacs/undo" + ]; + + programs.emacs = { + enable = true; + package = pkgs.emacs-pgtk; + extraPackages = epkgs: with epkgs; [ + evil evil-dvorak undo-tree magit haskell-tng-mode nix-mode + yaml-mode json-mode shakespeare-mode smart-mode-line + highlight-parentheses highlight-symbol ag sass-mode + lua-mode fira-code-mode use-package wanderlust # notmuch + git-gutter scratch edit-server mediawiki editorconfig + typescript-mode markdown-mode nftables-mode rustic + lsp-mode lsp-ui direnv company projectile + tomorrow-night-paradise-theme + treesit-grammars.with-all-grammars magit-delta scad-mode + typst-ts-mode + ]; + overrides = self: super: { + tomorrow-night-paradise-theme = super.trivialBuild { + inherit (sources.tomorrow-night-paradise-theme) pname version src; + }; + scratch = pkgs.stdenv.mkDerivation { + inherit (sources.emacs-scratch_el) pname version src; + + phases = [ "unpackPhase" "installPhase" ]; + + installPhase = '' + install -Dt $out/share/emacs/site-lisp scratch.el + ''; + }; + }; + }; + xdg.configFile."emacs/init.el".source = pkgs.substitute { + src = ./emacs.el; + substitutions = [ + "--subst-var-by" "ksshaskpass" (lib.getExe pkgs.kdePackages.ksshaskpass) + ]; + }; + + home.sessionVariables.EDITOR = lib.getExe' editor "emacsclient"; + + services.emacs = { + enable = true; + socketActivation.enable = true; + client = { + enable = true; + arguments = lib.mkForce ["--create-frame" "--alternate-editor" (lib.getExe config.services.emacs.package)]; + }; + }; + }; +} diff --git a/accounts/gkleen@skadhi/emacs/emacs.el b/accounts/gkleen@skadhi/emacs/emacs.el new file mode 100644 index 00000000..a9d22621 --- /dev/null +++ b/accounts/gkleen@skadhi/emacs/emacs.el @@ -0,0 +1,262 @@ +(menu-bar-mode -1) +(scroll-bar-mode -1) +(tool-bar-mode -1) + +(setq inhibit-startup-message t) +(defalias 'yes-or-no-p 'y-or-n-p) + +;; (set-face-attribute 'default nil :font "Fira Code" :height 49) +(set-frame-font "Fira Code 8" nil nil) +(load-theme 'modus-vivendi t) +;; (add-to-list 'default-frame-alist '(alpha-background . 90)) + +(require 'package) +(setq package-archives nil) +(package-initialize) +(require 'use-package) +;; (use-package use-package-ensure-system-package :ensure t) + +(require 'evil) +(evil-mode 1) + +(editorconfig-mode 1) + +(require 'edit-server) +(edit-server-start) + +(require 'mediawiki) +(setq mediawiki-mode-hook + (lambda () + (define-key mediawiki-mode-map (kbd "") nil) + (define-key mediawiki-mode-map (kbd "") nil))) + +(require 'typescript-mode) + +(global-subword-mode) +(global-undo-tree-mode) +(global-fira-code-mode) + +(evil-set-undo-system 'undo-tree) + +(add-hook 'magit-mode-hook (lambda () (magit-delta-mode +1))) + +(global-set-key (kbd "RET") 'newline-and-indent) +(global-set-key (kbd "M-g") 'magit-status) +(global-set-key (kbd "M-?") 'vc-git-grep) + +(require 'git-gutter) +(global-git-gutter-mode t) +(custom-set-variables '(git-gutter:update-interval 2)) +(custom-set-variables '(git-gutter:hide-gutter t)) + +(require 'scratch) +;; (global-set-key (kbd "C-x b") 'scratch-switch-to-buffer) +(global-set-key (kbd "C-x B") 'scratch-create) +;; (setq initial-major-mode 'scratch-mode) +(setq initial-scratch-message "") + +(global-set-key (kbd "C-x K") 'kill-current-buffer) + +(setq backup-directory-alist `(("." . "~/.local/state/emacs/saves"))) +(setq undo-tree-history-directory-alist `(("." . "~/.local/state/emacs/undo"))) +(setq delete-old-versions t + kept-new-versions 6 + kept-old-versions 2 + version-control t) + +(setq undo-tree-visualizer-timestamps t + undo-tree-visualizer-diff t + ;; 10X bump of the undo limits to avoid issues with premature + ;; Emacs GC which truncages the undo history very aggresively + undo-limit 800000 + undo-strong-limit 12000000 + undo-outer-limit 120000000) + +(add-hook 'haskell-mode-hook 'haskell-indentation-mode) +(add-hook 'haskell-mode-hook 'subword-mode) +(add-hook 'haskell-mode-hook 'highlight-symbol-mode) +(add-hook 'haskell-mode-hook 'highlight-paretheses-mode) + +(add-hook 'js-mode-hook 'highlight-symbol-mode) +(add-hook 'js-mode-hook 'highlight-parentheses-mode) +(defun my-js-mode-hook () + "Custom `js-mode' behaviours." + (setq js-indent-level 2) + ) +(add-hook 'js-mode-hook 'my-js-mode-hook) + +(define-derived-mode anansi-mode + markdown-mode "Anansi" + "Major mode for anansi" + (lambda () (setq indent-tabs-mode nil))) +(add-to-list 'auto-mode-alist '("\\.anansi\\'" . anansi-mode)) + +(projectile-mode +1) +(define-key projectile-mode-map (kbd "s-p") 'projectile-command-map) + +(add-hook 'rustic-mode-hook (lambda () (setq indent-tabs-mode nil))) +(add-hook 'rustic-mode-hook #'lsp-deferred) +(add-hook 'rustic-mode-hook (lambda () (setq lsp-ui-sideline-enable nil))) +(setq lsp-auto-guess-root t) +(setq lsp-inlay-hint-enable t) +;; (add-hook 'rust-mode-hook 'lsp-inlay-hints-mode) +(setq lsp-rust-analyzer-server-display-inlay-hints t) +(setq lsp-rust-analyzer-inlay-hints-mode t) +(setq lsp-rust-analyzer-display-parameter-hints t) +(setq lsp-rust-analyzer-display-chaining-hints t) +(setq lsp-rust-analyzer-hide-named-constructor nil) + +(setq undo-tree-auto-save-history t) + +(defvar expand-file-name-custom-tilde-alist '(("u2w-srv01" . "/ssh:srv01.uniworx.de:/home/gkleen/projects/uni2work") ("fra-srv01" . "/ssh:srv01.uniworx.de:/home/gkleen/projects/fradrive"))) +(defun my/add-to-tilde-alist (hash) + (let* ((tilde:dir (split-string hash "=")) + (tilde (car tilde:dir)) + (dir (cadr tilde:dir))) + (push (cons tilde dir) expand-file-name-custom-tilde-alist))) +(mapc #'my/add-to-tilde-alist + (split-string (let ((process-environment (cl-copy-list process-environment))) (setenv "TERM" nil) (with-output-to-string (call-process "script" nil standard-output nil "-qec" "zsh -ic \"hash -d\"" "/dev/null"))) + "\r\n" t)) + +(defadvice expand-file-name (before expand-file-name-custom-tilde + (name &optional default-directory) + activate compile) + "User-defined expansions for ~NAME in file names." + (save-match-data + (when (string-match "\\`\\(\\(.*/\\)?~\\([^:/]+\\)\\)/" name) + (let ((replacement (assoc (match-string 3 name) expand-file-name-custom-tilde-alist))) + (when replacement + (setq name (replace-match (cdr replacement) t t name 1))))))) + +(setq edit-server-new-frame nil) +(setq edit-server-url-major-mode-alist '(("rzint\\.mathinst\\.loc" . mediawiki-mode))) + +;; (setq notmuch-address-internal-completion '(received nil)) +;; (setq notmuch-always-prompt-for-sender t) +;; (setq notmuch-command "notmuch-ssh") +;; (setq notmuch-crypto-process-mime t) +;; (setq notmuch-draft-tags '("+draft" "-inbox")) +;; (setq notmuch-fcc-dirs nil) +;; (setq notmuch-hello-sections '(notmuch-hello-insert-header notmuch-hello-insert-saved-searches)) +;; (setq notmuch-hello-thousands-separator " ") +;; (setq notmuch-identities '("gkleen@yggdrasil.li" "g@141.li" "kleen@cip.ifi.lmu.de" "Gregor.Kleen@stud.ifi.lmu.de" "G.Kleen@campus.lmu.de" "G.Kleen@lmu.de" "gregor.kleen@ifi.lmu.de" "uni2work@ifi.lmu.de" "gregor@kleen.li")) +;; (setq notmuch-message-headers '("Subject" "To" "Cc" "Date")) +;; (setq notmuch-message-replied-tags '("+replied" "-unread" "-inbox")) +;; (setq notmuch-saved-searches +;; (quote +;; ((:name "inbox" :query "tag:inbox" :key "i") +;; (:name "unread" :query "tag:unread AND tag:inbox" :key "u") +;; (:name "drafts" :query "tag:draft" :key "d") +;; (:name "all mail" :query "date:month.." :key "a" :count-query "*") +;; (:name "sent" :query "is:sent" :key "s" :count-query "is:sent") +;; ))) +;; (setq notmuch-search-oldest-first nil) +;; (setq notmuch-show-all-tags-list t) +;; (setq notmuch-show-logo nil) + +(setq send-mail-function 'sendmail-send-it) +(setq mail-envelope-from 'header) +(setq mail-specify-envelope-from 't) +(setq mail-default-headers nil) +(setq message-default-headers "") +(setq message-default-mail-headers "") +(setq message-sendmail-envelope-from 'header) + +(setq highlight-symbol-idle-delay 0) + +(setq indent-tabs-mode nil) + +(setq ido-enable-flex-matching t) +(setq ido-everywhere t) +(ido-mode 1) + +(setq tramp-default-method "ssh") +(customize-set-variable 'tramp-use-ssh-controlmaster-options nil) +(setq tramp-allow-unsafe-temporary-files t) + +(direnv-mode) +(setq direnv-enabled-hosts '("uni2work-dev1")) + +(setq auth-sources '()) + +(setq ring-bell-function 'ignore) + +(setq enable-remote-dir-locals 't) + +(setq-default indicate-empty-lines t) +(setq-default show-trailing-whitespace t) + +(defun tramp-sh-handle-start-file-process@my-direnv (args) + "Enable Direnv for hosts in `direnv-enabled-hosts'." + (with-parsed-tramp-file-name (expand-file-name default-directory) nil + (if (member host direnv-enabled-hosts) + (pcase-let ((`(,name ,buffer ,program . ,args) args)) + `(,name + ,buffer + "direnv" + "exec" + ,localname + ,program + ,@args)) + args))) + +(with-eval-after-load "tramp-sh" + (advice-add 'tramp-sh-handle-start-file-process + :filter-args #'tramp-sh-handle-start-file-process@my-direnv)) + +(setq mail-host-address "sif.midgard.yggdrasil") +(setq user-full-name "Gregor Kleen") + +(defun tell-emacsclients-for-buffer-to-die () + "Sends error exit command to every client for the current buffer." + (interactive) + (dolist (proc server-buffer-clients) + (server-send-string proc "-error die"))) + +(defun kill-buffer-with-special-emacsclient-handling () + "Wrapper around kill-buffer that ensures tell-emacsclients-for-buffer-to-die is on the hooks" + (interactive) + (add-hook 'kill-buffer-hook 'tell-emacsclients-for-buffer-to-die nil t) + (kill-buffer)) + +;; (global-set-key (kbd "C-x k") 'kill-buffer) + +(defun install-emacsclient-wrapped-kill-buffer () + "Installs wrapped kill-buffer with special emacsclient handling. +Best not to install it unconditionally because the server is not +necessarily running." + (interactive) + (global-set-key (kbd "C-x k") 'kill-buffer-with-special-emacsclient-handling)) + +(add-hook 'server-switch-hook 'install-emacsclient-wrapped-kill-buffer) +(add-hook 'server-switch-hook #'raise-frame) + +(defun move-file (new-location) + "Write this file to NEW-LOCATION, and delete the old one." + (interactive (list (expand-file-name + (if buffer-file-name + (read-file-name "Move file to: ") + (read-file-name "Move file to: " + default-directory + (expand-file-name (file-name-nondirectory (buffer-name)) + default-directory)))))) + (when (file-exists-p new-location) + (delete-file new-location)) + (let ((old-location (expand-file-name (buffer-file-name)))) + (message "old file is %s and new file is %s" + old-location + new-location) + (write-file new-location t) + (when (and old-location + (file-exists-p new-location) + (not (string-equal old-location new-location))) + (delete-file old-location)))) + +(bind-key "C-x C-m" #'move-file) + +(let ((ssh_auth_sock (string-chop-newline (shell-command-to-string "gpgconf --list-dirs agent-ssh-socket")))) (setenv "SSH_AUTH_SOCK" ssh_auth_sock)) +(setenv "SSH_ASKPASS_REQUIRE" "prefer") +(setenv "SSH_ASKPASS" "@ksshaskpass@") + +(define-key typst-ts-mode-map (kbd "C-") nil) +(define-key typst-ts-mode-map (kbd "C-") nil) diff --git a/accounts/gkleen@skadhi/greetd.nix b/accounts/gkleen@skadhi/greetd.nix new file mode 100644 index 00000000..1972404c --- /dev/null +++ b/accounts/gkleen@skadhi/greetd.nix @@ -0,0 +1,92 @@ +{ config, pkgs, lib, flakeInputs, ... }: + +let + gkleenConfig = config.home-manager.users."gkleen"; + toIni = lib.generators.toINI { + mkKeyValue = + key: value: + let + value' = if lib.isBool value then lib.boolToString value else toString value; + in + "${lib.escape [ "=" ] key}=${value'}"; + }; + toDconfIni = let + gvariant = import (flakeInputs.home-manager + "/modules/lib/gvariant.nix") { inherit lib; }; + mkIniKeyValue = key: value: "${key}=${toString (gvariant.mkValue value)}"; + in lib.generators.toINI { mkKeyValue = mkIniKeyValue; }; +in { + config = { + services.greetd = { + enable = true; + settings.default_session.command = lib.getExe (pkgs.writeShellApplication { + name = "sway"; + runtimeInputs = [ pkgs.sway pkgs.fontconfig ]; + runtimeEnv = { + XDG_DATA_DIRS = lib.makeSearchPath "share" [ + pkgs.kdePackages.breeze-gtk pkgs.adwaita-icon-theme pkgs.fira + ]; + QT_PLUGIN_PATH = lib.makeSearchPath (pkgs.qt6.qtbase.qtPluginPrefix) [ + pkgs.qt6Packages.qtbase + ]; + QML2_IMPORT_PATH = lib.makeSearchPath (pkgs.qt6.qtbase.qtQmlPrefix) [ + pkgs.qt6Packages.qtbase + ]; + QT_QPA_PLATFORMTHEME = "gtk3"; + XDG_CONFIG_DIR = pkgs.symlinkJoin { + name = "config"; + paths = [ + (pkgs.writeTextDir "gtk-3.0/settings.ini" (toIni { + Settings = { + gtk-font-name = "Fira Sans 10"; + gtk-theme-name = "Breeze-Dark"; + gtk-icon-theme-name = "Adwaita"; + }; + })) + ]; + }; + # XDG_CACHE_HOME = "/var/cache/greetd/greeter"; + # XDG_CONFIG_HOME = "/var/cache/greetd/greeter/config"; + }; + text = '' + exec &>/tmp/sway-$$.log + + unset MANAGERPID SYSTEMD_EXEC_PID + + # ${lib.getExe' pkgs.coreutils "mkdir"} -p ''${XDG_CONFIG_HOME}/dconf + ${lib.getExe pkgs.dconf} load / < ${pkgs.writeText "dconf.ini" (toDconfIni { + "org/gnome/desktop/interface" = { + "color-scheme" = "prefer-dark"; + "font-name" = "Fira Sans 10"; + "gtk-theme" = "Breeze-Dark"; + "icon-theme" = "Adwaita"; + }; + })} + + exec sway --unsupported-gpu --config ${pkgs.writeText "sway-config" '' + exec "${lib.getExe' config.systemd.package "systemctl"} --user import-environment {,WAYLAND_}DISPLAY SWAYSOCK; ${lib.getExe gkleenConfig.programs.quickshell.package} --path ${gkleenConfig.xdg.configFile."quickshell".source}/displaymanager.qml; swaymsg exit" + + input type:keyboard { + xkb_layout "us,us" + xkb_variant "dvp," + xkb_options "compose:caps,grp:win_space_toggle" + } + + output eDP-1 scale 1.5 + ''} + ''; + }); + }; + + # security.pam.services.greetd.fprintAuth = false; + + systemd.services.greetd.serviceConfig = { + ExecStartPre = ''${lib.getExe' pkgs.coreutils "install"} -d -o greeter -g greeter -m 0700 ''${CACHE_DIRECTORY}/greeter''; + # CacheDirectory = "greetd"; + }; + + users.users.greeter = { + home = "/var/lib/greeter"; + createHome = true; + }; + }; +} diff --git a/accounts/gkleen@skadhi/niri.nix b/accounts/gkleen@skadhi/niri.nix new file mode 100644 index 00000000..b30c5701 --- /dev/null +++ b/accounts/gkleen@skadhi/niri.nix @@ -0,0 +1,1005 @@ +{ config, hostConfig, pkgs, lib, utils, flakeInputs, ... }: +let + cfg = config.programs.niri; + + kdl = flakeInputs.niri-flake.lib.kdl; + sleaf = name: arg: kdl.node name [arg] []; + + niri = cfg.package; + spawnTerminal = { command ? lib.getExe config.programs.zsh.package, extraArgs ? [] }: + [(lib.getExe config.programs.alacritty.package) "msg" "--socket" "/run/user/1000/alacritty.sock" "create-window"] ++ lib.toList extraArgs ++ ["--command" (lib.getExe' hostConfig.systemd.package "systemd-run") "--user" "--pty" "--same-dir" "--wait" "--collect" "--quiet" "--service-type=exec" "--expand-environment=no"] ++ lib.mapAttrsToList (k: v: "--property=Environment=${utils.escapeSystemdExecArg "${k}=${if v == null then "" else v}"}") execEnvironment ++ lib.toList command; + + execEnvironment = { + NIXOS_OZONE_WL = "1"; + QT_QPA_PLATFORM = "wayland"; + QT_WAYLAND_DISABLE_WINDOWDECORATION = "1"; + GDK_BACKEND = "wayland"; + SDL_VIDEODRIVER = "wayland"; + DISPLAY = ":0"; + ELECTRON_OZONE_PLATFORM_HINT = "auto"; + SSH_ASKPASS_REQUIRE = "prefer"; + SSH_ASKPASS = lib.getExe pkgs.kdePackages.ksshaskpass; + SUDO_ASKPASS = lib.getExe pkgs.kdePackages.ksshaskpass; + GTK_IM_MODULE = null; + QT_IM_MODULE = null; + QT_IM_MODULES = null; + }; + + focus_or_spawn = pkgs.writeShellApplication { + name = "focus-or-spawn"; + runtimeInputs = [ niri pkgs.gojq pkgs.gnugrep pkgs.socat ]; + text = '' + window_select="$1" + shift + workspace_name="$1" + shift + + workspaces_json="$(niri msg -j workspaces)" + workspace_output="$(jq -r --arg workspace_name "$workspace_name" '.[] | select(.name == $workspace_name) | .output' <<<"$workspaces_json")" + # active_workspace="$(jq -r --arg workspace_output "$workspace_output" '.[] | select(.output == $workspace_output and .is_active) | .id' <<<"$workspaces_json")" + active_output="$(jq -r '.[] | select(.is_focused) | .output' <<<"$workspaces_json")" + if [[ $workspace_output != "$active_output" ]]; then + niri msg action move-workspace-to-monitor --reference "$workspace_name" "$active_output" + # socat STDIO "$NIRI_SOCKET" <<<'{"Action":{"FocusWorkspace":{"reference":{"Id":'"''${active_workspace}"'}}}}' + # niri msg action move-workspace-to-index --reference "$workspace_name" 1 + fi + + while IFS=$'\n' read -r window_json; do + if [[ -n $(jq -c "$window_select" <<<"$window_json") ]]; then + if jq -e '.is_focused' <<<"$window_json" >/dev/null; then + niri msg action focus-workspace-previous + else + if [[ $(jq -r --arg workspace_name "$workspace_name" 'map(select(.name == $workspace_name)) | .[0].is_focused' <<<"$workspaces_json") != "true" ]] && [[ $(jq -r --arg workspace_name "$workspace_name" 'map(select(.name == $workspace_name)) | .[0].id' <<<"$workspaces_json") = $(jq -r '.workspace_id' <<<"$window_json") ]]; then + niri msg action focus-workspace "$workspace_name" + else + niri msg action focus-window --id "$(jq -r '.id' <<<"$window_json")" + fi + fi + exit 0 + fi + done < <(niri msg -j windows | jq -c '.[]') + + exec "$@" + ''; + }; + focus-or-spawn-action = config.lib.niri.actions.spawn (lib.getExe focus_or_spawn); + + with_adjacent_workspace = pkgs.writeShellApplication { + name = "with-adjacent-workspace"; + runtimeInputs = [ niri pkgs.gojq pkgs.socat ]; + text = '' + blacklist="$1" + shift + direction="$1" + shift + action="$1" + shift + + workspaces_json="$(niri msg -j workspaces)" + active_workspace="$(jq -r '.[] | select(.is_focused) | .id' <<<"$workspaces_json")" + workspace_output="$(jq -r --arg active_workspace "$active_workspace" '.[] | select(.id == ($active_workspace | tonumber)) | .output' <<<"$workspaces_json")" + workspace_idx="$(jq -r '.[] | select(.is_focused) | .idx' <<<"$workspaces_json")" + + jq_script='map(select(' + case "$direction" in + down) + # shellcheck disable=SC2016 + jq_script=''${jq_script}'.idx > ($workspace_idx | tonumber)';; + up) + # shellcheck disable=SC2016 + jq_script=''${jq_script}'.idx < ($workspace_idx | tonumber)';; + esac + # shellcheck disable=SC2016 + jq_script=''${jq_script}' and .output == $workspace_output and ((.name == null) or (.name | test($blacklist) | not)))) | sort_by(.idx)' + [[ $direction == "up" ]] && jq_script=''${jq_script}' | reverse' + jq_script=''${jq_script}' | .[0]' + + workspace_json=$(jq -c --arg blacklist "$blacklist" --arg workspace_output "$workspace_output" --arg workspace_idx "$workspace_idx" "$jq_script" <<<"$workspaces_json") + [[ -n $workspace_json && $workspace_json != null ]] || exit 0 + jq --arg active_workspace "$active_workspace" -c "$action" <<<"$workspace_json" | tee /dev/stderr | socat STDIO "$NIRI_SOCKET" + ''; + }; + with-adjacent-workspace-action = config.lib.niri.actions.spawn (lib.getExe with_adjacent_workspace) "^${lib.concatMapStringsSep "|" ({ name, ...}: name) cfg.scratchspaces}$"; + focus-adjacent-workspace = direction: with-adjacent-workspace-action direction ''{"Action":{"FocusWorkspace":{"reference":{"Id": .id}}}}''; + move-column-to-adjacent-workspace = direction: with-adjacent-workspace-action direction ''{"Action":{"MoveColumnToWorkspace":{"reference":{"Id": .id}, "focus": true}}}''; + + with_unnamed_workspace = pkgs.writeShellApplication { + name = "with-unnamed-workspace"; + runtimeInputs = [ niri pkgs.gojq pkgs.socat ]; + text = '' + action="$1" + shift + + workspaces_json="$(niri msg -j workspaces)" + active_output="$(jq -r '.[] | select(.is_focused) | .output' <<<"$workspaces_json")" + active_workspace="$(jq -r '.[] | select(.is_focused) | .id' <<<"$workspaces_json")" + + history_json="$(socat STDIO UNIX-CONNECT:"$XDG_RUNTIME_DIR"/niri-workspace-history.sock)" + workspace_json="$(jq -c --arg active_output "$active_output" --argjson history "$history_json" 'map(select(.output == $active_output and .name == null)) | map({"value": ., "history_idx": ((. as $workspace | ($history[$active_output] | index($workspace | .id))) as $active_idx | if $active_idx then $active_idx else ($history[$active_output] | length) + 1 end)}) | sort_by(.history_idx, .value.idx) | map(.value) | .[0]' <<<"$workspaces_json")" + [[ -n $workspace_json && $workspace_json != null ]] || exit 0 + jq --arg active_workspace "$active_workspace" -c "$action" <<<"$workspace_json" | tee /dev/stderr | socat STDIO "$NIRI_SOCKET" + ''; + }; + with-unnamed-workspace-action = config.lib.niri.actions.spawn (lib.getExe with_unnamed_workspace); + + with_empty_unnamed_workspace = pkgs.writeShellApplication { + name = "with-empty-unnamed-workspace"; + runtimeInputs = [ niri pkgs.gojq pkgs.socat ]; + text = '' + action="$1" + shift + + workspaces_json="$(niri msg -j workspaces)" + active_output="$(jq '.[] | select(.is_focused) | .output' <<<"$workspaces_json")" + target_workspace_id="$(jq --argjson active_output "$active_output" 'map(select(.active_window_id == null and .name == null and .output == $active_output)) | sort_by(.idx) | .[0].id' <<<"$workspaces_json")" + jq --argjson workspace_id "$target_workspace_id" -nc "$action" | tee /dev/stderr | socat STDIO "$NIRI_SOCKET" + ''; + }; + with-empty-unnamed-workspace-action = config.lib.niri.actions.spawn (lib.getExe with_empty_unnamed_workspace); + + with_select_window = pkgs.writeShellApplication { + name = "with-select-window"; + runtimeInputs = [ niri pkgs.gojq pkgs.socat config.programs.fuzzel.package pkgs.gawk ]; + text = '' + window_select="$1" + shift + action="$1" + shift + + windows_json="$(niri msg -j windows)" + active_workspace="$(jq -r '.[] | select(.is_focused) | .workspace_id' <<<"$windows_json")" + window_ix="$(gojq -r --arg active_workspace "$active_workspace" '.[] | select('"$window_select"') | "\(.title)\u0000icon\u001f\(.app_id)"' <<<"$windows_json" | fuzzel --width=60 --log-level=warning --dmenu --index)" + # shellcheck disable=SC2016 + window_json="$(gojq -rc --arg active_workspace "$active_workspace" --arg window_ix "$window_ix" 'map(select('"$window_select"')) | .[($window_ix | tonumber)]' <<<"$windows_json")" + + [[ -z "$window_json" ]] && exit 1 + + jq -c "$action" <<<"$window_json" | socat STDIO "$NIRI_SOCKET" + ''; + }; + with-select-window-action = config.lib.niri.actions.spawn (lib.getExe with_select_window); + + with_predicate_window = pred: pkgs.writeShellApplication { + name = "with-predicate-window"; + runtimeInputs = [ niri pkgs.gojq pkgs.socat ]; + text = '' + action="$1" + shift + + windows_json="$(niri msg -j windows)" + window_json="$(gojq -rc 'map(select(${pred})) | .[0]' <<<"$windows_json")" + + [[ -z "$window_json" || $window_json = "null" ]] && exit 1 + + jq -c "$action" <<<"$window_json" | socat STDIO "$NIRI_SOCKET" + ''; + }; + + with-urgent-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_urgent")); + with-focused-window-action = config.lib.niri.actions.spawn (lib.getExe (with_predicate_window ".is_focused")); +in { + options = { + programs.niri.scratchspaces = lib.mkOption { + type = lib.types.listOf (lib.types.submodule ({ config, ... }: { + options = { + name = lib.mkOption { + type = lib.types.str; + }; + match = lib.mkOption { + type = lib.types.listOf (lib.types.attrsOf kdl.types.kdl-args); + default = []; + }; + exclude = lib.mkOption { + type = lib.types.listOf (lib.types.attrsOf kdl.types.kdl-args); + default = []; + }; + windowRuleExtra = lib.mkOption { + type = kdl.types.kdl-nodes; + default = []; + }; + key = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + moveKey = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = let + keys = lib.splitString "+" config.key; + defMoveKey = lib.concatStringsSep "+" (lib.flatten [ + (lib.take (lib.length keys - 1) keys) + ["Shift"] + (lib.takeEnd 1 keys) + ]); + in if config.key == null then null else defMoveKey; + }; + spawn = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + }; + app-id = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + selector = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + }; + + config = lib.mkMerge [ + (lib.mkIf (config.app-id != null) { + match = lib.mkDefault [ { app-id = "^${lib.escapeRegex config.app-id}$"; } ]; + selector = lib.mkDefault "select(.app_id == \"${config.app-id}\")"; + }) + ]; + })); + default = []; + }; + }; + + config = { + home.packages = [ pkgs.xwayland-satellite-unstable ]; + + systemd.user.sockets.niri-workspace-history = { + Socket = { + ListenStream = "%t/niri-workspace-history.sock"; + SocketMode = "0600"; + }; + }; + systemd.user.services.niri-workspace-history = { + Unit = { + BindsTo = [ "niri.service" ]; + After = [ "niri.service" ]; + }; + Install = { + WantedBy = [ "niri.service" ]; + }; + Service = { + Type = "simple"; + Sockets = [ "niri-workspace-history.socket" ]; + ExecStart = pkgs.writers.writePython3 "niri-workspace-history" { flakeIgnore = ["E501"]; } '' + import os + import socket + import json + # import sys + from collections import defaultdict + from threading import Thread, Lock + from socketserver import StreamRequestHandler, ThreadingTCPServer + from contextlib import contextmanager + from io import TextIOWrapper + + + @contextmanager + def detaching(thing): + try: + yield thing + finally: + thing.detach() + + + workspace_history = defaultdict(list) + history_lock = Lock() + + + def monitor_niri(): + workspaces = list() + + def focus_workspace(output, workspace): + with history_lock: + workspace_history[output] = [workspace] + [ws for ws in workspace_history[output] if ws != workspace] + # print(json.dumps(workspace_history), file=sys.stderr) + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(os.environ["NIRI_SOCKET"]) + sock.send(b"\"EventStream\"\n") + for line in sock.makefile(buffering=1, encoding='utf-8'): + if line_json := json.loads(line): + if "WorkspacesChanged" in line_json: + workspaces = line_json["WorkspacesChanged"]["workspaces"] + for ws in workspaces: + if ws["is_focused"]: + focus_workspace(ws["output"], ws["id"]) + if "WorkspaceActivated" in line_json: + for ws in workspaces: + if ws["id"] != line_json["WorkspaceActivated"]["id"]: + continue + focus_workspace(ws["output"], ws["id"]) + break + + + class RequestHandler(StreamRequestHandler): + def handle(self): + with detaching(TextIOWrapper(self.wfile, encoding='utf-8', write_through=True)) as out: + with history_lock: + json.dump(workspace_history, out) + + + class Server(ThreadingTCPServer): + def __init__(self): + ThreadingTCPServer.__init__(self, ("", 8000), RequestHandler, bind_and_activate=False) + self.socket = socket.fromfd(3, self.address_family, self.socket_type) + + + def run_server(): + with Server() as server: + server.serve_forever() + + + niri = Thread(target=monitor_niri) + niri.daemon = True + niri.start() + + server_thread = Thread(target=run_server) + server_thread.daemon = True + server_thread.start() + + while True: + server_thread.join(timeout=0.5) + niri.join(timeout=0.5) + + if not (niri.is_alive() and server_thread.is_alive()): + break + ''; + }; + }; + systemd.user.services.niri-workspace-sort = { + Unit = { + BindsTo = [ "niri.service" ]; + After = [ "niri.service" ]; + }; + Install = { + WantedBy = [ "niri.service" ]; + }; + Service = { + Type = "simple"; + ExecStart = pkgs.writers.writePython3 "niri-workspace-sort" { flakeIgnore = ["E501"]; } '' + import os + import sys + import socket + import json + + outputs = None + only = {'HDMI-A-1': {'bmr'}, 'eDP-1': {'vid'}} + + + class Niri(socket.socket): + def __init__(self): + super().__init__(socket.AF_UNIX, socket.SOCK_STREAM) + super().connect(os.environ["NIRI_SOCKET"]) + self.fh = super().makefile(mode='rw', buffering=1, encoding='utf-8') + + def cmd(self, obj): + print(json.dumps(obj, separators=(',', ':')), flush=True, file=self.fh) + + def event_stream(self): + self.cmd("EventStream") + return self.fh + + + with Niri() as niri, Niri().event_stream() as niri_stream: + for line in niri_stream: + workspaces = None + if line_json := json.loads(line): + if "WorkspacesChanged" in line_json: + workspaces = line_json["WorkspacesChanged"]["workspaces"] + + if workspaces is None: + continue + + old_outputs = outputs + outputs = {ws["output"] for ws in workspaces} + if old_outputs is None: + print("Initial outputs: {}".format(outputs), file=sys.stderr) + continue + + new_outputs = outputs - old_outputs + if not new_outputs: + continue + print("New outputs: {}".format(new_outputs), file=sys.stderr) + + relevant_workspaces = list(filter(lambda ws: (ws["name"] is not None) or (ws["active_window_id"] is not None), workspaces)) + target_output = next(iter(outputs - set(only.keys()))) + if not target_output: + continue + for ws in relevant_workspaces: + ws_ident = ws["name"] if ws["name"] is not None else (ws["output"], ws["idx"]) + if ws["output"] not in set(only.keys()): + continue + if ws_ident in only[ws["output"]]: + continue + + print("{} -> {}".format(ws_ident, target_output), file=sys.stderr) + niri.cmd({"Action": {"MoveWorkspaceToMonitor": {"reference": {"Id": ws["id"]}, "output": target_output}}}) + ''; + Restart = "on-failure"; + RestartSec = 10; + }; + }; + + programs.niri.scratchspaces = [ + { name = "pwctl"; + key = "Mod+Control+A"; + spawn = ["pwvucontrol"]; + app-id = "com.saivert.pwvucontrol"; + } + { name = "kpxc"; + exclude = [ + { title = "^Unlock Database.*"; } + { title = "^Access Request.*"; } + { title = ".*Passkey credentials$"; } + ]; + windowRuleExtra = with kdl; [ + (sleaf "open-focused" false) + ]; + key = "Mod+Control+P"; + app-id = "org.keepassxc.KeePassXC"; + spawn = [ "keepassxc" ]; + } + { name = "bmgr"; + key = "Mod+Control+B"; + app-id = ".blueman-manager-wrapped"; + spawn = [ "blueman-manager" ]; + } + { name = "term"; + key = "Mod+Control+Return"; + app-id = "Alacritty-scratch"; + spawn = spawnTerminal { extraArgs = ["--class" "Alacritty-scratch"]; }; + } + { name = "edit"; + match = [ { title = "^scratch$"; app-id = "^emacs$"; } ]; + key = "Mod+Control+E"; + selector = "select(.app_id == \"emacs\" and .title == \"scratch\")"; + spawn = [ "emacsclient" "-c" "--frame-parameters=(quote (name . \"scratch\"))" ]; + } + { name = "eff"; + key = "Mod+Control+O"; + app-id = "com.github.wwmm.easyeffects"; + spawn = [ "easyeffects" ]; + } + { name = "time"; + key = "Mod+Control+K"; + app-id = "chrome-kimai.yggdrasil.li__-Default"; + spawn = [ (toString (pkgs.resholve.writeScript "kimai" { + interpreter = pkgs.runtimeShell; + inputs = [ pkgs.dex ]; + execer = [ "cannot:${lib.getExe pkgs.dex}" ]; + } '' + exec dex $HOME/.local/state/nix/profile/share/applications/kimai.desktop + '')) ]; + windowRuleExtra = with kdl; [ + (sleaf "block-out-from" "screencast") + ]; + } + ]; + programs.niri.config = + let + inherit (kdl) node plain leaf flag; + optional-node = cond: v: + if cond + then v + else null; + opt-props = lib.filterAttrs (lib.const (value: value != null)); + normalize-nodes = nodes: lib.remove null (lib.flatten nodes); + in + normalize-nodes [ + (flag "prefer-no-csd") + + (sleaf "screenshot-path" "~/screenshots/%Y-%m-%dT%H:%M:%S.png") + + (plain "hotkey-overlay" [ + (flag "skip-at-startup") + ]) + + (plain "input" [ + (plain "keyboard" [ + (sleaf "repeat-delay" 300) + (sleaf "repeat-rate" 50) + + (plain "xkb" [ + (sleaf "layout" "us,us") + (sleaf "variant" "dvp,") + (sleaf "options" "compose:caps,grp:win_space_toggle") + ]) + ]) + + (flag "workspace-auto-back-and-forth") + # (sleaf "focus-follows-mouse" {}) + # (flag "warp-mouse-to-focus") + + # (plain "touchpad" [ (flag "off") ]) + (plain "trackball" [ + (sleaf "scroll-method" "on-button-down") + (sleaf "scroll-button" 278) + ]) + (plain "touch" [ + (sleaf "map-to-output" "eDP-1") + ]) + (plain "tablet" [ + (sleaf "map-to-output" "eDP-1") + ]) + ]) + + (plain "gestures" [ + (plain "hot-corners" [(flag "off")]) + ]) + + (plain "environment" (lib.mapAttrsToList sleaf execEnvironment)) + + (node "output" ["eDP-1"] [ + (sleaf "scale" 1.5) + (sleaf "position" { x = 0; y = 0; }) + ]) + (node "output" ["Ancor Communications Inc ASUS PB287Q 0x0000DD9B"] [ + (sleaf "scale" 1.5) + (sleaf "position" { x = 2560; y = 0; }) + ]) + (node "output" ["HP Inc. HP 727pu CN4417143K"] [ + (sleaf "mode" "2560x1440@119.998") + (sleaf "scale" 1) + (sleaf "position" { x = 2560; y = 0; }) + (flag "variable-refresh-rate") + ]) + + (plain "debug" [ + (sleaf "render-drm-device" "/dev/dri/by-path/pci-0000:00:02.0-render") + ]) + + (plain "animations" [ + (sleaf "slowdown" 0.5) + (plain "workspace-switch" [(flag "off")]) + ]) + + (plain "layout" [ + (sleaf "gaps" 8) + (plain "struts" [ + (sleaf "left" 26) + (sleaf "right" 26) + (sleaf "top" 0) + (sleaf "bottom" 0) + ]) + (plain "border" [ + (sleaf "width" 2) + (sleaf "active-gradient" { + from = "hsla(195 100% 45% 1)"; + to = "hsla(155 100% 37.5% 1)"; + angle = 29; + relative-to = "workspace-view"; + }) + (sleaf "inactive-gradient" { + from = "hsla(0 0% 27.7% 1)"; + to = "hsla(0 0% 23% 1)"; + angle = 29; + relative-to = "workspace-view"; + }) + ]) + (plain "focus-ring" [ + (flag "off") + ]) + + (plain "preset-column-widths" (map (prop: sleaf "proportion" prop) [ + (1. / 4.) (1. / 3.) (1. / 2.) (2. / 3.) (3. / 4.) (1.) + ])) + (plain "default-column-width" [ (sleaf "proportion" (1. / 2.)) ]) + (plain "preset-window-heights" (map (prop: sleaf "proportion" prop) [ + (1. / 3.) (1. / 2.) (2. / 3.) (1.) + ])) + + (flag "always-center-single-column") + + (plain "tab-indicator" [ + (sleaf "gap" 4) + (sleaf "width" 8) + (sleaf "gaps-between-tabs" 4) + (flag "place-within-column") + (sleaf "length" { total-proportion = 1.; }) + (sleaf "active-gradient" { + from = "hsla(195 100% 60% 0.75)"; + to = "hsla(155 100% 50% 0.75)"; + angle = 29; + relative-to = "workspace-view"; + }) + (sleaf "inactive-gradient" { + from = "hsla(0 0% 42% 0.66)"; + to = "hsla(0 0% 35% 0.66)"; + angle = 29; + relative-to = "workspace-view"; + }) + ]) + ]) + + (plain "cursor" [ + (flag "hide-when-typing") + ]) + + (map (name: + (node "workspace" [name] [ + (sleaf "open-on-output" "eDP-1") + ]) + ) (map ({name, ...}: name) cfg.scratchspaces)) + (map (name: + (sleaf "workspace" name) + ) ["comm" "web" "vid" "bmr"]) + + (plain "window-rule" [ + (sleaf "clip-to-geometry" true) + ]) + + (plain "window-rule" [ + (sleaf "match" { is-floating = true; }) + (sleaf "geometry-corner-radius" 8) + (plain "shadow" [ (flag "on") ]) + ]) + + (plain "window-rule" [ + (sleaf "match" { app-id = "^org\\.keepassxc\\.KeePassXC$"; }) + (sleaf "block-out-from" "screencast") + ]) + (plain "window-rule" (normalize-nodes [ + (map (title: + (sleaf "match" { app-id = "^org\\.keepassxc\\.KeePassXC$"; inherit title; }) + ) ["^Unlock Database.*" "^Access Request.*" ".*Passkey credentials$" "Browser Access Request$"]) + (sleaf "open-focused" true) + (sleaf "open-floating" true) + ])) + + (map ({ name, match, exclude, windowRuleExtra, ... }: + (optional-node (match != []) (plain "window-rule" (normalize-nodes [ + (map (sleaf "match") match) + (map (sleaf "exclude") exclude) + (sleaf "open-on-workspace" name) + (sleaf "open-maximized" true) + windowRuleExtra + ]))) + ) cfg.scratchspaces) + + (plain "window-rule" [ + (sleaf "match" { app-id = "^emacs$"; }) + (sleaf "match" { app-id = "^firefox$"; }) + (sleaf "match" { app-id = "^gnucash$"; }) + (sleaf "exclude" { app-id = "^gnucash$"; title = "^(Securities|Price Database)$"; }) + (plain "default-column-width" [(sleaf "proportion" (2. / 3.))]) + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^Alacritty$"; }) + (sleaf "match" { app-id = "^Alacritty-play$"; }) + (plain "default-column-width" [(sleaf "proportion" (1. / 3.))]) + ]) + + (plain "window-rule" [ + (sleaf "match" { app-id = "^thunderbird$"; }) + (sleaf "match" { app-id = "^Element$"; }) + (sleaf "match" { app-id = "^chrome-web\.openrainbow\.com__-Default$"; }) + (sleaf "open-on-workspace" "comm") + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^firefox$"; }) + (sleaf "open-on-workspace" "web") + (sleaf "open-maximized" true) + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^mpv$"; }) + (sleaf "open-on-workspace" "vid") + (plain "default-column-width" [(sleaf "proportion" 1.)]) + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^Alacritty-play$"; }) + (sleaf "open-on-workspace" "vid") + (sleaf "open-focused" false) + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^chrome-audiobookshelf\.yggdrasil\.li__-Default$"; }) + (sleaf "match" { app-id = "^YouTube Music Desktop App$"; }) + (sleaf "open-on-workspace" "vid") + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^pdfpc$"; }) + (plain "default-column-width" [(sleaf "proportion" 1.)]) + ]) + (plain "window-rule" [ + (sleaf "match" { app-id = "^pdfpc$"; title = "^.*presentation.*$"; }) + (plain "default-column-width" [(sleaf "proportion" 1.)]) + (sleaf "open-fullscreen" true) + (sleaf "open-on-workspace" "bmr") + (sleaf "open-focused" false) + ]) + (plain "window-rule" (normalize-nodes [ + (map (sleaf "match") [ + { app-id = "^Gimp-"; title = "^Quit GIMP$"; } + { app-id = "^org\\.kde\\.polkit-kde-authentication-agent-1$"; } + { app-id = "^xdg-desktop-portal-gtk$"; } + ]) + (sleaf "open-floating" true) + ])) + (plain "window-rule" [ + (sleaf "match" { app-id = "^org\\.pwmt\\.zathura$"; }) + (sleaf "match" { app-id = "^evince$"; }) + (sleaf "match" { app-id = "^org\\.gnome\\.Papers$"; }) + (sleaf "default-column-display" "tabbed") + ]) + (plain "window-rule" [ + (sleaf "match" { is-window-cast-target = true; }) + (plain "border" [ + (sleaf "width" 2) + (sleaf "active-gradient" { + from = "hsla(20 100% 45% 1)"; + to = "hsla(340 100% 37.5% 1)"; + angle = 29; + relative-to = "workspace-view"; + }) + (sleaf "inactive-gradient" { + from = "hsla(20 50% 27.7% 1)"; + to = "hsla(340 50% 23% 1)"; + angle = 29; + relative-to = "workspace-view"; + }) + ]) + ]) + + (plain "layer-rule" [ + (sleaf "match" { namespace = "^notifications$"; }) + (sleaf "match" { namespace = "^bar$"; }) + (sleaf "match" { namespace = "^launcher$"; }) + (sleaf "block-out-from" "screencast") + ]) + + (plain "recent-windows" [ + (flag "off") + ]) + + (plain "binds" + (let + bind = name: cfg: node name [(lib.removeAttrs cfg ["action"])] (lib.mapAttrsToList leaf (lib.removeAttrs cfg.action ["__functor"])); + + move-column-to-workspace = kdl.magic-leaf "move-column-to-workspace"; + screenshot = kdl.magic-leaf "screenshot"; + screenshot-window = kdl.magic-leaf "screenshot-window"; + screenshot-screen = kdl.magic-leaf "screenshot-screen"; + in + normalize-nodes [ + (lib.mapAttrsToList bind (with config.lib.niri.actions; { + "Mod+Slash".action = show-hotkey-overlay; + + "Mod+Return".action = spawn (spawnTerminal {}); + "Mod+Shift+Return".action = spawn (spawnTerminal { + command = lib.getExe config.programs.nushell.package; + }); + "Mod+Q".action = close-window; + "Mod+O".action = spawn (lib.getExe config.programs.fuzzel.package); + "Mod+Shift+O".action = spawn (lib.getExe config.programs.fuzzel.package) "--list-executables-in-path"; + + "Mod+Alt+E".action = spawn (lib.getExe' config.services.emacs.package "emacsclient") "-c"; + "Mod+Alt+Y".action = spawn (lib.getExe (pkgs.writeShellApplication { + name = "queue-yt-dlp"; + runtimeInputs = with pkgs; [ wl-clipboard-rs socat ]; + text = '' + socat STDIO UNIX-CONNECT:"$XDG_RUNTIME_DIR"/yt-dlp.sock <<<$'{ "urls": ["'"$(wl-paste)"$'"] }' + ''; + })); + "Mod+Alt+L".action = spawn (lib.getExe (pkgs.writeShellApplication { + name = "queue-yt-dlp"; + runtimeInputs = with pkgs; [ wl-clipboard-rs ]; + text = '' + exec -- ${lib.concatStringsSep " " (spawnTerminal { + extraArgs = [ "--class" "Alacritty-play" "--working-directory" "\"$HOME\"/media" ]; + command = ["mpv" "\"$(wl-paste)\""]; + })} + ''; + })); + "Mod+Alt+M".action = spawn (lib.getExe' pkgs.screen-message "sm") "-n" "Fira Mono" "-a" "1" "-f" "#fff" "-b" "#000"; + + "Mod+U".action = spawn (lib.getExe (pkgs.writeShellApplication { + name = "qalc-fuzzel"; + runtimeInputs = with pkgs; [ wl-clipboard-rs libqalculate config.programs.fuzzel.package coreutils findutils libnotify gnugrep ]; + text = '' + RESULTS_DIR="$HOME/.cache/qalc-fuzzel" + prev() { + FOUND=false + while IFS= read -r line; do + [[ -n "$line" ]] || continue + FOUND=true + echo "$line" + done < <(export LC_ALL=C.UTF-8; echo; find "$RESULTS_DIR" -type f -printf $'%T@ %p\n' | sort -n | cut -d' ' -f2- | xargs -r cat) + $FOUND || echo + } + FUZZEL_RES=$(prev | fuzzel --dmenu --prompt "qalc> " --width=60) || exit $? + if [[ "$FUZZEL_RES" =~ .*\ =\ .* ]]; then + QALC_RES="$FUZZEL_RES" + QALC_RET=0 + else + QALC_RES=$(qalc -set "autocalc off" "$FUZZEL_RES" 2>&1) + QALC_RET=$? + fi + [[ -n "$QALC_RES" ]] || exit 1 + EXISTING=false + set +o pipefail + grep -Fxrl "$QALC_RES" "$RESULTS_DIR" | xargs -r touch + [[ ''${PIPESTATUS[0]} -eq 0 ]] && EXISTING=true + set -o pipefail + if [[ $QALC_RET -eq 0 ]] && ! $EXISTING; then + set +o pipefail + RES_FILE="$RESULTS_DIR"/$(date -uIs).$(tr -Cd 'a-zA-Z0-9' "$RES_FILE" <<<"$QALC_RES" + fi + [[ "$QALC_RES" =~ .*\ =\ (.*) ]] && QALC_RES="''${BASH_REMATCH[1]}" + [[ $QALC_RET -eq 0 ]] && wl-copy "$QALC_RES" + notify-send "$QALC_RES" + ''; + })); + "Mod+Shift+U".action = spawn (spawnTerminal { + command = lib.getExe pkgs.libqalculate; + }); + # "Mod+E".action = spawn (lib.getExe (pkgs.writeShellApplication { + # name = "emoji-fuzzel"; + # runtimeInputs = with pkgs; [ config.programs.fuzzel.package wtype wl-clipboard-rs ]; + # text = '' + # FUZZEL_RES=$(fuzzel --dmenu --prompt "emoji> " --cache "$HOME"/.cache/fuzzel-emoji --width=60 <"$HOME"/.local/share/emoji-data/list.txt) || exit $? + # [[ -n "$FUZZEL_RES" ]] || exit 1 + # wl-copy "$(cut -d ':' -f 1 <<<"$FUZZEL_RES" | tr -d '\n')" && wtype -k XF86Paste + # ''; + # })); + "Print".action = screenshot; + "Control+Print".action = screenshot-window; + "Shift+Print".action = screenshot-screen; + "Mod+B".action = with-select-window-action ".workspace_id == ($active_workspace | tonumber)" "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}"; + "Mod+Shift+B".action = with-select-window-action "true" "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}"; + + "Mod+Escape" = { + allow-inhibiting = false; + action = toggle-keyboard-shortcuts-inhibit; + }; + + "Mod+H".action = focus-column-left; + "Mod+T".action = focus-window-down; + "Mod+N".action = focus-window-up; + "Mod+S".action = focus-column-right; + + "Mod+Shift+H".action = move-column-left; + "Mod+Shift+T".action = move-window-down; + "Mod+Shift+N".action = move-window-up; + "Mod+Shift+S".action = move-column-right; + + "Mod+Control+H".action = focus-monitor-left; + "Mod+Control+T".action = focus-monitor-down; + "Mod+Control+N".action = focus-monitor-up; + "Mod+Control+S".action = focus-monitor-right; + + "Mod+Shift+Control+H".action = move-workspace-to-monitor-left; + "Mod+Shift+Control+T".action = move-workspace-to-monitor-down; + "Mod+Shift+Control+N".action = move-workspace-to-monitor-up; + "Mod+Shift+Control+S".action = move-workspace-to-monitor-right; + + "Mod+G".action = focus-adjacent-workspace "down"; + "Mod+C".action = focus-adjacent-workspace "up"; + + "Mod+Shift+G".action = move-column-to-adjacent-workspace "down"; + "Mod+Shift+C".action = move-column-to-adjacent-workspace "up"; + + "Mod+Shift+Control+G".action = move-workspace-down; + "Mod+Shift+Control+C".action = move-workspace-up; + + "Mod+ParenLeft".action = focus-workspace "comm"; + "Mod+Shift+ParenLeft".action = move-column-to-workspace "comm"; + + "Mod+ParenRight".action = focus-workspace "web"; + "Mod+Shift+ParenRight".action = move-column-to-workspace "web"; + + "Mod+BraceRight".action = focus-workspace "read"; + "Mod+Shift+BraceRight".action = move-column-to-workspace "read"; + + "Mod+BraceLeft".action = focus-workspace "mon"; + "Mod+Shift+BraceLeft".action = move-column-to-workspace "mon"; + + "Mod+Asterisk".action = focus-workspace "vid"; + "Mod+Shift+Asterisk".action = move-column-to-workspace "vid"; + + "Mod+Plus".action = with-unnamed-workspace-action ''{"Action":{"FocusWorkspace":{"reference":{"Id": .id}}}}''; + "Mod+Shift+Plus".action = with-unnamed-workspace-action ''{"Action":{"MoveColumnToWorkspace":{"reference":{"Id": .id}, "focus": true}}}''; + + "Mod+M".action = consume-or-expel-window-left; + "Mod+W".action = consume-or-expel-window-right; + + "Mod+Shift+M".action = toggle-column-tabbed-display; + + "Mod+L".action = maximize-window-to-edges; + "Mod+R".action = switch-preset-column-width; + "Mod+Shift+R".action = maximize-column; + "Mod+Shift+Ctrl+R".action = switch-preset-window-height; + "Mod+F".action = center-column; + "Mod+Shift+F".action = toggle-windowed-fullscreen; + "Mod+Ctrl+Shift+F".action = fullscreen-window; + + "Mod+V".action = switch-focus-between-floating-and-tiling; + "Mod+Shift+V".action = toggle-window-floating; + + "Mod+Left".action = set-column-width "-10%"; + "Mod+Down".action = set-window-height "-10%"; + "Mod+Up".action = set-window-height "+10%"; + "Mod+Right".action = set-column-width "+10%"; + + "Mod+Shift+Z" = { + action = power-off-monitors; + allow-when-locked = true; + }; + "Mod+Shift+E".action = quit; + + # "Mod+Semicolon".action = spawn makoctl "dismiss" "--group"; + # "Mod+Shift+Semicolon".action = spawn makoctl "dismiss" "--all"; + # "Mod+Period".action = spawn makoctl "menu" "--" (lib.getExe config.programs.fuzzel.package) "--dmenu"; + # "Mod+Comma".action = spawn makoctl "restore"; + + "Mod+Control+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"FocusWorkspace\":{\"reference\":{\"Id\": $workspace_id}}}}"; + "Mod+Control+Shift+W".action = with-empty-unnamed-workspace-action "{\"Action\":{\"MoveColumnToWorkspace\":{\"reference\":{\"Id\": $workspace_id}, \"focus\": true}}}"; + + "Mod+X".action = set-dynamic-cast-window; + "Mod+Shift+X".action = set-dynamic-cast-monitor; + "Mod+Control+Shift+X".action = clear-dynamic-cast-target; + + "Mod+D".action = with-urgent-window-action "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}"; + "Mod+Shift+D".action = with-focused-window-action "{\"Action\":{\"UnsetUrgent\":{\"id\": .id}}}"; + + "Mod+K".action = spawn (toString (pkgs.resholve.writeScript "worktime-ui" { + interpreter = pkgs.runtimeShell; + inputs = [ pkgs.worktime config.programs.quickshell.package ]; + execer = [ + "cannot:${lib.getExe' config.programs.quickshell.package "qs"}" + ]; + } '' + worktime-ui && qs ipc call Worktime refresh + '')); + "Mod+Shift+K".action = spawn (lib.getExe' pkgs.worktime "worktime-stop"); + })) + (lib.mapAttrsToList (name: cfg: node name [(lib.removeAttrs cfg ["action"])] [cfg.action]) (let + shell = obj: leaf "send-unix" [ + { path = ''''${XDG_RUNTIME_DIR}/shell.sock''; } + (builtins.toJSON obj + "\n") + ]; + in { + "XF86AudioRaiseVolume" = { + allow-when-locked = true; + action = shell { Volume.volume = "up"; }; + }; + "XF86AudioLowerVolume" = { + allow-when-locked = true; + action = shell { Volume.volume = "down"; }; + }; + "XF86AudioMute" = { + allow-when-locked = true; + action = shell { Volume.muted = "toggle"; }; + }; + "XF86AudioMicMute" = { + allow-when-locked = true; + action = shell { Volume."mic-muted" = "toggle"; }; + }; + "XF86MonBrightnessUp" = { + action = shell { Brightness = "up"; }; + allow-when-locked = true; + }; + "XF86MonBrightnessDown" = { + action = shell { Brightness = "down"; }; + allow-when-locked = true; + }; + "Mod+Shift+L".action = shell { LockSession = {}; }; + "Mod+Shift+Minus" = { + action = shell { Suspend = {}; }; + allow-when-locked = true; + }; + "Mod+Shift+Control+Minus" = { + action = shell { Hibernate = {}; }; + allow-when-locked = true; + }; + "Mod+Shift+P" = { + action = shell { Mpris = { PauseAll = {}; }; }; + allow-when-locked = true; + }; + "Mod+Semicolon".action = shell { Notifications = { DismissGroup = {}; }; }; + "Mod+Shift+Semicolon".action = shell { Notifications = { DismissAll = {}; }; }; + "Mod+Print".action = shell { ScreenRecord = { Toggle = {}; }; }; + })) + (map ({ name, selector, spawn, key, ...}: if key != null && selector != null && spawn != null then bind key { action = focus-or-spawn-action selector name spawn; } else null) cfg.scratchspaces) + (map ({ name, moveKey, ...}: if moveKey != null then bind moveKey { action = move-column-to-workspace name; } else null) cfg.scratchspaces) + ] + )) + ]; + }; +} diff --git a/accounts/gkleen@skadhi/shell/default.nix b/accounts/gkleen@skadhi/shell/default.nix new file mode 100644 index 00000000..09eb6863 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/default.nix @@ -0,0 +1,123 @@ +{ config, pkgs, lib, ... }: + +{ + config = { + programs.quickshell = { + enable = true; + package = pkgs.symlinkJoin { + pname = pkgs.quickshell.pname + "-wrapped"; + inherit (pkgs.quickshell) version meta; + paths = [ pkgs.quickshell ]; + buildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + for binary in quickshell qs; do + wrapProgram $out/bin/$binary \ + --prefix QT_PLUGIN_PATH : ${pkgs.qt6.qtimageformats}/${pkgs.qt6.qtbase.qtPluginPrefix} \ + --prefix QML_IMPORT_PATH : ${pkgs.qt6Packages.callPackage ./quickshell-plugins {}}/${pkgs.qt6.qtbase.qtQmlPrefix} + done + ''; + }; + config = { + src = ./quickshell; + replacements = { + ignore_workspaces = builtins.toJSON (map ({ name, ... }: name) config.programs.niri.scratchspaces); + wallpapers = builtins.toJSON (pkgs.stdenvNoCC.mkDerivation { + name = "wallpapers"; + srcs = [ + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/publicationtiff10k/carinanebula3.tif"; + hash = "sha256-YxZEweDKJfvfrdxb/QFmgJhcZDEJYxotoHrG+RRn1tw="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/pillarsofcreation_composite.tif"; + hash = "sha256-qRiODxR0lZWdxgYXna0fNRFFDErpBJDwOJuQl6sNjRc="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/publicationtiff10k/weic2212a.tif"; + hash = "sha256-l2fqE/z//C1a0xkvZwsnwPbTSb+WuA11h+SUl3E1dhw="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/publicationtiff10k/weic2415a.tif"; + hash = "sha256-onBy7cPoUpDuzQStbY2E+qmlGgSLXPwFCLX53ukAb4c="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/publicationtiff10k/weic2330a.tif"; + hash = "sha256-nn0ZtjZIrPcpj3YcLTsrL7XiXvyh3QYgCSmdDMD+3OM="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2426a.tif"; + hash = "sha256-EDnfPn3GE9jt6XPqiGInP7E2F3Az7d25NqATSWltDv0="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2503a.tif"; + hash = "sha256-3/RX6RQp8naELcgReHPd5/zhJkoCjnA10w5BEnNo+qI="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2506a.tif"; + hash = "sha256-aDld0aoY1owRxDVf7Jcyw71TH42M1foYotxn2thyFYw="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2514a.tif"; + hash = "sha256-jTi1G1Ofo5xsF6ggrbtYJHxqLaCQ7edM5B3uORiVQtg="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2425c.tif"; + hash = "sha256-oaEOexSJHEGj090dJF3ct5HAoR+Y5gRiPrUlxdvnTRo="; + }) + (pkgs.fetchurl { + url = "https://esawebb.org/media/archives/images/original/weic2605c.tif"; + hash = "sha256-IwWfgkz84LFXkfk77anb5e0Mf0wWiIszum7UC3yZn0U="; + }) + ]; + + dontUnpack = true; + + buildInputs = [ pkgs.imagemagick ]; + buildPhase = '' + runHook preBuild + + typeset sources=($srcs) + + mkdir -p $out + magick ''${sources[0]} -crop 10000x5625+0+79 +repage -define webp:target-size=10000000 $out/carinanebula3.webp + magick ''${sources[1]} -crop 6716x3778+329+80 +repage -define webp:target-size=10000000 $out/pillarsofcreation_composite.webp + magick ''${sources[2]} -crop 10000x5625+0+79 +repage -define webp:target-size=10000000 $out/weic2212a.webp + magick ''${sources[3]} -crop 7650x4302+1166+389 +repage -define webp:target-size=10000000 $out/weic2415a.webp + magick ''${sources[4]} -crop 8732x4912+0+434 +repage -define webp:target-size=10000000 $out/weic2330a.webp + magick ''${sources[5]} -crop 5302x2982+636+0 +repage -define webp:target-size=10000000 $out/weic2426a.webp + magick ''${sources[6]} -crop 4328x2434+0+906 +repage -define webp:target-size=10000000 $out/weic2503a.webp + magick ''${sources[7]} -crop 4152x2335+0+666 +repage -define webp:target-size=10000000 $out/weic2506a.webp + magick ''${sources[8]} -crop 4320x2430+0+0 +repage -define webp:target-size=10000000 $out/weic2514a.webp + magick ''${sources[9]} -crop 5863x3298+0+477 +repage -define webp:target-size=10000000 $out/weic2425c.webp + magick ''${sources[10]} -crop 3990x2244+0+1379 +repage -define webp:target-size=10000000 $out/weic2605c.webp + + runHook postBuild + ''; + }); + niri_session = builtins.toJSON [ + (pkgs.writeShellScript "niri-session" '' + exec ${lib.getExe pkgs.dex} -w ${config.programs.niri.package}/share/wayland-sessions/niri.desktop &>/tmp/niri-session-$$.log + '') + # (lib.getExe pkgs.dex) + # "${config.programs.niri.package}/share/wayland-sessions/niri.desktop" + ]; + username = builtins.toJSON config.home.username; + mdi = builtins.toJSON (pkgs.fetchFromGitHub { + owner = "Templarian"; + repo = "MaterialDesign"; + rev = "2424e748e0cc63ab7b9c095a099b9fe239b737c0"; + hash = "sha256-QMGl7soAhErrrnY3aKOZpt49yebkSNzy10p/v5OaqQ0="; + }); + worktime = builtins.toJSON (lib.getExe pkgs.worktime); + slurp = builtins.toJSON (lib.getExe pkgs.slurp); + wf-recorder = builtins.toJSON (lib.getExe pkgs.wf-recorder); + }; + }; + }; + systemd.user.services.quickshell = { + Service = { + RuntimeDirectory = "quickshell"; + }; + }; + }; +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/CMakeLists.txt b/accounts/gkleen@skadhi/shell/quickshell-plugins/CMakeLists.txt new file mode 100644 index 00000000..6b4f0d48 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/CMakeLists.txt @@ -0,0 +1,190 @@ +set(INSTALL_QMLDIR "" CACHE STRING "QML install dir") +set(INSTALL_QML_PREFIX "" CACHE STRING "QML install prefix") + +# There doesn't seem to be a standard cross-distro qml install path. +if ("${INSTALL_QMLDIR}" STREQUAL "" AND "${INSTALL_QML_PREFIX}" STREQUAL "") + message(WARNING "Neither INSTALL_QMLDIR nor INSTALL_QML_PREFIX is set. QML modules will not be installed.") +else() + if ("${INSTALL_QMLDIR}" STREQUAL "") + set(QML_FULL_INSTALLDIR "${CMAKE_INSTALL_PREFIX}/${INSTALL_QML_PREFIX}") + else() + set(QML_FULL_INSTALLDIR "${INSTALL_QMLDIR}") + endif() + + message(STATUS "QML install dir: ${QML_FULL_INSTALLDIR}") +endif() + +# Install a given target as a QML module. This is mostly pulled from ECM, as there does not seem +# to be an official way to do it. +# see https://github.com/KDE/extra-cmake-modules/blob/fe0f606bf7f222e36f7560fd7a2c33ef993e23bb/modules/ECMQmlModule6.cmake#L160 +function(install_qml_module arg_TARGET) + if (NOT DEFINED QML_FULL_INSTALLDIR) + return() + endif() + + qt_query_qml_module(${arg_TARGET} + URI module_uri + VERSION module_version + PLUGIN_TARGET module_plugin_target + TARGET_PATH module_target_path + QMLDIR module_qmldir + TYPEINFO module_typeinfo + QML_FILES module_qml_files + RESOURCES module_resources + ) + + set(module_dir "${QML_FULL_INSTALLDIR}/${module_target_path}") + + if (NOT TARGET "${module_plugin_target}") + message(FATAL_ERROR "install_qml_modules called for a target without a plugin") + endif() + + get_target_property(target_type "${arg_TARGET}" TYPE) + if (NOT "${target_type}" STREQUAL "STATIC_LIBRARY") + install( + TARGETS "${arg_TARGET}" + LIBRARY DESTINATION "${module_dir}" + RUNTIME DESTINATION "${module_dir}" + ) + + install( + TARGETS "${module_plugin_target}" + LIBRARY DESTINATION "${module_dir}" + RUNTIME DESTINATION "${module_dir}" + ) + endif() + + install(FILES "${module_qmldir}" DESTINATION "${module_dir}") + install(FILES "${module_typeinfo}" DESTINATION "${module_dir}") + + # Install QML files + list(LENGTH module_qml_files num_files) + if (NOT "${module_qml_files}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0) + qt_query_qml_module(${arg_TARGET} QML_FILES_DEPLOY_PATHS qml_files_deploy_paths) + + math(EXPR last_index "${num_files} - 1") + foreach(i RANGE 0 ${last_index}) + list(GET module_qml_files ${i} src_file) + list(GET qml_files_deploy_paths ${i} deploy_path) + get_filename_component(dst_name "${deploy_path}" NAME) + get_filename_component(dest_dir "${deploy_path}" DIRECTORY) + install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}") + endforeach() + endif() + + # Install resources + list(LENGTH module_resources num_files) + if (NOT "${module_resources}" MATCHES "NOTFOUND" AND ${num_files} GREATER 0) + qt_query_qml_module(${arg_TARGET} RESOURCES_DEPLOY_PATHS resources_deploy_paths) + + math(EXPR last_index "${num_files} - 1") + foreach(i RANGE 0 ${last_index}) + list(GET module_resources ${i} src_file) + list(GET resources_deploy_paths ${i} deploy_path) + get_filename_component(dst_name "${deploy_path}" NAME) + get_filename_component(dest_dir "${deploy_path}" DIRECTORY) + install(FILES "${src_file}" DESTINATION "${module_dir}/${dest_dir}" RENAME "${dst_name}") + endforeach() + endif() +endfunction() + + +cmake_minimum_required(VERSION 3.20) +project(custom LANGUAGES CXX) + +find_package(Qt6 REQUIRED COMPONENTS Core Qml DBus) + +qt_standard_project_setup(REQUIRES 6.6) + +qt6_policy(SET QTP0001 NEW) +qt6_add_qml_module(customplugin + URI "Custom" + PLUGIN_TARGET customplugin +) + +set_source_files_properties(org.keepassxc.KeePassXC.MainWindow.xml PROPERTIES + CLASSNAME DBusKeePassXC + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.keepassxc.KeePassXC.MainWindow.xml + dbus_keepassxc +) + +set_source_files_properties(org.freedesktop.systemd1.Manager.xml PROPERTIES + CLASSNAME DBusSystemdManager + NO_NAMESPACE TRUE + INCLUDE ListUnitsEntry.hpp +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.systemd1.Manager.xml + dbus_systemd_manager +) + +set_source_files_properties(org.freedesktop.login1.Manager.xml PROPERTIES + CLASSNAME DBusLogindManager + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.login1.Manager.xml + dbus_logind_manager +) + +set_source_files_properties(org.freedesktop.login1.Session.xml PROPERTIES + CLASSNAME DBusLogindSession + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.login1.Session.xml + dbus_logind_session +) + +set_source_files_properties(org.freedesktop.systemd1.Unit.xml PROPERTIES + CLASSNAME DBusSystemdUnit + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.systemd1.Unit.xml + dbus_systemd_unit +) + +set_source_files_properties(org.freedesktop.systemd1.Service.xml PROPERTIES + CLASSNAME DBusSystemdService + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.systemd1.Service.xml + dbus_systemd_service +) + +set_source_files_properties(org.freedesktop.DBus.Properties.xml PROPERTIES + CLASSNAME DBusProperties + NO_NAMESPACE TRUE +) +qt_add_dbus_interface(DBUS_INTERFACES + org.freedesktop.DBus.Properties.xml + dbus_properties +) + +include_directories(${CMAKE_SOURCE_DIR}/build) +include_directories(${QUICKSHELL_SRC}/src) + +target_compile_features(customplugin PUBLIC cxx_std_26) + +target_link_libraries(customplugin PRIVATE + Qt6::Core + Qt6::Qml + Qt6::DBus +) + +target_sources(customplugin PRIVATE + Chrono.cpp Chrono.hpp + FileSelector.cpp FileSelector.hpp + KeePassXC.cpp KeePassXC.hpp + Systemd.cpp Systemd.hpp + ListUnitsEntry.cpp ListUnitsEntry.hpp + ${DBUS_INTERFACES} + ${QUICKSHELL_SRC}/src/core/model.cpp ${QUICKSHELL_SRC}/src/core/model.hpp +) + +install_qml_module(customplugin) diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.cpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.cpp new file mode 100644 index 00000000..22b3469b --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.cpp @@ -0,0 +1,83 @@ +#include "Chrono.hpp" + +#include +#include +#include + +Chrono::Chrono(QObject* parent): QObject(parent) { + QObject::connect(&this->timer, &QTimer::timeout, this, &Chrono::onTimeout); + this->update(); +} + +bool Chrono::enabled() const { return this->mEnabled; } + +void Chrono::setEnabled(bool enabled) { + if (enabled == this->mEnabled) return; + this->mEnabled = enabled; + emit this->enabledChanged(); + this->update(); +} + +Chrono::Precision Chrono::precision() const { return this->mPrecision; } + +void Chrono::setPrecision(Chrono::Precision precision) { + if (precision == this->mPrecision) return; + this->mPrecision = precision; + emit this->precisionChanged(); + this->update(); +} + +void Chrono::onTimeout() { + this->setTime(this->targetTime); + this->schedule(this->targetTime); +} + +void Chrono::update() { + if (this->mEnabled) { + this->setTime(std::chrono::time_point()); + this->schedule(std::chrono::time_point()); + } else { + this->timer.stop(); + } +} + +void Chrono::setTime(const std::chrono::time_point& targetTime) { + using namespace std::chrono_literals; + + auto currentTime = std::chrono::system_clock::now(); + auto offset = std::chrono::duration_cast(targetTime - currentTime); + this->currentTime = abs(offset) < 500ms ? targetTime : currentTime; + + switch (this->mPrecision) { + case Chrono::Hours: this->currentTime = std::chrono::time_point_cast(this->currentTime); + case Chrono::Minutes: this->currentTime = std::chrono::time_point_cast(this->currentTime); + case Chrono::Seconds: this->currentTime = std::chrono::time_point_cast(this->currentTime); + } + + emit this->dateChanged(); +} + +void Chrono::schedule(const std::chrono::time_point& targetTime) { + using namespace std::chrono_literals; + + auto currentTime = std::chrono::system_clock::now(); + auto offset = std::chrono::duration_cast(targetTime - currentTime); + auto nextTime = abs(offset) < 500ms ? targetTime : currentTime; + + switch (this->mPrecision) { + case Chrono::Hours: nextTime = std::chrono::time_point_cast(nextTime) + 1h; + case Chrono::Minutes: nextTime = std::chrono::time_point_cast(nextTime) + 1min; + case Chrono::Seconds: nextTime = std::chrono::time_point_cast(nextTime) + 1s; + } + + this->targetTime = nextTime; + this->timer.start(std::chrono::duration_cast(nextTime - currentTime)); +} + +QString Chrono::format(const QString& fmt) const { + return QString::fromStdString(std::format(std::runtime_format(fmt.toStdString()), std::chrono::zoned_time(std::chrono::current_zone(), std::chrono::time_point_cast(this->currentTime)))); +} + +QDateTime Chrono::date() const { + return QDateTime::fromStdTimePoint(std::chrono::time_point_cast(this->currentTime)); +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.hpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.hpp new file mode 100644 index 00000000..04080187 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/Chrono.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include +#include +#include + +#include + +class Chrono : public QObject { + Q_OBJECT; + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + Q_PROPERTY(Chrono::Precision precision READ precision WRITE setPrecision NOTIFY precisionChanged); + Q_PROPERTY(QDateTime date READ date NOTIFY dateChanged) + QML_ELEMENT; + +public: + enum Precision : quint8 { + Hours = 1, + Minutes = 2, + Seconds = 3, + }; + Q_ENUM(Precision); + + explicit Chrono(QObject* parent = nullptr); + + bool enabled() const; + void setEnabled(bool enabled); + + Chrono::Precision precision() const; + void setPrecision(Chrono::Precision precision); + + Q_INVOKABLE QString format(const QString& fmt) const; + + QDateTime date() const; + +signals: + void enabledChanged(); + void precisionChanged(); + void dateChanged(); + +private slots: + void onTimeout(); + +private: + bool mEnabled = true; + Chrono::Precision mPrecision = Chrono::Seconds; + QTimer timer; + std::chrono::time_point currentTime, targetTime; + + void update(); + void setTime(const std::chrono::time_point& targetTime); + void schedule(const std::chrono::time_point& targetTime); +}; diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.cpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.cpp new file mode 100644 index 00000000..d7051d2a --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.cpp @@ -0,0 +1,102 @@ +#include "FileSelector.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace fs = std::filesystem; + +FileSelector::FileSelector(QObject* parent): QObject(parent) { + QObject::connect(&this->timer, &QTimer::timeout, this, &FileSelector::onTimeout); + this->timer.setTimerType(Qt::PreciseTimer); +} + +QString FileSelector::directory() const { + return QString::fromStdString(this->mDirectory->string()); +} +void FileSelector::setDirectory(QString directory) { + this->mDirectory = directory.toStdString(); + if (this->mDirectory && this->mEpoch) + this->update(); + emit this->directoryChanged(); +} + +unsigned int FileSelector::epoch() const { + return std::chrono::duration_cast(*this->mEpoch).count(); +} +void FileSelector::setEpoch(unsigned int epoch) { + this->mEpoch = std::chrono::milliseconds{epoch}; + if (this->mDirectory && this->mEpoch) + this->update(); + emit this->epochChanged(); +} + +QString FileSelector::seed() const { + return this->mSeed; +} +void FileSelector::setSeed(QString seed) { + this->mSeed = seed; + emit this->seedChanged(); + if (this->mDirectory && this->mEpoch) + emit this->selectedChanged(); +} + +QString FileSelector::selected() const { + if (!this->mDirectory || !this->mEpoch) + return QString(); + + std::vector shuffled(this->mFiles.begin(), this->mFiles.end()); + std::sort(shuffled.begin(), shuffled.end()); + + auto currentTime = std::chrono::system_clock::now(); + uint64_t currentEpoch = currentTime.time_since_epoch() / *this->mEpoch; + std::chrono::milliseconds timeInEpoch = std::chrono::duration_cast(currentTime.time_since_epoch()) % *this->mEpoch; + + std::ostringstream seed; + seed << this->mSeed.size() << ";" << this->mSeed.toStdString() << ";"; + seed << *this->mEpoch << ";"; + seed << currentEpoch << ";"; + seed << this->mDirectory->string().size() << ";" << *this->mDirectory << ";"; + seed << this->mFiles.size() << ";"; + for (const fs::path& p: this->mFiles) + seed << p.string().size() << ";" << p << ";"; + + std::vector v; + v.reserve(seed.str().size()); + for (const char& c: seed.str()) + v.push_back(c); + + std::seed_seq engine_seed(v.begin(), v.end()); + std::mt19937 g(engine_seed); + std::shuffle(shuffled.begin(), shuffled.end(), g); + + std::vector::size_type ix = shuffled.size() * timeInEpoch / *this->mEpoch; + return QString::fromStdString((*this->mDirectory / shuffled[ix]).string()); +} + +void FileSelector::onTimeout() { + if (!this->mFiles.size()) + return; + + auto currentTime = std::chrono::system_clock::now(); + uint64_t currentMinorEpoch = currentTime.time_since_epoch() / (*this->mEpoch / this->mFiles.size()); + auto nextTime = std::chrono::time_point((currentMinorEpoch + 1) * (*this->mEpoch / this->mFiles.size())); + this->timer.start(std::chrono::duration_cast(nextTime - currentTime)); + + emit this->selectedChanged(); +} + +void FileSelector::update() { + this->mFiles = std::set{}; + for (const fs::directory_entry& entry: + fs::recursive_directory_iterator(*this->mDirectory, fs::directory_options::follow_directory_symlink)) + { + this->mFiles.insert(fs::relative(entry, *this->mDirectory)); + } + + this->onTimeout(); +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.hpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.hpp new file mode 100644 index 00000000..72c4f2a7 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/FileSelector.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include + +class FileSelector : public QObject { + Q_OBJECT; + Q_PROPERTY(QString directory READ directory WRITE setDirectory NOTIFY directoryChanged REQUIRED); + Q_PROPERTY(unsigned int epoch READ epoch WRITE setEpoch NOTIFY epochChanged REQUIRED); + Q_PROPERTY(QString seed READ seed WRITE setSeed NOTIFY seedChanged); + Q_PROPERTY(QString selected READ selected NOTIFY selectedChanged); + QML_ELEMENT; + +public: + explicit FileSelector(QObject* parent = nullptr); + + QString directory() const; + void setDirectory(QString directory); + + unsigned int epoch() const; + void setEpoch(unsigned int epoch); + + QString seed() const; + void setSeed(QString seed); + + QString selected() const; + +signals: + void directoryChanged(); + void epochChanged(); + void seedChanged(); + void selectedChanged(); + +private slots: + void onTimeout(); + +private: + std::optional mDirectory; + std::optional mEpoch; + std::set mFiles; + QString mSeed; + QTimer timer; + + void update(); +}; diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.cpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.cpp new file mode 100644 index 00000000..f6e4dd6e --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.cpp @@ -0,0 +1,18 @@ +#include "KeePassXC.hpp" + +#include + +KeePassXC::KeePassXC() { + this->service = new DBusKeePassXC(DBusKeePassXC::staticInterfaceName(), "/keepassxc", QDBusConnection::sessionBus(), this); +} +KeePassXC::~KeePassXC() { + if (this->service) + delete this->service; +} + +void KeePassXC::lockAllDatabases() { + if (!this->service) + return; + + this->service->lockAllDatabases(); +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.hpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.hpp new file mode 100644 index 00000000..c4cd71e0 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/KeePassXC.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "dbus_keepassxc.h" + +#include +#include + +class KeePassXC : public QObject { + Q_OBJECT; + QML_SINGLETON; + QML_ELEMENT; + +public: + explicit KeePassXC(); + ~KeePassXC(); + + Q_INVOKABLE void lockAllDatabases(); + +private: + DBusKeePassXC* service = nullptr; +}; diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.cpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.cpp new file mode 100644 index 00000000..c1f9537c --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.cpp @@ -0,0 +1,41 @@ +#include "ListUnitsEntry.hpp" + +void ListUnitsEntry::registerMetaType() { + qRegisterMetaType("ListUnitsEntry"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const ListUnitsEntry& entry) { + argument.beginStructure(); + argument + << entry.primaryName + << entry.description + << entry.loadState + << entry.activeState + << entry.subState + << entry.followingUnit + << entry.unitPath + << entry.jobId + << entry.jobType + << entry.jobPath; + argument.endStructure(); + + return argument; +} +const QDBusArgument& operator>>(const QDBusArgument& argument, ListUnitsEntry& entry) { + argument.beginStructure(); + argument + >> entry.primaryName + >> entry.description + >> entry.loadState + >> entry.activeState + >> entry.subState + >> entry.followingUnit + >> entry.unitPath + >> entry.jobId + >> entry.jobType + >> entry.jobPath; + argument.endStructure(); + + return argument; +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.hpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.hpp new file mode 100644 index 00000000..15d789b9 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/ListUnitsEntry.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +struct ListUnitsEntry { + QString primaryName; + QString description; + QString loadState; + QString activeState; + QString subState; + QString followingUnit; + QDBusObjectPath unitPath; + uint jobId; + QString jobType; + QDBusObjectPath jobPath; + + friend QDBusArgument& operator<<(QDBusArgument& argument, const ListUnitsEntry& entry); + friend const QDBusArgument& operator>>(const QDBusArgument& argument, ListUnitsEntry& entry); + static void registerMetaType(); +}; + +Q_DECLARE_METATYPE(ListUnitsEntry) + +using ListUnitsResult = QList; +Q_DECLARE_METATYPE(ListUnitsResult) diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.cpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.cpp new file mode 100644 index 00000000..6376b527 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.cpp @@ -0,0 +1,288 @@ +#include "Systemd.hpp" + +#include + +#include +#include +#include +#include +#include + +#include + +Systemd::Systemd(QObject* parent) : QObject(parent) { + ListUnitsEntry::registerMetaType(); + qDBusRegisterMetaType(); + + this->systemdManager = new DBusSystemdManager( + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + QDBusConnection::sessionBus(), + this + ); + this->logindManager = new DBusLogindManager( + "org.freedesktop.login1", + "/org/freedesktop/login1", + QDBusConnection::systemBus(), + this + ); + + + QDBusReply sessionPath = this->logindManager->GetSession("auto"); + // qDebug() << sessionPath; + this->logindSession = new DBusLogindSession( + "org.freedesktop.login1", + sessionPath.value().path(), + QDBusConnection::systemBus(), + this + ); + this->logindSessionProperties = new DBusProperties( + "org.freedesktop.login1", + sessionPath.value().path(), + QDBusConnection::systemBus(), + this + ); + + QObject::connect(this->logindManager, &DBusLogindManager::PrepareForShutdown, this, &Systemd::shutdown); + QObject::connect(this->logindManager, &DBusLogindManager::PrepareForSleep, this, &Systemd::sleep); + QObject::connect(this->logindSession, &DBusLogindSession::Lock, this, &Systemd::lock); + QObject::connect(this->logindSession, &DBusLogindSession::Unlock, this, &Systemd::unlock); + QObject::connect(this->logindSessionProperties, &DBusProperties::PropertiesChanged, this, &Systemd::onLogindSessionPropertiesChanged); + this->systemdManager->Subscribe(); + QObject::connect(this->systemdManager, &DBusSystemdManager::UnitNew, this, &Systemd::onUserUnitNew); + QObject::connect(this->systemdManager, &DBusSystemdManager::UnitRemoved, this, &Systemd::onUserUnitRemoved); + + for (const auto& unit: this->systemdManager->ListUnits().value()) + this->onUserUnitNew(unit.primaryName, unit.unitPath); +} + +void Systemd::onLogindSessionPropertiesChanged(const QString& interface_name, const QVariantMap& changed_properties, const QStringList& invalidated_properties) { + if (changed_properties.contains("IdleHint") || invalidated_properties.contains("IdleHint")) + emit this->idleHintChanged(); + if (changed_properties.contains("LockedHint") || invalidated_properties.contains("LockedHint")) + emit this->lockedHintChanged(); +} +void Systemd::onUserUnitNew(const QString& id, const QDBusObjectPath& object_path) { + for (auto unit: this->mUserUnits.valueList()) + if (unit->path() == object_path) + return; + + for (const auto& unit: this->systemdManager->ListUnits().value()) + if (unit.unitPath == object_path) { + this->mUserUnits.insertObject(new SystemdUnit(this, object_path, id)); + return; + } +} +void Systemd::onUserUnitRemoved(const QString& id, const QDBusObjectPath& object_path) { + std::set toRemove; + for (auto unit: this->mUserUnits.valueList()) + if (unit->path() == object_path) + toRemove.insert(unit); + + for (auto unit: toRemove) { + this->mUserUnits.removeObject(unit); + delete unit; + } +} + +SystemdUnit::SystemdUnit(QObject* parent, const QDBusObjectPath& path, const QString& id): QObject(parent), mPath(path), mId(id) { + this->mUnitProperties = new DBusProperties( + "org.freedesktop.systemd1", + this->path().path(), + QDBusConnection::sessionBus(), + this + ); +} +std::unique_ptr SystemdUnit::systemdUnit() { + return std::make_unique( + "org.freedesktop.systemd1", + this->path().path(), + QDBusConnection::sessionBus(), + nullptr + ); +} +std::unique_ptr SystemdUnit::systemdService() { + if (!this->id().endsWith(".service")) + return std::unique_ptr(); + + return std::make_unique( + "org.freedesktop.systemd1", + this->path().path(), + QDBusConnection::sessionBus(), + nullptr + ); +} +QList SystemdUnit::runtimeDirectory() { + if (std::unique_ptr systemdService = this->systemdService()) { + if (!this->mRuntimeDirectory) + this->mRuntimeDirectory = systemdService->runtimeDirectory(); + return *this->mRuntimeDirectory; + } + + return QList{}; +} +QString SystemdUnit::activeState() { + if (!this->mActiveState) { + QObject::connect(this->mUnitProperties, &DBusProperties::PropertiesChanged, this, &SystemdUnit::onUnitPropertiesChanged); + this->mActiveState = this->systemdUnit()->activeState(); + } + + return *this->mActiveState; +} + +const QDBusObjectPath& SystemdUnit::path() const { return this->mPath; } +const QString& SystemdUnit::id() const { return this->mId; } +void SystemdUnit::onUnitPropertiesChanged(const QString& interface_name, const QVariantMap& changed_properties, const QStringList& invalidated_properties) { + if (changed_properties.contains("ActiveState") || invalidated_properties.contains("ActiveState")) { + this->mActiveState = this->systemdUnit()->activeState(); + emit this->activeStateChanged(); + } +} + +void Systemd::stopUserUnit(const QString& unit, const QString& mode) { this->systemdManager->StopUnit(unit, mode); } + +void Systemd::setBrightness(const QString& subsystem, const QString& name, quint32 brightness) { this->logindSession->SetBrightness(subsystem, name, brightness); } + +bool Systemd::idleHint() { return this->logindSession->idleHint(); } +void Systemd::setIdleHint(bool idle) { this->logindSession->SetIdleHint(idle); } +bool Systemd::lockedHint() { return this->logindSession->lockedHint(); } +void Systemd::setLockedHint(bool locked) { this->logindSession->SetLockedHint(locked); } +void Systemd::lockSession() { this->logindSession->call("Lock"); } +void Systemd::suspend() { this->logindManager->Suspend(true); } +void Systemd::hibernate() { this->logindManager->Hibernate(true); } + +ObjectModel* Systemd::userUnits() { return &this->mUserUnits; } + +std::string SystemdInhibitorParams::toString(SystemdInhibitorParams::WhatItem what) { + if (what == SystemdInhibitorParams::Shutdown) + return "shutdown"; + else if (what == SystemdInhibitorParams::Sleep) + return "sleep"; + else if (what == SystemdInhibitorParams::Idle) + return "idle"; + else if (what == SystemdInhibitorParams::HandlePowerKey) + return "handle-power-key"; + else if (what == SystemdInhibitorParams::HandleSuspendKey) + return "handle-suspend-key"; + else if (what == SystemdInhibitorParams::HandleHibernateKey) + return "handle-hibernate-key"; + else if (what == SystemdInhibitorParams::HandleLidSwitch) + return "handle-lid-switch"; + return ""; +} + +std::string SystemdInhibitorParams::toString(SystemdInhibitorParams::What what) { + std::ostringstream res; + bool first = true; + for (const WhatItem& item: SystemdInhibitorParams::allWhatItems) { + if (!(what & item)) + continue; + + if (!first) + res << ":"; + else + first = false; + res << SystemdInhibitorParams::toString(item); + } + return res.str(); +} + +std::string SystemdInhibitorParams::toString(SystemdInhibitorParams::Mode mode) { + if (mode == SystemdInhibitorParams::Block) + return "block"; + else if (mode == SystemdInhibitorParams::BlockWeak) + return "block-weak"; + else if (mode == SystemdInhibitorParams::Delay) + return "delay"; + return ""; +} + +bool SystemdInhibitor::enabled() const { return static_cast(this->activeInhibitor); } +void SystemdInhibitor::setEnabled(bool enabled) { + this->mEnabled = enabled; + + if (enabled) + this->update(); + else + this->release(); +} + +SystemdInhibitorParams::What SystemdInhibitor::what() const { return this->mWhat; } +void SystemdInhibitor::setWhat(SystemdInhibitorParams::What what) { + this->mWhat = what; + this->update(); +} + +QString SystemdInhibitor::who() const { return this->mWho; } +void SystemdInhibitor::setWho(QString who) { + this->mWho = who; + this->update(); +} + +QString SystemdInhibitor::why() const { return this->mWhy; } +void SystemdInhibitor::setWhy(QString why) { + this->mWhy = why; + this->update(); +} + +SystemdInhibitorParams::Mode SystemdInhibitor::mode() const { return this->mMode; } +void SystemdInhibitor::setMode(SystemdInhibitorParams::Mode mode) { + this->mMode = mode; + this->update(); +} + +SystemdInhibitor::ActiveSystemdInhibitor::ActiveSystemdInhibitor(SystemdInhibitorParams::What what_, QString who_, QString why_, SystemdInhibitorParams::Mode mode_): what(what_), who(who_), why(why_), mode(mode_) { + DBusLogindManager logindManager( + "org.freedesktop.login1", + "/org/freedesktop/login1", + QDBusConnection::systemBus() + ); + QDBusReply fd_ = logindManager.Inhibit(QString::fromStdString(SystemdInhibitorParams::toString(what)), who, why, QString::fromStdString(SystemdInhibitorParams::toString(mode))); + if (fd_.error().isValid()) + throw fd_.error(); + this->fd = ::dup(fd_.value().fileDescriptor()); +} +SystemdInhibitor::ActiveSystemdInhibitor::~ActiveSystemdInhibitor() { + if (this->fd != -1) + ::close(this->fd); +} + +void SystemdInhibitor::release() { + if (!this->activeInhibitor) + return; + + this->activeInhibitor.reset(); + emit this->enabledChanged(); +} + +void SystemdInhibitor::update() { + if (!this->mWhat || this->mWho.isEmpty() || this->mWhy.isEmpty() || !this->mMode || !this->mEnabled) + if (this->activeInhibitor) + this->release(); + else + return; + + if (this->activeInhibitor && this->mWhat == this->activeInhibitor->what && this->mWho == this->activeInhibitor->who && this->mWhy == this->activeInhibitor->why && this->mMode == this->activeInhibitor->mode) + return; + + std::unique_ptr otherInhibitor; + try { + otherInhibitor.reset(new SystemdInhibitor::ActiveSystemdInhibitor(this->mWhat, this->mWho, this->mWhy, this->mMode)); + } catch (const QDBusError& err) { + qCritical().noquote() + << err.name().toStdString() << " " << err.message().toStdString(); + return; + } + this->activeInhibitor.swap(otherInhibitor); + + if (!otherInhibitor && this->activeInhibitor) + emit this->enabledChanged(); + if (otherInhibitor && otherInhibitor->what != this->activeInhibitor->what) + emit this->whatChanged(); + if (otherInhibitor && otherInhibitor->who != this->activeInhibitor->who) + emit this->whoChanged(); + if (otherInhibitor && otherInhibitor->why != this->activeInhibitor->why) + emit this->whyChanged(); + if (otherInhibitor && otherInhibitor->mode != this->activeInhibitor->mode) + emit this->modeChanged(); +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.hpp b/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.hpp new file mode 100644 index 00000000..98f73d8f --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/Systemd.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +#include "dbus_systemd_manager.h" +#include "dbus_logind_manager.h" +#include "dbus_logind_session.h" +#include "dbus_systemd_unit.h" +#include "dbus_systemd_service.h" +#include "dbus_properties.h" + +#include "core/model.hpp" + +class SystemdUnit : public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE("SystemdUnits cannot be created directly"); + +public: + explicit SystemdUnit(QObject* parent, const QDBusObjectPath& path, const QString& id); + + Q_PROPERTY(QDBusObjectPath path READ path CONSTANT); + Q_PROPERTY(QString id READ id CONSTANT); + Q_PROPERTY(QList runtimeDirectory READ runtimeDirectory CONSTANT); + Q_PROPERTY(QString activeState READ activeState NOTIFY activeStateChanged); + + const QDBusObjectPath& path() const; + const QString& id() const; + QList runtimeDirectory(); + QString activeState(); + +signals: + void activeStateChanged(); + +private slots: + void onUnitPropertiesChanged(const QString& interface_name, const QVariantMap& changed_properties, const QStringList& invalidated_properties); + +private: + std::unique_ptr systemdUnit(); + std::unique_ptr systemdService(); + + DBusProperties* mUnitProperties; + QDBusObjectPath mPath; + QString mId; + std::optional mActiveState; + std::optional> mRuntimeDirectory; +}; + +class Systemd : public QObject { + Q_OBJECT; + QML_SINGLETON; + QML_ELEMENT; + +public: + explicit Systemd(QObject* parent = nullptr); + + Q_PROPERTY(bool idleHint READ idleHint WRITE setIdleHint NOTIFY idleHintChanged) + Q_PROPERTY(bool lockedHint READ lockedHint WRITE setLockedHint NOTIFY lockedHintChanged) + Q_PROPERTY(UntypedObjectModel* userUnits READ userUnits CONSTANT) + + Q_INVOKABLE void stopUserUnit(const QString& unit, const QString& mode); + Q_INVOKABLE void setBrightness(const QString& subsystem, const QString& name, quint32 brightness); + Q_INVOKABLE void setIdleHint(bool idle); + Q_INVOKABLE void setLockedHint(bool locked); + Q_INVOKABLE void lockSession(); + Q_INVOKABLE void suspend(); + Q_INVOKABLE void hibernate(); + + bool idleHint(); + bool lockedHint(); + ObjectModel* userUnits(); + +signals: + void shutdown(bool before); + void sleep(bool before); + void lock(); + void unlock(); + void idleHintChanged(); + void lockedHintChanged(); + +private slots: + void onLogindSessionPropertiesChanged(const QString& interface_name, const QVariantMap& changed_properties, const QStringList& invalidated_properties); + void onUserUnitNew(const QString& id, const QDBusObjectPath& objectPath); + void onUserUnitRemoved(const QString& id, const QDBusObjectPath& objectPath); + +private: + DBusSystemdManager* systemdManager; + DBusLogindManager* logindManager; + DBusLogindSession* logindSession; + DBusProperties* logindSessionProperties; + + ObjectModel mUserUnits{this}; +}; + +class SystemdInhibitorParams : public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + enum WhatItem : uint8_t { + Shutdown = 0b1, + Sleep = 0b10, + Idle = 0b100, + HandlePowerKey = 0b1000, + HandleSuspendKey = 0b10000, + HandleHibernateKey = 0b100000, + HandleLidSwitch = 0b1000000, + }; + Q_ENUM(WhatItem); + Q_DECLARE_FLAGS(What, WhatItem); + + enum Mode : uint8_t { + Block = 1, + BlockWeak = 2, + Delay = 3, + }; + Q_ENUM(Mode); + + Q_INVOKABLE static std::string toString(WhatItem what); + Q_INVOKABLE static std::string toString(What what); + Q_INVOKABLE static std::string toString(Mode mode); + + static constexpr WhatItem allWhatItems[] = { Shutdown, Sleep, Idle, HandlePowerKey, HandleSuspendKey, HandleHibernateKey, HandleLidSwitch }; +}; +Q_DECLARE_OPERATORS_FOR_FLAGS(SystemdInhibitorParams::What) + +class SystemdInhibitor : public QObject { + Q_OBJECT; + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged); + Q_PROPERTY(SystemdInhibitorParams::What what READ what WRITE setWhat NOTIFY whatChanged); + Q_PROPERTY(QString who READ who WRITE setWho NOTIFY whoChanged); + Q_PROPERTY(QString why READ why WRITE setWhy NOTIFY whyChanged); + Q_PROPERTY(SystemdInhibitorParams::Mode mode READ mode WRITE setMode NOTIFY modeChanged); + QML_ELEMENT; + +public: + explicit SystemdInhibitor(QObject* parent = nullptr): QObject(parent) {} + + bool enabled() const; + void setEnabled(bool enabled); + + SystemdInhibitorParams::What what() const; + void setWhat(SystemdInhibitorParams::What what); + + QString who() const; + void setWho(QString who); + + QString why() const; + void setWhy(QString why); + + SystemdInhibitorParams::Mode mode() const; + void setMode(SystemdInhibitorParams::Mode mode); + + Q_INVOKABLE void release(); + +signals: + void enabledChanged(); + void whatChanged(); + void whoChanged(); + void whyChanged(); + void modeChanged(); + +private: + class ActiveSystemdInhibitor { + public: + uint32_t fd = -1; + SystemdInhibitorParams::What what; + QString who; + QString why; + SystemdInhibitorParams::Mode mode; + + ActiveSystemdInhibitor(SystemdInhibitorParams::What what_, QString who_, QString why_, SystemdInhibitorParams::Mode mode_); + ~ActiveSystemdInhibitor(); + }; + + void update(); + + bool mEnabled = true; + std::unique_ptr activeInhibitor; + SystemdInhibitorParams::What mWhat = static_cast(0); + QString mWho; + QString mWhy; + SystemdInhibitorParams::Mode mMode = static_cast(0); +}; + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/customplugin.h b/accounts/gkleen@skadhi/shell/quickshell-plugins/customplugin.h new file mode 100644 index 00000000..e66ba9e3 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/customplugin.h @@ -0,0 +1,7 @@ +#include + +class CustomPlugin : public QQmlEngineExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID QQmlEngineExtensionInterface_iid) +}; diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/default.nix b/accounts/gkleen@skadhi/shell/quickshell-plugins/default.nix new file mode 100644 index 00000000..dfe93bca --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/default.nix @@ -0,0 +1,32 @@ +{ lib +, stdenv +, cmake +, qt6 +, fmt +, keepassxc +, systemd +, quickshell +}: + +stdenv.mkDerivation rec { + name = "quickshell-custom"; + + src = ./.; + + prePatch = '' + cp ${keepassxc.src}/src/gui/org.keepassxc.KeePassXC.MainWindow.xml . + ''; + + nativeBuildInputs = [ cmake qt6.wrapQtAppsHook ]; + buildInputs = [ + qt6.qtbase + qt6.qtdeclarative + ]; + + cmakeFlags = [ + (lib.cmakeFeature "INSTALL_QML_PREFIX" qt6.qtbase.qtQmlPrefix) + (lib.cmakeOptionType "string" "QUICKSHELL_SRC" (toString quickshell.src)) + ]; + + LC_ALL = "C.UTF-8"; +} diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.DBus.Properties.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.DBus.Properties.xml new file mode 100644 index 00000000..7588e7a5 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.DBus.Properties.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Manager.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Manager.xml new file mode 100644 index 00000000..120a06d9 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Manager.xml @@ -0,0 +1,445 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Session.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Session.xml new file mode 100644 index 00000000..7d6fc8ee --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.login1.Session.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Manager.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Manager.xml new file mode 100644 index 00000000..1dadbc55 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Manager.xml @@ -0,0 +1,821 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Service.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Service.xml new file mode 100644 index 00000000..316dd080 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Service.xml @@ -0,0 +1,1100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Unit.xml b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Unit.xml new file mode 100644 index 00000000..a554f513 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell-plugins/org.freedesktop.systemd1.Unit.xml @@ -0,0 +1,359 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/accounts/gkleen@skadhi/shell/quickshell/ActiveWindowDisplay.qml b/accounts/gkleen@skadhi/shell/quickshell/ActiveWindowDisplay.qml new file mode 100644 index 00000000..ac1cbf3a --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/ActiveWindowDisplay.qml @@ -0,0 +1,172 @@ +import QtQuick +import qs.Services +import Quickshell +import Quickshell.Widgets + +Item { + id: activeWindowDisplay + + required property int maxWidth + required property var screen + + property var activeWindow: { + let currWindowId = Array.from(NiriService.workspaces).find(ws => { + return ws.output === screen?.name && ws.is_active; + })?.active_window_id; + + return currWindowId ? Array.from(NiriService.windows).find(win => win.id == currWindowId) : null; + } + property var windowEntry: activeWindow ? DesktopEntries.heuristicLookup(activeWindow.app_id) : null + + anchors.verticalCenter: parent.verticalCenter + width: activeWindowDisplayContent.width + height: parent.height + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + + Item { + anchors.fill: parent + + Row { + id: activeWindowDisplayContent + + width: childrenRect.width + height: parent.height + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + IconImage { + id: activeWindowIcon + + implicitSize: 14 + + anchors.verticalCenter: parent.verticalCenter + + source: { + let icon = activeWindowDisplay.windowEntry?.icon + if (typeof icon === 'string' || icon instanceof String) { + if (icon.includes("?path=")) { + const split = icon.split("?path=") + if (split.length !== 2) + return icon + const name = split[0] + const path = split[1] + const fileName = name.substring( + name.lastIndexOf("/") + 1) + return `file://${path}/${fileName}` + } else + icon = Quickshell.iconPath(icon); + return icon + } + return "" + } + asynchronous: true + smooth: true + mipmap: true + } + + Text { + id: windowTitle + + width: Math.min(implicitWidth, activeWindowDisplay.maxWidth - activeWindowIcon.width - activeWindowDisplayContent.spacing) + + property var appAliases: { "Firefox": "Mozilla Firefox", "mpv Media Player": "mpv", "Thunderbird": "Mozilla Thunderbird", "Thunderbird (LMU)": "Mozilla Thunderbird" } + + elide: Text.ElideRight + maximumLineCount: 1 + color: "white" + anchors.verticalCenter: parent.verticalCenter + text: { + if (!activeWindowDisplay.activeWindow) + return ""; + + var title = activeWindowDisplay.activeWindow.title; + var appName = activeWindowDisplay.windowEntry?.name; + if (appAliases[appName]) + appName = appAliases[appName]; + if (appName && title.endsWith(appName)) { + const oldTitle = title; + title = title.substring(0, title.length - appName.length); + title = title.replace(/\s*(—|-)\s*$/, ""); + if (!title) + title = oldTitle; + } + return title; + } + } + } + } + } + + Loader { + id: tooltipLoader + + active: false + + Connections { + target: widgetMouseArea + function onContainsMouseChanged() { + if (widgetMouseArea.containsMouse) + tooltipLoader.active = true; + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: tooltip.visible ? 100 : 500 + onTriggered: { + tooltip.visible = tooltip.nextVisible; + if (!tooltip.visible) + tooltipLoader.active = false; + } + } + + implicitWidth: widgetTooltipText.contentWidth + 16 + implicitHeight: widgetTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: widgetTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Mono" + color: "white" + + text: JSON.stringify(Object.assign({}, activeWindowDisplay.activeWindow), null, 2) + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Bar.qml b/accounts/gkleen@skadhi/shell/quickshell/Bar.qml new file mode 100644 index 00000000..33909884 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Bar.qml @@ -0,0 +1,142 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.Services + +PanelWindow { + id: bar + + WlrLayershell.namespace: "bar" + + property bool haveMaximizedWindow: { + let currWindowId = Array.from(NiriService.workspaces).find(ws => { + return ws.output === bar.screen.name && ws.is_active; + })?.active_window_id; + let activeWindowTileSize = Array.from(NiriService.windows).find(win => win.id == currWindowId)?.layout?.tile_size; + + if (!activeWindowTileSize) + return false; + return activeWindowTileSize[0] >= bar.screen.width && activeWindowTileSize[1] >= bar.screen.height - bar.height; + } + + anchors { + top: true + left: true + right: true + } + margins { + left: bar.haveMaximizedWindow ? 0 : 26 + 8 + right: bar.haveMaximizedWindow ? 0 : 26 + 8 + } + + implicitHeight: 21 + color: "transparent" + + property bool haveScreenshare: Array.from(NiriService.casts).some(cast => cast.target.Output?.name == bar.screen.name) + + Rectangle { + color: { + if (bar.haveScreenshare) + return bar.haveMaximizedWindow ? Qt.rgba(0.2, 0, 0, 1) : Qt.rgba(0.2, 0, 0, 0.75); + return bar.haveMaximizedWindow ? "black" : Qt.rgba(0, 0, 0, 0.75); + } + anchors.fill: parent + // bottomLeftRadius: 8 + // bottomRightRadius: 8 + } + + Row { + id: left + + height: parent.height + width: childrenRect.width + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + WorkspaceSwitcher { + screen: bar.screen + } + } + + Row { + id: center + + height: parent.height + width: childrenRect.width + anchors.centerIn: parent + spacing: 5 + + ActiveWindowDisplay { + screen: bar.screen + maxWidth: bar.width - 2*Math.max(left.width, right.width) - 2*8 + } + } + + Row { + id: right + + height: parent.height + width: childrenRect.width + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + WorktimeWidget {} + + KeyboardLayout {} + + Item { + visible: privacy.visible || ytDlp.visible + height: parent.height + width: 8 - 4 + } + + PrivacyWidget { + id: privacy + } + + Item { + visible: privacy.visible && ytDlp.visible + height: parent.height + width: 8 - 4 + } + + YtDlpWidget { + id: ytDlp + } + + Item { + visible: privacy.visible || ytDlp.visible + height: parent.height + width: 8 - 4 + } + + SystemTray {} + + PipewireWidget {} + + BrightnessWidget {} + + BatteryWidget {} + + LockscreenInhibitorWidget {} + + WaylandInhibitorWidget { + window: bar + } + + LidSwitchInhibitorWidget {} + + NotificationInhibitorWidget {} + + Item { + height: parent.height + width: 8 - 4 + } + + Clock {} + } +} \ No newline at end of file diff --git a/accounts/gkleen@skadhi/shell/quickshell/BatteryWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/BatteryWidget.qml new file mode 100644 index 00000000..f465fa20 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/BatteryWidget.qml @@ -0,0 +1,139 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.UPower + +Item { + id: root + + height: parent.height + width: batteryIcon.width + 8 + anchors.verticalCenter: parent.verticalCenter + + property var batteryDevice: Array.from(UPower.devices.values).find(dev => dev.isLaptopBattery) + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: batteryIcon + + implicitSize: 14 + anchors.centerIn: parent + + icon: { + if (!root.batteryDevice?.ready) + return "battery-unknown"; + + if (root.batteryDevice.state == UPowerDeviceState.FullyCharged) + return "power-plug-battery"; + + const perdec = 10 * Math.max(1, Math.ceil(root.batteryDevice.percentage * 10)); + if (root.batteryDevice.state == UPowerDeviceState.Charging) + return `battery-charging-${perdec}`; + if (perdec == 100) + return "battery"; + return `battery-${perdec}`; + } + color: { + if (!root.batteryDevice?.ready) + return "#555"; + + if (root.batteryDevice.state != UPowerDeviceState.FullyCharged && root.batteryDevice.state != UPowerDeviceState.Charging && root.batteryDevice.timeToEmpty < 20 * 60) + return "#f2201f"; + if (root.batteryDevice.state != UPowerDeviceState.FullyCharged && root.batteryDevice.state != UPowerDeviceState.Charging && root.batteryDevice.timeToEmpty < 40 * 60) + return "#f28a21"; + if (root.batteryDevice.state != UPowerDeviceState.FullyCharged) + return "#fff"; + return "#555"; + } + } + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: widgetTooltipText.contentWidth + 16 + implicitHeight: widgetTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: widgetTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: { + if (!root.batteryDevice?.ready) + return "Not ready"; + + const stateStr = UPowerDeviceState.toString(root.batteryDevice.state); + var outStr = stateStr; + if (root.batteryDevice.state !== UPowerDeviceState.FullyCharged) + outStr += ` ${Math.round(root.batteryDevice.percentage * 100)}%`; + + function formatTime(t) { + var res = ""; + for (const unit of [{ "s": "h", "v": 3600 }, { "s": "m", "v": 60 }, { "s": "s", "v": 1 }]) { + if (t < unit.v) + continue; + res += Math.floor(t / unit.v) + unit.s; + t %= unit.v; + } + return res; + } + if (root.batteryDevice.timeToEmpty != 0) { + const tStr = formatTime(Math.floor(root.batteryDevice.timeToEmpty / 60) * 60); + if (tStr) + outStr += " " + tStr; + } else if (root.batteryDevice.timeToFull != 0) { + const tStr = formatTime(Math.ceil(root.batteryDevice.timeToFull / 60) * 60); + if (tStr) + outStr += " " + tStr; + } + + return outStr; + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/BrightnessOSD.qml b/accounts/gkleen@skadhi/shell/quickshell/BrightnessOSD.qml new file mode 100644 index 00000000..a432179e --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/BrightnessOSD.qml @@ -0,0 +1,117 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.Services + +Scope { + id: root + + property bool show: false + property bool inhibited: true + + Connections { + target: Brightness + + function onCurrBrightnessChanged() { + root.show = true; + hideTimer.restart(); + } + } + + onShowChanged: { + if (show) + hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 1000 + onTriggered: root.show = false + } + + Timer { + id: startInhibit + interval: 100 + running: true + onTriggered: { + root.show = false; + root.inhibited = false; + } + } + + LazyLoader { + active: root.show && !root.inhibited + + Variants { + model: Quickshell.screens + + delegate: Scope { + id: screenScope + + required property var modelData + + PanelWindow { + id: window + + screen: screenScope.modelData + + anchors.top: true + margins.top: screen.height / 2 - 50 + 3.5 + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + + implicitWidth: 400 + implicitHeight: 50 + + mask: Region {} + + color: "transparent" + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.75) + } + + RowLayout { + id: layout + + anchors.centerIn: parent + + height: 50 - 8*2 + width: 400 - 8*2 + + MaterialDesignIcon { + id: volumeIcon + + implicitWidth: parent.height + implicitHeight: parent.height + + icon: `brightness-${Math.min(7, Math.floor(Brightness.currBrightness * 7) + 1)}` + } + + Rectangle { + Layout.fillWidth: true + + implicitHeight: 10 + + color: "#50ffffff" + + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + + color: "white" + + implicitWidth: parent.width * Brightness.currBrightness + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/BrightnessWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/BrightnessWidget.qml new file mode 100644 index 00000000..3bb5a80e --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/BrightnessWidget.qml @@ -0,0 +1,84 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.Services + +Item { + height: parent.height + width: brightnessIcon.width + 8 + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + + property real sensitivity: (1 / 50) / 120 + onWheel: event => Brightness.currBrightness += event.angleDelta.y * sensitivity + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: brightnessIcon + + implicitSize: 14 + anchors.centerIn: parent + + icon: `brightness-${Math.min(7, Math.floor(Brightness.currBrightness * 7) + 1)}` + color: "#555" + } + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: widgetTooltipText.contentWidth + 16 + implicitHeight: widgetTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: widgetTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: `${Math.round(Brightness.currBrightness * 100)}%` + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Clock.qml b/accounts/gkleen@skadhi/shell/quickshell/Clock.qml new file mode 100644 index 00000000..58a80b8b --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Clock.qml @@ -0,0 +1,295 @@ +import QtQml +import QtQuick +import Quickshell +import Custom as Custom +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Widgets + +Item { + id: clockItem + + property bool calendarPopup: true + + width: clock.contentWidth + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: clockMouseArea + + anchors.fill: parent + hoverEnabled: true + enabled: clockItem.calendarPopup + + Item { + anchors.fill: parent + + Text { + id: clock + color: "white" + + anchors.verticalCenter: parent.verticalCenter + + Custom.Chrono { + id: chrono + + onDateChanged: clock.text = format("W{0:%V-%u} {0:%F} {0:%H:%M:%S%Ez}") + } + + font.pointSize: 10 + font.family: "Fira Sans" + font.features: { "tnum": 1 } + } + } + } + + Loader { + id: tooltipLoader + + active: false + + Connections { + target: clockMouseArea + function onContainsMouseChanged() { + if (clockMouseArea.containsMouse) + tooltipLoader.active = true; + } + } + + sourceComponent: PopupWindow { + id: tooltip + + property bool nextVisible: clockMouseArea.containsMouse || tooltipMouseArea.containsMouse + + anchor { + item: clockMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: { + tooltip.visible = tooltip.nextVisible; + if (!tooltip.visible) + tooltipLoader.active = false; + } + } + + implicitWidth: tooltipLayout.childrenRect.width + 16 + implicitHeight: tooltipLayout.childrenRect.height + 16 + color: "black" + + onVisibleChanged: { + yearCalendar.year = chrono.date.getFullYear(); + yearCalendar.angleRem = 0; + } + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + onWheel: event => yearCalendar.scrollYear(event) + + anchors.fill: parent + + Item { + id: clockTooltipContent + + anchors.fill: parent + + ColumnLayout { + id: tooltipLayout + + anchors { + left: parent.left + top: parent.top + leftMargin: 8 + topMargin: 8 + } + + Text { + id: yearLabel + + horizontalAlignment: Text.AlignHCenter + + font.pointSize: 14 + font.family: "Fira Sans" + font.features: { "tnum": 1 } + color: "white" + + text: yearCalendar.year + + Layout.fillWidth: true + Layout.bottomMargin: 8 + } + + GridLayout { + property int year: chrono.date.getFullYear() + + id: yearCalendar + + columns: 3 + columnSpacing: 16 + rowSpacing: 16 + + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: false + + property real angleRem: 0 + property real sensitivity: 1 / 120 + + function scrollYear(event) { + angleRem += event.angleDelta.y; + const d = Math.round(angleRem * sensitivity); + yearCalendar.year -= d; + angleRem -= d / sensitivity; + } + + Connections { + target: clockMouseArea + function onWheel(event) { yearCalendar.scrollYear(event); } + } + + Repeater { + model: 12 + + GridLayout { + columns: 2 + + required property int index + property int month: index + + id: monthCalendar + + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.fillWidth: false + + Text { + Layout.column: 1 + Layout.fillWidth: true + + horizontalAlignment: Text.AlignHCenter + + font.pointSize: 10 + font.family: "Fira Sans" + + text: new Date(yearCalendar.year, monthCalendar.month, 1).toLocaleString(Qt.locale("en_DK"), "MMMM") + + color: "#ffead3" + } + + DayOfWeekRow { + locale: grid.locale + + Layout.row: 1 + Layout.column: 1 + Layout.fillWidth: true + + delegate: WrapperItem { + required property string shortName + + width: dowLabel.contentWidth + 6 + + Text { + id: dowLabel + + anchors.fill: parent + + font.pointSize: 10 + font.family: "Fira Sans" + + text: parent.shortName + color: "#ffcc66" + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + + WeekNumberColumn { + month: grid.month + year: grid.year + locale: grid.locale + + Layout.fillHeight: true + + delegate: Text { + required property int weekNumber + + opacity: { + const simple = new Date(weekNumber == 1 && monthCalendar.month == 12 ? yearCalendar.year + 1 : yearCalendar.year, 0, 1 + (weekNumber - 1) * 7); + const dayOfWeek = simple.getDay(); + const isoWeekStart = simple; + + isoWeekStart.setDate(simple.getDate() - dayOfWeek + 1); + if (dayOfWeek > 4) { + isoWeekStart.setDate(isoWeekStart.getDate() + 7); + } + + for (let i = 0; i < 7; i++) { + const dayInWeek = new Date(isoWeekStart); + dayInWeek.setDate(dayInWeek.getDate() + i); + if (dayInWeek.getMonth() == monthCalendar.month) + return 1; + } + + return 0; + } + + font.pointSize: 10 + font.family: "Fira Sans" + font.features: { "tnum": 1 } + + text: weekNumber + color: "#99ffdd" + + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + } + } + + MonthGrid { + id: grid + + year: yearCalendar.year + month: monthCalendar.month + locale: Qt.locale("en_DK") + + Layout.fillWidth: true + Layout.fillHeight: true + + delegate: Text { + required property var model + + opacity: model.month === monthCalendar.month ? 1 : 0 + + font.pointSize: 10 + font.family: "Fira Sans" + font.features: { "tnum": 1 } + + property bool today: chrono.date.getFullYear() == model.year && chrono.date.getMonth() == model.month && chrono.date.getDate() == model.day + + text: model.day + color: today ? "#ff6699" : "white" + + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/KeyboardLayout.qml b/accounts/gkleen@skadhi/shell/quickshell/KeyboardLayout.qml new file mode 100644 index 00000000..46302e54 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/KeyboardLayout.qml @@ -0,0 +1,112 @@ +import Quickshell +import QtQuick +import qs.Services +import Quickshell.Widgets + +Item { + width: kbdLabel.contentWidth + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: kbdMouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: true + onClicked: { + NiriService.sendCommand({ "Action": { "SwitchLayout": { "layout": "Next" } } }, _ => {}) + } + onWheel: event => { + NiriService.sendCommand({ "Action": { "SwitchLayout": { "layout": event.angleDelta > 0 ? "Next" : "Prev" } } }, _ => {}) + } + + Rectangle { + id: kbdWidget + + property var keyboardAbbrev: { "English (programmer Dvorak)": "dvp", "English (US)": "us" } + + anchors.fill: parent + color: { + if (kbdMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Text { + id: kbdLabel + + font.pointSize: 10 + font.family: "Fira Sans" + color: { + if (NiriService.keyboardLayouts?.current_idx === 0) + return "#555"; + return "white"; + } + anchors.centerIn: parent + + text: { + const currentLayout = NiriService.keyboardLayouts?.names?.[NiriService.keyboardLayouts.current_idx]; + if (!currentLayout) + return ""; + return kbdWidget.keyboardAbbrev[currentLayout] ? kbdWidget.keyboardAbbrev[currentLayout] : currentLayout; + } + } + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: kbdMouseArea.containsMouse || tooltipMouseArea.containsMouse + + anchor { + item: kbdMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: kbdTooltipText.contentWidth + 16 + implicitHeight: kbdTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: kbdTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: { + const currentLayout = NiriService.keyboardLayouts?.names?.[NiriService.keyboardLayouts.current_idx]; + return currentLayout || ""; + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/LidSwitchInhibitorWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/LidSwitchInhibitorWidget.qml new file mode 100644 index 00000000..8410dcda --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/LidSwitchInhibitorWidget.qml @@ -0,0 +1,47 @@ +import Quickshell +import QtQuick +import Quickshell.Widgets +import qs.Services + +Item { + id: root + + width: icon.width + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: InhibitorState.lidSwitchInhibited = !InhibitorState.lidSwitchInhibited + + Rectangle { + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: InhibitorState.lidSwitchInhibited ? "laptop-off" : "laptop" + color: InhibitorState.lidSwitchInhibited ? "#f28a21" : "#555" + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/LockSurface.qml b/accounts/gkleen@skadhi/shell/quickshell/LockSurface.qml new file mode 100644 index 00000000..f4f8f0cd --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/LockSurface.qml @@ -0,0 +1,227 @@ +import Quickshell.Widgets +import QtQuick.Effects +import QtQuick.Layouts +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Fusion +import qs.Services +import QtQml + +Item { + id: lockSurface + + property var screen + property list messages: [] + property bool responseRequired: false + property bool responseVisible: false + property string currentText: "" + property bool authRunning: false + + signal response(string responseText) + + anchors.fill: parent + + Item { + id: background + + anchors.fill: parent + + property Img current: one + property string source: selector.selected + + WallpaperSelector { + id: selector + seed: lockSurface.screen?.name || "" + } + + onSourceChanged: { + if (!source) + current = null; + else if (current === one) + two.update() + else + one.update() + } + + Img { id: one } + Img { id: two } + + component Img: Item { + id: img + + property string source + + function update() { + source = background.source || "" + } + + anchors.fill: parent + + Image { + id: imageSource + + source: img.source + sourceSize: Qt.size(parent.width, parent.height) + fillMode: Image.PreserveAspectCrop + smooth: true + visible: false + asynchronous: true + cache: false + + onStatusChanged: { + if (status === Image.Ready) { + background.current = img + } + } + } + + MultiEffect { + id: imageEffect + + source: imageSource + anchors.fill: parent + blurEnabled: true + blur: 1 + blurMax: 64 + blurMultiplier: 2 + + opacity: 0 + + states: State { + name: "visible" + when: background.current === img + + PropertyChanges { + imageEffect.opacity: 1 + } + StateChangeScript { + name: "unloadOther" + script: { + if (img === one) + two.source = "" + if (img === two) + one.source = "" + } + } + } + + transitions: Transition { + SequentialAnimation { + NumberAnimation { + target: imageEffect + properties: "opacity" + duration: { + if (img === one && two.source == "" || img === two && one.source == "") + return 0; + return 5000; + } + easing.type: Easing.OutCubic + } + ScriptAction { + scriptName: "unloadOther" + } + } + } + } + } + } + + Item { + anchors { + top: lockSurface.top + left: lockSurface.left + right: lockSurface.right + } + + implicitWidth: lockSurface.width + implicitHeight: 21 + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.75) + } + + Clock { + anchors.centerIn: parent + calendarPopup: false + } + } + + WrapperRectangle { + id: unlockUi + + Keys.onPressed: event => { + if (!lockSurface.authRunning) { + event.accepted = true; + lockSurface.authRunning = true; + } + } + focus: !passwordBox.visible + + visible: lockSurface.authRunning + + color: Qt.rgba(0, 0, 0, 0.75) + margin: 8 + + anchors.centerIn: parent + + ColumnLayout { + spacing: 4 + + BusyIndicator { + visible: running + running: !Array.from(lockSurface.messages).length && !lockSurface.responseRequired + } + + Repeater { + model: lockSurface.messages + + Text { + required property var modelData + + font.pointSize: 10 + font.family: "Fira Sans" + color: modelData.error ? "#f28a21" : "#ffffff" + + text: String(modelData.text).trim() + + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + } + + TextField { + id: passwordBox + + visible: lockSurface.responseRequired + echoMode: lockSurface.responseVisible ? TextInput.Normal : TextInput.Password + inputMethodHints: Qt.ImhSensitiveData + + onTextChanged: lockSurface.currentText = passwordBox.text + onAccepted: { + passwordBox.readOnly = true; + lockSurface.response(lockSurface.currentText); + } + + Connections { + target: lockSurface + function onCurrentTextChanged() { + passwordBox.text = lockSurface.currentText + } + } + Connections { + target: lockSurface + function onResponseRequiredChanged() { + if (lockSurface.responseRequired) + passwordBox.readOnly = false; + passwordBox.focus = true; + passwordBox.selectAll(); + } + } + + Layout.topMargin: 4 + Layout.fillWidth: true + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Lockscreen.qml b/accounts/gkleen@skadhi/shell/quickshell/Lockscreen.qml new file mode 100644 index 00000000..0b4a68b2 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Lockscreen.qml @@ -0,0 +1,128 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Services.Pam +import Quickshell.Services.Mpris +import Custom as Custom +import qs.Services +import QtQml + +Scope { + id: lockscreen + + property string currentText: "" + + PamContext { + id: pam + + property list messages: [] + + config: "quickshell" + onCompleted: result => { + if (result === PamResult.Success) { + lock.locked = false; + } + } + onPamMessage: { + messages = Array.from(messages).concat([{ "text": pam.message, "error": pam.messageIsError }]) + } + onActiveChanged: { + messages = []; + } + } + + Connections { + target: Custom.Systemd + function onSleep(before: bool) { + console.log(`received prepare for sleep ${before}`); + if (before) { + lock.locked = true; + if (pam.active) + pam.abort(); + } + } + function onLock() { lock.locked = true; } + function onUnlock() { lock.locked = false; } + } + + IdleMonitor { + id: idleMonitor + enabled: !lock.secure && !InhibitorState.lockscreenInhibited + timeout: 600 + respectInhibitors: true + + onIsIdleChanged: { + if (idleMonitor.isIdle) + lock.locked = true; + } + } + + Custom.SystemdInhibitor { + enabled: !lock.secure + + what: Custom.SystemdInhibitorParams.Sleep + who: "quickshell" + why: "Lock session" + mode: Custom.SystemdInhibitorParams.Delay + } + + Binding { + target: NotificationManager + property: "lockscreenActive" + value: lock.locked + } + + WlSessionLock { + id: lock + + onLockStateChanged: { + if (!locked && pam.active) + pam.abort(); + + if (locked) { + NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {}); + Custom.KeePassXC.lockAllDatabases(); + Array.from(MprisProxy.players).forEach(player => { + if (player.canPause && player.isPlaying) + player.pause(); + }); + // Custom.Systemd.stopUserUnit("gpg-agent.service", "replace"); + GpgAgent.reloadAgent(); + } + } + Component.onCompleted: { (_ => {})(MprisProxy.players); } + + onSecureStateChanged: Custom.Systemd.lockedHint = lock.secure + + WlSessionLockSurface { + id: lockSurface + + color: "black" + + LockSurface { + id: surfaceContent + + onResponse: responseText => pam.respond(responseText) + onAuthRunningChanged: { + if (authRunning) + pam.start(); + } + Connections { + target: pam + function onMessagesChanged() { surfaceContent.messages = pam.messages; } + function onResponseRequiredChanged() { surfaceContent.responseRequired = pam.responseRequired; } + function onActiveChanged() { surfaceContent.authRunning = pam.active; } + } + onCurrentTextChanged: lockscreen.currentText = currentText + Connections { + target: lockscreen + function onCurrentTextChanged() { surfaceContent.currentText = lockscreen.currentText; } + } + Connections { + target: lockSurface + function onScreenChanged() { surfaceContent.screen = lockSurface.screen; } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/LockscreenInhibitorWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/LockscreenInhibitorWidget.qml new file mode 100644 index 00000000..1cbf3f16 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/LockscreenInhibitorWidget.qml @@ -0,0 +1,48 @@ +import Quickshell +import QtQuick +import Quickshell.Widgets +import qs.Services + +Item { + id: root + + width: icon.width + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + // enabled: InhibitorState.lockscreenInhibited || !InhibitorState.waylandIdleInhibited + hoverEnabled: true + cursorShape: widgetMouseArea.enabled ? Qt.PointingHandCursor : undefined + + onClicked: InhibitorState.lockscreenInhibited = !InhibitorState.lockscreenInhibited + + Rectangle { + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: InhibitorState.lockscreenInhibited || InhibitorState.waylandIdleInhibited ? "lock-off" : "lock" + color: InhibitorState.lockscreenInhibited ? "#f28a21" : "#555" + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/MaterialDesignIcon.qml b/accounts/gkleen@skadhi/shell/quickshell/MaterialDesignIcon.qml new file mode 100644 index 00000000..155a009e --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/MaterialDesignIcon.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Effects + +Item { + id: root + + required property string icon + property color color: "white" + + property real implicitSize: 0 + + readonly property real actualSize: Math.min(root.width, root.height) + + implicitWidth: root.implicitSize + implicitHeight: root.implicitSize + + Image { + id: sourceImage + source: "file://" + @mdi@ + "/svg/" + root.icon + ".svg" + anchors.fill: parent + fillMode: Image.PreserveAspectFit + + sourceSize.width: root.actualSize + sourceSize.height: root.actualSize + + layer.enabled: true + layer.effect: MultiEffect { + id: effect + + brightness: 1 + colorization: 1 + colorizationColor: root.color + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/NiriIdle.qml b/accounts/gkleen@skadhi/shell/quickshell/NiriIdle.qml new file mode 100644 index 00000000..beff205c --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/NiriIdle.qml @@ -0,0 +1,30 @@ +import QtQml +import Quickshell +import Quickshell.Wayland +import qs.Services +import Custom as Custom + +Scope { + IdleMonitor { + id: idleMonitor30 + timeout: 30 + + onIsIdleChanged: Custom.Systemd.setIdleHint(idleMonitor30.isIdle) + } + IdleMonitor { + id: idleMonitor540 + timeout: 540 + + onIsIdleChanged: { + if (idleMonitor540.isIdle) + NiriService.sendCommand({ "Action": { "PowerOffMonitors": {} } }, _ => {}); + } + } + Connections { + target: Custom.Systemd + function onSleep(before: bool) { + if (!before) + NiriService.sendCommand({ "Action": { "PowerOnMonitors": {} } }, _ => {}); + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/NotificationDisplay.qml b/accounts/gkleen@skadhi/shell/quickshell/NotificationDisplay.qml new file mode 100644 index 00000000..cc0e49b1 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/NotificationDisplay.qml @@ -0,0 +1,340 @@ +import QtQml +import QtQml.Models +import QtQuick +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import qs.Services +import QtQuick.Layouts +import Quickshell.Services.Notifications + +Scope { + id: root + + property var activeScreen: Array.from(Quickshell.screens).find(screen => screen.name === Array.from(NiriService.workspaces).find(ws => ws.is_focused)?.output) ?? null + + Instantiator { + id: notifsRepeater + + model: ScriptModel { + values: NotificationManager.groups + } + + delegate: PanelWindow { + id: notifWindow + + visible: NotificationManager.active + + screen: root.activeScreen + + WlrLayershell.namespace: "notifications" + + required property var modelData + required property var index + + property int activeIx: modelData.length - 1 + onModelDataChanged: { + notifWindow.activeIx = modelData.length - 1; + } + + property color textColor: { + if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Low) + return "#ff999999"; + return "white"; + } + property color backgroundColor: { + if (notifWindow.modelData?.[notifWindow.activeIx]?.urgency == NotificationUrgency.Critical) + return "#dd900000"; + return "black"; + } + + anchors { + right: true + top: true + } + + readonly property real spaceAbove: { + var res = 0; + for (let i = 0; i < notifWindow.index; i++) { + (_ => {})(notifsRepeater.objectAt(i).modelData); + res += notifsRepeater.objectAt(i).height + 8; + } + return res; + } + + margins { + right: 26 + 8 + top: 8 + spaceAbove + } + + color: "transparent" + + implicitHeight: Math.max(notifCount.visible ? notifCount.contentHeight : 0, notifSummary.contentHeight) + (notifBody.visible ? 8 + notifBody.contentHeight : 0) + (notifActions.visible ? 8 + notifActions.height : 0) + (notifTime.visible ? 8 + notifTime.contentHeight : 0) + 16 + implicitWidth: 400 + + WrapperMouseArea { + enabled: true + + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + for (const notif of notifWindow.modelData) + notif.dismiss(); + } + + property real angleRem: 0 + property real sensitivity: 1 / 120 + onWheel: event => { + angleRem += event.angleDelta.y; + const d = Math.round(angleRem * sensitivity); + angleRem -= d / sensitivity; + notifWindow.activeIx = ((notifWindow.modelData?.length ?? 1) + notifWindow.activeIx - d) % (notifWindow.modelData?.length ?? 1); + } + + Rectangle { + color: notifWindow.backgroundColor + anchors.fill: parent + border { + color: Qt.hsla(195/360, 1, 0.45, 1) + width: 2 + } + + GridLayout { + id: notifLayout + + width: 400 - 16 + anchors.fill: parent + anchors.margins: 8 + columnSpacing: 8 + rowSpacing: 8 + + columns: notifImage.visible ? 3 : 2 + rows: { + var res = 1; + if (notifBody.visible) + res += 1; + if (notifActions.visible) + res += 1; + if (notifTime.visible) + res += 1; + return res; + } + + Text { + id: notifCount + + visible: notifWindow.modelData?.length > 1 ?? false + text: `${notifWindow.activeIx + 1}/${notifWindow.modelData?.length ?? ""}` + + font.pointSize: 10 + font.family: "Fira Sans" + font.bold: true + font.features: { "tnum": 1 } + color: notifWindow.textColor + maximumLineCount: 1 + + Layout.fillWidth: false + Layout.row: 0 + Layout.column: notifImage.visible ? 1 : 0 + } + + Text { + id: notifSummary + + text: notifWindow.modelData?.[notifWindow.activeIx]?.summary ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + font.italic: true + color: notifWindow.textColor + maximumLineCount: 1 + elide: Text.ElideRight + + Layout.fillWidth: true + Layout.row: 0 + Layout.column: (notifCount.visible ? 1 : 0) + (notifImage.visible ? 1 : 0) + Layout.columnSpan: notifCount.visible ? 1 : 2 + } + + Image { + id: notifImage + + visible: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? false + + onStatusChanged: { + if (notifImage.status == Image.Error) + notifImage.visible = false; + } + + source: (notifWindow.modelData?.[notifWindow.activeIx]?.image || notifWindow.modelData?.[notifWindow.activeIx]?.appIcon) ?? "" + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + mipmap: true + + Layout.maximumWidth: 50 + Layout.column: 0 + Layout.row: 0 + Layout.fillHeight: true + Layout.rowSpan: 1 + (notifBody.visible ? 1 : 0) + (notifTime.visible ? 1 : 0) + } + + Text { + id: notifBody + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? false + text: notifWindow.modelData?.[notifWindow.activeIx]?.body ?? "" + textFormat: Text.RichText + wrapMode: Text.Wrap + + font.pointSize: 10 + font.family: "Fira Sans" + color: notifWindow.textColor + + Layout.fillWidth: true + Layout.row: 1 + Layout.column: notifImage.visible ? 1 : 0 + Layout.columnSpan: notifCount.visible ? 2 : 1 + } + + Text { + id: notifTime + + Connections { + target: NotificationManager.clock + function onDateChanged() { + notifTime.text = NotificationManager.formatTime(notifWindow.modelData?.[notifWindow.activeIx]?.receivedTime); + } + } + + visible: notifTime.text && notifTime.text !== "now" + text: NotificationManager.formatTime(notifWindow.modelData?.[notifWindow.activeIx]?.receivedTime) + + font.pointSize: 8 + font.family: "Fira Sans" + font.italic: true + color: "#555" + maximumLineCount: 1 + horizontalAlignment: Text.AlignRight + + Layout.fillWidth: true + Layout.row: notifBody.visible ? 2 : 1 + Layout.column: notifImage.visible ? 1 : 0 + Layout.columnSpan: 2 + } + + RowLayout { + id: notifActions + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.actions.length > 0 ?? false + + spacing: 8 + uniformCellSizes: true + + width: 400 - 16 + Layout.row: 1 + (notifBody.visible ? 1 : 0) + (notifTime.visible ? 1 : 0) + Layout.column: 0 + Layout.columnSpan: 2 + (notifImage.visible ? 1 : 0) + + Repeater { + model: ScriptModel { + values: notifWindow.modelData?.[notifWindow.activeIx]?.actions + } + + delegate: WrapperMouseArea { + id: actionMouseArea + + required property var modelData + + height: actionLabelWrapper.implicitHeight + Layout.fillWidth: true + Layout.horizontalStretchFactor: 1 + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: actionMouseArea.modelData?.invoke() + + Rectangle { + anchors.fill: parent + + color: actionMouseArea.containsMouse ? "#20ffffff" : "transparent" + + border { + width: 2 + color: "#20ffffff" + } + + WrapperItem { + id: actionLabelWrapper + + margin: 8 + anchors.centerIn: parent + + RowLayout { + id: actionLabelLayout + + spacing: 8 + + IconImage { + id: actionIcon + + visible: notifWindow.modelData?.[notifWindow.activeIx]?.hasActionIcons + + onStatusChanged: { + if (actionIcon.status == Image.Error) + actionIcon.visible = false; + } + + implicitSize: 16 + source: { + if (!actionIcon.visible) + return ""; + + let icon = actionMouseArea.modelData?.identifier ?? "" + if (icon.includes("?path=")) { + const split = icon.split("?path=") + if (split.length !== 2) + return icon + const name = split[0] + const path = split[1] + const fileName = name.substring( + name.lastIndexOf("/") + 1) + return `file://${path}/${fileName}` + } + return icon + } + asynchronous: true + smooth: true + mipmap: true + + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + } + + Text { + id: actionLabel + + visible: actionMouseArea.modelData?.text ?? false + + text: actionMouseArea.modelData?.text ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + color: notifWindow.textColor + maximumLineCount: 1 + elide: Text.ElideRight + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/NotificationInhibitorWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/NotificationInhibitorWidget.qml new file mode 100644 index 00000000..80cbba19 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/NotificationInhibitorWidget.qml @@ -0,0 +1,266 @@ +import Quickshell +import QtQuick +import Quickshell.Widgets +import qs.Services +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Shapes + +Item { + id: root + + width: icon.width + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: NotificationManager.displayInhibited = !NotificationManager.displayInhibited + + Rectangle { + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: NotificationManager.active ? "message" : "message-off" + color: { + if (!NotificationManager.active && !NotificationManager.displayInhibited) + return "#f28a21"; + if (NotificationManager.displayInhibited) + return "white"; + return "#555"; + } + } + } + } + } + + Loader { + id: tooltipLoader + + active: false + + Connections { + target: widgetMouseArea + function onContainsMouseChanged() { + if (widgetMouseArea.containsMouse) + tooltipLoader.active = true; + } + } + + sourceComponent: PopupWindow { + id: tooltip + + property bool nextVisible: NotificationManager.active && NotificationManager.history.length > 0 && (widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse) + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: tooltip.visible ? 100 : 500 + onTriggered: { + tooltip.visible = tooltip.nextVisible; + if (!tooltip.visible) + tooltipLoader.active = false; + } + } + + implicitWidth: 400 + implicitHeight: Math.min(tooltip.screen.height * 0.66, Math.max(100, scroll.contentHeight + 16)) + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + WrapperItem { + margin: 8 + + ScrollView { + id: scroll + + contentWidth: availableWidth + // ScrollBar.vertical.policy: ScrollBar.AlwaysOn + + ColumnLayout { + id: historyLayout + anchors { + left: parent.left + right: parent.right + } + + spacing: 8 + + Repeater { + model: ScriptModel { + values: [...NotificationManager.history].reverse().map(o => o.notification) + } + + delegate: GridLayout { + id: notif + + Layout.fillWidth: true + Layout.preferredHeight: notifSummary.contentHeight + (notifBody.visible ? notifBody.contentHeight + 8 : 0) + (notifSep.visible ? notifSep.height + 8 : 0) + notifTime.contentHeight + 8 + + required property var modelData + required property int index + + columnSpacing: 8 + rowSpacing: 8 + + columns: notifImage.visible ? 2 : 1 + rows: { + var res = 2; + if (notifBody.visible) + res += 1; + if (notifSep.visible) + res += 1; + return res; + } + + Shape { + id: notifSep + + visible: notif.index != 0 + + height: 2 + width: 400 - 32 + + ShapePath { + strokeWidth: 2 + strokeColor: "#20ffffff" + startX: 0; startY: 0; + PathLine { x: 400 - 32; y: 0; } + } + + Layout.row: 0 + Layout.column: 0 + Layout.columnSpan: notifImage.visible ? 2 : 1 + Layout.alignment: Qt.AlignHCenter + } + + Text { + id: notifSummary + + text: notif.modelData?.summary ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + font.italic: true + color: "white" + maximumLineCount: 1 + elide: Text.ElideRight + + Layout.fillWidth: true + Layout.row: notifSep.visible ? 1 : 0 + Layout.column: notifImage.visible ? 1 : 0 + } + + Image { + id: notifImage + + visible: (notif.modelData?.image || notif.modelData?.appIcon) ?? false + + onStatusChanged: { + if (notifImage.status == Image.Error) + notifImage.visible = false; + } + + source: (notif.modelData?.image || notif.modelData?.appIcon) ?? "" + fillMode: Image.PreserveAspectFit + asynchronous: true + smooth: true + mipmap: true + + Layout.maximumWidth: 50 + Layout.column: 0 + Layout.row: notifSep.visible ? 1 : 0 + Layout.fillHeight: true + Layout.rowSpan: notifBody.visible ? 3 : 2 + } + + Text { + id: notifBody + + visible: notif.modelData?.body ?? false + text: notif.modelData?.body ?? "" + textFormat: Text.RichText + wrapMode: Text.Wrap + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + Layout.fillWidth: true + Layout.row: notifSep.visible ? 2 : 1 + Layout.column: notifImage.visible ? 1 : 0 + } + + Text { + id: notifTime + + Connections { + target: NotificationManager.clock + function onDateChanged() { + notifTime.text = NotificationManager.formatTime(notif.modelData?.receivedTime); + } + } + + text: NotificationManager.formatTime(notif.modelData?.receivedTime) + + font.pointSize: 8 + font.family: "Fira Sans" + font.italic: true + color: "#555" + maximumLineCount: 1 + horizontalAlignment: Text.AlignRight + + Layout.fillWidth: true + Layout.row: { + var res = 1; + if (notifSep.visible) + res += 1; + if (notifBody.visible) + res += 1; + return res; + } + Layout.column: notifImage.visible ? 1 : 0 + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/PipewireWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/PipewireWidget.qml new file mode 100644 index 00000000..9c6b65a4 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/PipewireWidget.qml @@ -0,0 +1,483 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Fusion +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets + +Item { + height: parent.height + width: volumeIcon.width + 8 + anchors.verticalCenter: parent.verticalCenter + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (!Pipewire.defaultAudioSink) + return; + Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted; + } + + property real sensitivity: (1 / 40) / 120 + onWheel: event => { + if (!Pipewire.defaultAudioSink) + return; + Pipewire.defaultAudioSink.audio.volume += event.angleDelta.y * sensitivity; + } + + Rectangle { + id: volumeWidget + + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: volumeIcon + + implicitSize: 14 + anchors.centerIn: parent + + icon: { + if (!Pipewire.defaultAudioSink || Pipewire.defaultAudioSink.audio.muted) + return "volume-off"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.33) + return "volume-low"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.67) + return "volume-medium"; + return "volume-high"; + } + color: "#555" + } + } + } + } + + Loader { + id: tooltipLoader + + active: false + + Connections { + target: widgetMouseArea + function onContainsMouseChanged() { + if (widgetMouseArea.containsMouse) + tooltipLoader.active = true; + } + } + + PwObjectTracker { + objects: Pipewire.devices + } + PwObjectTracker { + objects: Pipewire.nodes + } + + sourceComponent: PopupWindow { + id: tooltip + + property bool openPopup: false + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse || openPopup + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: { + tooltip.visible = tooltip.nextVisible; + if (!tooltip.visible) + tooltipLoader.active = false; + } + } + + implicitWidth: tooltipContent.width + implicitHeight: tooltipContent.height + color: "transparent" + + Rectangle { + width: tooltip.width + height: tooltipLayout.childrenRect.height + 16 + color: "black" + } + + WrapperItem { + id: tooltipContent + + bottomMargin: Math.max(0, 200 - tooltipLayout.implicitHeight) + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + WrapperItem { + margin: 8 + bottomMargin: 8 + + GridLayout { + id: tooltipLayout + + columns: 4 + + Repeater { + model: Array.from(Pipewire.devices.values).filter(dev => dev.type == "Audio/Device") + + Item { + id: descItem + + required property var modelData + required property int index + + Layout.column: 0 + Layout.row: index + + implicitWidth: descText.contentWidth + implicitHeight: descText.contentHeight + + Text { + id: descText + + color: "white" + font.pointSize: 10 + font.family: "Fira Sans" + + text: descItem.modelData.description + } + } + } + + Repeater { + id: defaultSinkRepeater + + model: { + Array.from(Pipewire.devices.values) + .filter(dev => dev.type == "Audio/Device") + .map(device => Array.from(Pipewire.nodes.values).find(node => node.type == PwNodeType.AudioSink && node.device?.id == device.id )); + } + + Item { + id: defaultSinkItem + + required property var modelData + required property int index + + visible: Boolean(modelData) + + PwObjectTracker { + objects: [defaultSinkItem.modelData] + } + + Layout.column: 1 + Layout.row: index + + Layout.fillHeight: true + + implicitWidth: 16 + 8 + + WrapperMouseArea { + id: defaultSinkMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Pipewire.preferredDefaultAudioSink = defaultSinkItem.modelData + } + + onWheel: event => scrollVolume(event); + property real sensitivity: (1 / 40) / 120 + function scrollVolume(event) { + defaultSinkItem.modelData.audio.volume += event.angleDelta.y * sensitivity; + } + + Rectangle { + id: defaultSinkWidget + + anchors.fill: parent + color: { + if (defaultSinkMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + MaterialDesignIcon { + width: 16 + height: 16 + anchors.centerIn: parent + + icon: { + if (defaultSinkItem.modelData?.id == Pipewire.defaultAudioSink?.id) + return "speaker"; + return "speaker-off"; + } + color: icon == "speaker" ? "white" : "#555" + } + } + } + + PopupWindow { + id: volumeTooltip + + property bool nextVisible: defaultSinkMouseArea.containsMouse || volumeTooltipMouseArea.containsMouse + + anchor { + item: defaultSinkMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: volumeHangTimer.restart() + + onVisibleChanged: tooltip.openPopup = volumeTooltip.visible + + Timer { + id: volumeHangTimer + interval: 100 + onTriggered: volumeTooltip.visible = volumeTooltip.nextVisible + } + + implicitWidth: volumeTooltipText.contentWidth + 16 + implicitHeight: volumeTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: volumeTooltipMouseArea + + hoverEnabled: true + enabled: true + + onWheel: event => defaultSinkMouseArea.scrollVolume(event); + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: volumeTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: `${Math.round(defaultSinkItem.modelData?.audio?.volume * 100)}%` + } + } + } + } + } + } + + Repeater { + id: defaultSourceRepeater + + model: { + Array.from(Pipewire.devices.values) + .filter(dev => dev.type == "Audio/Device") + .map(device => Array.from(Pipewire.nodes.values).find(node => node.type == PwNodeType.AudioSource && node.device?.id == device.id )); + } + + Item { + id: defaultSourceItem + + required property var modelData + required property int index + + visible: Boolean(modelData) + + PwObjectTracker { + objects: [defaultSourceItem.modelData] + } + + Layout.column: 2 + Layout.row: index + + Layout.fillHeight: true + + implicitWidth: 16 + 8 + + WrapperMouseArea { + id: defaultSourceMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + Pipewire.preferredDefaultAudioSource = defaultSourceItem.modelData + } + + onWheel: event => scrollVolume(event); + property real sensitivity: (1 / 40) / 120 + function scrollVolume(event) { + defaultSourceItem.modelData.audio.volume += event.angleDelta.y * sensitivity; + } + + Rectangle { + id: defaultSourceWidget + + anchors.fill: parent + color: { + if (defaultSourceMouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + MaterialDesignIcon { + width: 16 + height: 16 + anchors.centerIn: parent + + icon: { + if (defaultSourceItem.modelData?.id == Pipewire.defaultAudioSource?.id) + return "microphone"; + return "microphone-off"; + } + color: icon == "microphone" ? "white" : "#555" + } + } + } + + PopupWindow { + id: volumeTooltip + + property bool nextVisible: defaultSourceMouseArea.containsMouse || volumeTooltipMouseArea.containsMouse + + anchor { + item: defaultSourceMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: volumeHangTimer.restart() + + onVisibleChanged: tooltip.openPopup = volumeTooltip.visible + + Timer { + id: volumeHangTimer + interval: 100 + onTriggered: volumeTooltip.visible = volumeTooltip.nextVisible + } + + implicitWidth: volumeTooltipText.contentWidth + 16 + implicitHeight: volumeTooltipText.contentHeight + 16 + color: "black" + + WrapperMouseArea { + id: volumeTooltipMouseArea + + hoverEnabled: true + enabled: true + + onWheel: event => defaultSourceMouseArea.scrollVolume(event); + + anchors.fill: parent + + Item { + anchors.fill: parent + + Text { + id: volumeTooltipText + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: `${Math.round(defaultSourceItem.modelData?.audio?.volume * 100)}%` + } + } + } + } + } + } + + Repeater { + id: profileRepeater + + model: Array.from(Pipewire.devices.values).filter(dev => dev.type == "Audio/Device") + + Item { + id: profileItem + + required property var modelData + required property int index + + PwObjectTracker { + objects: [profileItem.modelData] + } + + Layout.column: 3 + Layout.row: index + + Layout.fillWidth: true + + implicitWidth: Math.max(profileBox.implicitWidth, 300) + implicitHeight: profileBox.height + + ComboBox { + id: profileBox + + model: profileItem.modelData.profiles + + textRole: "description" + valueRole: "index" + onActivated: profileItem.modelData.setProfile(currentValue) + + anchors.fill: parent + + implicitContentWidthPolicy: ComboBox.WidestText + + Connections { + target: profileItem.modelData + function onCurrentProfileChanged() { + profileBox.currentIndex = Array.from(profileItem.modelData.profiles).findIndex(profile => profile.index == profileItem.modelData.currentProfile); + } + } + Component.onCompleted: { + profileBox.currentIndex = Array.from(profileItem.modelData.profiles).findIndex(profile => profile.index == profileItem.modelData.currentProfile); + } + + Connections { + target: profileBox.popup + function onVisibleChanged() { + tooltip.openPopup = profileBox.popup.visible + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/PrivacyWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/PrivacyWidget.qml new file mode 100644 index 00000000..d7ffadfe --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/PrivacyWidget.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.Services + +Item { + height: parent.height + width: layout.childrenRect.width + anchors.verticalCenter: parent.verticalCenter + + visible: Array.from(Privacy.activeItems).length > 0 + + RowLayout { + id: layout + + anchors.fill: parent + + spacing: 8 + + Repeater { + model: Privacy.activeItems + + Item { + id: privacyItem + + required property var modelData; + + height: parent.height + width: icon.width + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: { + if (privacyItem.modelData == Privacy.Item.Microphone) + return "microphone"; + if (privacyItem.modelData == Privacy.Item.Screensharing) + return "monitor-share"; + } + color: "#f2201f" + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml new file mode 100644 index 00000000..f3ae6804 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Brightness.qml @@ -0,0 +1,107 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Io +import Custom as Custom +import qs.Services + +Singleton { + id: root + + property string subsystem: "backlight" + property string device: "intel_backlight" + + property real currBrightness + property real exponent: 4 + + function calcCurrBrightness() { + if (!currFile.loaded || !maxFile.loaded) + return undefined; + const curr = Number(currFile.text()); + const max = Number(maxFile.text()); + const val = Math.pow(curr / max, 1 / root.exponent); + return val; + } + + Connections { + target: currFile + function onLoaded() { + const b = root.calcCurrBrightness(); + if (typeof b !== 'undefined') + root.currBrightness = b; + } + } + Connections { + target: maxFile + function onLoaded() { + const b = root.calcCurrBrightness(); + if (typeof b !== 'undefined') + root.currBrightness = b; + } + } + + onCurrBrightnessChanged: { + root.currBrightness = Math.max(0, Math.min(1, root.currBrightness)); + + const prev = root.calcCurrBrightness(); + if (typeof prev === 'undefined' || Math.abs(root.currBrightness - prev) < 0.01) + return; + + const max = Number(maxFile.text()); + const actual = Number(currFile.text()); + let curr = Math.max(0, Math.min(max, Math.pow(root.currBrightness, root.exponent) * max)); + if (Math.round(curr) == actual && curr < actual) + curr = Math.max(0, actual - 1); + else if (Math.round(curr) == actual && curr > actual) + curr = Math.min(max, actual + 1); + // root.currBrightness = Math.pow(curr / max, 1 / root.exponent); + Custom.Systemd.setBrightness(root.subsystem, root.device, Math.round(curr)); + } + + FileView { + id: currFile + path: `/sys/class/${root.subsystem}/${root.device}/brightness` + blockAllReads: true + watchChanges: true + onFileChanged: reload() + } + FileView { + id: maxFile + path: `/sys/class/${root.subsystem}/${root.device}/max_brightness` + blockAllReads: true + watchChanges: true + onFileChanged: reload() + } + + + Timer { + id: startupDelay + interval: 500 + running: true + repeat: false + } + property bool onlyInternal: true + Connections { + target: NiriService + function onOutputsChanged() { + root.onlyInternal = Object.keys(NiriService.outputs).every(oname => oname == "eDP-1"); + } + } + onOnlyInternalChanged: { + if (startupDelay.running) + return; + + if (!root.onlyInternal) + root.currBrightness = 1 + } + + + IpcHandler { + target: "Brightness" + + function set(brightness: real): void { + root.currBrightness = brightness; + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml new file mode 100644 index 00000000..3de69535 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/GpgAgent.qml @@ -0,0 +1,18 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + Socket { + id: agentSocket + connected: true + path: `${Quickshell.env("XDG_RUNTIME_DIR")}/gnupg/S.gpg-agent` + } + + function reloadAgent() { + agentSocket.write("RELOADAGENT\n") + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml new file mode 100644 index 00000000..60202a29 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/InhibitorState.qml @@ -0,0 +1,23 @@ +pragma Singleton + +import Quickshell +import Custom as Custom + +Singleton { + id: inhibitorState + + property bool waylandIdleInhibited: false + property alias lidSwitchInhibited: lidSwitchInhibitor.enabled + property bool lockscreenInhibited: false + + Custom.SystemdInhibitor { + id: lidSwitchInhibitor + + enabled: false + + what: Custom.SystemdInhibitorParams.HandleLidSwitch + who: "quickshell" + why: "User request" + mode: Custom.SystemdInhibitorParams.BlockWeak + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml new file mode 100644 index 00000000..e3ab9755 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/MprisProxy.qml @@ -0,0 +1,8 @@ +pragma Singleton + +import Quickshell +import Quickshell.Services.Mpris + +Scope { + property list players: Mpris.players.values +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml new file mode 100644 index 00000000..e9e86f95 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/NiriService.qml @@ -0,0 +1,217 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property var workspaces: [] + property var outputs: {} + property var keyboardLayouts: {} + property var windows: [] + property var casts: [] + readonly property string socketPath: Quickshell.env("NIRI_SOCKET") + + function refreshOutputs() { + commandSocket.sendCommand("Outputs", data => { + outputs = data.Ok.Outputs; + }); + } + + function sendCommand(command, callback) { + commandSocket.sendCommand(command, callback); + } + + Socket { + id: eventStreamSocket + path: root.socketPath + connected: true + + property bool acked: false + + onConnectionStateChanged: { + if (connected) { + acked = false; + write('"EventStream"\n'); + } + } + + parser: SplitParser { + onRead: line => { + try { + const event = JSON.parse(line) + + // console.log(JSON.stringify(event)) + + if (event.WorkspacesChanged) { + root.workspaces = event.WorkspacesChanged.workspaces + root.refreshOutputs(); + } else if (event.WorkspaceActivated) + eventWorkspaceActivated(event.WorkspaceActivated); + else if (event.WorkspaceUrgencyChanged) + eventWorkspaceUrgencyChanged(event.WorkspaceUrgencyChanged); + else if (event.WorkspaceActiveWindowChanged) + eventWorkspaceActiveWindowChanged(event.WorkspaceActiveWindowChanged); + else if (event.KeyboardLayoutsChanged) + root.keyboardLayouts = event.KeyboardLayoutsChanged.keyboard_layouts; + else if (event.KeyboardLayoutSwitched) + root.keyboardLayouts = Object.assign({}, root.keyboardLayouts, {"current_idx": event.KeyboardLayoutSwitched.idx }); + else if (event.WindowsChanged) + root.windows = event.WindowsChanged.windows + else if (event.WindowOpenedOrChanged) + eventWindowOpenedOrChanged(event.WindowOpenedOrChanged); + else if (event.WindowClosed) + eventWindowClosed(event.WindowClosed); + else if (event.WindowFocusChanged) + eventWindowFocusChanged(event.WindowFocusChanged); + else if (event.WindowUrgencyChanged) + eventWindowUrgencyChanged(event.WindowUrgencyChanged); + else if (event.WindowLayoutsChanged) + eventWindowLayoutsChanged(event.WindowLayoutsChanged); + else if (event.WindowFocusTimestampChanged) + eventWindowFocusTimestampChanged(event.WindowFocusTimestampChanged); + else if (event.CastsChanged) + root.casts = event.CastsChanged.casts + else if (event.CastStartedOrChanged) + eventCastStartedOrChanged(event.CastStartedOrChanged); + else if (event.CastStopped) + eventCastStopped(event.CastStopped); + else if (event.Ok && !eventStreamSocket.acked) { eventStreamSocket.acked = true; } + else if (event.OverviewOpenedOrClosed) {} + else if (event.ScreenshotCaptured) {} + else if (event.ConfigLoaded) {} + else + console.log(JSON.stringify(event)); + } catch (e) { + console.warn("NiriService: Failed to parse event:", line, e) + } + } + } + } + + Socket { + id: commandSocket + path: root.socketPath + connected: true + + property var awaitingAnswer: null + property var cmdQueue: [] + + parser: SplitParser { + onRead: line => { + if (commandSocket.awaitingAnswer === null) + return; + + try { + const response = JSON.parse(line); + commandSocket.awaitingAnswer.callback(response); + commandSocket.awaitingAnswer = null; + } catch (e) { + console.warn("NiriService: Failed to parse response:", line, e) + } + commandSocket._handleQueue(); + } + } + + onCmdQueueChanged: { + _handleQueue(); + } + onAwaitingAnswerChanged: { + _handleQueue(); + } + + function _handleQueue() { + if (cmdQueue.length <= 0 || awaitingAnswer !== null) + return; + + let localQueue = Array.from(cmdQueue); + awaitingAnswer = localQueue.shift(); + cmdQueue = localQueue; + write(JSON.stringify(awaitingAnswer.command) + '\n'); + } + + function sendCommand(command, callback) { + cmdQueue = Array.from(cmdQueue).concat([{ "command": command, "callback": callback }]) + } + } + + function eventWorkspaceActivated(data) { + let relevant_output = null; + Array.from(root.workspaces).forEach(ws => { + if (data.id === ws.id) + relevant_output = ws.output; + }); + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.focused) + ws.is_focused = false; + if (ws.output === relevant_output) + ws.is_active = false; + if (data.id === ws.id) { + ws.is_active = true; + ws.is_focused = data.focused; + } + return ws; + }); + } + function eventWorkspaceUrgencyChanged(data) { + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.id == ws.id) + ws.is_urgent = data.urgent; + return ws; + }); + } + function eventWorkspaceActiveWindowChanged(data) { + root.workspaces = Array.from(root.workspaces).map(ws => { + if (data.workspace_id === ws.id) + ws.active_window_id = data.active_window_id; + return ws; + }); + } + function eventWindowOpenedOrChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (data.window.is_focused) + win.is_focused = false; + return win; + }).filter(win => win.id !== data.window.id).concat([data.window]); + } + function eventWindowClosed(data) { + root.windows = Array.from(root.windows).filter(win => win.id !== data.id); + } + function eventWindowFocusChanged(data) { + root.windows = Array.from(root.windows).map(win => { + win.is_focused = win.id === data.id; + return win; + }); + } + function eventWindowUrgencyChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (win.id === data.id) + win.is_urgent = data.urgent; + return win; + }); + } + function eventWindowLayoutsChanged(data) { + root.windows = Array.from(root.windows).map(win => { + Array.from(data.changes).forEach(change => { + if (win.id === change[0]) + win.layout = change[1]; + }); + return win; + }); + } + function eventWindowFocusTimestampChanged(data) { + root.windows = Array.from(root.windows).map(win => { + if (win.id === data.id) + win.focus_timestamp = data.focus_timestamp; + return win; + }); + } + function eventCastStartedOrChanged(data) { + root.casts = [...Array.from(root.casts).filter(cast => cast.stream_id !== data.cast.stream_id), data.cast]; + } + function eventCastStopped(data) { + root.casts = Array.from(root.casts).filter(cast => cast.stream_id !== data.stream_id); + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml new file mode 100644 index 00000000..5f8ff419 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/NotificationManager.qml @@ -0,0 +1,162 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Services.Notifications + +Singleton { + id: root + + readonly property bool active: !root.lockscreenActive && !root.displayInhibited + property bool lockscreenActive: false + property bool displayInhibited: false + property alias trackedNotifications: server.trackedNotifications + readonly property var groups: { + function matchesGroupKey(notif, groupKey) { + var matches = true; + for (const prop in groupKey.test) { + if (notif[prop] !== groupKey.test[prop]) { + matches = false; + break; + } + } + return matches; + } + + var groups = new Map(); + var notifs = new Array(); + for (const [ix, notif] of server.trackedNotifications.values.entries()) { + var didGroup = false; + for (const groupKey of root.groupKeys) { + if (!matchesGroupKey(notif, groupKey)) + continue; + + const key = JSON.stringify({ + "key": groupKey, + "values": Object.assign({}, ...(Array.from(groupKey["group-by"]).map(prop => { + var res = {}; + res[prop] = notif[prop]; + return res; + }))) + }); + if (!groups.has(key)) + groups.set(key, new Array()); + groups.get(key).push({ "ix": ix, "notif": notif }); + didGroup = true; + break; + } + + if (!didGroup) + notifs.push([{ "ix": ix, "notif": notif }]); + } + notifs.push(...groups.values()); + notifs.sort((as, bs) => Math.min(...(as.map(o => o.ix))) - Math.min(...(bs.map(o => o.ix)))); + return notifs.map(ns => ns.map(n => n.notif)); + } + + property var groupKeys: [ + { "test": { "appName": "Element" }, "group-by": [ "summary" ] } + ]; + + property int historyLimit: 100 + property var history: [] + + Component { + id: expirationTimer + + QtObject { + id: timer + + required property QtObject parent + required property int expirationTime + + property list data: [ + Timer { + running: root.active && !timer.expired + interval: timer.expirationTime + onTriggered: { + timer.parent.expirationTimer.destroy(); + timer.parent.expirationTimer = null; + timer.parent.expire(); + } + } + ] + } + } + + Component { + id: notificationLock + + RetainableLock {} + } + + readonly property SystemClock clock: SystemClock { + precision: SystemClock.Minutes + } + + function formatTime(time) { + const now = root.clock.date; + const diff = now - time; + const minutes = Math.ceil(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours < 1) { + if (minutes < 1) + return "now"; + if (minutes == 1) + return "1 minute"; + return `${minutes} minutes`; + } + + const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate()) + const days = Math.floor((nowDate - timeDate) / (1000 * 86400)) + + const timeStr = time.toLocaleTimeString(Qt.locale(), "HH:mm"); + + if (days === 0) + return timeStr; + if (days === 1) + return `yesterday ${timeStr}`; + + const dateStr = time.toLocaleDateString(Qt.locale(), "YYYY-MM-DD"); + return `${dateStr} ${timeStr}`; + } + + NotificationServer { + id: server + + bodySupported: true + actionsSupported: true + actionIconsSupported: true + imageSupported: true + bodyMarkupSupported: true + bodyImagesSupported: true + + onNotification: notification => { + var timeout = notification.expireTimeout * 1000; + if (notification.appName == "poweralertd") + timeout = 2000; + if (timeout > 0) { + Object.defineProperty(notification, "expirationTimer", { configurable: true, enumerable: true, writable: true }); + notification.expirationTimer = expirationTimer.createObject(notification, { parent: notification, expirationTime: timeout }); + } + Object.defineProperty(notification, "receivedTime", { configurable: true, enumerable: true, writable: true }); + notification.receivedTime = root.clock.date; + notification.closed.connect((reason) => server.onNotificationClosed(notification, reason)); + notification.tracked = true; + } + + function onNotificationClosed(notification, reason) { + while (root.history.length >= root.historyLimit) { + root.history[0].lock.locked = false; + root.history.shift(); + } + + root.history.push({ + lock: notificationLock.createObject(root, { locked: true, object: notification }), + notification: notification + }); + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml new file mode 100644 index 00000000..9c813e49 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Privacy.qml @@ -0,0 +1,63 @@ +pragma Singleton + +import QtQml +import Quickshell +import Quickshell.Services.Pipewire + +Singleton { + id: root + + PwObjectTracker { + objects: Pipewire.nodes.values + } + + enum Item { + Microphone, + Screensharing + } + + readonly property list activeItems: { + var items = []; + if (microphoneActive) + items.push(Privacy.Item.Microphone); + if (screensharingActive) + items.push(Privacy.Item.Screensharing); + return items; + } + + readonly property bool microphoneActive: { + if (!Pipewire.ready || !Pipewire.nodes?.values) { + return false + } + + for (const node of Pipewire.nodes.values) { + if (!node || (node.type & PwNodeType.AudioInStream) != PwNodeType.AudioInStream) + continue; + + if (node.properties?.["stream.monitor"] === "true") + continue; + + if (node.audio?.muted) + continue; + + return true; + } + + return false; + } + + readonly property bool screensharingActive: { + if (!Pipewire.ready || !Pipewire.nodes?.values) { + return false + } + + for (const node of Pipewire.nodes.values) { + if (!node || (node.type & PwNodeType.VideoInStream) != PwNodeType.VideoInStream) + continue; + + return true; + } + + return false; + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml new file mode 100644 index 00000000..eb415452 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/ScreenRecord.qml @@ -0,0 +1,63 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property bool active: false + property bool slurpSuccess: false + + onActiveChanged: { + if (!active) { + slurp.running = false; + screenRecorder.running = false; + } + if (active) + slurp.running = true; + } + + Process { + id: screenRecorder + running: false + onRunningChanged: { + console.log("wf-recorder running: ", screenRecorder.running); + + if (!screenRecorder.running && !slurp.running) + root.active = false; + } + stderr: SplitParser { + onRead: line => console.log("wf-recorder stderr: ", line) + } + stdout: SplitParser { + onRead: line => console.log("wf-recorder stdout: ", line) + } + } + + Process { + id: slurp + running: false + command: [ @slurp@, "-o", "-d" ] + stdout: StdioCollector {} + stderr: SplitParser { + onRead: line => console.log("slurp stderr: ", line) + } + onExited: exitCode => { + if (exitCode !== 0) { + console.log("slurp failed: ", exitCode); + root.active = false; + return; + } + console.log("slurp succeeded: ", slurp.stdout.text); + + const nowDate = new Date(); + + screenRecorder.command = [ + @wf-recorder@, + "-g", slurp.stdout.text, + "-f", `${Quickshell.env("HOME")}/screenshots/${nowDate.toLocaleString(Qt.locale(), "yyyy-MM-ddThh:mm:ss")}.mkv`, + ]; + screenRecorder.running = true; + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml new file mode 100644 index 00000000..c71a9cca --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/WallpaperSelector.qml @@ -0,0 +1,8 @@ +import Custom as Custom + +Custom.FileSelector { + id: root + + directory: @wallpapers@ + epoch: 79200000 +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml b/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml new file mode 100644 index 00000000..d98378f1 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/Services/Worktime.qml @@ -0,0 +1,84 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import QtQml + +Singleton { + id: root + + property alias time: timeState + property alias today: todayState + + CommandState { + id: timeState + command: "time" + } + CommandState { + id: todayState + command: "today" + } + + IpcHandler { + target: "Worktime" + + function refresh(): void { + time.running = true; + today.running = true; + } + } + + component CommandState : Scope { + id: commandState + + required property string command + property var state: null + + property bool strikeout: !strikeoutTimer.running + property alias running: process.running + property alias updating: updateTimer.running + + Process { + id: process + running: true + command: [ @worktime@, commandState.command, "--waybar" ] + stdout: StdioCollector { + id: processCollector + onStreamFinished: { + try { + commandState.state = JSON.parse(processCollector.text); + strikeoutTimer.restart(); + } catch (e) { + console.warn("Worktime: Failed to parse output:", processCollector.text, e); + } + } + } + } + + Timer { + id: updateTimer + running: commandState.state?.class == "running" || commandState.state?.class == "over" + interval: 60000 + repeat: true + onTriggered: process.running = true + } + + Timer { + id: strikeoutTimer + running: false + interval: 5 * updateTimer.interval + repeat: false + } + } + + Timer { + running: Boolean(timeState.state) && Boolean(todayState.state) && timeState.strikeout && todayState.strikeout + interval: 1000 + repeat: false + onTriggered: { + timeState.state = null; + todayState.state = null; + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/SystemTray.qml b/accounts/gkleen@skadhi/shell/quickshell/SystemTray.qml new file mode 100644 index 00000000..f0c50632 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/SystemTray.qml @@ -0,0 +1,201 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.SystemTray + +Item { + anchors.verticalCenter: parent.verticalCenter + width: systemTrayRow.childrenRect.width + height: parent.height + clip: true + + Row { + id: systemTrayRow + anchors.centerIn: parent + width: childrenRect.width + height: parent.height + spacing: 0 + + Repeater { + model: ScriptModel { + values: { + var trayItems = Array.from(SystemTray.items.values).filter(item => item.status !== Status.Passive && !(["Fcitx"].includes(item.id))); + trayItems.sort((a, b) => a.category !== b.category ? b.category - a.category : a.id.localeCompare(b.id)) + return trayItems; + } + } + + delegate: Item { + id: trayItemWrapper + + required property var modelData + required property int index + + property var trayItem: modelData + property string iconSource: { + let icon = trayItem && trayItem.icon + if (typeof icon === 'string' || icon instanceof String) { + if (icon.includes("?path=")) { + const split = icon.split("?path=") + if (split.length !== 2) + return icon + const name = split[0] + const path = split[1] + const fileName = name.substring( + name.lastIndexOf("/") + 1) + return `file://${path}/${fileName}` + } + return icon + } + return "" + } + + width: icon.width + 6 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: trayItemArea + + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: trayItem.onlyMenu ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: mouse => { + if (!trayItem) + return + + if (mouse.button === Qt.LeftButton + && !trayItem.onlyMenu) { + trayItem.activate() + return + } + + if (trayItem.hasMenu) { + var globalPos = mapToGlobal(0, 0) + var currentScreen = screen || Screen + var screenX = currentScreen.x || 0 + var relativeX = globalPos.x - screenX + menuAnchor.menu = trayItem.menu + menuAnchor.anchor.window = bar + menuAnchor.anchor.rect = Qt.rect( + relativeX, + 21, + parent.width, 1) + menuAnchor.open() + } + } + + Rectangle { + anchors.fill: parent + color: { + if (trayItemArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + Item { + anchors.fill: parent + + layer.enabled: true + layer.effect: MultiEffect { + colorization: 1 + colorizationColor: "#555" + } + + IconImage { + id: icon + + anchors.centerIn: parent + implicitSize: 16 + source: trayItemWrapper.iconSource + asynchronous: true + smooth: true + mipmap: true + + layer.enabled: true + layer.effect: MultiEffect { + id: effect + + brightness: 1 + } + } + } + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: (trayItem.tooltipTitle || trayItem.tooltipDescription) && (trayItemArea.containsMouse || tooltipMouseArea.containsMouse) && !menuAnchor.visible + + anchor { + item: trayItemArea + edges: Edges.Bottom + } + + visible: false + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + color: "black" + + implicitWidth: Math.max(tooltipTitle.contentWidth, tooltipDescription.contentWidth) + 16 + implicitHeight: (trayItem.tooltipTitle ? tooltipTitle.contentHeight : 0) + (trayItem.tooltipDescription ? tooltipDescription.contentHeight : 0) + 16 + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + margin: 4 + + anchors.fill: parent + + Item { + anchors.fill: parent + + Column { + anchors.centerIn: parent + Text { + id: tooltipTitle + + enabled: trayItem.tooltipTitle + + font.pointSize: 10 + font.family: "Fira Sans" + font.bold: true + color: "white" + + text: trayItem.tooltipTitle + } + + Text { + id: tooltipDescription + + enabled: trayItem.tooltipDescription + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: trayItem.tooltipDescription + } + } + } + } + } + } + } + } + QsMenuAnchor { + id: menuAnchor + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/UnixIPC.qml b/accounts/gkleen@skadhi/shell/quickshell/UnixIPC.qml new file mode 100644 index 00000000..3d950031 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/UnixIPC.qml @@ -0,0 +1,106 @@ +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import Quickshell.Services.Mpris +import qs.Services +import Custom as Custom +import QtQml + +Scope { + id: root + + SocketServer { + active: true + path: `${Quickshell.env("XDG_RUNTIME_DIR")}/shell.sock` + handler: Socket { + parser: SplitParser { + onRead: line => { + const command = (() => { + try { + return JSON.parse(line); + } catch (e) { + console.warn("UnixIPC: Failed to parse command:", line, e); + } + })(); + if (!command) + return; + + if (command.Volume) + root.onCommandVolume(command.Volume); + else if (command.Brightness) + root.onCommandBrightness(command.Brightness); + else if (command.LockSession) + Custom.Systemd.lockSession(); + else if (command.Suspend) + Custom.Systemd.suspend(); + else if (command.Hibernate) + Custom.Systemd.hibernate(); + else if (command.Mpris) + root.onCommandMpris(command.Mpris); + else if (command.Notifications) + root.onCommandNotifications(command.Notifications); + else if (command.ScreenRecord) + root.onCommandScreenRecord(command.ScreenRecord); + else + console.warn("UnixIPC: Command not handled:", JSON.stringify(command)); + } + } + + onError: e => { + if (e == 1) + return; + console.warn("QLocalSocket::LocalSocketError", e); + } + } + } + + PwObjectTracker { + objects: [ Pipewire.defaultAudioSink, Pipewire.defaultAudioSource ] + } + function onCommandVolume(command) { + if (command.muted === "toggle") + Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted; + if (command.volume === "up") + Pipewire.defaultAudioSink.audio.volume += 0.02; + if (command.volume === "down") + Pipewire.defaultAudioSink.audio.volume -= 0.02; + + if (command["mic-muted"] === "toggle") + Pipewire.defaultAudioSource.audio.muted = !Pipewire.defaultAudioSource.audio.muted; + } + + function onCommandBrightness(command) { + if (command === "up") + Brightness.currBrightness += 0.02 + if (command === "down") + Brightness.currBrightness -= 0.02 + } + + function onCommandMpris(command) { + if (command.PauseAll) + Array.from(MprisProxy.players).forEach(player => { + if (player.canPause && player.isPlaying) + player.pause(); + }); + } + Component.onCompleted: { (_ => {})(MprisProxy.players); } + + function onCommandNotifications(command) { + if (command.DismissGroup && NotificationManager.active) { + if (NotificationManager.groups.length > 0) + for (const notif of [...NotificationManager.groups[0]]) + notif.dismiss(); + } + if (command.DismissAll && NotificationManager.active) { + for (const notif of [...NotificationManager.trackedNotifications.values]) + notif.dismiss(); + } + } + + function onCommandScreenRecord(command) { + if (command.Toggle) + ScreenRecord.active = !ScreenRecord.active; + else + console.warn("UnixIPC.ScreenRecord: Command not handled:", JSON.stringify(command)); + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/VolumeOSD.qml b/accounts/gkleen@skadhi/shell/quickshell/VolumeOSD.qml new file mode 100644 index 00000000..91dc9591 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/VolumeOSD.qml @@ -0,0 +1,163 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Pipewire +import Quickshell.Widgets + +Scope { + id: root + + property string show: "" + property bool inhibited: true + + PwObjectTracker { + objects: [ Pipewire.defaultAudioSink, Pipewire.defaultAudioSource ] + } + + Connections { + enabled: Pipewire.defaultAudioSink + target: Pipewire.defaultAudioSink?.audio || null + + function onVolumeChanged() { + root.show = "sink"; + hideTimer.restart(); + } + function onMutedChanged() { + root.show = "sink"; + hideTimer.restart(); + } + } + + Connections { + enabled: Pipewire.defaultAudioSource + target: Pipewire.defaultAudioSource?.audio || null + + function onVolumeChanged() { + root.show = "source"; + hideTimer.restart(); + } + function onMutedChanged() { + root.show = "source"; + hideTimer.restart(); + } + } + + onShowChanged: { + if (show) + hideTimer.restart(); + } + + Timer { + id: hideTimer + interval: 1000 + onTriggered: root.show = "" + } + + Timer { + id: startInhibit + interval: 100 + running: true + onTriggered: { + root.show = ""; + root.inhibited = false; + } + } + + LazyLoader { + active: root.show && !root.inhibited + + Variants { + model: Quickshell.screens + + delegate: Scope { + id: screenScope + + required property var modelData + + PanelWindow { + id: window + + screen: screenScope.modelData + + anchors.top: true + margins.top: screen.height / 2 - 50 + 3.5 + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + + implicitWidth: 400 + implicitHeight: 50 + + mask: Region {} + + color: "transparent" + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.75) + } + + RowLayout { + id: layout + + anchors.centerIn: parent + + height: 50 - 8*2 + width: 400 - 8*2 + + MaterialDesignIcon { + id: volumeIcon + + implicitWidth: parent.height + implicitHeight: parent.height + + icon: { + if (root.show == "sink") { + if (!Pipewire.defaultAudioSink || Pipewire.defaultAudioSink.audio.muted) + return "volume-off"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.33) + return "volume-low"; + if (Pipewire.defaultAudioSink.audio.volume <= 0.67) + return "volume-medium"; + return "volume-high"; + } else if (root.show == "source") { + if (!Pipewire.defaultAudioSource || Pipewire.defaultAudioSource.audio.muted) + return "microphone-off"; + if (Pipewire.defaultAudioSource.audio.volume > 1) + return "microphone-plus"; + return "microphone"; + } + return "volume-high"; + } + } + + Rectangle { + Layout.fillWidth: true + + implicitHeight: 10 + + color: "#50ffffff" + + Rectangle { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + + color: Pipewire.defaultAudioSink?.audio.muted ? "#70ffffff" : "white" + + implicitWidth: { + if (root.show == "sink") + return parent.width * (Pipewire.defaultAudioSink?.audio.volume ?? 0); + else if (root.show == "source") + return parent.width * Math.min(1, (Pipewire.defaultAudioSource?.audio.volume ?? 0)); + return 0; + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/WallpaperBackground.qml b/accounts/gkleen@skadhi/shell/quickshell/WallpaperBackground.qml new file mode 100644 index 00000000..4f85a900 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/WallpaperBackground.qml @@ -0,0 +1,89 @@ +import QtQuick +import Quickshell +import qs.Services + +Item { + id: root + + anchors.fill: parent + + required property string screen + + property Img current: one + property string source: selector.selected + + WallpaperSelector { + id: selector + seed: screen + } + + onSourceChanged: { + if (!source) + current = null; + else if (current === one) + two.update() + else + one.update() + } + + Img { id: one } + Img { id: two } + + component Img: Image { + id: img + + function update() { + source = root.source || "" + } + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + smooth: true + asynchronous: true + cache: false + + opacity: 0 + + onStatusChanged: { + if (status === Image.Ready) { + root.current = this + } + } + + states: State { + name: "visible" + when: root.current === img + + PropertyChanges { + img.opacity: 1 + } + StateChangeScript { + name: "unloadOther" + script: { + if (img === one) + two.source = "" + if (img === two) + one.source = "" + } + } + } + + transitions: Transition { + SequentialAnimation { + NumberAnimation { + target: img + properties: "opacity" + duration: { + if (img === one && two.source == "" || img === two && one.source == "") + return 0; + return 5000; + } + easing.type: Easing.OutCubic + } + ScriptAction { + scriptName: "unloadOther" + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/WaylandInhibitorWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/WaylandInhibitorWidget.qml new file mode 100644 index 00000000..0512ff51 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/WaylandInhibitorWidget.qml @@ -0,0 +1,56 @@ +import Quickshell +import QtQuick +import Quickshell.Widgets +import Quickshell.Wayland +import qs.Services + +Item { + id: root + + required property var window + + width: icon.width + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + IdleInhibitor { + id: inhibitor + enabled: InhibitorState.waylandIdleInhibited + window: root.window + } + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: InhibitorState.waylandIdleInhibited = !InhibitorState.waylandIdleInhibited + + Rectangle { + anchors.fill: parent + color: { + if (widgetMouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: inhibitor.enabled ? "eye" : "eye-off" + color: inhibitor.enabled ? "white" : "#555" + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/WorkspaceSwitcher.qml b/accounts/gkleen@skadhi/shell/quickshell/WorkspaceSwitcher.qml new file mode 100644 index 00000000..a893937a --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/WorkspaceSwitcher.qml @@ -0,0 +1,204 @@ +import Quickshell +import QtQuick +import qs.Services +import Quickshell.Widgets +import QtQuick.Layouts + +Row { + id: workspaces + + required property var screen + + property var ignoreWorkspaces: @ignore_workspaces@ + + height: parent.height + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Repeater { + model: ScriptModel { + values: { + let currWorkspaces = NiriService.workspaces; + const ignoreWorkspaces = Array.from(workspaces.ignoreWorkspaces); + currWorkspaces = currWorkspaces.filter(ws => ws.output == workspaces.screen?.name).filter(ws => ws.is_active || ignoreWorkspaces.every(iws => iws !== ws.name)); + currWorkspaces.sort((a, b) => { + if (NiriService.outputs?.[a.output]?.logical?.x !== NiriService.outputs?.[b.output]?.logical?.x) + return NiriService.outputs?.[a.output]?.logical?.x - NiriService.outputs?.[b.output]?.logical?.x + if (NiriService.outputs?.[a.output]?.logical?.y !== NiriService.outputs?.[b.output]?.logical?.y) + return NiriService.outputs?.[a.output]?.logical?.y - NiriService.outputs?.[b.output]?.logical?.y + return a.idx - b.idx; + }); + return currWorkspaces.map(o => Object.assign({}, o)); + } + } + + Item { + id: wsItem + + property var workspaceData: modelData + + width: wsLabel.contentWidth + 8 + height: parent.height + anchors.verticalCenter: parent.verticalCenter + + WrapperMouseArea { + id: mouseArea + + anchors.fill: parent + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: true + onClicked: { + NiriService.sendCommand({ "Action": { "FocusWorkspace": { "reference": { "Id": workspaceData.id } } } }, _ => {}); + } + + Rectangle { + anchors.fill: parent + + color: { + if (mouseArea.containsMouse) { + return "#33808080"; + } + return "transparent"; + } + + Text { + id: wsLabel + + anchors.centerIn: parent + + font.pointSize: 10 + font.family: "Fira Sans" + color: { + if (workspaceData.is_active) + return "#23fd00"; + if (workspaceData.active_window_id === null) + return "#555"; + return "white"; + } + + text: workspaceData.name ? workspaceData.name : workspaceData.idx + } + } + } + + PopupWindow { + id: tooltip + + property bool nextVisible: (mouseArea.containsMouse || tooltipMouseArea.containsMouse) && [...windowsModel.values].length > 0 + + anchor { + item: mouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: tooltipContent.implicitWidth + implicitHeight: tooltipContent.implicitHeight + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + anchors.fill: parent + + WrapperItem { + id: tooltipContent + + margin: 0 + + ColumnLayout { + spacing: 0 + + Repeater { + model: ScriptModel { + id: windowsModel + + values: { + let currWindows = NiriService.windows.filter(win => win.workspace_id == wsItem.workspaceData.id); + currWindows.sort((a, b) => { + if (a.is_floating !== b.is_floating) + return b.is_floating - a.is_floating; + if (a.layout.tile_pos_in_workspace_view?.[0] !== b.layout.tile_pos_in_workspace_view?.[0]) + return a.layout.tile_pos_in_workspace_view?.[0] - b.layout.tile_pos_in_workspace_view?.[0] + if (a.layout.tile_pos_in_workspace_view?.[1] !== b.layout.tile_pos_in_workspace_view?.[1]) + return a.layout.tile_pos_in_workspace_view?.[1] - b.layout.tile_pos_in_workspace_view?.[1] + if (a.layout.pos_in_scrolling_layout?.[0] !== b.layout.pos_in_scrolling_layout?.[0]) + return a.layout.pos_in_scrolling_layout?.[0] - b.layout.pos_in_scrolling_layout?.[0] + if (a.layout.pos_in_scrolling_layout?.[1] !== b.layout.pos_in_scrolling_layout?.[1]) + return a.layout.pos_in_scrolling_layout?.[1] - b.layout.pos_in_scrolling_layout?.[1] + if (a.app_id !== b.app_id) + return a.app_id?.localeCompare(b.app_id); + + return a.title.localeCompare(b.title); + }); + return currWindows.map(o => Object.assign({}, o)); + } + } + + WrapperMouseArea { + id: windowMouseArea + + required property int index + required property var modelData + property var windowData: modelData + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: true + + Layout.fillWidth: true + + onClicked: { + NiriService.sendCommand({ "Action": { "FocusWindow": { "id": windowData.id } } }, _ => {}) + } + + WrapperRectangle { + color: windowMouseArea.containsMouse ? "#33808080" : "transparent"; + + WrapperItem { + rightMargin: 8 + leftMargin: 8 + topMargin: windowMouseArea.index == 0 ? 8 : 4 + bottomMargin: windowMouseArea.index == windowsModel.values.length - 1 ? 8 : 4 + + Text { + id: windowLabel + + font.pointSize: 10 + font.family: "Fira Sans" + color: { + if (windowData.is_focused) + return "#23fd00"; + if (NiriService.workspaces.find(ws => ws.id == windowData.workspace_id)?.active_window_id == windowData.id) + return "white"; + return "#555"; + } + + text: windowData.title + + horizontalAlignment: Text.AlignLeft + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/WorktimeWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/WorktimeWidget.qml new file mode 100644 index 00000000..b7baee34 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/WorktimeWidget.qml @@ -0,0 +1,212 @@ +import QtQml +import Quickshell +import QtQuick +import Quickshell.Widgets +import qs.Services + +Item { + id: root + + height: parent.height + width: (timeWidget.visible ? timeWidget.label.contentWidth + 8 : 0) + (todayWidget.visible ? todayWidget.label.contentWidth + 8 : 0) + (icon.visible ? icon.implicitWidth + 8 : 0) + anchors.verticalCenter: parent.verticalCenter + + component TextWidget : Item { + id: textWidget + + visible: textWidget.state.state?.text ?? false + + required property var state + property alias label: label + property alias mouseArea: mouseArea + + anchors.verticalCenter: parent.verticalCenter + implicitWidth: label.contentWidth + 8 + height: parent.height + + WrapperMouseArea { + id: mouseArea + + anchors.fill: parent + + enabled: true + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + + Item { + anchors.fill: parent + + Text { + id: label + + anchors.centerIn: parent + + text: textWidget.state.state?.text ?? "" + + font.pointSize: 10 + font.family: "Fira Sans" + font.strikeout: textWidget.state.strikeout + color: { + if (textWidget.state.state?.class == "running") + return "white"; + if (textWidget.state.state?.class == "over") + return "#f28a21"; + return "#555"; + } + } + } + } + } + + WrapperMouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + + cursorShape: Qt.PointingHandCursor + onClicked: { + Worktime.time.running = true; + Worktime.today.running = true; + } + + Rectangle { + anchors.fill: parent + color: { + if (mouseArea.containsMouse) + return "#33808080"; + return "transparent"; + } + + Row { + height: parent.height + anchors.centerIn: parent + spacing: 0 + + TextWidget { + id: timeWidget + state: Worktime.time + } + + TextWidget { + id: todayWidget + state: Worktime.today + } + + MaterialDesignIcon { + id: icon + + anchors.verticalCenter: parent.verticalCenter + + implicitSize: 14 + + visible: !timeWidget.visible && !todayWidget.visible + + icon: (Worktime.time.running || Worktime.today.running) ? "update" : "timer-off" + color: "#555" + } + } + } + } + + component WorktimePopup : PopupWindow { + id: tooltip + + required property var state + required property var mouseArea + + property bool iconVisible: tooltip.state.running || !tooltip.state.updating + property bool tooltipVisible: tooltip.state.state?.tooltip ?? false + + property bool nextVisible: (tooltip.tooltipVisible || tooltip.iconVisible) && (tooltip.mouseArea.containsMouse || tooltipMouseArea.containsMouse) + + anchor { + item: tooltip.mouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: (tooltipIcon.visible ? tooltipIcon.implicitWidth : 0) + (tooltipIcon.visible && tooltipText.visible ? 8 : 0) + (tooltipText.visible ? tooltipText.implicitWidth : 0) + 16 + implicitHeight: tooltipText.implicitHeight + 16 + color: "black" + + WrapperMouseArea { + id: tooltipMouseArea + + enabled: true + hoverEnabled: true + + anchors.fill: parent + + Item { + anchors.fill: parent + + Row { + id: tooltipLayout + + anchors { + left: parent.left + top: parent.top + leftMargin: 8 + topMargin: 8 + verticalCenter: parent.verticalCenter + } + + height: parent.height + width: childrenRect.width + + spacing: 0 + + MaterialDesignIcon { + id: tooltipIcon + + implicitSize: 14 + anchors.verticalCenter: parent.verticalCenter + + visible: tooltip.iconVisible + + icon: tooltip.state.running ? "update" : "timer-off" + } + + Item { + visible: tooltipIcon.visible && tooltipText.visible + height: parent.height + width: 8 + } + + Text { + id: tooltipText + + visible: tooltip.tooltipVisible + + anchors.verticalCenter: parent.verticalCenter + + font.pointSize: 10 + font.family: "Fira Sans" + color: "white" + + text: tooltip.state.state?.tooltip ?? "" + } + } + } + } + } + + WorktimePopup { + state: Worktime.time + mouseArea: timeWidget.mouseArea + } + WorktimePopup { + state: Worktime.today + mouseArea: todayWidget.mouseArea + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/YtDlpWidget.qml b/accounts/gkleen@skadhi/shell/quickshell/YtDlpWidget.qml new file mode 100644 index 00000000..ff338d49 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/YtDlpWidget.qml @@ -0,0 +1,190 @@ +import QtQml.Models +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls.Material +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Custom as Custom + +Item { + id: root + + height: parent.height + width: icon.width + anchors.verticalCenter: parent.verticalCenter + + visible: Boolean(unitsModel.values.length) + + WrapperMouseArea { + id: widgetMouseArea + + anchors.fill: parent + hoverEnabled: true + + Item { + anchors.fill: parent + + MaterialDesignIcon { + id: icon + + implicitSize: 14 + anchors.centerIn: parent + + icon: "tray-arrow-down" + color: "white" + } + } + } + + PopupWindow { + id: tooltip + + property bool openPopup: false + property bool nextVisible: widgetMouseArea.containsMouse || tooltipMouseArea.containsMouse || openPopup + + anchor { + item: widgetMouseArea + edges: Edges.Bottom | Edges.Left + } + visible: false + + onNextVisibleChanged: hangTimer.restart() + + Timer { + id: hangTimer + interval: 100 + onTriggered: tooltip.visible = tooltip.nextVisible + } + + implicitWidth: tooltipContent.width + implicitHeight: tooltipContent.height + color: "transparent" + + Rectangle { + width: tooltip.width + height: tooltipLayout.childrenRect.height + 16 + color: "black" + } + + WrapperItem { + id: tooltipContent + + WrapperMouseArea { + id: tooltipMouseArea + + hoverEnabled: true + enabled: true + + WrapperItem { + margin: 8 + + child: GridLayout { + id: tooltipLayout + + columns: 2 + } + + Repeater { + model: ScriptModel { + id: unitsModel + + values: [...Custom.Systemd.userUnits.values].filter(unit => unit.id.match(/^yt-dlp@.+\.service$/) && unit.activeState === "active" && unit.runtimeDirectory) + } + + Item { + id: unitDelegate + + Component.onCompleted: { + while (children.length) { children[0].parent = tooltipLayout; } + } + + required property var modelData + required property int index + + property var state: null + + Socket { + id: socket + + path: Quickshell.env("XDG_RUNTIME_DIR") + "/" + unitDelegate.modelData.runtimeDirectory[0] + "/status.sock" + connected: true + onError: e => { + console.warn("YtDlpWidget: Error in Socket:", socket.path, e); + } + parser: SplitParser { + onRead: line => { + try { + unitDelegate.state = JSON.parse(line) + } catch (e) { + console.warn("YtDlpWidget: Failed to parse state:", line, e) + } + } + } + } + + WrapperItem { + Layout.column: 0 + Layout.row: unitDelegate.index + + implicitWidth: descText.contentWidth + implicitHeight: descText.contentHeight + + Text { + id: descText + + color: "white" + font.pointSize: 10 + font.family: "Fira Sans" + + text: unitDelegate.state?.title || unitDelegate.modelData.id + } + } + + /* + WrapperItem { + Layout.column: 1 + Layout.row: unitDelegate.index + + implicitWidth: fragText.contentWidth + implicitHeight: fragText.contentHeight + + visible: Boolean(unitDelegate.state) + + Text { + id: fragText + + color: "white" + font.pointSize: 10 + font.family: "Fira Sans" + + text: `${unitDelegate.state?.fragment_index}/${unitDelegate.state?.fragment_count}` + } + } + */ + + WrapperItem { + Layout.column: 1 + Layout.row: unitDelegate.index + + visible: Boolean(unitDelegate.state) + + ProgressBar { + id: progress + + Material.background: "black" + Material.accent: "white" + + from: 0 + to: unitDelegate.state?.total_bytes || unitDelegate.state?.total_bytes_estimate || 0 + value: unitDelegate.state?.downloaded_bytes || 0 + indeterminate: !Boolean(unitDelegate.state?.total_bytes || unitDelegate.state?.total_bytes_estimate) || unitDelegate.state?.status !== "downloading" + } + } + } + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/displaymanager.qml b/accounts/gkleen@skadhi/shell/quickshell/displaymanager.qml new file mode 100644 index 00000000..b452c03d --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/displaymanager.qml @@ -0,0 +1,115 @@ +//@ pragma UseQApplication + +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Services.Greetd +import QtQml + + +ShellRoot { + id: displaymanager + + settings.watchFiles: false + + property string currentText: "" + property string username: @username@ + property list command: @niri_session@ + property list messages: [] + property bool responseRequired: false + property bool responseVisible: false + + signal startAuth() + + onStartAuth: { + if (Greetd.state !== GreetdState.Inactive) + Greetd.cancelSession(); + displaymanager.messages = []; + Greetd.createSession(displaymanager.username); + } + + Connections { + target: Greetd + function onStateChanged() { + console.log("greetd state: ", GreetdState.toString(Greetd.state)); + if (Greetd.state === GreetdState.ReadyToLaunch) + Greetd.launch(displaymanager.command); + } + function onAuthMessage(message: string, error: bool, responseRequired: bool, echoResponse: bool) { + displaymanager.responseVisible = echoResponse; + displaymanager.responseRequired = responseRequired; + displaymanager.messages = Array.from(displaymanager.messages).concat([{ "text": message, "error": error }]); + } + function onAuthFailure(message: string) { + displaymanager.responseRequired = false; + displaymanager.messages = Array.from(displaymanager.messages).concat([{ "text": message, "error": true }]); + } + } + + Component.onCompleted: { + if (Greetd.state !== GreetdState.Inactive) + Greetd.cancelSession(); + } + + Variants { + model: Quickshell.screens + + delegate: Scope { + id: screenScope + + required property var modelData + + PanelWindow { + color: "black" + + screen: screenScope.modelData + + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + LockSurface { + id: surfaceContent + + screen: screenScope.modelData + + onCurrentTextChanged: displaymanager.currentText = currentText + Connections { + target: displaymanager + function onCurrentTextChanged() { surfaceContent.currentText = displaymanager.currentText; } + function onMessagesChanged() { surfaceContent.messages = Array.from(displaymanager.messages); } + function onResponseRequiredChanged() { surfaceContent.responseRequired = displaymanager.responseRequired; } + function onResponseVisibleChanged() { surfaceContent.responseVisible = displaymanager.responseVisible; } + } + + onResponse: responseText => Greetd.respond(responseText); + Connections { + target: Greetd + function onStateChanged() { + if (Greetd.state === GreetdState.Authenticating) { + surfaceContent.authRunning = true; + } else { + surfaceContent.authRunning = false; + } + } + } + + onAuthRunningChanged: { + if (surfaceContent.authRunning && Greetd.state !== GreetdState.Authenticating) + displaymanager.startAuth(); + } + Component.onCompleted: { + surfaceContent.authRunning = Greetd.state === GreetdState.Authenticating + surfaceContent.messages = Array.from(displaymanager.messages); + surfaceContent.responseVisible = displaymanager.responseVisible; + surfaceContent.responseRequired = displaymanager.responseRequired; + surfaceContent.currentText = displaymanager.currentText; + } + } + } + } + } +} diff --git a/accounts/gkleen@skadhi/shell/quickshell/shell.qml b/accounts/gkleen@skadhi/shell/quickshell/shell.qml new file mode 100644 index 00000000..1a5875f0 --- /dev/null +++ b/accounts/gkleen@skadhi/shell/quickshell/shell.qml @@ -0,0 +1,53 @@ +//@ pragma UseQApplication + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + settings.watchFiles: false + + Variants { + model: Quickshell.screens + + delegate: Scope { + id: screenScope + + required property var modelData + + PanelWindow { + id: bgWindow + + screen: screenScope.modelData + + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.namespace: "background" + exclusionMode: ExclusionMode.Ignore + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + color: "black" + + WallpaperBackground { + screen: bgWindow.screen.name + } + } + + Bar { + screen: screenScope.modelData + } + } + } + + Lockscreen {} + NiriIdle {} + + VolumeOSD {} + BrightnessOSD {} + + NotificationDisplay {} + + UnixIPC {} +} diff --git a/accounts/gkleen@skadhi/zsh/default.nix b/accounts/gkleen@skadhi/zsh/default.nix new file mode 100644 index 00000000..ddc69f0d --- /dev/null +++ b/accounts/gkleen@skadhi/zsh/default.nix @@ -0,0 +1,106 @@ +{ pkgs, lib, config, sysConfig, flakeInputs, ... }: +let + zshrc = pkgs.resholve.mkDerivation { + pname = "zshrc"; + version = "0.0.0"; + + src = ./zshrc; + + dontUnpack = true; + dontConfigure = true; + dontBuild = true; + + installPhase = '' + mkdir -p $out/share + install "$src" $out/share/zshrc + ''; + + solutions = { + default = { + scripts = [ "share/zshrc" ]; + interpreter = "none"; + inputs = with pkgs; [ + coreutils + rpm + binutils + squashfsTools + unzip + config.programs.git.package + magickWrapped + curl + file + gnutar + cpio + magic-wormhole + config.programs.zsh.package + util-linux + findutils + qrencode + tty-clock + config.programs.jq.package + eza + less + sysConfig.systemd.package + sysConfig.programs.ssh.package + gnused + miniserve + p7zip + ]; + execer = with pkgs; [ + "cannot:${lib.getExe' rpm "rpm2cpio"}" + "cannot:${lib.getExe' squashfsTools "unsquashfs"}" + "cannot:${lib.getExe' unzip "unzip"}" + "cannot:${lib.getExe config.programs.git.package}" + "cannot:${lib.getExe cpio}" + "cannot:${lib.getExe' magic-wormhole "wormhole"}" + "cannot:${lib.getExe' fuse "fusermount"}" + "cannot:${lib.getExe less}" + "cannot:${lib.getExe' sysConfig.systemd.package "systemctl"}" + "cannot:${lib.getExe sysConfig.programs.ssh.package}" + "cannot:${lib.getExe' p7zip "7z"}" + ]; + wrapper = with pkgs; [ + "${lib.getExe' magickWrapped "magick"}:${lib.getExe' imagemagick "magick"}" + ]; + fake = { + builtin = ["print"]; + external = lib.mapAttrsToList (_: cfg: cfg.program) sysConfig.security.wrappers; + }; + }; + }; + }; + magickWrapped = pkgs.symlinkJoin { + inherit (pkgs.imagemagick) name; + paths = [ pkgs.imagemagick ]; + + buildInputs = with pkgs; [ makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/magick \ + --prefix PATH : ${lib.makeBinPath (with pkgs; [ ghostscript ])} + ''; + }; +in { + config = { + programs.zsh.initContent = '' + source ${zshrc}/share/zshrc + ''; + programs.zsh.dirHashes = let + flakeHashes = lib.mapAttrs' (n: v: lib.nameValuePair (inputNames.${n} or n) (toString v)) flakeInputs; + inputNames = { + self = "nixos"; + }; + in flakeHashes // { + docs = "$HOME/documents"; + dl = "$HOME/Downloads"; + scrot = "$HOME/screenshots"; + media = "$HOME/media"; + }; + programs.starship.settings = { + directory.substitutions = lib.mapAttrs' (name: value: let + value' = builtins.unsafeDiscardStringContext value; + value'' = if lib.hasPrefix "$HOME/" value' then "~" + lib.removePrefix "$HOME" value' else value'; + in lib.nameValuePair value'' "~${name}" + ) config.programs.zsh.dirHashes; + }; + }; +} diff --git a/accounts/gkleen@skadhi/zsh/zshrc b/accounts/gkleen@skadhi/zsh/zshrc new file mode 100644 index 00000000..c532b308 --- /dev/null +++ b/accounts/gkleen@skadhi/zsh/zshrc @@ -0,0 +1,298 @@ +dir() { + curlArchive=false + templateArchive="" + repoUrl="" + dir="" + forceShell=false + wormhole=false + gitWorktree="" + modifyPDF="" + miniserve=false + + while getopts ':t:a:d:ir:wg:p:m' arg; do + case $arg in + "t") ;; + "a") + if [[ ${OPTARG} =~ "://" ]]; then + templateArchive=${OPTARG} + curlArchive=true + else + templateArchive=${OPTARG:a} + fi + ;; + "d") dir=${OPTARG} ;; + "i") forceShell=true ;; + "r") repoUrl=${OPTARG} ;; + "w") wormhole=true ;; + "g") gitWorktree=${OPTARG} ;; + "p") modifyPDF=${OPTARG:a} ;; + "m") miniserve=true ;; + *) printf "Invalid option: %s\n" $arg >&2; exit 2 ;; + esac + done + + shift $((OPTIND - 1)) + + if [[ -z ${dir} && ${#@} -ge 1 ]]; then + dir=${1} + shift + fi + + [[ -n ${dir} ]] || return 2; + + if [[ ! -e ${dir} ]]; then + if [[ -z "${gitWorktree}" ]]; then + mkdir -vp ${dir} + else + git -C ${gitWorktree} worktree add ${dir} + fi + else + gitWorktree="" + fi + + miniservePIDFile="" + if [[ ${miniserve} = "true" ]]; then + miniservePIDFile=$(mktemp --tmpdir --suffix=.pid) + fi + + cleanup() + { + if [[ -n ${modifyPDF} ]]; then + cd ${modifyPDF:h} + typeset -a pages + eval 'pages=(${dir}/${modifyPDF:t:r}_*.png(on))' + magick -verbose "${pages[@]}" $(mktemp --tmpdir=${modifyPDF:h} ${modifyPDF:t:r}_XXXXXXXXXX.${modifyPDF:e}) + modifyPDF="" + fi + if [[ -n ${miniservePIDFile} ]]; then + command kill --verbose -- $(cat ${miniservePIDFile}) && wait $(cat ${miniservePIDFile}) + miniservePIDFile="" + fi + } + + ( + set -o localoptions -o localtraps + trap 'return 1' INT TERM + trap cleanup EXIT + + cd ${dir} + export dir; + + [[ -n ${repoUrl} ]] && git clone -- ${repoUrl} . + + [[ -n ${modifyPDF} ]] && templateArchive=${modifyPDF} + + if [[ -n ${templateArchive} ]]; then + ( + archiveFile="" + cleanup() { + [[ -n "${archiveFile}" ]] && rm -fv ${archiveFile} + } + trap cleanup EXIT + + if [[ $curlArchive = "true" ]]; then + archiveFile=$(mktemp -t "archive.XXXXXXXXXX.${templateArchive:t}") + + curl -SfL -o ${archiveFile} ${templateArchive} + + templateArchive=${archiveFile} + fi + + unpack=true + while [[ $unpack = "true" ]]; do + case $(file --brief --mime-type --dereference ${templateArchive}) in + application/zip) + unzip ${templateArchive} + unpack=false + ;; + application/vnd.debian.binary-package) + ar x ${templateArchive} + mkdir control data + tar -C control -xvaf control.* + tar -C data -xvaf data.* + unpack=false + ;; + application/x-rpm) + cpioArchive=$(mktemp -t "archive.XXXXXXXXXX.${templateArchive:t:r}.cpio") + rpm2cpio ${templateArchive} > ${cpioArchive} + templateArchive=${cpioArchive} + unpack=true + ;; + application/x-cpio) + cpio --extract --make-directories --no-absolute-filenames -F ${templateArchive} + unpack=false + ;; + application/pdf) + magick -verbose -density 400 ${templateArchive} ${modifyPDF:t:r}_%0d.png + unpack=false + ;; + application/octet-stream) + if [[ $(file --brief --dereference ${templateArchive}) =~ Squashfs ]]; then + unsquashfs -d . ${templateArchive} + unpack=false + fi + ;; + application/x-iso9660-image) + 7z x ${templateArchive} + unpack=false + ;; + *) + tar -xvaf ${templateArchive} + unpack=false + ;; + esac + done + ) + fi + + + [[ $wormhole = "true" ]] && wormhole receive --accept-file + + + if [[ ${#@} -gt 0 ]]; then + ${@} + fi + + cd $(pwd) # Needed for mounting to work + + if [[ ${miniserve} = "true" ]]; then + miniserve --random-route --hidden --enable-tar-gz --enable-zip . & + echo $! > "${miniservePIDFile}" + fi + + if [[ ${#@} -eq 0 ]] && [[ ${miniserve} != "true" ]] || [[ $forceShell = "true" ]]; then + isSingleDir() { + typeset -a contents + contents=(*(N) .*(N)) + + if [[ ${#contents} -eq 1 && -d ${contents[1]} ]]; then + print ${contents[1]} + return 0 + else + return 1 + fi + } + while d=$(isSingleDir); do cd ${d}; done + + zsh + elif [[ ${miniserve} == "true" ]]; then + wait $(cat "${miniservePIDFile}") + fi + ) +} + +tmpdir() { + cleanup() + { + cd / + unmount() { + printf "Unmounting %s\n" ${1} >&2 + fusermount3 -u ${1} || umount ${1} || sudo umount ${1} + } + + if [[ -n ${dir} ]]; then + if mountpoint -q -- ${dir}; then + unmount ${dir} || return $? + else + while read -d $'\0' subDir; do + mountpoint -q -- ${subDir} || continue + unmount ${subDir} || return $? + done <<<$(find ${dir} -xdev -type d -print0 | sort -zr) + fi + + rm -rfv --one-file-system -- ${dir} + dir="" + fi + } + + local tmpdir="" + + while getopts ':t:a:d:ir:wg:p:m' arg; do + case $arg in + "t") tmpdir="=${OPTARG}" ;; + "?"|":") printf "Invalid option: %s\n" $arg >&2; exit 2 ;; + esac + done + + ( + set -o localoptions -o localtraps + trap 'return 1' INT TERM + trap cleanup EXIT + + + local dir=$(mktemp -ud --tmpdir${tmpdir} ${0}.XXXXXXXXXX || return $?) + + ( dir -d ${dir} ${@} ) + ) +} + +qr() { + qrencode -l M -o - -t ANSIUTF8 $@ +} + +clock() { + tty-clock -sSbc -C 7 -d 0 -a 100000000 +} + +public-ip() { + curl -sSf -H 'Accept: application/json' $@ ifconfig.co | jq -r '.ip' +} + +swap() { + f1=${1} + f2=${2} + + if [[ -z "${f1}" || ! -e "${f1}" ]]; then + printf "‘%s’ does not exist\n" "${f1}" >&2 + return 2 + fi + if [[ -z "${f2}" || ! -e "${f2}" ]]; then + printf "‘%s’ does not exist\n" "${f2}" >&2 + return 2 + fi + + tmpfile=$(mktemp --dry-run --tmpdir=${f1:h} .swap.XXXXXXXXXX) + mv -v ${f1} ${tmpfile} + mv -v ${f2} ${f1} + mv -v ${tmpfile} ${f2} +} + +ls() { + eza --all --group-directories-first --colour=always --sort=modified --smart-group $@ | less --mouse -FR +} + +l() { + ls --long --binary --git --time-style=iso --header $@ +} + +ssh-installer() { + ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentityFile=~/.ssh/gkleen@sif.midgard.yggdrasil $@ +} + +dichotomic_search() { + min=$1; shift + max=$1; shift + + while [[ $min -lt $max ]]; do + # Compute the mean between min and max, rounded up to the superior unit + current=$(( (min + max + 1 ) / 2 )) + if $@ $current; then + min=$current + else + max=$((current - 1)) + fi + done + + echo $min +} + +while read -r -d $'\0' d _ s; do + hash -d ${s}=${d} +done < <(find ~/projects ~/uni -regextype posix-extended -maxdepth 2 -type d -regex '\S+/[0-9]{2}[ws]/\S+' -print0 2>/dev/null | \ + sed -zr 's|(.*/([0-9]{2}[ws])/(.+))|\1 \2 \3|' | \ + sort -z -r -k2 | sort -z -s -k3 | uniq -z -f 2) + +alias rzadm=$'tmpdir -i sh -c \'mkdir adm; sshfs gkleen@mgmt01:/adm adm\'' +alias mathcloud=$'tmpdir -i rclone mount --daemon mathcloud:// .' + +export DEFAULT_USER=gkleen -- cgit v1.2.3