Add niri to work machine

This commit is contained in:
Nate Anderson 2026-01-23 13:41:56 -07:00
parent 67331242df
commit b22d24f619
28 changed files with 5396 additions and 389 deletions

7
.gitignore vendored
View File

@ -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

12
flake.lock generated
View File

@ -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": {

View File

@ -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

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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!"; }
}

View File

@ -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";

View File

@ -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;
};
}

View File

@ -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 "";
};
};
};
};
}

View File

@ -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;
};
};
};
}

View File

@ -72,6 +72,8 @@
# sdkmanager
unstable.opencode
unstable.claude-code
usbutils
openscad
#
# Gaming
@ -85,6 +87,7 @@
#
bat
duf
dust
fd
fzf
lsd

View File

@ -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)

View File

@ -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/`

View File

@ -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

View File

@ -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
```

View File

@ -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())

View File

@ -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())

View File

@ -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;
};
};
}

View File

@ -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)

View File

@ -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

View File

@ -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};
}

View File

@ -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;
}

View File

@ -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

View File

@ -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}}"

View File

@ -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())

View File

@ -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())

View File

@ -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
];
};
};
}

View File

@ -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.