diff --git a/.gitignore b/.gitignore index a3f8e63..0f49322 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ **/node_modules/** **/*.db + +# Purchased wallpapers - not for distribution +shared/modules/services/wallpapers/**/*.jpg +shared/modules/services/wallpapers/**/*.png +shared/modules/services/wallpapers/**/*.jpeg +shared/modules/services/wallpapers/**/*.webp +shared/modules/services/wallpapers/**/*.avif diff --git a/flake.lock b/flake.lock index e1d1cb5..6aab55a 100644 --- a/flake.lock +++ b/flake.lock @@ -94,11 +94,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1765186076, - "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", + "lastModified": 1769018530, + "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=", "owner": "nixos", "repo": "nixpkgs", - "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", + "rev": "88d3861acdd3d2f0e361767018218e51810df8a1", "type": "github" }, "original": { @@ -110,11 +110,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1765311797, - "narHash": "sha256-mSD5Ob7a+T2RNjvPvOA1dkJHGVrNVl8ZOrAwBjKBDQo=", + "lastModified": 1769089682, + "narHash": "sha256-9yA/LIuAVQq0lXelrZPjLuLVuZdm03p8tfmHhnDIkms=", "owner": "nixos", "repo": "nixpkgs", - "rev": "09eb77e94fa25202af8f3e81ddc7353d9970ac1b", + "rev": "078d69f03934859a181e81ba987c2bb033eebfc5", "type": "github" }, "original": { diff --git a/nate-work/desktop-configuration.nix b/nate-work/desktop-configuration.nix index 8eee74d..3d6fa2a 100644 --- a/nate-work/desktop-configuration.nix +++ b/nate-work/desktop-configuration.nix @@ -1,13 +1,13 @@ { config, lib, inputs, outputs, pkgs, timeZone, system, ... }: let - supportedDesktops = [ "sway" "hyprland" ]; + supportedDesktops = [ "sway" "hyprland" "niri" ]; supportedDesktopsStr = lib.strings.concatStringsSep ", " supportedDesktops; deskCfg = config.deskCfg; in { options.deskCfg = { de = lib.mkOption { - default = "sway"; + default = "niri"; type = lib.types.str; description = "Desktop Environment"; }; @@ -30,8 +30,10 @@ in modules/user/main_user.nix modules/sway/sway_conf.nix modules/hypr/hyprland.nix + modules/niri/niri_conf.nix ../shared/modules/system/power_manager.nix ../shared/modules/services/motu-m4-combined.nix + # ../shared/modules/services/theme_switcher/default.nix # inputs.nur.hmModules.nur ]; @@ -88,7 +90,16 @@ in }; hypr = { + enable = false; + user = deskCfg.userName; + systemPackages = with pkgs; [ + libreoffice + ]; + }; + + niriwm = { enable = true; + useNonFree = true; user = deskCfg.userName; systemPackages = with pkgs; [ libreoffice diff --git a/nate-work/dotfiles/swaync/style.css b/nate-work/dotfiles/swaync/style.css deleted file mode 100644 index 328f344..0000000 --- a/nate-work/dotfiles/swaync/style.css +++ /dev/null @@ -1,342 +0,0 @@ -* { - all: unset; - font-size: 14px; - font-family: "Ubuntu Nerd Font"; - transition: 200ms; -} - -trough highlight { - background: #cad3f5; -} - -scale trough { - margin: 0rem 1rem; - background-color: #363a4f; - min-height: 8px; - min-width: 70px; -} - -slider { - background-color: #8aadf4; -} - -.floating-notifications.background .notification-row .notification-background { - box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.8), inset 0 0 0 1px #363a4f; - border-radius: 12.6px; - margin: 18px; - background-color: #24273a; - color: #cad3f5; - padding: 0; -} - -.floating-notifications.background .notification-row .notification-background .notification { - padding: 7px; - border-radius: 12.6px; -} - -.floating-notifications.background .notification-row .notification-background .notification.critical { - box-shadow: inset 0 0 7px 0 #ed8796; -} - -.floating-notifications.background .notification-row .notification-background .notification .notification-content { - margin: 7px; -} - -.floating-notifications.background .notification-row .notification-background .notification .notification-content .summary { - color: #cad3f5; -} - -.floating-notifications.background .notification-row .notification-background .notification .notification-content .time { - color: #a5adcb; -} - -.floating-notifications.background .notification-row .notification-background .notification .notification-content .body { - color: #cad3f5; -} - -.floating-notifications.background .notification-row .notification-background .notification > *:last-child > * { - min-height: 3.4em; -} - -.floating-notifications.background .notification-row .notification-background .notification > *:last-child > * .notification-action { - border-radius: 7px; - color: #cad3f5; - background-color: #363a4f; - box-shadow: inset 0 0 0 1px #494d64; - margin: 7px; -} - -.floating-notifications.background .notification-row .notification-background .notification > *:last-child > * .notification-action:hover { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #363a4f; - color: #cad3f5; -} - -.floating-notifications.background .notification-row .notification-background .notification > *:last-child > * .notification-action:active { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #7dc4e4; - color: #cad3f5; -} - -.floating-notifications.background .notification-row .notification-background .close-button { - margin: 7px; - padding: 2px; - border-radius: 6.3px; - color: #24273a; - background-color: #ed8796; -} - -.floating-notifications.background .notification-row .notification-background .close-button:hover { - background-color: #ee99a0; - color: #24273a; -} - -.floating-notifications.background .notification-row .notification-background .close-button:active { - background-color: #ed8796; - color: #24273a; -} - -.control-center { - box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.8), inset 0 0 0 1px #363a4f; - border-radius: 12.6px; - margin: 18px; - background-color: #24273a; - color: #cad3f5; - padding: 14px; -} - -.control-center .widget-title > label { - color: #cad3f5; - font-size: 1.3em; -} - -.control-center .widget-title button { - border-radius: 7px; - color: #cad3f5; - background-color: #363a4f; - box-shadow: inset 0 0 0 1px #494d64; - padding: 8px; -} - -.control-center .widget-title button:hover { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #5b6078; - color: #cad3f5; -} - -.control-center .widget-title button:active { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #7dc4e4; - color: #24273a; -} - -.control-center .notification-row .notification-background { - border-radius: 7px; - color: #cad3f5; - background-color: #363a4f; - box-shadow: inset 0 0 0 1px #494d64; - margin-top: 14px; -} - -.control-center .notification-row .notification-background .notification { - padding: 7px; - border-radius: 7px; -} - -.control-center .notification-row .notification-background .notification.critical { - box-shadow: inset 0 0 7px 0 #ed8796; -} - -.control-center .notification-row .notification-background .notification .notification-content { - margin: 7px; -} - -.control-center .notification-row .notification-background .notification .notification-content .summary { - color: #cad3f5; -} - -.control-center .notification-row .notification-background .notification .notification-content .time { - color: #a5adcb; -} - -.control-center .notification-row .notification-background .notification .notification-content .body { - color: #cad3f5; -} - -.control-center .notification-row .notification-background .notification > *:last-child > * { - min-height: 3.4em; -} - -.control-center .notification-row .notification-background .notification > *:last-child > * .notification-action { - border-radius: 7px; - color: #cad3f5; - background-color: #181926; - box-shadow: inset 0 0 0 1px #494d64; - margin: 7px; -} - -.control-center .notification-row .notification-background .notification > *:last-child > * .notification-action:hover { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #363a4f; - color: #cad3f5; -} - -.control-center .notification-row .notification-background .notification > *:last-child > * .notification-action:active { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #7dc4e4; - color: #cad3f5; -} - -.control-center .notification-row .notification-background .close-button { - margin: 7px; - padding: 2px; - border-radius: 6.3px; - color: #24273a; - background-color: #ee99a0; -} - -.close-button { - border-radius: 6.3px; -} - -.control-center .notification-row .notification-background .close-button:hover { - background-color: #ed8796; - color: #24273a; -} - -.control-center .notification-row .notification-background .close-button:active { - background-color: #ed8796; - color: #24273a; -} - -.control-center .notification-row .notification-background:hover { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #8087a2; - color: #cad3f5; -} - -.control-center .notification-row .notification-background:active { - box-shadow: inset 0 0 0 1px #494d64; - background-color: #7dc4e4; - color: #cad3f5; -} - -.notification.critical progress { - background-color: #ed8796; -} - -.notification.low progress, -.notification.normal progress { - background-color: #8aadf4; -} - -.control-center-dnd { - margin-top: 5px; - border-radius: 8px; - background: #363a4f; - border: 1px solid #494d64; - box-shadow: none; -} - -.control-center-dnd:checked { - background: #363a4f; -} - -.control-center-dnd slider { - background: #494d64; - border-radius: 8px; -} - -.widget-dnd { - margin: 0px; - font-size: 1.1rem; -} - -.widget-dnd > switch { - font-size: initial; - border-radius: 8px; - background: #363a4f; - border: 1px solid #494d64; - box-shadow: none; -} - -.widget-dnd > switch:checked { - background: #363a4f; -} - -.widget-dnd > switch slider { - background: #494d64; - border-radius: 8px; - border: 1px solid #6e738d; -} - -.widget-mpris .widget-mpris-player { - background: #363a4f; - padding: 7px; -} - -.widget-mpris .widget-mpris-title { - font-size: 1.2rem; -} - -.widget-mpris .widget-mpris-subtitle { - font-size: 0.8rem; -} - -.widget-menubar > box > .menu-button-bar > button > label { - font-size: 3rem; - padding: 0.5rem 2rem; -} - -.widget-menubar > box > .menu-button-bar > :last-child { - color: #ed8796; -} - -.power-buttons button:hover, -.powermode-buttons button:hover, -.screenshot-buttons button:hover { - background: #363a4f; -} - -.control-center .widget-label > label { - color: #cad3f5; - font-size: 2rem; -} - -.widget-buttons-grid { - padding-top: 1rem; -} - -.widget-buttons-grid > flowbox > flowboxchild > button label { - font-size: 2.5rem; -} - -.widget-volume { - padding-top: 1rem; -} - -.widget-volume label { - font-size: 1.5rem; - color: #7dc4e4; -} - -.widget-volume trough highlight { - background: #7dc4e4; -} - -.widget-backlight trough highlight { - background: #eed49f; -} - -.widget-backlight label { - font-size: 1.5rem; - color: #eed49f; -} - -.widget-backlight .KB { - padding-bottom: 1rem; -} - -.image { - padding-right: 0.5rem; -} diff --git a/nate-work/linked-dotfiles/hypr/hyprland.conf b/nate-work/linked-dotfiles/hypr/hyprland.conf index 4da96c5..4dcb30c 100644 --- a/nate-work/linked-dotfiles/hypr/hyprland.conf +++ b/nate-work/linked-dotfiles/hypr/hyprland.conf @@ -56,9 +56,10 @@ general { # Decoration decoration { rounding = 12 - inactive_opacity = 0.9 + active_opacity = 0.98 + inactive_opacity = 0.95 dim_inactive = true - dim_strength = 0.1 + dim_strength = 0.03 blur { enabled = true @@ -67,7 +68,7 @@ decoration { } shadow { - enabled = false + enabled = true } } diff --git a/nate-work/linked-dotfiles/niri/config.kdl b/nate-work/linked-dotfiles/niri/config.kdl new file mode 100644 index 0000000..eafcc4d --- /dev/null +++ b/nate-work/linked-dotfiles/niri/config.kdl @@ -0,0 +1,374 @@ +// +// MISCELLANEOUS +// + +// gui startup +spawn-at-startup "waybar" +spawn-at-startup "keepassxc" +spawn-at-startup "flatpak" "run" "org.signal.Signal" +// shell startup +spawn-sh-at-startup "kanshi" +spawn-sh-at-startup "sleep 5 && nm-applet --indicator" +spawn-sh-at-startup "sleep 5 && syncthingtray --wait" +spawn-sh-at-startup "sleep 5 && swaync" + +screenshot-path null // save screenshots just to clipboard +prefer-no-csd // (Client Side Decorations) ask clients to not add their own decorations + +hotkey-overlay { + skip-at-startup +} + +// +// OUTPUTS +// +output "HDMI-A-1" { + scale 1.0 + variable-refresh-rate on-demand=true + transform "normal" +} + +workspace "chat" { + layout { + always-center-single-column + } +} +workspace "net" +workspace "term" +workspace "scratch" + +// +// INPUTS +// +input { + keyboard { + xkb { + layout "us" + } + repeat-delay 175 + repeat-rate 50 + } + + touchpad { + tap + dwt + dwtp + natural-scroll + accel-speed 0.2 + accel-profile "adaptive" + } + + mouse { + accel-speed 0.2 + accel-profile "adaptive" + } + + trackpoint { + accel-speed 0.2 + accel-profile "adaptive" + } + +} + +// +// LAYOUT +// +layout { + gaps 2 + + center-focused-column "on-overflow" + always-center-single-column + default-column-display "normal" + + tab-indicator { + hide-when-single-tab + } + + preset-column-widths { + proportion 0.33333 + proportion 0.5 + proportion 0.66667 + } + + default-column-width { + proportion 0.5; + } + + focus-ring { + width 0 + } + + border { + active-gradient from="#eaa4a8" to="#cbeaa6" angle=45 in="oklch longer hue" relative-to="workspace-view" + inactive-gradient from="#886D59" to="#517B65" angle=45 in="oklch longer hue" relative-to="workspace-view" + } + + shadow { + softness 30 + spread 5 + offset x=8 y=8 + draw-behind-window true + color "#00444444" + } + + struts { + left 10 + right 10 + top 10 + bottom 10 + } +} + + +// +// WINDOW RULES +// + +// All windows - corner radius, opacity +window-rule { + geometry-corner-radius 14 14 0 14 + clip-to-geometry true +} + +window-rule { + match is-active=true + opacity 0.99 +} + +window-rule { + match is-floating=true + opacity 0.92 +} + +// Term windows +window-rule { + match app-id="com.mitchellh.ghostty" + + default-column-width { proportion 0.5; } + open-on-workspace "term" +} + +// Net windows +window-rule { + match app-id="firefox" + + default-column-width { proportion 1.0; } + open-on-workspace "net" +} + +// Scratch windows +window-rule { + match app-id=r#"^org\.keepassxc\.KeePassXC$"# + match app-id=r#"^org\.signal\.Signal$"# + + block-out-from "screencast" + open-on-workspace "scratch" +} + +// Bright border on screen-shared windows +window-rule { + match is-window-cast-target=true + + focus-ring { + active-color "#f38ba8" + inactive-color "#7d0d2d" + } + + border { + inactive-color "#7d0d2d" + } + + shadow { + color "#7d0d2d70" + } + + tab-indicator { + active-color "#f38ba8" + inactive-color "#7d0d2d" + } +} + +// Block out notifications from screencasts. +layer-rule { + match namespace="^notifications$" + + block-out-from "screencast" +} + +// App specific window rules +window-rule { + match app-id=r#"^com.slack.Slack$"# + + open-on-workspace "chat" +} + +// +// BINDS +// + +binds { + Mod+Shift+Slash { show-hotkey-overlay; } + + // Terminal - consistent with sway/hyprland + Mod+Return { spawn "ghostty"; } + + // Application launcher - consistent with sway/hyprland + Mod+D { spawn "wofi" "--show" "drun"; } + + // File manager - consistent with sway/hyprland + Mod+T { spawn "nautilus"; } + + // Browser - consistent with sway/hyprland + Mod+W { spawn "firefox"; } + + // Close window - consistent with sway/hyprland + Mod+Q { close-window; } + + // Arrow keys for focus movement + Mod+Left { focus-column-left; } + Mod+Down { focus-window-down; } + Mod+Up { focus-window-up; } + Mod+Right { focus-column-right; } + + // Colemak-DH keys + Mod+N { focus-column-left; } + Mod+I { focus-window-down; } + Mod+E { focus-window-up; } + Mod+O { focus-column-right; } + + // Arrow keys for window movement + Mod+Shift+Left { move-column-left; } + Mod+Shift+Down { move-window-down; } + Mod+Shift+Up { move-window-up; } + Mod+Shift+Right { move-column-right; } + + // Colemak-DH keys for window movement + Mod+Shift+N { move-column-left; } + Mod+Shift+I { move-window-down; } + Mod+Shift+E { move-window-up; } + Mod+Shift+O { move-column-right; } + + Mod+Home { focus-column-first; } + Mod+End { focus-column-last; } + Mod+Ctrl+Home { move-column-to-first; } + Mod+Ctrl+End { move-column-to-last; } + + // Monitor focus (keeping existing Shift pattern since this conflicts with window movement) + Mod+Ctrl+Left { focus-monitor-left; } + Mod+Ctrl+Down { focus-monitor-down; } + Mod+Ctrl+Up { focus-monitor-up; } + Mod+Ctrl+Right { focus-monitor-right; } + + // Colemak-DH keys for monitor focus + Mod+Ctrl+N { focus-monitor-left; } + Mod+Ctrl+I { focus-monitor-down; } + Mod+Ctrl+E { focus-monitor-up; } + Mod+Ctrl+O { focus-monitor-right; } + + // Move column to monitor + Mod+Shift+Ctrl+Left { move-column-to-monitor-left; } + Mod+Shift+Ctrl+Down { move-column-to-monitor-down; } + Mod+Shift+Ctrl+Up { move-column-to-monitor-up; } + Mod+Shift+Ctrl+Right { move-column-to-monitor-right; } + + // Colemak-DH keys for moving column to monitor + Mod+Shift+Ctrl+N { move-column-to-monitor-left; } + Mod+Shift+Ctrl+I { move-column-to-monitor-down; } + Mod+Shift+Ctrl+E { move-column-to-monitor-up; } + Mod+Shift+Ctrl+O { move-column-to-monitor-right; } + + // Workspace navigation (using Page keys to avoid conflicts with Colemak-DH movement) + Mod+Page_Down { focus-workspace-down; } + Mod+Page_Up { focus-workspace-up; } + Mod+Ctrl+Page_Down { move-column-to-workspace-down; } + Mod+Ctrl+Page_Up { move-column-to-workspace-up; } + + Mod+Shift+Page_Down { move-workspace-down; } + Mod+Shift+Page_Up { move-workspace-up; } + + Mod+Minus { focus-workspace "scratch"; } + Mod+1 { focus-workspace "term"; } + Mod+2 { focus-workspace "net"; } + Mod+3 { focus-workspace "chat"; } + Mod+4 { focus-workspace 1; } + Mod+5 { focus-workspace 2; } + Mod+6 { focus-workspace 3; } + Mod+7 { focus-workspace 4; } + Mod+8 { focus-workspace 5; } + Mod+9 { focus-workspace 6; } + Mod+Shift+Minus { move-column-to-workspace "scratch"; } + Mod+Shift+1 { move-column-to-workspace "term"; } + Mod+Shift+2 { move-column-to-workspace "net"; } + Mod+Shift+3 { move-column-to-workspace "chat"; } + Mod+Shift+4 { move-column-to-workspace 1; } + Mod+Shift+5 { move-column-to-workspace 2; } + Mod+Shift+6 { move-column-to-workspace 3; } + Mod+Shift+7 { move-column-to-workspace 4; } + Mod+Shift+8 { move-column-to-workspace 5; } + Mod+Shift+9 { move-column-to-workspace 6; } + + Mod+Comma { consume-window-into-column; } + Mod+Period { expel-window-from-column; } + + Mod+R { switch-preset-column-width; } + Mod+Shift+R { reset-window-height; } + // Fullscreen - consistent with sway/hyprland + Mod+F { fullscreen-window; } + Mod+Shift+F { maximize-column; } + + // Floating toggle + Mod+Space { toggle-window-floating; } + Mod+Shift+Space { switch-focus-between-floating-and-tiling; } + + Mod+C { center-column; } + + Mod+bracketleft { set-column-width "-10%"; } + Mod+bracketright { set-column-width "+10%"; } + + Mod+Shift+bracketleft { set-window-height "-10%"; } + Mod+Shift+bracketright { set-window-height "+10%"; } + + // + // Utilities + // + + // Notifications + Mod+Shift+C { spawn-sh "swaync-client --toggle-panel"; } + + // Screenshots - consistent with sway/hyprland + Mod+P { screenshot; } + Mod+Shift+P { screenshot-screen; } + + // Traditional screenshot keys + Print { screenshot; } + Ctrl+Print { screenshot-screen; } + Alt+Print { screenshot-window; } + + // Volume control + XF86AudioRaiseVolume { spawn "pactl" "set-sink-volume" "@DEFAULT_SINK@" "+5%"; } + XF86AudioLowerVolume { spawn "pactl" "set-sink-volume" "@DEFAULT_SINK@" "-5%"; } + XF86AudioMute { spawn "pactl" "set-sink-mute" "@DEFAULT_SINK@" "toggle"; } + XF86AudioMicMute { spawn "pactl" "set-source-mute" "@DEFAULT_SOURCE@" "toggle"; } + + // Media control + XF86AudioPlay { spawn "playerctl" "play-pause"; } + XF86AudioPause { spawn "playerctl" "pause"; } + XF86AudioNext { spawn "playerctl" "next"; } + XF86AudioPrev { spawn "playerctl" "previous"; } + XF86AudioStop { spawn "playerctl" "stop"; } + + // Brightness control + XF86MonBrightnessUp { spawn "brightnessctl" "set" "+5%"; } + XF86MonBrightnessDown { spawn "brightnessctl" "set" "5%-"; } + + Mod+Shift+Q { quit; } + Mod+Shift+Ctrl+P { power-off-monitors; } + + Mod+Shift+Ctrl+T { toggle-debug-tint; } +} + +switch-events { + // lid-close { spawn "systemctl" "suspend"; } + // lid-open { spawn "notify-send" "The laptop lid is open!"; } +} + diff --git a/nate-work/modules/home-manager/home.nix b/nate-work/modules/home-manager/home.nix index 90f81ac..80c4a61 100644 --- a/nate-work/modules/home-manager/home.nix +++ b/nate-work/modules/home-manager/home.nix @@ -12,7 +12,7 @@ imports = [ ../../../shared/modules/apps/firefox/firefox.nix - ../hypr/hypr_home.nix + ../niri/niri_home.nix ../vpn-proxy/vpn-proxy.nix ]; @@ -41,7 +41,7 @@ # Enable VPN proxy script vpnProxy.enable = true; - hyprhome = { + nirihome = { enable = true; homePackages = with pkgs; [ # @@ -57,6 +57,7 @@ cmake gcc oxker # docker desktop tui <3 + python3 ## nodejs frontend nodejs_24 husky @@ -329,6 +330,7 @@ xdg.configFile = { # Active linked dotfiles "hypr".source = config.lib.file.mkOutOfStoreSymlink "/home/nate/nixos/nate-work/linked-dotfiles/hypr"; + "niri".source = config.lib.file.mkOutOfStoreSymlink "/home/nate/nixos/nate-work/linked-dotfiles/niri"; "waybar".source = config.lib.file.mkOutOfStoreSymlink "/home/nate/nixos/nate-work/linked-dotfiles/waybar"; # Shared "helix".source = config.lib.file.mkOutOfStoreSymlink "/home/nate/nixos/shared/linked-dotfiles/helix"; diff --git a/nate-work/modules/hypr/hyprland.nix b/nate-work/modules/hypr/hyprland.nix index d34c47b..b51c3bc 100644 --- a/nate-work/modules/hypr/hyprland.nix +++ b/nate-work/modules/hypr/hyprland.nix @@ -237,6 +237,11 @@ in # package = unstable.mesa.drivers; enable32Bit = true; # package32 = unstable.pkgsi686Linux.mesa.drivers; + extraPackages = with pkgs; [ + intel-vaapi-driver + intel-media-driver + vpl-gpu-rt + ]; }; nvidia = { # Modesetting is required. @@ -275,42 +280,68 @@ in }; }; }; - # Create special on the go boot entry - # - ## Commenting out for now as it doesn't really work with the laptop well... - # - # specialisation = { - # on-the-go.configuration = { - # system.nixos.tags = [ "on-the-go" ]; + # Create special on the go boot entry for battery saving + # This disables NVIDIA GPU completely and uses Intel integrated graphics only + specialisation = { + on-the-go.configuration = { + system.nixos.tags = [ "on-the-go" ]; - # boot.extraModprobeConfig = '' - # blacklist nouveau - # options nouveau modeset=0 - # ''; + # Blacklist all NVIDIA kernel modules + boot.blacklistedKernelModules = [ "nouveau" "nvidia" "nvidia_drm" "nvidia_modeset" "nvidia_uvm" ]; + + # Force Intel i915 driver for 13th gen (Raptor Lake) integrated graphics + # Device IDs: a7a0 or a7a8 for i7-13700H + boot.kernelParams = [ + "i915.force_probe=a7a0" + "module_blacklist=nvidia,nvidia_drm,nvidia_modeset,nvidia_uvm" + ]; + + boot.extraModprobeConfig = '' + blacklist nouveau + blacklist nvidia + blacklist nvidia_drm + blacklist nvidia_modeset + blacklist nvidia_uvm + options nouveau modeset=0 + ''; - # services.udev.extraRules = '' - # # Remove NVIDIA USB xHCI Host Controller devices, if present - # ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c0330", ATTR{power/control}="auto", ATTR{remove}="1" - # # Remove NVIDIA USB Type-C UCSI devices, if present - # ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c8000", ATTR{power/control}="auto", ATTR{remove}="1" - # # Remove NVIDIA Audio devices, if present - # ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x040300", ATTR{power/control}="auto", ATTR{remove}="1" - # # Remove NVIDIA VGA/3D controller devices - # ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x03[0-9]*", ATTR{power/control}="auto", ATTR{remove}="1" - # ''; - # hardware.nvidia = { - # prime.offload.enable = lib.mkForce false; - # prime.offload.enableOffloadCmd = lib.mkForce false; - # powerManagement.finegrained = lib.mkForce false; - # prime.sync.enable = lib.mkForce false; - # }; - # boot.blacklistedKernelModules = [ "nouveau" "nvidia" "nvidia_drm" "nvidia_modeset" ]; - # # Hint to kernel for integrated graphics, ID from command `$ nix-shell -p pciutils --run "lspci -nn | grep VGA"` - # boot.kernelParams = [ "i915.force_probe=a7a0" "acpi_backlight=native" ]; - # # Default drivers - # services.xserver.videoDrivers = [ "modesetting" "fbdev" ]; - # }; - # }; + # Remove NVIDIA devices from PCI bus to save power + services.udev.extraRules = '' + # Remove NVIDIA USB xHCI Host Controller devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c0330", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA USB Type-C UCSI devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c8000", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA Audio devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x040300", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA VGA/3D controller devices + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x03[0-9]*", ATTR{power/control}="auto", ATTR{remove}="1" + ''; + + # Use Intel modesetting driver only + services.xserver.videoDrivers = lib.mkForce [ "modesetting" ]; + + # Disable all NVIDIA hardware configurations + hardware.nvidia = { + prime.offload.enable = lib.mkForce false; + prime.offload.enableOffloadCmd = lib.mkForce false; + powerManagement.finegrained = lib.mkForce false; + prime.sync.enable = lib.mkForce false; + }; + + # Ensure Intel graphics packages are available + hardware.graphics.extraPackages = lib.mkForce (with pkgs; [ + intel-vaapi-driver + intel-media-driver + vpl-gpu-rt + ]); + + # Clear NVIDIA-specific environment variables + environment.sessionVariables = { + GBM_BACKEND = lib.mkForce ""; + __GLX_VENDOR_LIBRARY_NAME = lib.mkForce ""; + }; + }; + }; nixpkgs.config.allowUnfree = true; }; } diff --git a/nate-work/modules/niri/niri_conf.nix b/nate-work/modules/niri/niri_conf.nix new file mode 100644 index 0000000..8e38a88 --- /dev/null +++ b/nate-work/modules/niri/niri_conf.nix @@ -0,0 +1,386 @@ +{ inputs, lib, config, pkgs, userName, ... }: + let + unstable = import inputs.nixpkgs-unstable { system = "x86_64-linux"; config.allowUnfree = true; }; + isOnTheGo = builtins.elem "on-the-go" config.system.nixos.tags; + in +{ + options.niriwm = { + enable = lib.mkEnableOption "Enable niri window manager."; + useNonFree = lib.mkOption { + default = false; + example = true; + description = "Whether to enable non-free software in the niri config"; + }; + systemPackages = lib.mkOption { + default = []; + description = "Add any additional packages desired. Merged with niri defaults."; + }; + user = lib.mkOption { + type = lib.types.str; + }; + + }; + + ### + ## Configuration + ### + config = lib.mkIf config.niriwm.enable { + + nixpkgs.config.allowUnfree = config.niriwm.useNonFree; + + ### + ## XDG portal setup + ### + xdg.portal = { + config = { + common = { + default = [ + "wlr" + ]; + }; + }; + extraPortals = with pkgs; [ + xdg-desktop-portal-gnome + ]; + wlr.enable = true; + enable = true; + }; + xdg.sounds.enable = true; + + ### + ## System Packages + ### + environment.systemPackages = with pkgs; lib.lists.flatten [ + [ + bash + egl-wayland + foot + unstable.ghostty + git + glib # gsettings + grim + libnotify + man-pages + man-pages-posix + nbfc-linux + nautilus + networkmanagerapplet + pavucontrol + slurp + syncthingtray + swaylock + wl-clipboard + waybar + wdisplays + wofi + xdg-utils + zsh + lxqt.lxqt-policykit + unstable.xwayland-satellite + kanshi + # Fonts + ] + config.niriwm.systemPackages + ]; + environment.variables.QT_STYLE_OVERRIDE = "kvantum"; + environment.sessionVariables = { + # use wayland + MOZ_ENABLE_WAYLAND = "1"; + T_QPA_PLATFORM = "wayland"; + GDK_BACKEND = "wayland"; + WLR_NO_HARDWARE_CURSORS = "1"; + ELECTRON_OZONE_PLATFORM_HINT = "auto"; + NIXOS_OZONE_WL = "1"; + # For NVIDIA - only enable if not using on-the-go + GBM_BACKEND = if isOnTheGo then "" else "nvidia-drm"; + __GLX_VENDOR_LIBRARY_NAME = if isOnTheGo then "" else "nvidia"; + }; + + # adds additional man pages + documentation.dev.enable = true; + + programs.steam = { + enable = true; + gamescopeSession.enable = true; + }; + programs.gamemode = { + enable = true; + settings = { + general = { + reaper_freq = 5; + desiredgov = "performance"; + softrealtime = "auto"; + }; + }; + }; + programs.kdeconnect.enable = true; + programs.niri.enable = true; + programs.virt-manager.enable = true; + programs.xfconf.enable = true; + programs.regreet.enable = true; + programs.zsh.enable = true; + programs.ssh.startAgent = false; # Using GNOME Keyring's gcr-ssh-agent instead + + # For nautilus + services.gnome.sushi.enable = true; + programs.nautilus-open-any-terminal = { + enable = true; + terminal = "ghostty"; + }; + + services.syncthing = { + enable = true; + dataDir = "/home/${config.niriwm.user}/.syncthing"; + openDefaultPorts = true; + user = config.niriwm.user; + }; + systemd.services.syncthing.environment.STNODEFAULTFOLDER = "true"; # Don't create default ~/Sync folder + + # Set zsh as the default shell system-wide + users.defaultUserShell = pkgs.zsh; + environment.shells = with pkgs; [ zsh bash ]; + + ### + ## Services + ### + virtualisation = { + docker = { + enable = true; + enableOnBoot = true; + package = unstable.docker_25; + }; + libvirtd = { + enable = true; + qemu = { + swtpm.enable = true; + }; + }; + spiceUSBRedirection.enable = true; + }; + boot.initrd.supportedFilesystems = { nfs = true; }; + users.groups.libvirtd.members = ["nate"]; + + services.blueman.enable = true; + services.gvfs.enable = true; # file manager mount, trash, etc + services.tumbler.enable = true; # thunar thumbnails + services.openssh.enable = true; + services.dbus.enable = true; + services.gnome.gnome-keyring.enable = true; + services.flatpak.enable = true; + services.usbmuxd.enable = true; + + # For yubioath desktop + services.pcscd.enable = true; + + # Printing + services.printing = { + enable = true; + browsing = true; + drivers = [ pkgs.brlaser ]; + }; + + services.avahi = { + enable = true; + nssmdns4 = true; + openFirewall = true; + }; + + services.fprintd.enable = true; + services.greetd = { + enable = true; + settings = rec { + initial_session = { + command = "${pkgs.niri}/bin/niri-session"; + user = config.niriwm.user; + }; + default_session = initial_session; + }; + }; + # disable lid switch sleep when plugged into power, laptop docked + services.logind.settings.Login.HandleLidSwitchExternalPower = "ignore"; + + # Keyring setup + security.pam.services.greetd.enableGnomeKeyring = true; + security.pam.services.login.enableGnomeKeyring = true; + + # Audio - Modern PipeWire setup for Framework laptop + # Disable PulseAudio in favor of PipeWire + services.pulseaudio.enable = false; + security.rtkit.enable = true; + services.pipewire = { + enable = true; + audio.enable = true; + alsa.enable = true; + alsa.support32Bit = true; + pulse.enable = true; + wireplumber.enable = true; + wireplumber.extraConfig = { + "wireplumber.settings" = { + bluetooth.autoswitch-to-headset-profile = false; + }; + bluetoothEnhancements = { + "monitor.bluez.properties" = { + "bluez5.enable-sbc-xq" = true; + "bluez5.enable-msbc" = true; + "bluez5.enable-hw-volume" = true; + # Default roles: https://pipewire.pages.freedesktop.org/wireplumber/daemon/configuration/bluetooth.html#monitor-properties + "bluez5.roles" = [ "a2dp_sink" "a2dp_source" "bap_sink" "bap_source" "hfp_hf" "hfp_ag" ]; + }; + }; + }; + }; + + ### + ## Misc + ### + # Necessary for home-manager niri setup + security.polkit.enable = true; + + hardware.bluetooth = { + enable = true; + powerOnBoot = true; # powers up the default Bluetooth controller on boot + settings = { + General = { + Name = "Nate-Vasion"; + ControllerMode = "dual"; + FastConnectable = "true"; + Experimental = "true"; + }; + Policy = { AutoEnable = "true"; }; + LE = { EnableAdvMonInterleaveScan = 1; }; + }; + }; + + # + # Hardware scanning support + # + hardware.sane = { + enable = true; + brscan5.enable = true; + }; + + # + # Nvidia Setup + # + + services.udev.extraRules = '' + # For betaflight configurator + # DFU (Internal bootloader for STM32 and AT32 MCUs) + SUBSYSTEM=="usb", ATTRS{idVendor}=="2e3c", ATTRS{idProduct}=="df11", MODE="0664", GROUP="dialout" + SUBSYSTEM=="usb", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="df11", MODE="0664", GROUP="dialout" + # For ddcutil monitor controls + KERNEL=="i2c-[0-9]*", GROUP="i2c", MODE="0660" + ''; + + services.xserver.videoDrivers = [ "nvidia" ]; + hardware = { + graphics = { + enable = true; + enable32Bit = true; + extraPackages = with pkgs; [ + intel-vaapi-driver + intel-media-driver + vpl-gpu-rt + ]; + }; + nvidia = { + # Modesetting is required. + modesetting.enable = true; + # Nvidia power management. Experimental, and can cause sleep/suspend to fail. + # Enable this if you have graphical corruption issues or application crashes after waking + # up from sleep. This fixes it by saving the entire VRAM memory to /tmp/ instead + # of just the bare essentials. + powerManagement.enable = false; + + # Fine-grained power management. Turns off GPU when not in use. + # Experimental and only works on modern Nvidia GPUs (Turing or newer). + powerManagement.finegrained = true; + + # Use the NVidia open source kernel module (not to be confused with the + # independent third-party "nouveau" open source driver). + # Support is limited to the Turing and later architectures. Full list of + # supported GPUs is at: + # https://github.com/NVIDIA/open-gpu-kernel-modules#compatible-gpus + # Only available from driver 515.43.04+ + # Currently alpha-quality/buggy, so false is currently the recommended setting. + open = true; + + # Enable the Nvidia settings menu, + # accessible via `nvidia-settings`. + nvidiaSettings = true; + + # Optionally, you may need to select the appropriate driver version for your specific GPU. + package = config.boot.kernelPackages.nvidiaPackages.stable; + prime = { + # sync.enable = true; + offload.enable = true; + offload.enableOffloadCmd = true; # adds `nvidia-offload` command to env + intelBusId = "PCI:0:2:0"; + nvidiaBusId = "PCI:1:0:0"; + }; + }; + }; + # Create special on the go boot entry for battery saving + # This disables NVIDIA GPU completely and uses Intel integrated graphics only + specialisation = { + on-the-go.configuration = { + system.nixos.tags = [ "on-the-go" ]; + + # Blacklist all NVIDIA kernel modules + boot.blacklistedKernelModules = [ "nouveau" "nvidia" "nvidia_drm" "nvidia_modeset" "nvidia_uvm" ]; + + # Force Intel i915 driver for 13th gen (Raptor Lake) integrated graphics + # Device IDs: a7a0 or a7a8 for i7-13700H + boot.kernelParams = [ + "i915.force_probe=a7a0" + "module_blacklist=nvidia,nvidia_drm,nvidia_modeset,nvidia_uvm" + ]; + + boot.extraModprobeConfig = '' + blacklist nouveau + blacklist nvidia + blacklist nvidia_drm + blacklist nvidia_modeset + blacklist nvidia_uvm + options nouveau modeset=0 + ''; + + # Remove NVIDIA devices from PCI bus to save power + services.udev.extraRules = '' + # Remove NVIDIA USB xHCI Host Controller devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c0330", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA USB Type-C UCSI devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x0c8000", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA Audio devices, if present + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x040300", ATTR{power/control}="auto", ATTR{remove}="1" + # Remove NVIDIA VGA/3D controller devices + ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x10de", ATTR{class}=="0x03[0-9]*", ATTR{power/control}="auto", ATTR{remove}="1" + ''; + + # Use Intel modesetting driver only + services.xserver.videoDrivers = lib.mkForce [ "modesetting" ]; + + # Disable all NVIDIA hardware configurations + hardware.nvidia = { + prime.offload.enable = lib.mkForce false; + prime.offload.enableOffloadCmd = lib.mkForce false; + powerManagement.finegrained = lib.mkForce false; + prime.sync.enable = lib.mkForce false; + }; + + # Ensure Intel graphics packages are available + hardware.graphics.extraPackages = lib.mkForce (with pkgs; [ + intel-vaapi-driver + intel-media-driver + vpl-gpu-rt + ]); + + # Clear NVIDIA-specific environment variables + environment.sessionVariables = { + GBM_BACKEND = lib.mkForce ""; + __GLX_VENDOR_LIBRARY_NAME = lib.mkForce ""; + }; + }; + }; + }; +} diff --git a/nate-work/modules/niri/niri_home.nix b/nate-work/modules/niri/niri_home.nix new file mode 100644 index 0000000..6adc12e --- /dev/null +++ b/nate-work/modules/niri/niri_home.nix @@ -0,0 +1,64 @@ +{ inputs, lib, config, pkgs, ... }: +{ + options.nirihome = { + enable = lib.mkEnableOption "Enable niri home config"; + homePackages = lib.mkOption { + default = []; + description = "Add any additional packages desired. Merged with niri defaults."; + }; + }; + + config = lib.mkIf config.nirihome.enable { + # Note: We don't use wayland.windowManager.niri in home-manager + # because we manage the niri config through dotfiles. + # The system-level module enables niri via programs.niri.enable + + # Import systemd variables for niri + systemd.user.sessionVariables = { + WAYLAND_DISPLAY = "wayland-1"; + XDG_CURRENT_DESKTOP = "niri"; + }; + + home.pointerCursor = { + gtk.enable = true; + x11.enable = true; + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + size = 32; + }; + + home.packages = with pkgs; lib.lists.flatten [ + [ + ### niri packages + swaybg + swaylock-effects + waybar + wofi + # Etc + gopsuinfo # For system stats in panel + wl-clipboard # System clipboard + brightnessctl + wev + wdisplays + # Notifs + libnotify + swaynotificationcenter + # Tray Applets + networkmanagerapplet + pavucontrol + syncthingtray + tailscale-systray + # include portals here for flatpak + xdg-desktop-portal-gnome + xdg-desktop-portal-gtk + ] + config.nirihome.homePackages + ]; + programs.cava = { + enable = true; + settings = { + smoothing.noise_reduction = 55; + }; + }; + }; +} diff --git a/nate/modules/home-manager/home.nix b/nate/modules/home-manager/home.nix index 62be0ca..cf2ad6d 100644 --- a/nate/modules/home-manager/home.nix +++ b/nate/modules/home-manager/home.nix @@ -72,6 +72,8 @@ # sdkmanager unstable.opencode unstable.claude-code + usbutils + openscad # # Gaming @@ -85,6 +87,7 @@ # bat duf + dust fd fzf lsd diff --git a/shared/modules/services/theme_switcher/EXAMPLE_CONFIG.nix b/shared/modules/services/theme_switcher/EXAMPLE_CONFIG.nix new file mode 100644 index 0000000..9f584d9 --- /dev/null +++ b/shared/modules/services/theme_switcher/EXAMPLE_CONFIG.nix @@ -0,0 +1,74 @@ +# Example configuration for enabling the theme switcher module +# Add this to your host's desktop-configuration.nix or similar file + +{ + # Import the module (if not already in your imports list) + imports = [ + ../../shared/modules/services/theme_switcher + ]; + + # Enable and configure the theme switcher + services.themeSwitcher = { + enable = true; + user = "nate"; # Change to your username + + # Theme generation settings + backend = "haishoku"; # Recommended color extraction backend + lightTime = "06:00:00"; # Switch to light theme at 6 AM + darkTime = "18:00:00"; # Switch to dark theme at 6 PM + enableAutoSwitch = true; # Enable automatic time-based switching + + # Palette generation strategy + paletteGeneration = "continuous"; # Options: continuous, light-dark + + # Wallpaper rotation settings + rotation = { + interval = "5m"; # Rotate wallpaper every 5 minutes + sorting = "random"; # Currently only random is supported + }; + + # Wpaperd wallpaper daemon settings + wpaperd = { + enable = true; + mode = "center"; # Options: center, fit, fit-border-color, stretch, tile + transition = { + effect = "fade"; # Transition effect (fade, simple, etc.) + duration = 300; # Transition duration in milliseconds + }; + }; + }; + + # IMPORTANT: Disable the old wallpaper-rotator module if you were using it + # services.wallpaperRotator.enable = false; # Comment out or remove + + # Note: You may want to comment out or remove static theme configurations + # when enabling the dynamic theme switcher. For example: + + # BEFORE (static theme) - COMMENT THESE OUT: + # home-manager.users.nate = { + # gtk.theme = { + # name = "catppuccin-macchiato-lavender-compact+rimless"; + # package = pkgs.catppuccin-gtk.override { ... }; + # }; + # + # xdg.configFile."Kvantum/kvantum.kvconfig".source = ...; + # }; + + # AFTER (dynamic theme): + # The theme switcher module handles GTK/Qt theming automatically + # You can still keep icon and cursor themes: + # home-manager.users.nate = { + # gtk.iconTheme = { + # name = "Papirus-Dark"; + # package = pkgs.catppuccin-papirus-folders; + # }; + # gtk.cursorTheme = { + # name = "Bibata-Modern-Classic"; + # package = pkgs.bibata-cursors; + # }; + # }; +} + +# For manual testing before enabling auto-switch: +# services.themeSwitcher.enableAutoSwitch = false; +# Then use: ~/.local/bin/apply-theme.sh --light (or --dark) diff --git a/shared/modules/services/theme_switcher/OUTLINE.md b/shared/modules/services/theme_switcher/OUTLINE.md new file mode 100644 index 0000000..13af94f --- /dev/null +++ b/shared/modules/services/theme_switcher/OUTLINE.md @@ -0,0 +1,399 @@ +# NixOS Dynamic Theme Switcher - Implementation Instructions + +## Objective +Build a time-based dynamic theme switching system on NixOS that: +1. Extracts color palettes from wallpapers using pywal16 with haishoku backend +2. Generates and applies themes for GTK, Qt/Kvantum, Ghostty, and other applications +3. Switches between light/dark themes based on time of day +4. Integrates with existing wpaperd wallpaper rotation service + +## Current System State + +### Existing Theming Configuration +**GTK Configuration:** +- Current theme: `catppuccin-macchiato-lavender-compact+rimless` +- Settings location: `~/.config/gtk-3.0/settings.ini`, `~/.config/gtk-4.0/settings.ini` +- Icon theme: `Papirus-Dark` +- Cursor: `Bibata-Modern-Classic` +- Files are symlinked from Nix store via home-manager + +**Qt/Kvantum Configuration:** +- Current theme: `catppuccin-macchiato-lavender` +- Location: `~/.config/Kvantum/kvantum.kvconfig` +- Style: kvantum (both Qt5 and Qt6) +- Environment variable: `QT_STYLE_OVERRIDE=kvantum` + +**Wallpaper Service:** +- Using wpaperd with home-manager integration +- Wallpapers in: `/home/nate/nixos/shared/modules/services/wallpapers/` +- Organized into `Light/` and `Dark/` subdirectories +- Service configured via `services.wallpaperRotator` module + +**Actions Required:** +1. Disable/modify static GTK theme configuration in home.nix +2. Disable/modify static Kvantum theme configuration +3. Integrate with wpaperd to trigger theme changes on wallpaper rotation +4. Remove hardcoded Catppuccin theme packages when pywal themes are working + +## Implementation Components + +### 3. Install Required Packages + +Add to module configuration: +```nix +home.packages = with pkgs; [ + pywal16 # Color scheme generator with 16-color support + python3Packages.haishoku # Recommended backend for pywal16 + wpaperd # Already installed via wallpaper service +]; +``` + +**Available pywal16 backends:** modern_colorthief, wal, fast_colorthief, haishoku, colorthief, colorz, okthief, schemer2 + +### 4. Color Extraction & Theme Generation + +#### 4.1 Pywal16 Built-in Templates +**Pywal16 already includes templates for:** +- Ghostty: `ghostty.conf` template (tested and confirmed) +- CSS variables: `colors.css` template +- Waybar: `colors-waybar.css` +- Hyprland: `colors-hyprland.conf` +- Rofi: `colors-rofi-dark.rasi` and `colors-rofi-light.rasi` +- Sway: `colors-sway` +- Mako: `colors-mako` +- Foot, Kitty, Alacritty terminal configs +- Many others (47 templates total) + +**Output location:** `~/.cache/wal/` after running `wal -i /path/to/wallpaper` + +**Usage:** +```bash +wal -i /path/to/wallpaper --backend haishoku -n +# -n flag skips setting wallpaper (wpaperd handles that) +# --backend haishoku uses the haishoku color extraction +# -l flag for light themes +``` + +#### 4.2 GTK Theme Generation +**Goal:** Create custom GTK 3.0 and GTK 4.0 CSS themes from pywal colors + +**Note:** Pywal16 includes `colors.css` template with CSS variables, but NOT full GTK themes. + +**Required files:** +- `~/.config/gtk-3.0/gtk.css` - Custom CSS overlay +- `~/.config/gtk-4.0/gtk.css` - Custom CSS overlay + +**Approach:** Create pywal template to generate GTK CSS using colors.json output +- Map pywal colors to GTK color definitions +- Use @define-color for GTK theme variables +- Apply to common widgets + +#### 4.3 Qt/Kvantum Theme Generation +**Goal:** Generate Kvantum theme from pywal colors + +**Kvantum structure (.kvconfig file):** +- `[General]` section with metadata +- `[GeneralColors]` section with color definitions (window.color, base.color, button.color, etc.) +- `[Hacks]` section with tweaks +- Per-widget sections (PanelButtonCommand, etc.) + +**Location:** `~/.config/Kvantum/PywalTheme/PywalTheme.kvconfig` + +**Required:** Create pywal template that maps colors.json to .kvconfig format +**Optional:** SVG file can be minimal or omitted for basic themes + +**Application:** Use `kvantummanager --set PywalTheme` to activate + +### 5. Theme Application Script + +**Script: `apply-theme.sh`** + +**Parameters:** +- `--light` or `--dark` (theme mode) +- Optional: `--wallpaper-path` (specific wallpaper, otherwise use wpaperd current) + +**Script Logic:** + +1. **Determine wallpaper source:** + - If `--wallpaper-path` provided, use it + - Otherwise, let wpaperd select based on mode (Light/ or Dark/ directory) + - Use `wpaperctl` to interact with wpaperd if needed + +2. **Generate color scheme:** + ```bash + if [ "$MODE" = "light" ]; then + wal -i "$WALLPAPER" --backend haishoku -l -n + else + wal -i "$WALLPAPER" --backend haishoku -n + fi + ``` + +3. **Apply GTK theme:** + ```bash + # pywal doesn't generate full GTK themes, use custom CSS + ln -sf ~/.cache/wal/gtk-3.0.css ~/.config/gtk-3.0/gtk.css + ln -sf ~/.cache/wal/gtk-4.0.css ~/.config/gtk-4.0/gtk.css + + gsettings set org.gnome.desktop.interface color-scheme "prefer-$MODE" + ``` + +4. **Apply Qt/Kvantum theme:** + ```bash + kvantummanager --set PywalTheme + ``` + +5. **Reload configurations:** + - Ghostty: Auto-reloads config on file change (no action needed) + - GTK apps: Automatically detect gsettings changes + - Qt apps: May need restart for Kvantum changes + - Hyprland/Sway: Reload via IPC if using pywal templates + +6. **Optional: Update Flatpak:** + ```bash + flatpak override --user --filesystem=~/.cache/wal:ro + ``` + +### 6. Wallpaper Integration & Time-Based Switching + +**Option A: Integrate with wpaperd events (Recommended)** +- Modify wallpaper-rotator module to support Light/Dark subdirectories +- Change wallpaper directory based on time of day +- Trigger theme generation on wallpaper change + +**Option B: Independent systemd timer** + +**Timer configuration:** +```nix +systemd.user.timers.theme-switcher = { + Unit.Description = "Dynamic Theme Switcher Timer"; + Timer = { + OnCalendar = [ "*-*-* 06:00:00" "*-*-* 18:00:00" ]; # 6am light, 6pm dark + Persistent = true; + }; + Install.WantedBy = [ "timers.target" ]; +}; +``` + +**Service configuration:** +```nix +systemd.user.services.theme-switcher = { + Unit.Description = "Dynamic Theme Switcher"; + Service = { + Type = "oneshot"; + ExecStart = "${pkgs.bash}/bin/bash ${./scripts/theme-switcher.sh}"; + Environment = "PATH=${lib.makeBinPath [ pkgs.pywal16 pkgs.coreutils ]}"; + }; +}; +``` + +**Script determines light/dark based on current hour:** +```bash +hour=$(date +%H) +if [ $hour -ge 6 ] && [ $hour -lt 18 ]; then + MODE="light" + WALLPAPER_DIR="Light" +else + MODE="dark" + WALLPAPER_DIR="Dark" +fi +``` + +### 7. NixOS Module Structure + +**Module: `theme_switcher/default.nix`** + +```nix +{ config, lib, pkgs, ... }: + +let + cfg = config.services.themeSwitcher; +in +{ + options.services.themeSwitcher = { + enable = lib.mkEnableOption "dynamic theme switching based on time and wallpapers"; + + user = lib.mkOption { + type = lib.types.str; + description = "Username for theme switching"; + }; + + wallpaperPath = lib.mkOption { + type = lib.types.str; + default = "/home/${cfg.user}/nixos/shared/modules/services/wallpapers"; + description = "Path to wallpaper directories (Light/ and Dark/)"; + }; + + backend = lib.mkOption { + type = lib.types.enum [ "haishoku" "modern_colorthief" "fast_colorthief" ]; + default = "haishoku"; + description = "Pywal color extraction backend"; + }; + + lightTime = lib.mkOption { + type = lib.types.str; + default = "06:00:00"; + description = "Time to switch to light theme (HH:MM:SS)"; + }; + + darkTime = lib.mkOption { + type = lib.types.str; + default = "18:00:00"; + description = "Time to switch to dark theme (HH:MM:SS)"; + }; + }; + + config = lib.mkIf cfg.enable { + home-manager.users.${cfg.user} = { + home.packages = with pkgs; [ + pywal16 + python3Packages.haishoku + ]; + + # Install theme application scripts + home.file.".local/bin/apply-theme.sh" = { + source = ./scripts/apply-theme.sh; + executable = true; + }; + + # Pywal custom templates for GTK and Kvantum + xdg.configFile."wal/templates/gtk-3.0.css".source = ./templates/gtk-3.0.css; + xdg.configFile."wal/templates/gtk-4.0.css".source = ./templates/gtk-4.0.css; + xdg.configFile."wal/templates/kvantum.kvconfig".source = ./templates/kvantum.kvconfig; + + # Systemd timer and service + systemd.user.timers.theme-switcher = { /* ... */ }; + systemd.user.services.theme-switcher = { /* ... */ }; + + # Keep Qt/GTK enabled but don't set static themes + qt = { + enable = true; + platformTheme.name = "kvantum"; + style.name = "kvantum"; + }; + + gtk.enable = true; + }; + }; +} +``` + +**Template files needed:** +- `templates/gtk-3.0.css` - GTK3 theme using pywal variables +- `templates/gtk-4.0.css` - GTK4 theme using pywal variables +- `templates/kvantum.kvconfig` - Kvantum config using pywal colors + +## Testing & Validation + +### 8. Test Plan + +1. **Test pywal color extraction:** + ```bash + # Test with light wallpaper + wal -i ~/nixos/shared/modules/services/wallpapers/Light/IU-Light.jpg --backend haishoku -l -n + cat ~/.cache/wal/colors.json # Verify colors extracted + + # Test with dark wallpaper + wal -i ~/nixos/shared/modules/services/wallpapers/Dark/IU-Dark.jpg --backend haishoku -n + cat ~/.cache/wal/colors.json # Verify colors extracted + ``` + +2. **Verify pywal template output:** + ```bash + ls -la ~/.cache/wal/ + # Should see: colors.json, ghostty.conf, colors.css, and custom templates + cat ~/.cache/wal/ghostty.conf # Check ghostty colors + ``` + +3. **Manual theme application:** + ```bash + ~/.local/bin/apply-theme.sh --light + # Check: GTK apps, Qt apps, Ghostty colors update + + ~/.local/bin/apply-theme.sh --dark + # Check: Theme switches to dark + ``` + +4. **Verify theme files:** + - `~/.cache/wal/colors.json` - Generated by pywal + - `~/.cache/wal/gtk-3.0.css` - From custom template + - `~/.cache/wal/gtk-4.0.css` - From custom template + - `~/.cache/wal/kvantum.kvconfig` - From custom template + - `~/.cache/wal/ghostty.conf` - From built-in template + +5. **Test Kvantum theme:** + ```bash + kvantummanager --set PywalTheme + # Or check: cat ~/.config/Kvantum/kvantum.kvconfig + ``` + +6. **Test systemd automation:** + ```bash + systemctl --user start theme-switcher.service + systemctl --user status theme-switcher.timer + systemctl --user list-timers | grep theme + ``` + +7. **Test with live applications:** + - Open GTK app (nautilus, gnome-calculator) - verify colors + - Open Qt app (if available) - verify Kvantum theme + - Open Ghostty terminal - verify color palette matches wallpaper + - Check waybar/rofi if using pywal templates for those + +## Implementation Steps Summary + +### Phase 1: Setup pywal with existing wallpapers +1. Create theme_switcher module with pywal16 package +2. Test color extraction on Light/ and Dark/ wallpapers +3. Verify built-in templates (ghostty.conf, colors.css) generate correctly + +### Phase 2: Create custom templates +1. Create `templates/gtk-3.0.css` using pywal variables +2. Create `templates/gtk-4.0.css` using pywal variables +3. Create `templates/kvantum.kvconfig` mapping colors.json to Kvantum format +4. Install templates to `~/.config/wal/templates/` + +### Phase 3: Theme application script +1. Write `apply-theme.sh` script that: + - Runs pywal with appropriate flags (-l for light) + - Symlinks generated CSS to GTK config directories + - Copies Kvantum config to `~/.config/Kvantum/PywalTheme/` + - Runs `kvantummanager --set PywalTheme` + - Updates gsettings for color-scheme preference + +### Phase 4: Time-based automation +1. Create systemd timer for light/dark switching times +2. Create systemd service that calls apply-theme.sh +3. Script determines mode based on current time +4. Selects random wallpaper from Light/ or Dark/ directory + +### Phase 5: Integration and cleanup +1. Modify existing home.nix files to disable static themes when module enabled +2. Keep icon themes, cursor themes (not wallpaper-dependent) +3. Test on one host before deploying to all +4. Document wpaperd integration approach for future enhancement + +## Key Technical Details + +**Pywal template syntax:** +``` +{background} -> #081212 +{foreground} -> #c1c3c3 +{color0} -> #081212 +... +{color15} -> #c1c3c3 +{cursor} -> #c1c3c3 +{wallpaper} -> /path/to/wallpaper.jpg +``` + +**Template location:** `~/.config/wal/templates/filename.ext` +**Output location:** `~/.cache/wal/filename.ext` + +**Ghostty:** Automatically reloads config when files change - no manual reload needed + +**GTK:** Changes propagate via gsettings/dconf - running apps update automatically + +**Kvantum:** May require app restart to see changes (Qt limitation) + +**Wallpaper directories:** +- Light: `/home/nate/nixos/shared/modules/services/wallpapers/Light/` +- Dark: `/home/nate/nixos/shared/modules/services/wallpapers/Dark/` diff --git a/shared/modules/services/theme_switcher/README.md b/shared/modules/services/theme_switcher/README.md new file mode 100644 index 0000000..8cbdc56 --- /dev/null +++ b/shared/modules/services/theme_switcher/README.md @@ -0,0 +1,447 @@ +# Dynamic Theme Switcher Module + +A NixOS module that automatically generates and applies color themes from wallpapers using pywal16, with integrated wpaperd wallpaper management and support for GTK, Qt/Kvantum, Ghostty, and other applications. + +## Features + +- **Integrated Wallpaper Management**: Built-in wpaperd integration with configurable rotation modes +- **Automatic Color Extraction**: Uses pywal16 with configurable backends (haishoku, colorthief, etc.) to extract color palettes from wallpapers +- **Multi-Application Support**: Generates themes for GTK 3.0, GTK 4.0, Qt/Kvantum, Ghostty terminal, and more +- **Time-Based Switching**: Automatically switches between light and dark themes based on configured times +- **Flexible Rotation Modes**: Choose between continuous rotation, switch-only, or hybrid modes +- **Manual Control**: Provides scripts for manual theme switching +- **Synchronized Wallpaper & Theme**: Wallpaper and color theme always match + +## Directory Structure + +``` +theme_switcher/ +├── default.nix # Main module configuration +├── templates/ +│ ├── gtk-3.0.css # GTK 3.0 theme template +│ ├── gtk-4.0.css # GTK 4.0 theme template +│ └── PywalTheme.kvconfig # Kvantum Qt theme template +├── OUTLINE.md # Implementation specification +└── README.md # This file +``` + +## Installation + +### 1. Import the Module + +Add the module to your host configuration: + +```nix +# In your host's configuration.nix or desktop-configuration.nix +imports = [ + ../../shared/modules/services/theme_switcher +]; +``` + +### 2. Enable and Configure + +```nix +services.themeSwitcher = { + enable = true; + user = "yourUsername"; + + # Optional: Customize settings + backend = "haishoku"; # Color extraction backend + lightTime = "06:00:00"; # Switch to light theme at 6 AM + darkTime = "18:00:00"; # Switch to dark theme at 6 PM + enableAutoSwitch = true; # Enable automatic time-based switching + + # Wallpaper rotation settings + rotation = { + mode = "continuous"; # Options: continuous, switch-only, hybrid + interval = "5m"; # Rotation interval (for continuous mode) + }; + + # Wpaperd configuration + wpaperd = { + enable = true; + mode = "center"; # Options: center, fit, stretch, tile + transition.effect = "fade"; + transition.duration = 300; # milliseconds + }; +}; +``` + +**Note:** If you were previously using the separate `wallpaper-rotator` module, you should disable it to avoid conflicts. + +### 3. Wallpaper Directory Structure + +Ensure your wallpaper directory has Light and Dark subdirectories: + +``` +wallpapers/ +├── Light/ +│ ├── wallpaper1.jpg +│ ├── wallpaper2.jpg +│ └── ... +└── Dark/ + ├── wallpaper1.jpg + ├── wallpaper2.jpg + └── ... +``` + +### 4. Rebuild Your System + +```bash +sudo nixos-rebuild switch --flake .#hostname +``` + +## Configuration Options + +### Core Options + +#### `services.themeSwitcher.enable` +- **Type**: boolean +- **Default**: false +- **Description**: Enable the dynamic theme switcher with integrated wallpaper management + +#### `services.themeSwitcher.user` +- **Type**: string +- **Required**: yes +- **Description**: Username for which to apply theme switching + +#### `services.themeSwitcher.wallpaperPath` +- **Type**: string +- **Default**: `${homeDirectory}/nixos/shared/modules/services/wallpapers` +- **Description**: Path to wallpaper directories (must contain Light/ and Dark/ subdirectories) + +#### `services.themeSwitcher.backend` +- **Type**: enum +- **Default**: `"haishoku"` +- **Options**: `"haishoku"`, `"modern_colorthief"`, `"fast_colorthief"`, `"colorthief"`, `"colorz"`, `"wal"` +- **Description**: Pywal color extraction backend algorithm + +#### `services.themeSwitcher.lightTime` +- **Type**: string +- **Default**: `"06:00:00"` +- **Description**: Time to switch to light theme (HH:MM:SS format) + +#### `services.themeSwitcher.darkTime` +- **Type**: string +- **Default**: `"18:00:00"` +- **Description**: Time to switch to dark theme (HH:MM:SS format) + +#### `services.themeSwitcher.enableAutoSwitch` +- **Type**: boolean +- **Default**: true +- **Description**: Enable automatic time-based theme switching via systemd timer + +### Rotation Options + +#### `services.themeSwitcher.rotation.mode` +- **Type**: enum +- **Default**: `"continuous"` +- **Options**: + - `"continuous"`: Rotate wallpapers every interval AND regenerate theme each time + - `"switch-only"`: Only change wallpaper at light/dark switch times (6 AM/6 PM) + - `"hybrid"`: Rotate wallpapers but only regenerate theme at switch times (not yet implemented) +- **Description**: Wallpaper rotation and theme generation behavior + +#### `services.themeSwitcher.rotation.interval` +- **Type**: string +- **Default**: `"5m"` +- **Example**: `"30s"`, `"10m"`, `"1h"` +- **Description**: How often to rotate wallpapers in continuous mode + +#### `services.themeSwitcher.rotation.sorting` +- **Type**: enum +- **Default**: `"random"` +- **Options**: `"random"` +- **Description**: Wallpaper selection method (currently only random is supported) + +### Wpaperd Integration Options + +#### `services.themeSwitcher.wpaperd.enable` +- **Type**: boolean +- **Default**: true +- **Description**: Enable wpaperd wallpaper daemon integration + +#### `services.themeSwitcher.wpaperd.mode` +- **Type**: enum +- **Default**: `"center"` +- **Options**: `"center"`, `"fit"`, `"fit-border-color"`, `"stretch"`, `"tile"` +- **Description**: How to display wallpapers when size differs from display resolution + +#### `services.themeSwitcher.wpaperd.transition.effect` +- **Type**: string +- **Default**: `"fade"` +- **Example**: `"simple"`, `"fade"` +- **Description**: Wallpaper transition effect name + +#### `services.themeSwitcher.wpaperd.transition.duration` +- **Type**: integer +- **Default**: 300 +- **Description**: Transition duration in milliseconds + +## Manual Usage + +### Apply Theme Manually + +The module installs `apply-theme.sh` to `~/.local/bin/`: + +```bash +# Apply light theme with random wallpaper from Light/ directory +~/.local/bin/apply-theme.sh --light + +# Apply dark theme with random wallpaper from Dark/ directory +~/.local/bin/apply-theme.sh --dark + +# Apply theme with specific wallpaper +~/.local/bin/apply-theme.sh --light --wallpaper-path /path/to/wallpaper.jpg +``` + +### Check Systemd Timer Status + +```bash +# View timer status +systemctl --user status theme-switcher.timer + +# List upcoming timer events +systemctl --user list-timers | grep theme + +# Manually trigger theme switch +systemctl --user start theme-switcher.service + +# View service logs +journalctl --user -u theme-switcher.service +``` + +### Test Pywal Color Extraction + +```bash +# Test light theme generation +wal -i ~/nixos/shared/modules/services/wallpapers/Light/IU-Light.jpg --backend haishoku -l -n + +# Test dark theme generation +wal -i ~/nixos/shared/modules/services/wallpapers/Dark/IU-Dark.jpg --backend haishoku -n + +# View generated colors +cat ~/.cache/wal/colors.json + +# View generated templates +ls -la ~/.cache/wal/ +``` + +## Generated Files + +After running the theme switcher, pywal generates the following files: + +### Pywal Cache (`~/.cache/wal/`) +- `colors.json` - Extracted color palette in JSON format +- `colors.sh` - Shell script with color variables +- `ghostty.conf` - Ghostty terminal theme (auto-reloads) +- `gtk-3.0.css` - GTK 3.0 theme (from custom template) +- `gtk-4.0.css` - GTK 4.0 theme (from custom template) +- `PywalTheme.kvconfig` - Kvantum Qt theme (from custom template) + +### User Configuration +- `~/.config/gtk-3.0/gtk.css` - Symlink to pywal-generated GTK3 theme +- `~/.config/gtk-4.0/gtk.css` - Symlink to pywal-generated GTK4 theme +- `~/.config/Kvantum/PywalTheme/PywalTheme.kvconfig` - Kvantum theme copy + +## Integration with Existing Themes + +When enabling this module, you should adjust your existing theme configuration: + +### Before (Static Catppuccin Theme) +```nix +gtk = { + enable = true; + theme = { + name = "catppuccin-macchiato-lavender-compact+rimless"; + package = pkgs.catppuccin-gtk.override { ... }; + }; +}; + +qt = { + enable = true; + platformTheme.name = "kvantum"; + style.name = "kvantum"; +}; + +xdg.configFile."Kvantum/kvantum.kvconfig".source = ...; +``` + +### After (Dynamic Pywal Theme) +```nix +# The theme switcher module automatically sets: +# - gtk.enable = true +# - qt.enable = true +# - qt.platformTheme.name = "kvantum" +# - qt.style.name = "kvantum" + +# You can still keep static icon and cursor themes: +gtk.iconTheme = { + name = "Papirus-Dark"; + package = pkgs.catppuccin-papirus-folders; +}; + +gtk.cursorTheme = { + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; +}; +``` + +## Troubleshooting + +### Theme Not Applying +```bash +# Check if pywal generated files +ls -la ~/.cache/wal/ + +# Check systemd service status +systemctl --user status theme-switcher.service + +# View detailed logs +journalctl --user -u theme-switcher.service -n 50 + +# Manually run apply-theme.sh with verbose output +~/.local/bin/apply-theme.sh --light +``` + +### Kvantum Theme Not Working +```bash +# Check if Kvantum theme was created +ls -la ~/.config/Kvantum/PywalTheme/ + +# Manually set Kvantum theme +kvantummanager --set PywalTheme + +# Check if Qt applications are using Kvantum +echo $QT_STYLE_OVERRIDE # Should be "kvantum" +``` + +### Colors Don't Match Wallpaper +Try a different pywal backend: +```nix +services.themeSwitcher.backend = "modern_colorthief"; # or "fast_colorthief", "colorthief", etc. +``` + +### GTK Applications Not Updating +```bash +# Check if GTK CSS files exist +ls -la ~/.config/gtk-3.0/gtk.css +ls -la ~/.config/gtk-4.0/gtk.css + +# Restart GTK applications for changes to take effect +``` + +## Supported Applications + +### Automatically Themed +- **GTK 3 Applications**: Nautilus, GNOME Calculator, etc. +- **GTK 4 Applications**: GNOME Text Editor, newer GNOME apps +- **Qt Applications**: KDE apps when using Kvantum +- **Ghostty Terminal**: Auto-reloads on config change +- **Waybar**: If using pywal template (built-in) +- **Rofi**: If using pywal template (built-in) +- **Hyprland/Sway**: If using pywal template (built-in) + +### Manual Integration Required +- **Firefox**: Use pywal-firefox extension +- **VSCode**: Use pywal theme extension +- **Other Applications**: Check if pywal templates exist in `~/.cache/wal/` + +## Advanced Usage + +### Custom Pywal Templates + +To add your own templates, create files in `~/.config/wal/templates/`: + +```bash +# Template file: ~/.config/wal/templates/myapp.conf +# Use pywal template variables +background = {background} +foreground = {foreground} +color0 = {color0} +... +color15 = {color15} +``` + +After running `apply-theme.sh`, find your generated file in `~/.cache/wal/myapp.conf`. + +### Disable Automatic Switching + +To use manual control only: +```nix +services.themeSwitcher = { + enable = true; + user = "username"; + enableAutoSwitch = false; # Disable timer +}; +``` + +Then use `~/.local/bin/apply-theme.sh` manually as needed. + +### Rotation Modes Explained + +#### Continuous Mode (Default) +- Wallpaper rotates every interval (e.g., 5 minutes) +- Theme is regenerated from each new wallpaper +- Wallpaper and theme always match +- **Resource usage**: Moderate (pywal runs every interval) +- **Best for**: Users who want frequently changing, perfectly matched themes + +#### Switch-Only Mode +- Wallpaper changes only at light/dark switch times (6 AM / 6 PM) +- Theme is generated once per switch +- Wallpaper and theme always match +- **Resource usage**: Minimal (pywal runs twice per day) +- **Best for**: Users who prefer consistent themes throughout the day + +#### Hybrid Mode (Future) +- Wallpaper rotates every interval +- Theme is generated only at switch times +- Wallpaper and theme may not match mid-day +- **Not yet implemented** + +### Migration from wallpaper-rotator Module + +If you were previously using the separate `wallpaper-rotator` module: + +1. **Disable the old module** in your configuration: + ```nix + # Comment out or remove: + # services.wallpaperRotator.enable = true; + ``` + +2. **Enable themeSwitcher** with equivalent settings: + ```nix + services.themeSwitcher = { + enable = true; + user = "yourUsername"; + rotation.interval = "5m"; # Same as old duration setting + wpaperd.mode = "center"; # Same as old mode setting + }; + ``` + +3. **Rebuild** your system - wpaperd will now be managed by themeSwitcher + +## Technical Details + +- **Pywal Template Syntax**: `{background}`, `{foreground}`, `{color0}`-`{color15}`, `{cursor}`, `{wallpaper}` +- **Template Location**: `~/.config/wal/templates/filename.ext` +- **Output Location**: `~/.cache/wal/filename.ext` +- **Color Extraction**: 16-color palette using pywal16 +- **GTK Reload**: Automatic via gsettings changes +- **Kvantum Reload**: May require app restart +- **Ghostty Reload**: Automatic on config file change + +## Future Enhancements + +- Direct wpaperd integration (trigger theme change on wallpaper rotation) +- Per-monitor theme configuration +- Additional application templates (Firefox, VSCode, etc.) +- Theme preview before applying +- Fallback color schemes for monochrome wallpapers + +## Credits + +- Built for NixOS using home-manager +- Uses [pywal16](https://github.com/eylles/pywal16) for color extraction +- Based on [pywal](https://github.com/dylanaraps/pywal) original concept diff --git a/shared/modules/services/theme_switcher/TESTING_GUIDE.md b/shared/modules/services/theme_switcher/TESTING_GUIDE.md new file mode 100644 index 0000000..087cbb3 --- /dev/null +++ b/shared/modules/services/theme_switcher/TESTING_GUIDE.md @@ -0,0 +1,347 @@ +# Theme Switcher Testing Guide + +This guide will help you test the theme switcher module on the nate-work host. + +## Pre-Testing Checklist + +### 1. Review Current Configuration + +Check your current nate-work configuration: + +```bash +# View current wallpaper rotator settings +grep -A 10 "wallpaperRotator" nate-work/desktop-configuration.nix + +# View current GTK/Qt theme settings +grep -A 10 "gtk\|kvantum" nate-work/modules/home-manager/home.nix +``` + +### 2. Files to Modify + +You'll need to modify these files: + +1. **nate-work/desktop-configuration.nix** (or wherever you import modules) + - Add themeSwitcher import + - Disable wallpaperRotator if enabled + - Configure themeSwitcher settings + +2. **nate-work/modules/home-manager/home.nix** + - Comment out static GTK theme configuration + - Comment out static Kvantum configuration + - Keep icon and cursor themes + +## Testing Phase 1: Manual Mode + +Start with manual mode to test theme generation before enabling automation. + +### Step 1: Add Module Configuration + +Edit `nate-work/desktop-configuration.nix`: + +```nix +{ + imports = [ + # ... existing imports ... + ../../shared/modules/services/theme_switcher + ]; + + # Disable old wallpaper rotator if present + # services.wallpaperRotator.enable = false; + + # Enable theme switcher in manual mode + services.themeSwitcher = { + enable = true; + user = "nate"; + + # Start with manual mode (no auto-switching) + enableAutoSwitch = false; + + # Use switch-only mode for testing (no continuous rotation yet) + rotation.mode = "switch-only"; + + # Configure wpaperd + wpaperd = { + enable = true; + mode = "center"; + transition.effect = "fade"; + transition.duration = 300; + }; + }; +} +``` + +### Step 2: Comment Out Static Themes + +Edit `nate-work/modules/home-manager/home.nix`: + +```nix +# Comment out these sections: +# gtk.theme = { ... }; +# xdg.configFile."Kvantum/kvantum.kvconfig" = ...; +# xdg.configFile."gtk-4.0/..." = ...; + +# Keep these (icon and cursor themes are independent): +gtk.iconTheme = { ... }; # Keep +gtk.cursorTheme = { ... }; # Keep +``` + +### Step 3: Rebuild System + +```bash +# From /home/nate/nixos directory +sudo nixos-rebuild switch --flake .#nate-work +``` + +This will: +- Install pywal16, haishoku, wpaperd +- Install apply-theme.sh to ~/.local/bin/ +- Install pywal templates +- Start wpaperd (but with no auto-rotation) +- NOT start any timers (manual mode) + +### Step 4: Manual Testing + +After rebuild, test the script manually: + +```bash +# Test light theme +~/.local/bin/apply-theme.sh --light + +# Verify: +# 1. Wallpaper changed to one from Light/ directory +# 2. Colors extracted: cat ~/.cache/wal/colors.json +# 3. GTK theme generated: ls ~/.config/gtk-3.0/gtk.css +# 4. Kvantum theme generated: ls ~/.config/Kvantum/PywalTheme/ +# 5. Open a GTK app (nautilus, calculator) - check colors +``` + +```bash +# Test dark theme +~/.local/bin/apply-theme.sh --dark + +# Verify same things with Dark wallpaper +``` + +```bash +# Test with specific wallpaper +~/.local/bin/apply-theme.sh --light --wallpaper-path ~/nixos/shared/modules/services/wallpapers/Light/IU-Light.jpg +``` + +### Step 5: Verify Wpaperd Integration + +```bash +# Check if wpaperd is running +pgrep wpaperd + +# Check wpaperd status +systemctl --user status wpaperd + +# Manually change wallpaper using wpaperctl +wpaperctl wallpaper ~/nixos/shared/modules/services/wallpapers/Dark/IU-Dark.jpg +``` + +## Testing Phase 2: Automatic Switching (Time-Based) + +Once manual testing works, enable automatic time-based switching. + +### Step 1: Enable Auto-Switch + +Edit `nate-work/desktop-configuration.nix`: + +```nix +services.themeSwitcher = { + enable = true; + user = "nate"; + + # Enable automatic switching + enableAutoSwitch = true; + + # Keep switch-only mode for now + rotation.mode = "switch-only"; + + # Optional: Adjust times for testing + lightTime = "08:00:00"; # Your preferred time + darkTime = "17:00:00"; # Your preferred time +}; +``` + +### Step 2: Rebuild and Check Timer + +```bash +sudo nixos-rebuild switch --flake .#nate-work + +# Verify timer is installed +systemctl --user list-timers | grep theme + +# Should show two entries (one for lightTime, one for darkTime) +systemctl --user status theme-switcher.timer +``` + +### Step 3: Test Timer Manually + +```bash +# Manually trigger the service +systemctl --user start theme-switcher.service + +# Check logs +journalctl --user -u theme-switcher.service -n 50 + +# Should show theme being applied based on current time +``` + +## Testing Phase 3: Continuous Rotation + +Once automatic switching works, enable continuous wallpaper rotation. + +### Step 1: Enable Continuous Mode + +Edit `nate-work/desktop-configuration.nix`: + +```nix +services.themeSwitcher = { + enable = true; + user = "nate"; + enableAutoSwitch = true; + + # Enable continuous rotation + rotation = { + mode = "continuous"; + interval = "5m"; # Rotate every 5 minutes + }; +}; +``` + +### Step 2: Rebuild and Monitor + +```bash +sudo nixos-rebuild switch --flake .#nate-work + +# Check rotation timer +systemctl --user list-timers | grep wallpaper +systemctl --user status wallpaper-rotation.timer + +# Watch logs in real-time +journalctl --user -u wallpaper-rotation.service -f +``` + +### Step 3: Verify Rotation + +Wait 5 minutes and verify: +- Wallpaper changes +- Theme regenerates +- Colors match new wallpaper + +## Troubleshooting + +### Theme Not Applying + +```bash +# Check if pywal generated files +ls -la ~/.cache/wal/ + +# Expected files: +# - colors.json +# - gtk-3.0.css +# - gtk-4.0.css +# - PywalTheme.kvconfig +# - ghostty.conf +``` + +### Wallpaper Not Changing + +```bash +# Check wpaperd status +systemctl --user status wpaperd +journalctl --user -u wpaperd -n 50 + +# Try manual change +wpaperctl wallpaper ~/path/to/wallpaper.jpg +``` + +### GTK Theme Not Loading + +```bash +# Check GTK config +ls -la ~/.config/gtk-3.0/ +ls -la ~/.config/gtk-4.0/ + +# Check if files are symlinks +readlink ~/.config/gtk-3.0/gtk.css +# Should point to ~/.cache/wal/gtk-3.0.css +``` + +### Kvantum Theme Not Loading + +```bash +# Check Kvantum directory +ls -la ~/.config/Kvantum/PywalTheme/ + +# Manually set theme +kvantummanager --set PywalTheme + +# Restart Qt applications +``` + +### Service Failures + +```bash +# Check service logs +journalctl --user -u theme-switcher.service --since today +journalctl --user -u wallpaper-rotation.service --since today + +# Check environment +systemctl --user show theme-switcher.service | grep PATH +``` + +## Performance Monitoring + +### Resource Usage (Continuous Mode) + +```bash +# Monitor theme regeneration +watch -n 60 'ls -lh ~/.cache/wal/colors.json' + +# Check pywal CPU usage +top -b -n 1 | grep wal +``` + +If continuous mode is too resource-intensive, switch to switch-only mode. + +## Success Criteria + +- ✓ Manual theme switching works (--light and --dark) +- ✓ Wallpapers change via wpaperctl +- ✓ GTK apps reflect new colors +- ✓ Qt apps reflect new Kvantum theme +- ✓ Ghostty terminal shows new colors (if installed) +- ✓ Timers appear in systemctl --user list-timers +- ✓ Automatic switching works at configured times +- ✓ Continuous rotation works (if enabled) +- ✓ No service failures in journalctl + +## Next Steps After Testing + +Once everything works on nate-work: + +1. Consider applying to other hosts (frame12, nate, scrappy) +2. Fine-tune rotation interval if needed +3. Try different pywal backends (modern_colorthief, etc.) +4. Customize wpaperd transition effects +5. Add custom pywal templates for other apps + +## Rollback Plan + +If something goes wrong: + +```bash +# Disable the module +# In nate-work/desktop-configuration.nix: +services.themeSwitcher.enable = false; + +# Re-enable old configuration +# services.wallpaperRotator.enable = true; +# Uncomment GTK/Qt theme settings in home.nix + +# Rebuild +sudo nixos-rebuild switch --flake .#nate-work +``` diff --git a/shared/modules/services/theme_switcher/apply_themes.py b/shared/modules/services/theme_switcher/apply_themes.py new file mode 100755 index 0000000..eeb398d --- /dev/null +++ b/shared/modules/services/theme_switcher/apply_themes.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Apply Themes Script - Applies GTK/Kvantum/Helix themes after pywal runs. + +This script: +1. Links GTK CSS files from pywal cache +2. Sets GTK color scheme preference based on light/dark mode +3. Copies and applies Kvantum theme +4. Copies Helix theme to config directory +5. Optionally runs color_mapper.py for semantic color mapping +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path +import logging + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(levelname)s: %(message)s' +) +logger = logging.getLogger(__name__) + + +class ThemeApplicator: + """Applies themes after pywal has generated color palette.""" + + def __init__(self): + self.home = Path.home() + self.cache_dir = self.home / ".cache/wal" + self.config_dir = self.home / ".config" + + def is_light_mode(self) -> bool: + """ + Determine if current theme is light mode by checking pywal cache. + + Returns: + True if light mode, False if dark mode + """ + colors_json = self.cache_dir / "colors.json" + + if not colors_json.exists(): + logger.warning("colors.json not found, assuming dark mode") + return False + + try: + with open(colors_json) as f: + data = json.load(f) + return data.get("special", {}).get("light", False) + except (json.JSONDecodeError, KeyError) as e: + logger.warning(f"Failed to parse colors.json: {e}, assuming dark mode") + return False + + def setup_gtk_themes(self) -> None: + """Link GTK CSS files from pywal cache.""" + gtk3_dir = self.config_dir / "gtk-3.0" + gtk4_dir = self.config_dir / "gtk-4.0" + + # Ensure directories exist + gtk3_dir.mkdir(parents=True, exist_ok=True) + gtk4_dir.mkdir(parents=True, exist_ok=True) + + # Link GTK 3.0 CSS + gtk3_source = self.cache_dir / "gtk-3.0.css" + gtk3_target = gtk3_dir / "gtk.css" + + if gtk3_source.exists(): + # Remove old symlink/file + if gtk3_target.exists() or gtk3_target.is_symlink(): + gtk3_target.unlink() + gtk3_target.symlink_to(gtk3_source) + logger.info(f"Linked GTK 3.0 theme: {gtk3_target}") + else: + logger.warning(f"GTK 3.0 CSS not found: {gtk3_source}") + + # Link GTK 4.0 CSS + gtk4_source = self.cache_dir / "gtk-4.0.css" + gtk4_target = gtk4_dir / "gtk.css" + + if gtk4_source.exists(): + # Remove old symlink/file + if gtk4_target.exists() or gtk4_target.is_symlink(): + gtk4_target.unlink() + gtk4_target.symlink_to(gtk4_source) + logger.info(f"Linked GTK 4.0 theme: {gtk4_target}") + else: + logger.warning(f"GTK 4.0 CSS not found: {gtk4_source}") + + def set_gtk_color_scheme(self, is_light: bool) -> None: + """ + Set GTK color scheme preference using gsettings. + + Args: + is_light: True for light mode, False for dark mode + """ + scheme = "prefer-light" if is_light else "prefer-dark" + + try: + subprocess.run( + ['gsettings', 'set', 'org.gnome.desktop.interface', 'color-scheme', scheme], + check=True, + timeout=5, + capture_output=True + ) + logger.info(f"Set GTK color scheme to: {scheme}") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.warning(f"Failed to set GTK color scheme (this is normal if not using GNOME): {e}") + + def setup_kvantum_theme(self) -> None: + """Copy and apply Kvantum theme from pywal cache.""" + kvantum_dir = self.config_dir / "Kvantum/PywalTheme" + kvantum_dir.mkdir(parents=True, exist_ok=True) + + # Copy Kvantum config + kvantum_source = self.cache_dir / "PywalTheme.kvconfig" + kvantum_target = kvantum_dir / "PywalTheme.kvconfig" + + if kvantum_source.exists(): + shutil.copy2(kvantum_source, kvantum_target) + logger.info(f"Copied Kvantum theme: {kvantum_target}") + + # Apply Kvantum theme + try: + subprocess.run( + ['kvantummanager', '--set', 'PywalTheme'], + check=True, + timeout=5, + capture_output=True + ) + logger.info("Applied Kvantum theme: PywalTheme") + except FileNotFoundError: + logger.warning("kvantummanager not found, skipping Kvantum theme application") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning(f"Failed to apply Kvantum theme: {e}") + else: + logger.warning(f"Kvantum theme not found: {kvantum_source}") + + def setup_helix_theme(self) -> None: + """Copy Helix theme from pywal cache to config directory.""" + helix_themes_dir = self.config_dir / "helix/themes" + helix_themes_dir.mkdir(parents=True, exist_ok=True) + + # Copy minimal Helix theme + helix_source = self.cache_dir / "helix_minimal.toml" + helix_target = helix_themes_dir / "pywal_minimal.toml" + + if helix_source.exists(): + shutil.copy2(helix_source, helix_target) + logger.info(f"Updated Helix theme: pywal_minimal") + else: + logger.warning(f"Helix theme not found: {helix_source}") + + # Copy semantic Helix theme if it exists + helix_semantic_source = self.cache_dir / "helix_semantic.toml" + helix_semantic_target = helix_themes_dir / "pywal_semantic.toml" + + if helix_semantic_source.exists(): + shutil.copy2(helix_semantic_source, helix_semantic_target) + logger.info(f"Updated Helix semantic theme: pywal_semantic") + + def run_color_mapper(self) -> None: + """ + Run color_mapper.py to generate semantic color mappings. + + This is optional and will only run if the script exists. + """ + # Look for color_mapper.py in the same directory as this script + script_dir = Path(__file__).parent + color_mapper = script_dir / "color_mapper.py" + + if not color_mapper.exists(): + logger.debug(f"color_mapper.py not found at {color_mapper}, skipping") + return + + try: + result = subprocess.run( + ['python3', str(color_mapper)], + capture_output=True, + text=True, + timeout=10, + check=True + ) + logger.info("Applied semantic color mapping") + # Log color mapper output for debugging + if result.stdout: + for line in result.stdout.strip().split('\n'): + logger.debug(f"color_mapper: {line}") + except FileNotFoundError: + logger.warning("python3 not found, skipping color mapper") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning(f"Failed to run color_mapper.py: {e}") + + def run(self) -> int: + """ + Execute theme application process. + + Returns: + Exit code (0 for success, 1 for failure) + """ + try: + # Check if pywal has been run + if not self.cache_dir.exists(): + logger.error(f"Pywal cache not found: {self.cache_dir}") + logger.error("Please run pywal first") + return 1 + + # Determine mode + is_light = self.is_light_mode() + mode = "light" if is_light else "dark" + logger.info(f"Applying {mode} mode themes") + + # Setup GTK themes + self.setup_gtk_themes() + self.set_gtk_color_scheme(is_light) + + # Setup Kvantum theme + self.setup_kvantum_theme() + + # Setup Helix theme + self.setup_helix_theme() + + # Run color mapper for semantic themes + self.run_color_mapper() + + logger.info("All themes applied successfully") + return 0 + + except Exception as e: + logger.error(f"Theme application failed: {e}", exc_info=True) + return 1 + + +def main(): + """Main entry point.""" + applicator = ThemeApplicator() + return applicator.run() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/shared/modules/services/theme_switcher/color_mapper.py b/shared/modules/services/theme_switcher/color_mapper.py new file mode 100644 index 0000000..71b2055 --- /dev/null +++ b/shared/modules/services/theme_switcher/color_mapper.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +Intelligent color mapper for pywal themes. + +Analyzes generated pywal colors and maps them to semantic roles based on +hue, saturation, and lightness. Creates a mapping file that can be used +to apply consistent semantic meaning to colors regardless of wallpaper. +""" + +import json +import colorsys +from pathlib import Path +from typing import Dict, Tuple, List + +def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: + """Convert hex color to RGB tuple.""" + hex_color = hex_color.lstrip('#') + return (int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)) + +def rgb_to_hsl(rgb: Tuple[int, int, int]) -> Tuple[float, float, float]: + """Convert RGB to HSL.""" + r, g, b = [x / 255.0 for x in rgb] + h, l, s = colorsys.rgb_to_hls(r, g, b) + return (h * 360, s * 100, l * 100) # Convert to degrees and percentages + +def analyze_color(hex_color: str) -> Dict: + """Analyze a color and return its properties.""" + rgb = hex_to_rgb(hex_color) + h, s, l = rgb_to_hsl(rgb) + + # Determine color category based on hue + if s < 10: + category = "neutral" + elif 0 <= h < 30 or 330 <= h <= 360: + category = "red" + elif 30 <= h < 60: + category = "orange" + elif 60 <= h < 90: + category = "yellow" + elif 90 <= h < 150: + category = "green" + elif 150 <= h < 210: + category = "cyan" + elif 210 <= h < 270: + category = "blue" + elif 270 <= h < 330: + category = "magenta" + else: + category = "neutral" + + return { + "hex": hex_color, + "rgb": rgb, + "hue": h, + "saturation": s, + "lightness": l, + "category": category + } + +def calculate_contrast(color1_hsl: Tuple[float, float, float], color2_hsl: Tuple[float, float, float]) -> float: + """Calculate perceived contrast between two colors based on lightness difference.""" + return abs(color1_hsl[2] - color2_hsl[2]) + +def find_best_color_for_role(colors: Dict[str, Dict], role: str, is_light_mode: bool, bg_lightness: float = 50.0) -> str: + """ + Find the best color from the palette for a given semantic role. + + Roles: error, warning, success, info, emphasis, secondary + """ + + # Define preferred characteristics for each role + role_preferences = { + "error": { + "categories": ["red", "orange"], + "min_saturation": 30, + "prefer_vibrant": True, + "prefer_contrast": True # Errors need high visibility + }, + "warning": { + "categories": ["orange", "yellow"], + "min_saturation": 30, + "prefer_vibrant": True, + "prefer_contrast": True + }, + "comment": { + "categories": ["yellow", "orange", "green", "cyan"], # Flexible for high contrast + "min_saturation": 25, + "prefer_vibrant": False, + "prefer_contrast": True # Comments should be very visible + }, + "string": { + "categories": ["green", "cyan"], + "min_saturation": 25, + "prefer_vibrant": False, + "prefer_contrast": False + }, + "keyword": { + "categories": ["magenta", "blue"], + "min_saturation": 20, + "prefer_vibrant": False, + "prefer_contrast": False + }, + "number": { + "categories": ["orange", "red", "magenta"], + "min_saturation": 30, + "prefer_vibrant": True, + "prefer_contrast": False + }, + "info": { + "categories": ["blue", "cyan"], + "min_saturation": 20, + "prefer_vibrant": False, + "prefer_contrast": False + } + } + + prefs = role_preferences.get(role, {}) + preferred_cats = prefs.get("categories", []) + min_sat = prefs.get("min_saturation", 0) + prefer_vibrant = prefs.get("prefer_vibrant", False) + prefer_contrast = prefs.get("prefer_contrast", False) + + # Score each color + best_score = -1 + best_color = None + + for color_name, props in colors.items(): + if props["category"] == "neutral": + continue + + score = 0 + + # Category match (highest priority) + if props["category"] in preferred_cats: + score += 100 + + # Saturation requirements + if props["saturation"] >= min_sat: + score += 50 + + # Contrast preference (for comments and errors) + if prefer_contrast: + # Calculate contrast with background + contrast = abs(props["lightness"] - bg_lightness) + score += contrast * 0.5 # Add up to 50 points for maximum contrast + + # Vibrancy preference + if prefer_vibrant: + # For vibrant colors, prefer higher saturation and medium lightness + score += props["saturation"] / 2 + if 40 <= props["lightness"] <= 60: + score += 20 + else: + # For less vibrant, prefer moderate saturation + if 30 <= props["saturation"] <= 70: + score += 20 + + # Light mode adjustments - prefer colors with good contrast + if is_light_mode: + # In light mode, prefer darker, more saturated colors for visibility + if props["lightness"] < 50: + score += 10 + else: + # In dark mode, prefer brighter colors + if props["lightness"] > 40: + score += 10 + + if score > best_score: + best_score = score + best_color = color_name + + # Fallback to first color if no match found + if best_color is None: + best_color = list(colors.keys())[0] + + return best_color + +def create_semantic_mapping(colors_json_path: Path) -> Dict: + """Create semantic color mapping from pywal colors.""" + + with open(colors_json_path) as f: + data = json.load(f) + + # Determine if light mode and get background lightness + bg_hex = data.get("special", {}).get("background", "#000000") + bg_rgb = hex_to_rgb(bg_hex) + bg_hsl = rgb_to_hsl(bg_rgb) + bg_lightness = bg_hsl[2] + is_light_mode = bg_lightness > 50 + + # Analyze all colors + analyzed = {} + for i in range(1, 8): # color1 through color7 (skip color0 which is usually bg) + color_key = f"color{i}" + hex_color = data["colors"][color_key] + analyzed[color_key] = analyze_color(hex_color) + + # Map semantic roles to best matching colors + mapping = { + "error": find_best_color_for_role(analyzed, "error", is_light_mode, bg_lightness), + "warning": find_best_color_for_role(analyzed, "warning", is_light_mode, bg_lightness), + "comment": find_best_color_for_role(analyzed, "comment", is_light_mode, bg_lightness), + "string": find_best_color_for_role(analyzed, "string", is_light_mode, bg_lightness), + "keyword": find_best_color_for_role(analyzed, "keyword", is_light_mode, bg_lightness), + "number": find_best_color_for_role(analyzed, "number", is_light_mode, bg_lightness), + "info": find_best_color_for_role(analyzed, "info", is_light_mode, bg_lightness), + } + + # Add the original color values for reference + result = { + "semantic_mapping": mapping, + "is_light_mode": is_light_mode, + "color_analysis": analyzed + } + + return result + +def apply_semantic_mapping(template_path: Path, output_path: Path, mapping: Dict): + """Apply semantic mapping to Helix template.""" + + with open(template_path) as f: + template = f.read() + + # Read pywal colors + colors_json = Path.home() / ".cache/wal/colors.json" + with open(colors_json) as f: + colors_data = json.load(f) + + # Replace color references with semantically mapped ones + semantic = mapping["semantic_mapping"] + + # First, do standard pywal substitutions + output = template + + # Replace special colors + for key, value in colors_data["special"].items(): + output = output.replace(f"{{{key}}}", value) + + # Replace palette colors + for key, value in colors_data["colors"].items(): + output = output.replace(f"{{{key}}}", value) + + # Now apply semantic color replacements + error_color = colors_data["colors"][semantic["error"]] + warning_color = colors_data["colors"][semantic["warning"]] + comment_color = colors_data["colors"][semantic["comment"]] + string_color = colors_data["colors"][semantic["string"]] + keyword_color = colors_data["colors"][semantic["keyword"]] + number_color = colors_data["colors"][semantic["number"]] + info_color = colors_data["colors"][semantic["info"]] + + # Replace semantic placeholders + output = output.replace("{{ERROR_COLOR}}", error_color) + output = output.replace("{{WARNING_COLOR}}", warning_color) + output = output.replace("{{COMMENT_COLOR}}", comment_color) + output = output.replace("{{STRING_COLOR}}", string_color) + output = output.replace("{{KEYWORD_COLOR}}", keyword_color) + output = output.replace("{{NUMBER_COLOR}}", number_color) + output = output.replace("{{INFO_COLOR}}", info_color) + + with open(output_path, 'w') as f: + f.write(output) + +def main(): + """Main entry point.""" + import sys + + colors_json = Path.home() / ".cache/wal/colors.json" + + if not colors_json.exists(): + print("Error: Pywal colors.json not found. Run pywal first.") + return 1 + + # Analyze colors and create mapping + mapping = create_semantic_mapping(colors_json) + + # Save mapping for debugging/inspection + mapping_output = Path.home() / ".cache/wal/semantic_mapping.json" + with open(mapping_output, 'w') as f: + json.dump(mapping, f, indent=2) + + print("Semantic color mapping created:") + for role, color in mapping["semantic_mapping"].items(): + props = mapping["color_analysis"][color] + print(f" {role:12} -> {color} ({props['category']:8} {props['hex']})") + + # Apply to helix template if it exists + helix_template = Path.home() / ".config/wal/templates/helix_semantic.toml" + if helix_template.exists(): + helix_output = Path.home() / ".cache/wal/helix_semantic.toml" + apply_semantic_mapping(helix_template, helix_output, mapping) + print(f"\nApplied semantic mapping to: {helix_output}") + + # Also copy to helix themes directory + helix_themes_dir = Path.home() / ".config/helix/themes" + helix_themes_dir.mkdir(parents=True, exist_ok=True) + final_output = helix_themes_dir / "pywal_semantic.toml" + apply_semantic_mapping(helix_template, final_output, mapping) + print(f"Copied to: {final_output}") + + return 0 + +if __name__ == "__main__": + exit(main()) diff --git a/shared/modules/services/theme_switcher/default.nix b/shared/modules/services/theme_switcher/default.nix new file mode 100644 index 0000000..c9a8e1d --- /dev/null +++ b/shared/modules/services/theme_switcher/default.nix @@ -0,0 +1,348 @@ +{ config, lib, pkgs, ... }: + +# Theme Switcher Module - Architecture Overview +# +# This module integrates wpaperd for wallpaper management with pywal for dynamic theming. +# wpaperd handles wallpaper rotation and transitions, while Python scripts handle palette +# generation and intelligent semantic color mapping. +# +# Python Scripts: +# - theme_switcher.py: Time-based light/dark theme switching +# - wallpaper_rotation.py: Continuous wallpaper rotation with theme regeneration +# - apply_themes.py: Applies GTK/Kvantum/Helix themes after pywal runs +# - color_mapper.py: Intelligent semantic color mapping (reds for errors, high contrast for comments) +# +# Two modes of operation: +# +# 1. Light/Dark Theme Switching (theme-switcher service, triggered by timer at configured times): +# - Updates wpaperd config to point to Light/ or Dark/ directory +# - Restarts wpaperd (which picks a random wallpaper with transition) +# - Generates pywal palette from the selected wallpaper +# - Runs semantic color mapper to assign colors based on hue analysis +# - Applies GTK/Kvantum/Helix themes with intelligent color assignments +# +# 2. Continuous Wallpaper Rotation (wallpaper-rotation service, triggered by timer every N minutes): +# - Pauses wpaperd's auto-rotation +# - Calls wpaperctl next-wallpaper (wpaperd handles the smooth transition) +# - Gets current wallpaper from wpaperd +# - Regenerates pywal palette from new wallpaper +# - Runs semantic color mapper for intelligent color assignment +# - Applies GTK/Kvantum/Helix themes +# - Resumes wpaperd's auto-rotation +# +# This approach leverages wpaperd's built-in features (transitions, random selection, timing) +# while keeping palette generation and theme application in sync with wallpaper changes. +# The semantic color mapper ensures consistent meaning (e.g., reds for errors) regardless of wallpaper. + +let + cfg = config.services.themeSwitcher; + + # Get home directory for the user + userConfig = config.home-manager.users.${cfg.user}; + homeDir = userConfig.home.homeDirectory or "/home/${cfg.user}"; + + # Create a custom pywal16 with haishoku backend available + pywal16WithBackends = pkgs.pywal16.overridePythonAttrs (old: { + propagatedBuildInputs = (old.propagatedBuildInputs or []) ++ [ + pkgs.python313Packages.haishoku + ]; + }); + + # Python scripts for theme management + themeSwitcherScript = pkgs.writeScript "theme-switcher" '' + #!${pkgs.python3}/bin/python3 + import subprocess + import sys + + # Execute the theme switcher Python script + result = subprocess.run([ + "${./theme_switcher.py}", + "--wallpaper-path", "${cfg.wallpaperPath}", + "--light-time", "${cfg.lightTime}", + "--dark-time", "${cfg.darkTime}", + "--backend", "${cfg.backend}", + "--rotation-interval", "${cfg.rotation.interval}", + "--wpaperd-mode", "${cfg.wpaperd.mode}", + "--sorting", "${cfg.rotation.sorting}", + "--apply-themes-script", "${applyThemesScript}" + ]) + sys.exit(result.returncode) + ''; + + # Python script to apply GTK/Kvantum/Helix themes + applyThemesScript = ./apply_themes.py; + + # Python script for wallpaper rotation + wallpaperRotationScript = pkgs.writeScript "wallpaper-rotation" '' + #!${pkgs.python3}/bin/python3 + import subprocess + import sys + + # Execute the wallpaper rotation Python script + result = subprocess.run([ + "${./wallpaper_rotation.py}", + "--light-time", "${cfg.lightTime}", + "--dark-time", "${cfg.darkTime}", + "--backend", "${cfg.backend}", + "--apply-themes-script", "${applyThemesScript}" + ]) + sys.exit(result.returncode) + ''; +in +{ + options.services.themeSwitcher = { + enable = lib.mkEnableOption "dynamic theme switching based on time and wallpapers with integrated wallpaper management"; + + user = lib.mkOption { + type = lib.types.str; + description = "Username for theme switching"; + }; + + wallpaperPath = lib.mkOption { + type = lib.types.str; + default = "${homeDir}/nixos/shared/modules/services/wallpapers"; + defaultText = lib.literalExpression ''"''${config.home-manager.users.''${cfg.user}.home.homeDirectory}/nixos/shared/modules/services/wallpapers"''; + description = "Path to wallpaper directories (Light/ and Dark/ subdirectories expected)"; + }; + + backend = lib.mkOption { + type = lib.types.enum [ "haishoku" "modern_colorthief" "fast_colorthief" "colorthief" "colorz" "wal" ]; + default = "haishoku"; + description = "Pywal color extraction backend"; + }; + + lightTime = lib.mkOption { + type = lib.types.str; + default = "09:00:00"; + description = "Time to switch to light theme (HH:MM:SS)"; + }; + + darkTime = lib.mkOption { + type = lib.types.str; + default = "16:30:00"; + description = "Time to switch to dark theme (HH:MM:SS)"; + }; + + enableThemeSwitch = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable automatic time-based theme switching via systemd timer"; + }; + + paletteGeneration = lib.mkOption { + type = lib.types.enum [ "light-dark" "continuous" ]; + default = "continuous"; + description = '' + When to regenerate color palettes from wallpapers: + - light-dark: Only regenerate at light/dark switch times (6 AM/6 PM) + - continuous: Regenerate palette every time wallpaper rotates + ''; + }; + + # Wallpaper rotation options + rotation = { + interval = lib.mkOption { + type = lib.types.str; + default = "5m"; + example = "30s"; + description = "How often to rotate wallpapers (e.g., 30s, 5m, 1h)"; + }; + + sorting = lib.mkOption { + type = lib.types.enum [ "random" ]; + default = "random"; + description = "Wallpaper selection method (currently only random is supported)"; + }; + }; + + # Wpaperd integration options + wpaperd = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable wpaperd wallpaper daemon integration"; + }; + + mode = lib.mkOption { + type = lib.types.enum [ "center" "fit" "fit-border-color" "stretch" "tile" ]; + default = "center"; + description = "How to display wallpapers when size differs from display resolution"; + }; + + transition = { + effect = lib.mkOption { + type = lib.types.str; + default = "fade"; + example = "simple"; + description = "Transition effect name (fade, simple, etc.)"; + }; + + duration = lib.mkOption { + type = lib.types.int; + default = 300; + example = 2000; + description = "Transition duration in milliseconds"; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + home-manager.users.${cfg.user} = { + home.packages = with pkgs; [ + pywal16WithBackends # pywal16 with haishoku backend included + imagemagick # Useful for image manipulation + wpaperd # Wallpaper daemon and CLI tool + ]; + + # Install manual theme switching script to user's local bin + home.file.".local/bin/switch-theme.sh" = { + source = pkgs.writeShellScript "switch-theme.sh" '' + # Manual theme switching script + # Usage: switch-theme.sh [light|dark] + + if [ $# -ne 1 ]; then + echo "Usage: $0 [light|dark]" + exit 1 + fi + + MODE="$1" + + if [ "$MODE" != "light" ] && [ "$MODE" != "dark" ]; then + echo "Error: Mode must be 'light' or 'dark'" + exit 1 + fi + + # Trigger the theme switcher service which will handle everything + ${pkgs.systemd}/bin/systemctl --user start theme-switcher.service + ''; + executable = true; + }; + + # Pywal custom templates for GTK, Kvantum, and Helix + xdg.configFile."wal/templates/gtk-3.0.css".source = ./templates/gtk-3.0.css; + xdg.configFile."wal/templates/gtk-4.0.css".source = ./templates/gtk-4.0.css; + xdg.configFile."wal/templates/PywalTheme.kvconfig".source = ./templates/PywalTheme.kvconfig; + xdg.configFile."wal/templates/helix_minimal.toml".source = ./templates/helix_minimal.toml; + xdg.configFile."wal/templates/helix_semantic.toml".source = ./templates/helix_semantic.toml; + + # Start wpaperd daemon if enabled + # Note: We don't use services.wpaperd from home-manager because we need to + # dynamically update the config file, which would be read-only if managed by home-manager + systemd.user.services.wpaperd = lib.mkIf cfg.wpaperd.enable { + Unit = { + Description = "Wallpaper daemon for Wayland"; + After = [ "graphical-session.target" ]; + PartOf = [ "graphical-session.target" ]; + }; + Service = { + Type = "simple"; + # Create initial config before starting if it doesn't exist + ExecStartPre = pkgs.writeShellScript "wpaperd-init.sh" '' + mkdir -p ~/.config/wpaperd + if [ ! -f ~/.config/wpaperd/config.toml ] || [ -L ~/.config/wpaperd/config.toml ]; then + # Remove old symlink if exists and create initial config + rm -f ~/.config/wpaperd/config.toml + + # Determine initial directory based on current time + hour=$(${pkgs.coreutils}/bin/date +%H) + light_hour=$(echo "${cfg.lightTime}" | ${pkgs.coreutils}/bin/cut -d: -f1) + dark_hour=$(echo "${cfg.darkTime}" | ${pkgs.coreutils}/bin/cut -d: -f1) + + if [ "$hour" -ge "$light_hour" ] && [ "$hour" -lt "$dark_hour" ]; then + WALLPAPER_DIR="${cfg.wallpaperPath}/Light" + else + WALLPAPER_DIR="${cfg.wallpaperPath}/Dark" + fi + + cat > ~/.config/wpaperd/config.toml << EOF +[default] +path = "$WALLPAPER_DIR" +duration = "${cfg.rotation.interval}" +mode = "${cfg.wpaperd.mode}" +sorting = "${cfg.rotation.sorting}" +EOF + fi + ''; + ExecStart = "${pkgs.wpaperd}/bin/wpaperd"; + Restart = "on-failure"; + }; + Install.WantedBy = [ "graphical-session.target" ]; + }; + + # Systemd timer and service for theme switching at light/dark times + systemd.user.timers.theme-switcher = lib.mkIf cfg.enableThemeSwitch { + Unit.Description = "Dynamic Theme Switcher Timer"; + Timer = { + OnCalendar = [ "*-*-* ${cfg.lightTime}" "*-*-* ${cfg.darkTime}" ]; + Persistent = true; + }; + Install.WantedBy = [ "timers.target" ]; + }; + + systemd.user.services.theme-switcher = lib.mkIf cfg.enableThemeSwitch { + Unit.Description = "Dynamic Theme Switcher Service"; + Service = { + Type = "oneshot"; + ExecStart = "${themeSwitcherScript}"; + # Use home-manager profile to get pywal with all backends + Environment = [ + "PATH=${config.home-manager.users.${cfg.user}.home.profileDirectory}/bin:${lib.makeBinPath (with pkgs; [ + python3 + systemd + coreutils + findutils + glib + libsForQt5.qtstyleplugin-kvantum + bash + wpaperd + procps # for pgrep + ])}" + ]; + }; + }; + + # Wallpaper rotation timer and service (for continuous palette generation) + systemd.user.timers.wallpaper-rotation = lib.mkIf (cfg.paletteGeneration == "continuous") { + Unit.Description = "Wallpaper Rotation Timer (with Theme Regeneration)"; + Timer = { + OnBootSec = "1min"; # Start 1 minute after boot + OnUnitActiveSec = cfg.rotation.interval; + Persistent = false; + }; + Install.WantedBy = [ "timers.target" ]; + }; + + systemd.user.services.wallpaper-rotation = lib.mkIf (cfg.paletteGeneration == "continuous") { + Unit.Description = "Wallpaper Rotation Service (with Theme Regeneration)"; + Service = { + Type = "oneshot"; + ExecStart = "${wallpaperRotationScript}"; + # Use home-manager profile to get pywal with all backends + Environment = [ + "PATH=${config.home-manager.users.${cfg.user}.home.profileDirectory}/bin:${lib.makeBinPath (with pkgs; [ + python3 + systemd + coreutils + findutils + glib + libsForQt5.qtstyleplugin-kvantum + bash + wpaperd + procps # for pgrep + ])}" + ]; + }; + }; + + # Keep Qt/GTK enabled but allow pywal to override themes + qt = { + enable = true; + platformTheme.name = "kvantum"; + style.name = "kvantum"; + }; + + gtk.enable = true; + }; + }; +} diff --git a/shared/modules/services/theme_switcher/helix_theme_tester.py b/shared/modules/services/theme_switcher/helix_theme_tester.py new file mode 100755 index 0000000..2c87d78 --- /dev/null +++ b/shared/modules/services/theme_switcher/helix_theme_tester.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Simple Helix Theme Tester - Generate Helix theme from image for testing. +Usage: + ./helix_theme_tester.py test image.jpg + ./helix_theme_tester.py test image.jpg --light + ./helix_theme_tester.py test image.jpg --output custom_name.toml +""" + +import argparse +import subprocess +import sys +from pathlib import Path +import json +# Import from existing color_mapper +from color_mapper import create_semantic_mapping, apply_semantic_mapping +def generate_palette_from_image(image_path: Path, is_light: bool = False) -> bool: + """Run pywal on image to generate colors.json""" + + cmd = ['wal', '-i', str(image_path), '--backend', 'haishoku', '-n'] + if is_light: + cmd.append('-l') + + try: + subprocess.run(cmd, check=True, timeout=30) + print(f"✓ Generated palette from {image_path.name}") + return True + except subprocess.CalledProcessError as e: + print(f"✗ Failed to generate palette: {e}") + return False + except FileNotFoundError: + print("✗ Error: 'wal' command not found. Is pywal16 installed?") + return False +def generate_helix_theme(output_name: str = "pywal_test") -> bool: + """Generate Helix theme using color_mapper logic""" + + colors_json = Path.home() / ".cache/wal/colors.json" + if not colors_json.exists(): + print(f"✗ colors.json not found at {colors_json}") + return False + + # Use existing color_mapper to create semantic mapping + print("✓ Analyzing colors and creating semantic mapping...") + mapping = create_semantic_mapping(colors_json) + + # Print the mapping for visibility + print("\nSemantic color mapping:") + for role, color in mapping["semantic_mapping"].items(): + props = mapping["color_analysis"][color] + print(f" {role:12} -> {color} ({props['category']:8} {props['hex']})") + + # Check for contrast issues + print("\nContrast analysis:") + bg_lightness = mapping.get("color_analysis", {}).get("color0", {}).get("lightness", 50) + for role, color_key in mapping["semantic_mapping"].items(): + color_props = mapping["color_analysis"][color_key] + contrast = abs(color_props["lightness"] - bg_lightness) + status = "✓" if contrast > 30 else "⚠" + print(f" {status} {role:12}: contrast {contrast:.1f}") + + # Apply to helix template + helix_template = Path(__file__).parent / "templates/helix_semantic.toml" + helix_output = Path.home() / f".config/helix/themes/{output_name}.toml" + helix_output.parent.mkdir(parents=True, exist_ok=True) + + apply_semantic_mapping(helix_template, helix_output, mapping) + print(f"\n✓ Helix theme created: {helix_output}") + print(f"\nTest in Helix:") + print(f" hx some_file.py") + print(f" :theme {output_name}") + + return True +def main(): + parser = argparse.ArgumentParser( + description='Generate Helix theme from image for testing' + ) + parser.add_argument('command', choices=['test'], help='Command to run') + parser.add_argument('image', type=Path, help='Path to image file') + parser.add_argument('--light', action='store_true', help='Generate light theme') + parser.add_argument('--output', default='pywal_test', help='Output theme name') + + args = parser.parse_args() + + if not args.image.exists(): + print(f"✗ Image not found: {args.image}") + return 1 + + print(f"Testing with image: {args.image}") + print(f"Mode: {'light' if args.light else 'dark'}\n") + + # Step 1: Generate palette + if not generate_palette_from_image(args.image, args.light): + return 1 + + # Step 2: Generate Helix theme + if not generate_helix_theme(args.output): + return 1 + + print("\n✓ Done! Now test the theme in Helix and iterate.") + return 0 +if __name__ == '__main__': + sys.exit(main()) +Enhanced color_mapper.py with Tweaking +Let me show you what minimal enhancements to add for tweaking: +# Add to color_mapper.py after existing imports +def adjust_color_lightness(hex_color: str, factor: float) -> str: + """ + Adjust color lightness by factor. + factor > 1.0 = lighter, factor < 1.0 = darker + """ + rgb = hex_to_rgb(hex_color) + h, s, l = rgb_to_hsl(rgb) + + # Adjust lightness + l = max(0, min(100, l * factor)) + + # Convert back to RGB then hex + r, g, b = colorsys.hls_to_rgb(h/360, l/100, s/100) + return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" +def adjust_color_saturation(hex_color: str, factor: float) -> str: + """ + Adjust color saturation by factor. + factor > 1.0 = more saturated, factor < 1.0 = less saturated + """ + rgb = hex_to_rgb(hex_color) + h, s, l = rgb_to_hsl(rgb) + + # Adjust saturation + s = max(0, min(100, s * factor)) + + # Convert back + r, g, b = colorsys.hls_to_rgb(h/360, l/100, s/100) + return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" +def calculate_contrast_ratio(color1_hex: str, color2_hex: str) -> float: + """Calculate WCAG contrast ratio between two colors""" + def relative_luminance(hex_color): + rgb = hex_to_rgb(hex_color) + r, g, b = [x / 255.0 for x in rgb] + + # Apply gamma correction + r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4 + g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4 + b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4 + + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + l1 = relative_luminance(color1_hex) + l2 = relative_luminance(color2_hex) + + lighter = max(l1, l2) + darker = min(l1, l2) + + return (lighter + 0.05) / (darker + 0.05) +def enforce_minimum_contrast(fg_hex: str, bg_hex: str, min_ratio: float = 4.5) -> str: + """ + Adjust foreground color to meet minimum contrast ratio with background. + Preserves hue and saturation, only adjusts lightness. + """ + current_ratio = calculate_contrast_ratio(fg_hex, bg_hex) + + if current_ratio >= min_ratio: + return fg_hex # Already meets requirement + + # Determine if we need to lighten or darken + bg_rgb = hex_to_rgb(bg_hex) + bg_h, bg_s, bg_l = rgb_to_hsl(bg_rgb) + + fg_rgb = hex_to_rgb(fg_hex) + fg_h, fg_s, fg_l = rgb_to_hsl(fg_rgb) + + # If background is dark, lighten foreground; if light, darken foreground + step = 5 if bg_l < 50 else -5 + + # Iteratively adjust lightness + attempts = 0 + while attempts < 20: # Prevent infinite loop + fg_l += step + fg_l = max(0, min(100, fg_l)) + + # Convert back to hex + r, g, b = colorsys.hls_to_rgb(fg_h/360, fg_l/100, fg_s/100) + adjusted_hex = f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + + if calculate_contrast_ratio(adjusted_hex, bg_hex) >= min_ratio: + return adjusted_hex + + attempts += 1 + + # If we couldn't meet contrast, return best effort + return adjusted_hex +# Add parameters to apply_semantic_mapping +def apply_semantic_mapping(template_path: Path, output_path: Path, mapping: Dict, + adjustments: Dict = None): + """ + Apply semantic mapping to Helix template. + + adjustments: Optional dict with tweaking parameters: + { + 'comment': {'contrast': 4.5, 'saturation': 1.2}, + 'error': {'saturation': 1.3, 'force_hue': (0, 30)}, + } + """ + + with open(template_path) as f: + template = f.read() + + # Read pywal colors + colors_json = Path.home() / ".cache/wal/colors.json" + with open(colors_json) as f: + colors_data = json.load(f) + + # Get background for contrast calculations + bg_color = colors_data["special"]["background"] + + # Replace standard pywal colors first + output = template + for key, value in colors_data["special"].items(): + output = output.replace(f"{{{key}}}", value) + for key, value in colors_data["colors"].items(): + output = output.replace(f"{{{key}}}", value) + + # Now apply semantic colors with optional adjustments + semantic = mapping["semantic_mapping"] + + for role in ["error", "warning", "comment", "string", "keyword", "number", "info"]: + color_key = semantic[role] + color_hex = colors_data["colors"][color_key] + + # Apply adjustments if provided + if adjustments and role in adjustments: + adj = adjustments[role] + + # Apply saturation adjustment + if 'saturation' in adj: + color_hex = adjust_color_saturation(color_hex, adj['saturation']) + + # Apply lightness adjustment + if 'lightness' in adj: + color_hex = adjust_color_lightness(color_hex, adj['lightness']) + + # Enforce minimum contrast + if 'contrast' in adj: + color_hex = enforce_minimum_contrast(color_hex, bg_color, adj['contrast']) + + # Replace in template + placeholder = f"{{{role.upper()}_COLOR}}" + output = output.replace(placeholder, color_hex) + + with open(output_path, 'w') as f: + f.write(output) diff --git a/shared/modules/services/theme_switcher/templates/PywalTheme.kvconfig b/shared/modules/services/theme_switcher/templates/PywalTheme.kvconfig new file mode 100644 index 0000000..09b6eb5 --- /dev/null +++ b/shared/modules/services/theme_switcher/templates/PywalTheme.kvconfig @@ -0,0 +1,360 @@ +[%General] +author=Pywal Theme Generator +comment=A Kvantum theme generated from wallpaper colors +x11drag=menubar_and_primary_toolbar +alt_mnemonic=true +left_tabs=false +attach_active_tab=false +mirror_doc_tabs=true +group_toolbar_buttons=false +spread_progressbar=true +composite=true +menu_shadow_depth=6 +tooltip_shadow_depth=4 +scroll_width=12 +scroll_arrows=false +scroll_min_extent=40 +slider_width=6 +slider_handle_width=20 +slider_handle_length=20 +center_toolbar_handle=true +check_size=16 +textless_progressbar=false +progressbar_thickness=4 +menubar_mouse_tracking=true +toolbutton_style=0 +double_click=false +translucent_windows=false +blurring=false +popup_blurring=false +opaque=kaffeine,kmplayer,subtitlecomposer,kdenlive,vlc,smplayer,smplayer2,avidemux,avidemux2_qt4,avidemux3_qt4,avidemux3_qt5,kamoso,QtCreator,VirtualBox,VirtualBoxVM,trojita,dragon,digikam,lyx,calligra,ink +vertical_spin_indicators=false +spin_button_width=16 +fill_rubberband=false +merge_menubar_with_toolbar=true +small_icon_size=16 +large_icon_size=32 +button_icon_size=16 +toolbar_icon_size=22 +combo_as_lineedit=true +hide_combo_checkboxes=true +combo_menu=true +hide_spin_checkboxes=true +button_contents_shift=false +groupbox_top_label=true +inline_spin_indicators=true +joined_inactive_tabs=false + +[GeneralColors] +window.color={background} +base.color={background} +alt.base.color={color0} +button.color={color0} +light.color={color8} +mid.light.color={color8} +dark.color={color0} +mid.color={color8} +highlight.color={color4} +inactive.highlight.color={color8} +text.color={foreground} +window.text.color={foreground} +button.text.color={foreground} +disabled.text.color={color8} +tooltip.base.color={color0} +tooltip.text.color={foreground} +highlight.text.color={background} +link.color={color4} +link.visited.color={color5} +progress.indicator.text.color={background} + +[Hacks] +transparent_ktitle_label=true +transparent_dolphin_view=true +transparent_pcmanfm_sidepane=true +blur_translucent=false +transparent_menutitle=true +respect_darkness=true +kcapacitybar_as_progressbar=true +force_size_grip=true +iconless_pushbutton=false +iconless_menu=false +disabled_icon_opacity=70 +lxqtmainmenu_iconsize=22 +normal_default_pushbutton=true +single_top_toolbar=true +tint_on_mouseover=0 +transparent_pcmanfm_view=false +no_selection_tint=false + +[PanelButtonCommand] +frame=true +frame.element=button +frame.top=4 +frame.bottom=4 +frame.left=4 +frame.right=4 +interior=true +interior.element=button +indicator.size=10 +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +text.toggle.color={background} +text.shadow=0 +text.margin=1 +text.iconspacing=4 +indicator.element=arrow +text.margin.top=2 +text.margin.bottom=2 +text.margin.left=2 +text.margin.right=2 +min_width=+0.3font +min_height=+0.3font +frame.expansion=0 + +[PanelButtonTool] +inherits=PanelButtonCommand + +[Toolbar] +inherits=PanelButtonCommand +interior.element=none +frame.element=none +text.normal.color={foreground} +text.focus.color={foreground} + +[ToolbarButton] +inherits=PanelButtonCommand +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} + +[DockTitle] +inherits=PanelButtonCommand +frame=false +interior=false +text.normal.color={foreground} +text.focus.color={background} + +[IndicatorSpinBox] +inherits=PanelButtonCommand +indicator.element=arrow +indicator.size=10 + +[RadioButton] +inherits=PanelButtonCommand +frame=false +interior.element=radio +text.normal.color={foreground} +text.focus.color={foreground} + +[CheckBox] +inherits=PanelButtonCommand +frame=false +interior.element=checkbox +text.normal.color={foreground} +text.focus.color={foreground} + +[GenericFrame] +inherits=PanelButtonCommand +frame=true +interior=false +frame.element=common +interior.element=common +frame.top=1 +frame.bottom=1 +frame.left=1 +frame.right=1 + +[LineEdit] +inherits=PanelButtonCommand +frame.element=lineedit +interior.element=lineedit +text.normal.color={foreground} +text.focus.color={foreground} + +[DropDownButton] +inherits=PanelButtonCommand +indicator.element=arrow-down + +[IndicatorArrow] +indicator.element=arrow +indicator.size=10 + +[ToolboxTab] +inherits=PanelButtonCommand +text.normal.color={foreground} +text.press.color={background} +text.focus.color={background} + +[Tab] +inherits=PanelButtonCommand +interior.element=tab +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +frame.element=tab +indicator.element=tab +indicator.size=10 +frame.top=4 +frame.bottom=4 +frame.left=4 +frame.right=4 + +[TabFrame] +inherits=PanelButtonCommand +frame.element=tabframe +interior.element=tabframe +frame.top=2 +frame.bottom=2 +frame.left=2 +frame.right=2 + +[TreeExpander] +inherits=PanelButtonCommand +indicator.size=10 +indicator.element=tree + +[HeaderSection] +inherits=PanelButtonCommand +interior.element=header +frame.element=header +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +text.toggle.color={background} +frame.top=1 +frame.bottom=1 +frame.left=1 +frame.right=1 + +[SizeGrip] +indicator.element=resize-grip + +[Scrollbar] +inherits=PanelButtonCommand +indicator.element=arrow +indicator.size=10 + +[ScrollbarSlider] +inherits=PanelButtonCommand +frame.element=scrollbarslider +interior=false +frame.left=6 +frame.right=6 +frame.top=6 +frame.bottom=6 +indicator.element=grip +indicator.size=13 + +[ScrollbarGroove] +inherits=PanelButtonCommand +interior=false +frame=false + +[Progressbar] +inherits=PanelButtonCommand +frame.element=progress +interior.element=progress +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +text.toggle.color={background} +text.bold=false +frame.expansion=8 + +[ProgressbarContents] +inherits=PanelButtonCommand +frame=true +frame.element=progress-pattern +interior.element=progress-pattern + +[ItemView] +inherits=PanelButtonCommand +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +text.toggle.color={background} +frame.element=itemview +interior.element=itemview +frame.top=2 +frame.bottom=2 +frame.left=2 +frame.right=2 + +[Splitter] +indicator.size=48 + +[Menu] +inherits=PanelButtonCommand +frame.top=3 +frame.bottom=3 +frame.left=3 +frame.right=3 +frame.element=menu +interior.element=menu +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} + +[MenuItem] +inherits=PanelButtonCommand +frame=true +frame.element=menuitem +interior.element=menuitem +indicator.element=menuitem +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +text.toggle.color={background} +frame.top=3 +frame.bottom=3 +frame.left=3 +frame.right=3 + +[MenuBar] +inherits=PanelButtonCommand +frame.element=menubar +interior.element=menubar +frame.bottom=0 + +[MenuBarItem] +inherits=PanelButtonCommand +interior=true +interior.element=menubaritem +frame.element=menubaritem +text.normal.color={foreground} +text.focus.color={background} +text.press.color={background} +frame.top=2 +frame.bottom=2 +frame.left=2 +frame.right=2 + +[TitleBar] +inherits=PanelButtonCommand +frame=false +interior.element=titlebar +indicator.size=16 +indicator.element=mdi +text.normal.color={foreground} +text.focus.color={background} +text.bold=true + +[ComboBox] +inherits=PanelButtonCommand +interior.element=combo +frame.element=combo +indicator.element=carrow + +[ToolTip] +inherits=GenericFrame +frame.top=3 +frame.bottom=3 +frame.left=3 +frame.right=3 +interior=true +text.shadow=0 +text.margin=0 +interior.element=tooltip +frame.element=tooltip +frame.expansion=0 diff --git a/shared/modules/services/theme_switcher/templates/gtk-3.0.css b/shared/modules/services/theme_switcher/templates/gtk-3.0.css new file mode 100644 index 0000000..0d88a72 --- /dev/null +++ b/shared/modules/services/theme_switcher/templates/gtk-3.0.css @@ -0,0 +1,255 @@ +/* GTK 3.0 Theme - Generated by pywal */ +/* This theme uses colors extracted from wallpapers */ + +/* Define color variables */ +@define-color theme_bg_color {background}; +@define-color theme_fg_color {foreground}; +@define-color theme_base_color {background}; +@define-color theme_text_color {foreground}; +@define-color theme_selected_bg_color {color4}; +@define-color theme_selected_fg_color {background}; +@define-color insensitive_bg_color shade({background}, 0.95); +@define-color insensitive_fg_color shade({foreground}, 0.7); +@define-color insensitive_base_color {background}; +@define-color theme_unfocused_bg_color {background}; +@define-color theme_unfocused_fg_color {foreground}; +@define-color theme_unfocused_base_color {background}; +@define-color theme_unfocused_text_color {foreground}; +@define-color theme_unfocused_selected_bg_color {color4}; +@define-color theme_unfocused_selected_fg_color {background}; +@define-color borders shade({background}, 0.8); +@define-color unfocused_borders shade({background}, 0.85); + +/* Additional accent colors */ +@define-color accent_bg_color {color4}; +@define-color accent_fg_color {background}; +@define-color accent_color {color4}; +@define-color destructive_bg_color {color1}; +@define-color destructive_fg_color {background}; +@define-color destructive_color {color1}; +@define-color success_bg_color {color2}; +@define-color success_fg_color {background}; +@define-color success_color {color2}; +@define-color warning_bg_color {color3}; +@define-color warning_fg_color {background}; +@define-color warning_color {color3}; +@define-color error_bg_color {color1}; +@define-color error_fg_color {background}; +@define-color error_color {color1}; + +/* Window styling */ +window { + background-color: {background}; + color: {foreground}; +} + +/* Widget styling */ +* {{ + background-color: {background}; + color: {foreground}; +}} + +button { + background-color: {color0}; + color: {foreground}; + border: 1px solid {color8}; + border-radius: 4px; + padding: 6px 12px; +} + +button:hover { + background-color: {color8}; + border-color: {color4}; +} + +button:active, +button:checked { + background-color: {color4}; + color: {background}; +} + +button:disabled { + background-color: shade({background}, 0.95); + color: shade({foreground}, 0.7); +} + +/* Entry/Input fields */ +entry { + background-color: {color0}; + color: {foreground}; + border: 1px solid {color8}; + border-radius: 4px; + padding: 6px; +} + +entry:focus { + border-color: {color4}; +} + +entry:disabled { + background-color: shade({background}, 0.95); + color: shade({foreground}, 0.7); +} + +/* Selections */ +selection, +*:selected { + background-color: {color4}; + color: {background}; +} + +/* Headerbar */ +headerbar { + background-color: {color0}; + color: {foreground}; + border-bottom: 1px solid {color8}; +} + +headerbar button { + background-color: transparent; + border: none; +} + +headerbar button:hover { + background-color: {color8}; +} + +/* Sidebar */ +.sidebar { + background-color: {color0}; + border-right: 1px solid {color8}; +} + +/* Notebook/Tabs */ +notebook > header { + background-color: {color0}; + border-bottom: 1px solid {color8}; +} + +notebook > header > tabs > tab { + background-color: transparent; + color: {foreground}; + padding: 6px 12px; +} + +notebook > header > tabs > tab:checked { + background-color: {background}; + border-bottom: 2px solid {color4}; +} + +/* Menu */ +menu, +.menu { + background-color: {color0}; + color: {foreground}; + border: 1px solid {color8}; +} + +menuitem { + padding: 6px 12px; +} + +menuitem:hover { + background-color: {color4}; + color: {background}; +} + +/* Scrollbar */ +scrollbar { + background-color: {background}; +} + +scrollbar slider { + background-color: {color8}; + border-radius: 8px; + min-width: 8px; + min-height: 8px; +} + +scrollbar slider:hover { + background-color: {color4}; +} + +/* Progressbar */ +progressbar { + background-color: {color0}; + border-radius: 4px; +} + +progressbar progress { + background-color: {color4}; + border-radius: 4px; +} + +/* Switch */ +switch { + background-color: {color0}; + border: 1px solid {color8}; + border-radius: 12px; +} + +switch:checked { + background-color: {color4}; +} + +switch slider { + background-color: {foreground}; + border-radius: 50%; +} + +/* Checkbutton & Radiobutton */ +checkbutton check, +radiobutton radio { + background-color: {color0}; + border: 1px solid {color8}; +} + +checkbutton check:checked, +radiobutton radio:checked { + background-color: {color4}; + border-color: {color4}; +} + +/* Tooltip */ +tooltip { + background-color: {color0}; + color: {foreground}; + border: 1px solid {color8}; + border-radius: 4px; + padding: 6px; +} + +/* Popover */ +popover { + background-color: {color0}; + color: {foreground}; + border: 1px solid {color8}; + border-radius: 8px; + padding: 6px; +} + +/* Frame */ +frame { + border: 1px solid {color8}; + border-radius: 4px; +} + +/* List */ +list, +.view { + background-color: {background}; + color: {foreground}; +} + +list row { + padding: 6px; +} + +list row:hover { + background-color: {color8}; +} + +list row:selected { + background-color: {color4}; + color: {background}; +} diff --git a/shared/modules/services/theme_switcher/templates/gtk-4.0.css b/shared/modules/services/theme_switcher/templates/gtk-4.0.css new file mode 100644 index 0000000..d0f1602 --- /dev/null +++ b/shared/modules/services/theme_switcher/templates/gtk-4.0.css @@ -0,0 +1,397 @@ +/* GTK 4.0 Theme - Generated by pywal */ +/* This theme uses colors extracted from wallpapers */ + +/* Define color variables */ +@define-color window_bg_color {background}; +@define-color window_fg_color {foreground}; +@define-color view_bg_color {background}; +@define-color view_fg_color {foreground}; +@define-color accent_bg_color {color4}; +@define-color accent_fg_color {background}; +@define-color accent_color {color4}; +@define-color destructive_bg_color {color1}; +@define-color destructive_fg_color {background}; +@define-color destructive_color {color1}; +@define-color success_bg_color {color2}; +@define-color success_fg_color {background}; +@define-color success_color {color2}; +@define-color warning_bg_color {color3}; +@define-color warning_fg_color {background}; +@define-color warning_color {color3}; +@define-color error_bg_color {color1}; +@define-color error_fg_color {background}; +@define-color error_color {color1}; +@define-color headerbar_bg_color {color0}; +@define-color headerbar_fg_color {foreground}; +@define-color headerbar_border_color {color8}; +@define-color sidebar_bg_color {color0}; +@define-color sidebar_fg_color {foreground}; +@define-color card_bg_color {color0}; +@define-color card_fg_color {foreground}; +@define-color popover_bg_color {color0}; +@define-color popover_fg_color {foreground}; +@define-color dialog_bg_color {background}; +@define-color dialog_fg_color {foreground}; + +/* Legacy GTK3 compatibility colors */ +@define-color theme_bg_color {background}; +@define-color theme_fg_color {foreground}; +@define-color theme_base_color {background}; +@define-color theme_text_color {foreground}; +@define-color theme_selected_bg_color {color4}; +@define-color theme_selected_fg_color {background}; +@define-color insensitive_bg_color alpha({foreground}, 0.1); +@define-color insensitive_fg_color alpha({foreground}, 0.5); +@define-color borders alpha({foreground}, 0.2); + +/* Window styling */ +window { + background-color: @window_bg_color; + color: @window_fg_color; +} + +/* Widget styling */ +* { + background-color: @window_bg_color; + color: @window_fg_color; +} + +/* Button */ +button { + background-color: @card_bg_color; + color: @window_fg_color; + border: 1px solid @borders; + border-radius: 6px; + padding: 6px 12px; + min-height: 24px; +} + +button:hover { + background-color: alpha(@accent_bg_color, 0.1); + border-color: @accent_color; +} + +button:active { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +button:disabled { + background-color: @insensitive_bg_color; + color: @insensitive_fg_color; +} + +button.suggested-action { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +button.destructive-action { + background-color: @destructive_bg_color; + color: @destructive_fg_color; +} + +/* Entry/Input fields */ +entry { + background-color: @view_bg_color; + color: @view_fg_color; + border: 1px solid @borders; + border-radius: 6px; + padding: 6px; + min-height: 32px; +} + +entry:focus { + border-color: @accent_color; + outline: 2px solid alpha(@accent_color, 0.3); + outline-offset: -1px; +} + +entry:disabled { + background-color: @insensitive_bg_color; + color: @insensitive_fg_color; +} + +/* Selections */ +selection, +*:selected { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +/* Headerbar */ +headerbar { + background-color: @headerbar_bg_color; + color: @headerbar_fg_color; + border-bottom: 1px solid @headerbar_border_color; + min-height: 46px; + padding: 0 6px; +} + +headerbar button { + background-color: transparent; + border: none; +} + +headerbar button:hover { + background-color: alpha(@accent_bg_color, 0.1); +} + +headerbar button:active { + background-color: alpha(@accent_bg_color, 0.2); +} + +/* Sidebar */ +.sidebar { + background-color: @sidebar_bg_color; + border-right: 1px solid @borders; +} + +.sidebar row { + padding: 6px; +} + +.sidebar row:hover { + background-color: alpha(@accent_bg_color, 0.1); +} + +.sidebar row:selected { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +/* Notebook/Tabs */ +notebook > header { + background-color: @card_bg_color; + border-bottom: 1px solid @borders; +} + +notebook > header > tabs > tab { + background-color: transparent; + color: @window_fg_color; + padding: 6px 12px; + min-height: 32px; +} + +notebook > header > tabs > tab:hover { + background-color: alpha(@accent_bg_color, 0.1); +} + +notebook > header > tabs > tab:checked { + background-color: @window_bg_color; + border-bottom: 2px solid @accent_color; +} + +/* Menu */ +menubar { + background-color: @headerbar_bg_color; + color: @headerbar_fg_color; +} + +menu, +.menu { + background-color: @popover_bg_color; + color: @popover_fg_color; + border: 1px solid @borders; + border-radius: 8px; + padding: 4px; +} + +menuitem { + padding: 6px 12px; + border-radius: 4px; + min-height: 28px; +} + +menuitem:hover { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +/* Scrollbar */ +scrollbar { + background-color: transparent; +} + +scrollbar slider { + background-color: alpha(@window_fg_color, 0.3); + border-radius: 8px; + min-width: 10px; + min-height: 10px; +} + +scrollbar slider:hover { + background-color: alpha(@window_fg_color, 0.5); +} + +scrollbar slider:active { + background-color: @accent_color; +} + +/* Progressbar */ +progressbar { + background-color: @view_bg_color; + border-radius: 4px; +} + +progressbar progress { + background-color: @accent_bg_color; + border-radius: 4px; +} + +progressbar trough { + background-color: alpha(@window_fg_color, 0.1); + border-radius: 4px; +} + +/* Switch */ +switch { + background-color: alpha(@window_fg_color, 0.2); + border-radius: 12px; + min-width: 48px; + min-height: 24px; +} + +switch:checked { + background-color: @accent_bg_color; +} + +switch slider { + background-color: @window_fg_color; + border-radius: 50%; + min-width: 20px; + min-height: 20px; + margin: 2px; +} + +/* Checkbutton & Radiobutton */ +checkbutton check, +radiobutton radio { + background-color: @view_bg_color; + border: 1px solid @borders; + min-width: 16px; + min-height: 16px; +} + +checkbutton check { + border-radius: 4px; +} + +radiobutton radio { + border-radius: 50%; +} + +checkbutton check:checked, +radiobutton radio:checked { + background-color: @accent_bg_color; + border-color: @accent_bg_color; + color: @accent_fg_color; +} + +/* Tooltip */ +tooltip { + background-color: @popover_bg_color; + color: @popover_fg_color; + border: 1px solid @borders; + border-radius: 6px; + padding: 6px 8px; +} + +tooltip label { + padding: 0; +} + +/* Popover */ +popover { + background-color: @popover_bg_color; + color: @popover_fg_color; + border: 1px solid @borders; + border-radius: 12px; + padding: 6px; +} + +popover > contents { + background-color: transparent; +} + +/* Frame */ +frame { + border: 1px solid @borders; + border-radius: 6px; +} + +/* List */ +list, +listview, +.view { + background-color: @view_bg_color; + color: @view_fg_color; + border-radius: 6px; +} + +list row, +listview row { + padding: 6px; + border-radius: 4px; +} + +list row:hover, +listview row:hover { + background-color: alpha(@accent_bg_color, 0.1); +} + +list row:selected, +listview row:selected { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +/* Card */ +.card { + background-color: @card_bg_color; + color: @card_fg_color; + border: 1px solid @borders; + border-radius: 12px; + padding: 12px; +} + +/* Dialog */ +dialog { + background-color: @dialog_bg_color; + color: @dialog_fg_color; +} + +/* Searchbar */ +searchbar { + background-color: @headerbar_bg_color; + border-bottom: 1px solid @borders; +} + +/* Toolbar */ +toolbar { + background-color: @headerbar_bg_color; + padding: 4px; + border-bottom: 1px solid @borders; +} + +/* Infobar */ +.info { + background-color: @accent_bg_color; + color: @accent_fg_color; +} + +.warning { + background-color: @warning_bg_color; + color: @warning_fg_color; +} + +.error { + background-color: @error_bg_color; + color: @error_fg_color; +} + +.success, +.question { + background-color: @success_bg_color; + color: @success_fg_color; +} diff --git a/shared/modules/services/theme_switcher/templates/helix_minimal.toml b/shared/modules/services/theme_switcher/templates/helix_minimal.toml new file mode 100644 index 0000000..35f85dc --- /dev/null +++ b/shared/modules/services/theme_switcher/templates/helix_minimal.toml @@ -0,0 +1,153 @@ +# Pywal Minimal - Judicious Syntax Highlighting +# Uses only 4 colors for syntax +# Functions and variables remain plain text for reduced visual noise +# Colors dynamically generated from wallpaper via pywal + +"ui.background" = {{ bg = "base" }} +"ui.virtual" = {{ fg = "surface0" }} +"ui.virtual.ruler" = {{ bg = "surface0" }} +"ui.virtual.indent-guide" = {{ fg = "surface0" }} +"ui.virtual.inlay-hint" = {{ fg = "overlay1", bg = "mantle", modifiers = ["italic"] }} +"ui.virtual.jump-label" = {{ fg = "red", modifiers = ["bold"] }} + +"ui.selection" = {{ fg = "text", bg = "surface1" }} +"ui.selection.primary" = {{ fg = "text", bg = "surface2" }} + +"ui.cursor" = {{ fg = "base", bg = "cursor" }} +"ui.cursor.primary" = {{ fg = "base", bg = "cursor" }} +"ui.cursor.match" = {{ fg = "color1", modifiers = ["bold"] }} +"ui.cursorline.primary" = {{ bg = "surface0" }} +"ui.cursorcolumn.primary" = {{ bg = "surface0" }} + +"ui.linenr" = {{ fg = "surface1" }} +"ui.linenr.selected" = {{ fg = "cursor", modifiers = ["bold"] }} + +"ui.statusline" = {{ fg = "text", bg = "mantle" }} +"ui.statusline.inactive" = {{ fg = "overlay0", bg = "mantle" }} +"ui.statusline.normal" = {{ fg = "base", bg = "cursor", modifiers = ["bold"] }} +"ui.statusline.insert" = {{ fg = "base", bg = "color2", modifiers = ["bold"] }} +"ui.statusline.select" = {{ fg = "base", bg = "color5", modifiers = ["bold"] }} + +"ui.bufferline" = {{ fg = "overlay0", bg = "mantle" }} +"ui.bufferline.active" = {{ fg = "cursor", bg = "base", modifiers = ["bold"] }} + +"ui.help" = {{ fg = "text", bg = "surface0" }} +"ui.text" = "text" +"ui.text.focus" = {{ fg = "text", bg = "surface0" }} +"ui.text.inactive" = "overlay1" + +"ui.menu" = {{ fg = "text", bg = "surface0" }} +"ui.menu.selected" = {{ fg = "text", bg = "surface1", modifiers = ["bold"] }} +"ui.menu.scroll" = {{ fg = "overlay0", bg = "surface0" }} + +"ui.popup" = {{ fg = "text", bg = "surface0" }} +"ui.window" = {{ fg = "base" }} + +"diagnostic.error" = {{ underline = {{ color = "color1", style = "curl" }} }} +"diagnostic.warning" = {{ underline = {{ color = "color3", style = "curl" }} }} +"diagnostic.info" = {{ underline = {{ color = "color6", style = "curl" }} }} +"diagnostic.hint" = {{ underline = {{ color = "color4", style = "curl" }} }} +"diagnostic.unnecessary" = {{ modifiers = ["dim"] }} +"diagnostic.deprecated" = {{ modifiers = ["crossed_out"] }} + +"error" = "color1" +"warning" = "color3" +"info" = "color6" +"hint" = "color4" + +"diff.plus" = "color2" +"diff.minus" = "color1" +"diff.delta" = "color3" + +"markup.heading" = {{ fg = "cursor", modifiers = ["bold"] }} +"markup.list" = "color5" +"markup.bold" = {{ modifiers = ["bold"] }} +"markup.italic" = {{ modifiers = ["italic"] }} +"markup.strikethrough" = {{ modifiers = ["crossed_out"] }} +"markup.link.url" = {{ fg = "color4", modifiers = ["underlined"] }} +"markup.link.text" = "color5" +"markup.quote" = "color2" +"markup.raw" = "color2" + +# Minimal syntax highlighting - only 4 colors used +"comment" = "color3" # Comments pop (yellow/bright color) + +"keyword" = {{ fg = "color5", modifiers = ["italic"] }} # Keywords highlighted and italic (magenta) +"keyword.control" = {{ fg = "color5", modifiers = ["italic"] }} +"keyword.directive" = {{ fg = "color5", modifiers = ["italic"] }} +"keyword.function" = {{ fg = "color5", modifiers = ["italic"] }} +"keyword.operator" = {{ fg = "color5", modifiers = ["italic"] }} +"keyword.return" = {{ fg = "color5", modifiers = ["italic"] }} +"keyword.storage" = {{ fg = "color5", modifiers = ["italic"] }} + +"string" = "color2" # Strings highlighted (green) +"string.regexp" = "color1" +"string.special" = "color2" + +"constant.numeric" = "color1" # Numbers highlighted (orange/red) +"constant.builtin" = "color1" +"constant.character.escape" = "color1" + +# Everything else remains plain text +"function" = "text" # Functions are plain text +"function.builtin" = "text" +"function.method" = "text" +"function.macro" = "text" + +"variable" = "text" # Variables are plain text +"variable.builtin" = "text" +"variable.parameter" = "text" +"variable.other.member" = "text" + +"type" = {{ fg = "cursor", modifiers = ["italic"]}} # Types are slightly highlighted +"type.builtin" = "cursor" + +"constructor" = "text" # Constructors are plain text + +"attribute" = "text" +"label" = "text" +"namespace" = "text" +"tag" = "text" + +# Top-level declarations get accent color +"function.definition" = {{ fg = "color7", modifiers = ["bold"] }} +"type.definition" = {{ fg = "color7", modifiers = ["bold"] }} + +# Punctuation is slightly dimmed but still readable +"punctuation" = "subtext0" +"punctuation.bracket" = "subtext0" +"punctuation.delimiter" = "subtext0" +"punctuation.special" = "subtext0" + +"operator" = "subtext0" # Operators slightly dimmed + +[palette] +# Pywal generated colors - background to foreground gradient +base = "{background}" +mantle = "{color0}" +crust = "{color0}" + +surface0 = "{color8}" +surface1 = "{color8}" +surface2 = "{color7}" + +overlay0 = "{color8}" +overlay1 = "{color7}" +overlay2 = "{color7}" + +subtext0 = "{color7}" +subtext1 = "{color15}" +text = "{foreground}" + +# Core colors from pywal palette +red = "{color1}" +color1 = "{color1}" # Red/Orange - numbers, errors +color2 = "{color2}" # Green - strings +color3 = "{color3}" # Yellow - comments +color4 = "{color4}" # Blue - info +color5 = "{color5}" # Magenta - keywords +color6 = "{color6}" # Cyan - hints +color7 = "{color7}" # Light gray - definitions + +color15 = "{color15}" # Bright white +cursor = "{cursor}" # Cursor color diff --git a/shared/modules/services/theme_switcher/templates/helix_semantic.toml b/shared/modules/services/theme_switcher/templates/helix_semantic.toml new file mode 100644 index 0000000..305984b --- /dev/null +++ b/shared/modules/services/theme_switcher/templates/helix_semantic.toml @@ -0,0 +1,164 @@ +# Pywal Semantic - Intelligent color mapping based on semantic meaning +# Colors are automatically mapped based on hue analysis: +# - Reds/oranges for errors and warnings +# - High contrast colors for comments +# - Appropriate semantic colors for syntax elements +# Colors dynamically generated from wallpaper via pywal with semantic mapping + +"ui.background" = {{ bg = "base" }} +"ui.virtual" = {{ fg = "surface0" }} +"ui.virtual.ruler" = {{ bg = "surface0" }} +"ui.virtual.indent-guide" = {{ fg = "surface0" }} +"ui.virtual.inlay-hint" = {{ fg = "overlay1", bg = "mantle", modifiers = ["italic"] }} +"ui.virtual.jump-label" = {{ fg = "error_color", modifiers = ["bold"] }} + +"ui.selection" = {{ fg = "text", bg = "surface1" }} +"ui.selection.primary" = {{ fg = "text", bg = "surface2" }} + +"ui.cursor" = {{ fg = "base", bg = "cursor" }} +"ui.cursor.primary" = {{ fg = "base", bg = "cursor" }} +"ui.cursor.match" = {{ fg = "keyword_color", modifiers = ["bold"] }} +"ui.cursorline.primary" = {{ bg = "surface0" }} +"ui.cursorcolumn.primary" = {{ bg = "surface0" }} + +"ui.linenr" = {{ fg = "surface1" }} +"ui.linenr.selected" = {{ fg = "cursor", modifiers = ["bold"] }} + +"ui.statusline" = {{ fg = "text", bg = "mantle" }} +"ui.statusline.inactive" = {{ fg = "overlay0", bg = "mantle" }} +"ui.statusline.normal" = {{ fg = "base", bg = "cursor", modifiers = ["bold"] }} +"ui.statusline.insert" = {{ fg = "base", bg = "string_color", modifiers = ["bold"] }} +"ui.statusline.select" = {{ fg = "base", bg = "keyword_color", modifiers = ["bold"] }} + +"ui.bufferline" = {{ fg = "overlay0", bg = "mantle" }} +"ui.bufferline.active" = {{ fg = "cursor", bg = "base", modifiers = ["bold"] }} + +"ui.help" = {{ fg = "text", bg = "surface0" }} +"ui.text" = "text" +"ui.text.focus" = {{ fg = "text", bg = "surface0" }} +"ui.text.inactive" = "overlay1" + +"ui.menu" = {{ fg = "text", bg = "surface0" }} +"ui.menu.selected" = {{ fg = "text", bg = "surface1", modifiers = ["bold"] }} +"ui.menu.scroll" = {{ fg = "overlay0", bg = "surface0" }} + +"ui.popup" = {{ fg = "text", bg = "surface0" }} +"ui.window" = {{ fg = "base" }} + +"diagnostic.error" = {{ underline = {{ color = "error_color", style = "curl" }} }} +"diagnostic.warning" = {{ underline = {{ color = "warning_color", style = "curl" }} }} +"diagnostic.info" = {{ underline = {{ color = "info_color", style = "curl" }} }} +"diagnostic.hint" = {{ underline = {{ color = "info_color", style = "curl" }} }} +"diagnostic.unnecessary" = {{ modifiers = ["dim"] }} +"diagnostic.deprecated" = {{ modifiers = ["crossed_out"] }} + +"error" = "error_color" +"warning" = "warning_color" +"info" = "info_color" +"hint" = "info_color" + +"diff.plus" = "string_color" +"diff.minus" = "error_color" +"diff.delta" = "warning_color" + +"markup.heading" = {{ fg = "cursor", modifiers = ["bold"] }} +"markup.list" = "keyword_color" +"markup.bold" = {{ modifiers = ["bold"] }} +"markup.italic" = {{ modifiers = ["italic"] }} +"markup.strikethrough" = {{ modifiers = ["crossed_out"] }} +"markup.link.url" = {{ fg = "info_color", modifiers = ["underlined"] }} +"markup.link.text" = "keyword_color" +"markup.quote" = "string_color" +"markup.raw" = "string_color" + +# Semantic syntax highlighting +"comment" = "comment_color" # Highest contrast color for visibility + +"keyword" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.control" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.directive" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.function" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.operator" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.return" = {{ fg = "keyword_color", modifiers = ["italic"] }} +"keyword.storage" = {{ fg = "keyword_color", modifiers = ["italic"] }} + +"string" = "string_color" +"string.regexp" = "error_color" +"string.special" = "string_color" + +"constant.numeric" = "number_color" +"constant.builtin" = "number_color" +"constant.character.escape" = "number_color" + +# Everything else remains plain text +"function" = "text" +"function.builtin" = "text" +"function.method" = "text" +"function.macro" = "text" + +"variable" = "text" +"variable.builtin" = "text" +"variable.parameter" = "text" +"variable.other.member" = "text" + +"type" = {{ fg = "cursor", modifiers = ["italic"]}} +"type.builtin" = "cursor" + +"constructor" = "text" + +"attribute" = "text" +"label" = "text" +"namespace" = "text" +"tag" = "text" + +# Top-level declarations get accent color +"function.definition" = {{ fg = "color7", modifiers = ["bold"] }} +"type.definition" = {{ fg = "color7", modifiers = ["bold"] }} + +# Punctuation is slightly dimmed but still readable +"punctuation" = "subtext0" +"punctuation.bracket" = "subtext0" +"punctuation.delimiter" = "subtext0" +"punctuation.special" = "subtext0" + +"operator" = "subtext0" + +[palette] +# Pywal generated colors - background to foreground gradient +base = "{background}" +mantle = "{color0}" +crust = "{color0}" + +surface0 = "{color8}" +surface1 = "{color8}" +surface2 = "{color7}" + +overlay0 = "{color8}" +overlay1 = "{color7}" +overlay2 = "{color7}" + +subtext0 = "{color7}" +subtext1 = "{color15}" +text = "{foreground}" + +# Core colors from pywal palette +red = "{color1}" +color1 = "{color1}" +color2 = "{color2}" +color3 = "{color3}" +color4 = "{color4}" +color5 = "{color5}" +color6 = "{color6}" +color7 = "{color7}" + +color15 = "{color15}" +cursor = "{cursor}" + +# Semantic colors (will be replaced by color_mapper.py) +error_color = "{{ERROR_COLOR}}" +warning_color = "{{WARNING_COLOR}}" +comment_color = "{{COMMENT_COLOR}}" +string_color = "{{STRING_COLOR}}" +keyword_color = "{{KEYWORD_COLOR}}" +number_color = "{{NUMBER_COLOR}}" +info_color = "{{INFO_COLOR}}" diff --git a/shared/modules/services/theme_switcher/theme_switcher.py b/shared/modules/services/theme_switcher/theme_switcher.py new file mode 100755 index 0000000..0260424 --- /dev/null +++ b/shared/modules/services/theme_switcher/theme_switcher.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Theme Switcher Script - Time-based light/dark theme switching. + +This script: +1. Determines current theme mode (light/dark) based on time +2. Updates wpaperd config to use appropriate wallpaper directory +3. Restarts wpaperd to load new config +4. Gets the selected wallpaper from wpaperd +5. Generates pywal color palette from the wallpaper +6. Applies GTK/Kvantum/Helix themes +""" + +import os +import subprocess +import sys +import time +import shutil +from datetime import datetime +from pathlib import Path +from typing import Optional +import logging +import argparse + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(levelname)s: %(message)s' +) +logger = logging.getLogger(__name__) + + +class ThemeSwitcher: + """Handles time-based light/dark theme switching.""" + + def __init__(self, wallpaper_path: str, light_time: str, dark_time: str, + backend: str, rotation_interval: str, wpaperd_mode: str, + sorting: str, apply_themes_script: str): + self.wallpaper_path = Path(wallpaper_path) + self.light_hour = int(light_time.split(':')[0]) + self.dark_hour = int(dark_time.split(':')[0]) + self.backend = backend + self.rotation_interval = rotation_interval + self.wpaperd_mode = wpaperd_mode + self.sorting = sorting + self.apply_themes_script = apply_themes_script + self.home = Path.home() + + # Find command paths + self.systemctl = self._find_command('systemctl') + self.wpaperctl = self._find_command('wpaperctl') + self.wal = self._find_command('wal') + + def _find_command(self, cmd: str) -> str: + """Find full path to a command.""" + path = shutil.which(cmd) + if path is None: + # Try common NixOS locations + common_paths = [ + f'/run/current-system/sw/bin/{cmd}', + f'/etc/profiles/per-user/{os.environ.get("USER", "")}/bin/{cmd}', + f'{self.home}/.nix-profile/bin/{cmd}', + ] + for p in common_paths: + if Path(p).exists(): + return p + raise FileNotFoundError(f"Command not found: {cmd}") + return path + + def is_wpaperd_running(self) -> bool: + """Check if wpaperd process is running.""" + try: + pgrep = shutil.which('pgrep') or 'pgrep' + result = subprocess.run( + [pgrep, '-x', 'wpaperd'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def determine_mode(self) -> tuple[str, Path]: + """ + Determine current theme mode based on time. + + Returns: + Tuple of (mode_name, wallpaper_directory) + """ + current_hour = datetime.now().hour + + if self.light_hour <= current_hour < self.dark_hour: + mode = "light" + wallpaper_dir = self.wallpaper_path / "Light" + else: + mode = "dark" + wallpaper_dir = self.wallpaper_path / "Dark" + + return mode, wallpaper_dir + + def update_wpaperd_config(self, wallpaper_dir: Path) -> None: + """ + Update wpaperd configuration file. + + Args: + wallpaper_dir: Path to wallpaper directory (Light or Dark) + """ + config_dir = self.home / ".config/wpaperd" + config_file = config_dir / "config.toml" + + # Ensure config directory exists + config_dir.mkdir(parents=True, exist_ok=True) + + # Remove old config file + if config_file.exists(): + config_file.unlink() + + # Create new config + config_content = f"""[default] +path = "{wallpaper_dir}" +duration = "{self.rotation_interval}" +mode = "{self.wpaperd_mode}" +sorting = "{self.sorting}" +""" + + config_file.write_text(config_content) + logger.info(f"Updated wpaperd config: {config_file}") + + def restart_wpaperd(self) -> None: + """Restart wpaperd systemd service.""" + try: + subprocess.run( + [self.systemctl, '--user', 'restart', 'wpaperd.service'], + check=True, + timeout=10 + ) + logger.info("Restarted wpaperd service") + # Give wpaperd time to start and load wallpaper + time.sleep(2) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to restart wpaperd: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout restarting wpaperd") + raise + + def get_current_wallpaper(self) -> Path: + """ + Get the current wallpaper path from wpaperd. + + Returns: + Path to current wallpaper file + + Raises: + RuntimeError: If unable to get wallpaper path + """ + wallpaper_state_dir = self.home / ".local/state/wpaperd/wallpapers" + + if not wallpaper_state_dir.exists(): + raise RuntimeError(f"Wpaperd state directory not found: {wallpaper_state_dir}") + + # Get first monitor directory + try: + monitor_dirs = list(wallpaper_state_dir.iterdir()) + if not monitor_dirs: + raise RuntimeError("No monitor directories found in wpaperd state") + + monitor_name = monitor_dirs[0].name + + # Query wpaperd for current wallpaper + result = subprocess.run( + [self.wpaperctl, 'get-wallpaper', monitor_name], + capture_output=True, + text=True, + check=True, + timeout=5 + ) + + wallpaper_path = result.stdout.strip() + if not wallpaper_path: + raise RuntimeError("wpaperctl returned empty wallpaper path") + + return Path(wallpaper_path) + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, IndexError) as e: + raise RuntimeError(f"Failed to get current wallpaper: {e}") + + def generate_palette(self, wallpaper: Path, mode: str) -> None: + """ + Generate pywal color palette from wallpaper. + + Args: + wallpaper: Path to wallpaper image + mode: Theme mode ('light' or 'dark') + """ + cmd = [self.wal, '-i', str(wallpaper), '--backend', self.backend, '-n'] + + if mode == 'light': + cmd.append('-l') + + try: + subprocess.run(cmd, check=True, timeout=30) + logger.info(f"Generated {mode} palette from: {wallpaper}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to generate palette: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while generating palette") + raise + + def apply_themes(self) -> None: + """Apply GTK and Kvantum themes using helper script.""" + try: + subprocess.run( + ['python3', self.apply_themes_script], + check=True, + timeout=30 + ) + logger.info("Applied themes successfully") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to apply themes: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while applying themes") + raise + + def run(self) -> int: + """ + Execute the theme switching process. + + Returns: + Exit code (0 for success, 1 for failure) + """ + try: + # Determine mode and wallpaper directory + mode, wallpaper_dir = self.determine_mode() + logger.info(f"Switching to {mode} theme with wallpapers from {wallpaper_dir}") + + # Check if wpaperd is running + if not self.is_wpaperd_running(): + logger.error("wpaperd is not running") + return 1 + + # Update wpaperd config + self.update_wpaperd_config(wallpaper_dir) + + # Restart wpaperd + self.restart_wpaperd() + + # Get current wallpaper + current_wallpaper = self.get_current_wallpaper() + logger.info(f"Current wallpaper: {current_wallpaper}") + + # Generate palette + self.generate_palette(current_wallpaper, mode) + + # Apply themes + self.apply_themes() + + logger.info(f"Theme switched to {mode} mode successfully!") + return 0 + + except Exception as e: + logger.error(f"Theme switching failed: {e}", exc_info=True) + return 1 + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Time-based theme switcher') + parser.add_argument('--wallpaper-path', required=True, help='Path to wallpaper directories') + parser.add_argument('--light-time', default='09:00:00', help='Light theme start time (HH:MM:SS)') + parser.add_argument('--dark-time', default='16:30:00', help='Dark theme start time (HH:MM:SS)') + parser.add_argument('--backend', default='haishoku', help='Pywal color extraction backend') + parser.add_argument('--rotation-interval', default='5m', help='Wallpaper rotation interval') + parser.add_argument('--wpaperd-mode', default='center', help='Wpaperd display mode') + parser.add_argument('--sorting', default='random', help='Wallpaper sorting method') + parser.add_argument('--apply-themes-script', required=True, help='Path to apply-themes script') + + args = parser.parse_args() + + switcher = ThemeSwitcher( + wallpaper_path=args.wallpaper_path, + light_time=args.light_time, + dark_time=args.dark_time, + backend=args.backend, + rotation_interval=args.rotation_interval, + wpaperd_mode=args.wpaperd_mode, + sorting=args.sorting, + apply_themes_script=args.apply_themes_script + ) + + return switcher.run() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/shared/modules/services/theme_switcher/wallpaper_rotation.py b/shared/modules/services/theme_switcher/wallpaper_rotation.py new file mode 100755 index 0000000..9f402d1 --- /dev/null +++ b/shared/modules/services/theme_switcher/wallpaper_rotation.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Wallpaper Rotation Script - Handles continuous wallpaper rotation with palette regeneration. + +This script: +1. Checks if wpaperd is running +2. Determines current theme mode (light/dark) for correct pywal flags +3. Pauses wpaperd's auto-rotation +4. Advances to next wallpaper +5. Gets the new wallpaper path +6. Regenerates pywal palette +7. Applies themes +8. Resumes wpaperd's auto-rotation +""" + +import os +import subprocess +import sys +import time +import shutil +from datetime import datetime +from pathlib import Path +import logging +import argparse + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(levelname)s: %(message)s' +) +logger = logging.getLogger(__name__) + + +class WallpaperRotator: + """Handles wallpaper rotation with theme regeneration.""" + + def __init__(self, light_time: str, dark_time: str, backend: str, + apply_themes_script: str): + self.light_hour = int(light_time.split(':')[0]) + self.dark_hour = int(dark_time.split(':')[0]) + self.backend = backend + self.apply_themes_script = apply_themes_script + self.home = Path.home() + + # Find command paths + self.wpaperctl = self._find_command('wpaperctl') + self.wal = self._find_command('wal') + + def _find_command(self, cmd: str) -> str: + """Find full path to a command.""" + path = shutil.which(cmd) + if path is None: + # Try common NixOS locations + common_paths = [ + f'/run/current-system/sw/bin/{cmd}', + f'/etc/profiles/per-user/{os.environ.get("USER", "")}/bin/{cmd}', + f'{self.home}/.nix-profile/bin/{cmd}', + ] + for p in common_paths: + if Path(p).exists(): + return p + raise FileNotFoundError(f"Command not found: {cmd}") + return path + + def is_wpaperd_running(self) -> bool: + """Check if wpaperd process is running.""" + try: + pgrep = shutil.which('pgrep') or 'pgrep' + result = subprocess.run( + [pgrep, '-x', 'wpaperd'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def determine_mode(self) -> tuple[str, list[str]]: + """ + Determine current theme mode and pywal flags based on time. + + Returns: + Tuple of (mode_name, pywal_flags_list) + """ + current_hour = datetime.now().hour + + if self.light_hour <= current_hour < self.dark_hour: + mode = "light" + pywal_flags = ['-l', '-n'] + else: + mode = "dark" + pywal_flags = ['-n'] + + return mode, pywal_flags + + def pause_wpaperd(self) -> None: + """Pause wpaperd's automatic wallpaper rotation.""" + try: + subprocess.run( + [self.wpaperctl, 'pause-wallpaper'], + check=True, + timeout=5, + capture_output=True + ) + logger.debug("Paused wpaperd rotation") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to pause wpaperd: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while pausing wpaperd") + raise + except FileNotFoundError: + logger.error("wpaperctl command not found") + raise + + def resume_wpaperd(self) -> None: + """Resume wpaperd's automatic wallpaper rotation.""" + try: + subprocess.run( + [self.wpaperctl, 'resume-wallpaper'], + check=True, + timeout=5, + capture_output=True + ) + logger.debug("Resumed wpaperd rotation") + except subprocess.CalledProcessError as e: + logger.warning(f"Failed to resume wpaperd: {e}") + except subprocess.TimeoutExpired: + logger.warning("Timeout while resuming wpaperd") + except FileNotFoundError: + logger.warning("wpaperctl command not found") + + def advance_wallpaper(self) -> None: + """Advance to next wallpaper using wpaperctl.""" + try: + subprocess.run( + [self.wpaperctl, 'next-wallpaper'], + check=True, + timeout=5, + capture_output=True + ) + logger.info("Advanced to next wallpaper") + # Give wpaperd time to complete transition + time.sleep(1) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to advance wallpaper: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while advancing wallpaper") + raise + except FileNotFoundError: + logger.error("wpaperctl command not found") + raise + + def get_current_wallpaper(self) -> Path: + """ + Get the current wallpaper path from wpaperd. + + Returns: + Path to current wallpaper file + + Raises: + RuntimeError: If unable to get wallpaper path + """ + wallpaper_state_dir = self.home / ".local/state/wpaperd/wallpapers" + + if not wallpaper_state_dir.exists(): + raise RuntimeError(f"Wpaperd state directory not found: {wallpaper_state_dir}") + + # Get first monitor directory + try: + monitor_dirs = list(wallpaper_state_dir.iterdir()) + if not monitor_dirs: + raise RuntimeError("No monitor directories found in wpaperd state") + + monitor_name = monitor_dirs[0].name + + # Query wpaperd for current wallpaper + result = subprocess.run( + [self.wpaperctl, 'get-wallpaper', monitor_name], + capture_output=True, + text=True, + check=True, + timeout=5 + ) + + wallpaper_path = result.stdout.strip() + if not wallpaper_path: + raise RuntimeError("wpaperctl returned empty wallpaper path") + + return Path(wallpaper_path) + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, IndexError) as e: + raise RuntimeError(f"Failed to get current wallpaper: {e}") + + def generate_palette(self, wallpaper: Path, pywal_flags: list[str]) -> None: + """ + Generate pywal color palette from wallpaper. + + Args: + wallpaper: Path to wallpaper image + pywal_flags: List of additional pywal flags + """ + cmd = [self.wal, '-i', str(wallpaper), '--backend', self.backend] + pywal_flags + + try: + subprocess.run(cmd, check=True, timeout=30, capture_output=True) + logger.info(f"Generated palette from: {wallpaper.name}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to generate palette: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while generating palette") + raise + except FileNotFoundError: + logger.error("wal command not found") + raise + + def apply_themes(self) -> None: + """Apply GTK and Kvantum themes using helper script.""" + try: + subprocess.run( + ['python3', self.apply_themes_script], + check=True, + timeout=30, + capture_output=True + ) + logger.debug("Applied themes successfully") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to apply themes: {e}") + raise + except subprocess.TimeoutExpired: + logger.error("Timeout while applying themes") + raise + except FileNotFoundError as e: + logger.error(f"Theme application script not found: {e}") + raise + + def run(self) -> int: + """ + Execute the wallpaper rotation and theme regeneration process. + + Returns: + Exit code (0 for success, 1 for failure) + """ + # Check if wpaperd is running + if not self.is_wpaperd_running(): + logger.info("wpaperd is not running, skipping rotation") + return 0 + + try: + # Determine mode + mode, pywal_flags = self.determine_mode() + logger.info(f"Rotating wallpaper and regenerating {mode} palette...") + + # Pause wpaperd + self.pause_wpaperd() + + try: + # Advance to next wallpaper + self.advance_wallpaper() + + # Get new wallpaper path + current_wallpaper = self.get_current_wallpaper() + logger.info(f"New wallpaper: {current_wallpaper.name}") + + # Generate palette + self.generate_palette(current_wallpaper, pywal_flags) + + # Apply themes + self.apply_themes() + + logger.info("Wallpaper rotated and palette regenerated successfully!") + return 0 + + finally: + # Always resume wpaperd, even if something fails + self.resume_wpaperd() + + except Exception as e: + logger.error(f"Wallpaper rotation failed: {e}", exc_info=True) + return 1 + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Wallpaper rotation with theme regeneration') + parser.add_argument('--light-time', default='09:00:00', help='Light theme start time (HH:MM:SS)') + parser.add_argument('--dark-time', default='16:30:00', help='Dark theme start time (HH:MM:SS)') + parser.add_argument('--backend', default='haishoku', help='Pywal color extraction backend') + parser.add_argument('--apply-themes-script', required=True, help='Path to apply-themes script') + + args = parser.parse_args() + + rotator = WallpaperRotator( + light_time=args.light_time, + dark_time=args.dark_time, + backend=args.backend, + apply_themes_script=args.apply_themes_script + ) + + return rotator.run() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/shared/modules/services/wallpaper-rotator.nix b/shared/modules/services/wallpaper-rotator.nix new file mode 100644 index 0000000..4d5adb6 --- /dev/null +++ b/shared/modules/services/wallpaper-rotator.nix @@ -0,0 +1,100 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.wallpaperRotator; +in +{ + options.services.wallpaperRotator = { + enable = lib.mkEnableOption "wallpaper rotation service using wpaperd"; + + user = lib.mkOption { + type = lib.types.str; + description = "Username for the wallpaper service"; + }; + + wallpaperPath = lib.mkOption { + type = lib.types.str; + default = "/home/${cfg.user}/nixos/shared/modules/services/wallpapers"; + description = "Path to the wallpaper directory"; + }; + + duration = lib.mkOption { + type = lib.types.str; + default = "5m"; + example = "30s"; + description = "How long to display each wallpaper (e.g., 30s, 5m, 1h)"; + }; + + sorting = lib.mkOption { + type = lib.types.enum [ "random" "ascending" "descending" ]; + default = "random"; + description = "Order in which to display wallpapers"; + }; + + mode = lib.mkOption { + type = lib.types.enum [ "center" "fit" "fit-border-color" "stretch" "tile" ]; + default = "center"; + description = "How to display wallpapers when size differs from display resolution"; + }; + + transition = { + effect = lib.mkOption { + type = lib.types.str; + default = "fade"; + example = "hexagonalize"; + description = '' + Transition effect name. Available effects include: + fade, hexagonalize, simple-zoom, swirl, circle, wipe, slide, etc. + See wpaperd documentation for full list. + ''; + }; + + duration = lib.mkOption { + type = lib.types.int; + default = 300; + example = 2000; + description = "Transition duration in milliseconds"; + }; + }; + + queueSize = lib.mkOption { + type = lib.types.int; + default = 10; + description = "Size of wallpaper history queue for random mode"; + }; + + initialTransition = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable transition effect at wpaperd startup"; + }; + }; + + config = lib.mkIf cfg.enable { + home-manager.users.${cfg.user} = { + services.wpaperd = { + enable = true; + settings = { + # Default configuration for all displays + default = { + path = cfg.wallpaperPath; + duration = cfg.duration; + sorting = cfg.sorting; + mode = cfg.mode; + transition-time = cfg.transition.duration; + queue-size = cfg.queueSize; + initial-transition = cfg.initialTransition; + }; + + # Apply transition effect + "default.transition.${cfg.transition.effect}" = {}; + }; + }; + + # Add wpaperd CLI tool to user packages for manual control + home.packages = with pkgs; [ + wpaperd + ]; + }; + }; +} diff --git a/shared/modules/services/wallpapers/README.md b/shared/modules/services/wallpapers/README.md new file mode 100644 index 0000000..4a08e07 --- /dev/null +++ b/shared/modules/services/wallpapers/README.md @@ -0,0 +1,26 @@ +# Wallpapers + +This directory contains purchased wallpapers that are licensed for personal use only. + +## License + +These images are purchased and copyrighted by their respective creators. +They are **not included in this git repository** and are licensed for personal use only. + +No reproduction, distribution, or commercial use is permitted. + +## Usage + +Place your wallpaper images in this directory. The wallpaper rotation service +will automatically detect and rotate through them. + +Supported formats: +- JPEG (.jpg, .jpeg) +- PNG (.png) +- WebP (.webp) +- AVIF (.avif) + +## Configuration + +The wallpaper rotation service is configured via the `services.wallpaperRotator` +option in your host configuration. See the module documentation for available options.