8 Commits

66 changed files with 3264 additions and 3455 deletions

View File

@@ -125,7 +125,7 @@ branch exposes `hl.plugin.hyprexpo.expo(...)`, so the Lua config can invoke
## 6. Scratchpads
- [x] Preserve named scratchpads: element, htop, slack,
- [x] Preserve named scratchpads: element, gmail, htop, messages, slack,
spotify, transmission, volume.
- [x] Preserve dropdown terminal scratchpad.
- [x] Scratchpads near-fullscreen and centered.

View File

@@ -43,13 +43,13 @@ Required behavior:
- Moving the focused window to the next empty workspace and following it is a
first-class operation.
- Normal workspaces are bounded to `1..9`.
- Workspace history is tracked per monitor.
- Last-workspace toggle uses the current monitor's workspace history.
- Workspace cycling works on the current monitor within the bounded workspace
set.
Important behavior:
- Workspace history is tracked per monitor.
- Last-workspace toggle uses the current monitor's workspace history.
- Workspace history cycling works on the current monitor within the bounded
workspace set.
- Swapping the current workspace contents with another workspace is available.
- Moving a window to an empty workspace on another monitor is available.
- Moving the focused window to another monitor without following keeps keyboard
@@ -62,30 +62,6 @@ Important behavior:
- Hidden/special workspaces are excluded from the status bar's normal workspace
list.
### Workspace History Cycling
Important behavior:
- The model is most-recently-used workspace switching, scoped to the monitor
where the action starts.
- Each monitor has its own ordered workspace history. The focused monitor's
history is not shared with other monitors.
- Only ordinary bounded workspaces are candidates. Special, scratchpad,
minimized, hidden, and out-of-range workspaces are excluded.
- Starting a cycle freezes the candidate list for that cycle. Previewing
workspaces while the cycle is active must not rewrite the history order.
- Starting a cycle previews the previous workspace for the current monitor.
- Repeating the forward cycle action continues farther back through that
monitor's frozen history.
- A reverse cycle action moves through the same frozen history in the opposite
direction.
- Releasing the initiating modifier key commits the currently previewed
workspace and updates history exactly once.
- A cancel path may return to the workspace where the cycle started.
This behavior is important for workflow continuity, but it is not a hard
requirement for a minimal daily-driver window manager.
## Directional Navigation
Required behavior:
@@ -133,9 +109,6 @@ Required behavior:
- Dialogs are centered.
- There is a command to jump directly to the columns layout and one to jump
directly to the tabbed/fullscreen layout.
- `Super+Ctrl+Space` jumps directly to the tabbed/fullscreen layout.
- Direct fullscreen or floating-fullscreen behavior should not have a
keybinding.
- Layout state is per workspace when the compositor supports it.
Important behavior:
@@ -145,6 +118,7 @@ Important behavior:
Nice behavior:
- Gaps can be toggled.
- Fullscreen can be toggled.
- Smart borders can be toggled.
- Layout-related modifiers remain available for experiments.
- Inactive windows are slightly dimmed when supported.
@@ -179,7 +153,9 @@ Important behavior:
Required behavior:
- A named scratchpad exists for element.
- A named scratchpad exists for gmail.
- A named scratchpad exists for htop.
- A named scratchpad exists for messages.
- A named scratchpad exists for slack.
- A named scratchpad exists for spotify.
- A named scratchpad exists for transmission.
@@ -298,14 +274,12 @@ Required behavior:
- `Super+p` opens the application launcher.
- `Super+Shift+p` opens the run menu.
- `Super+Shift+Return` opens a terminal.
- `Super+q` reloads the window manager config.
- `Super+Shift+c` closes the focused window.
- `Super+Shift+q` exits the window manager session.
- `Super+Tab` opens the overview.
- `Super+Shift+Tab` opens the overview in bring-window mode when supported.
- `Super+g` opens the go-to-window picker.
- `Super+b` opens the bring-window picker.
- `Super+Shift+b` opens the replace-window picker.
- `Super+\` toggles to the previous workspace on the current monitor.
- `Super+Shift+e` moves the focused window to the next empty workspace and
follows it. This is the target replacement for the older `Super+Shift+h`
binding.
@@ -313,13 +287,6 @@ Required behavior:
- `Hyper+5` swaps the current workspace with a selected workspace.
- `Hyper+g` gathers windows of the focused class onto the current workspace.
Important behavior:
- `Super+\` starts or advances current-monitor workspace history cycling.
- `Super+/` reverses current-monitor workspace history cycling while the
initiating `Super` key is held.
- Releasing the initiating `Super` key commits the workspace history cycle.
### Directional Navigation Bindings
Required behavior:
@@ -350,7 +317,9 @@ Required behavior:
Required behavior:
- `Super+Alt+e` toggles the element scratchpad.
- `Super+Alt+g` toggles the gmail scratchpad.
- `Super+Alt+h` toggles the htop scratchpad.
- `Super+Alt+m` toggles the messages scratchpad.
- `Super+Alt+k` toggles the slack scratchpad.
- `Super+Alt+s` toggles the spotify scratchpad.
- `Super+Alt+t` toggles the transmission scratchpad.

View File

@@ -3,5 +3,3 @@
!AGENTS.md
!config.toml
!skills
# Generated/local Codex state, including config.local.toml, stays ignored.

View File

@@ -2,8 +2,147 @@ model = "gpt-5.5"
model_reasoning_effort = "high"
personality = "pragmatic"
# Portable Codex defaults. Machine-local additions are appended from
# dotfiles/codex/config.local.toml by Home Manager.
notify = ["/Users/kat/dotfiles/dotfiles/codex/plugins/cache/openai-bundled/computer-use/1.0.755/Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient", "turn-ended"]
[projects."/home/imalison/Projects/nixpkgs"]
trust_level = "trusted"
[projects."/home/imalison/dotfiles"]
trust_level = "trusted"
[projects."/home/imalison/Projects/railbird"]
trust_level = "trusted"
[projects."/home/imalison/Projects/subtr-actor"]
trust_level = "trusted"
[projects."/home/imalison/Projects/google-messages-api"]
trust_level = "trusted"
[projects."/home/imalison"]
trust_level = "trusted"
[projects."/home/imalison/Projects/scrobble-scrubber"]
trust_level = "trusted"
[projects."/home/imalison/temp"]
trust_level = "trusted"
[projects."/home/imalison/Projects/org-agenda-api"]
trust_level = "untrusted"
[projects."/home/imalison/org"]
trust_level = "trusted"
[projects."/home/imalison/dotfiles/.git/modules/dotfiles/config/taffybar"]
trust_level = "trusted"
[projects."/home/imalison/Projects/notifications-tray-icon"]
trust_level = "trusted"
[projects."/home/imalison/Projects/hyprland"]
trust_level = "trusted"
[projects."/home/imalison/Projects/git-sync-rs"]
trust_level = "trusted"
[projects."/home/imalison/Projects/keepbook"]
trust_level = "trusted"
[projects."/home/imalison/Projects/boxcars"]
trust_level = "trusted"
[projects."/home/imalison/Projects/rumno"]
trust_level = "trusted"
[projects."/home/imalison/Projects/git-blame-rank"]
trust_level = "trusted"
[projects."/home/imalison/Projects/hatchet"]
trust_level = "trusted"
[projects."/home/imalison/dotfiles/dotfiles/emacs.d/elpaca/sources/org-project-capture"]
trust_level = "trusted"
[projects."/home/imalison/dotfiles/dotfiles/config/taffybar/taffybar/packages"]
trust_level = "trusted"
[projects."/home/imalison/Projects/scrobble-tools"]
trust_level = "trusted"
[projects."/home/imalison/.password-store"]
trust_level = "trusted"
[projects."/home/imalison/Projects/subtr-actor-mechanics"]
trust_level = "trusted"
[projects."/home/imalison/Projects/lastfm-edit"]
trust_level = "trusted"
[projects."/home/imalison/Projects/mova"]
trust_level = "trusted"
[projects."/home/imalison/dotfiles/dotfiles/config/taffybar/taffybar"]
trust_level = "trusted"
[projects."/home/imalison/Projects"]
trust_level = "trusted"
[projects."/home/imalison/Projects/rofi-systemd"]
trust_level = "trusted"
[projects."/home/imalison/Projects/map-quiz"]
trust_level = "trusted"
[projects."/run/media/imalison/NETDEBUGUSB"]
trust_level = "trusted"
[projects."/home/imalison/Projects/coqui-tts-streamer"]
trust_level = "trusted"
[projects."/home/imalison/Downloads"]
trust_level = "trusted"
[projects."/home/imalison/keysmith_generated"]
trust_level = "trusted"
[projects."/run/media/imalison/NIXOS_SD"]
trust_level = "trusted"
[projects."/Users/kat/dotfiles"]
trust_level = "trusted"
[projects."/Users/kat"]
trust_level = "trusted"
[projects."/Users/kat/org"]
trust_level = "trusted"
[projects."/Users/kat/Documents/Codex/2026-04-25/do-you-see-the-sandisk-external"]
trust_level = "trusted"
[projects."/Volumes/Extreme SSD/Projects/keepbook"]
trust_level = "trusted"
[projects."/Users/kat/Documents/Codex/2026-04-25/it-seems-like-maybe-we-dont"]
trust_level = "trusted"
[projects."/Users/kat/Documents/Codex/2026-04-25/what-is-the-state-of-tiling"]
trust_level = "trusted"
[projects."/home/imalison/Pictures/ai/2026/celeb"]
trust_level = "trusted"
[projects."/home/imalison/.local/share/keepbook"]
trust_level = "trusted"
[notice]
hide_gpt5_1_migration_prompt = true
"hide_gpt-5.1-codex-max_migration_prompt" = true
[notice.model_migrations]
"gpt-5.2" = "gpt-5.2-codex"
[mcp_servers.chrome-devtools]
command = "npx"
@@ -21,6 +160,16 @@ unified_exec = true
apps = true
steer = true
[marketplaces.openai-bundled]
last_updated = "2026-04-21T17:43:57Z"
source_type = "local"
source = "/Users/kat/.codex/.tmp/bundled-marketplaces/openai-bundled"
[marketplaces.openai-primary-runtime]
last_updated = "2026-04-25T23:49:36Z"
source_type = "local"
source = "/Users/kat/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime"
[plugins."google-calendar@openai-curated"]
enabled = true
@@ -47,3 +196,6 @@ enabled = true
[plugins."browser-use@openai-bundled"]
enabled = true
[tui.model_availability_nux]
"gpt-5.5" = 4

View File

@@ -1,10 +1,11 @@
general {
lock_cmd = pidof hyprlock || hyprlock
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch dpms on
}
listener {
timeout = 300
on-timeout = /home/imalison/dotfiles/dotfiles/lib/bin/hypr-screensaver start
on-resume = /home/imalison/dotfiles/dotfiles/lib/bin/hypr-screensaver stop
timeout = 900
on-timeout = hypr-screensaver stop && hyprctl dispatch dpms off
on-resume = hyprctl dispatch dpms on
}

View File

@@ -0,0 +1,562 @@
# Hyprland Configuration
# XMonad-like dynamic tiling using hy3 plugin
# Based on XMonad configuration from xmonad.hs
# =============================================================================
# PLUGINS (Hyprland pinned to 0.53.0 to match hy3)
# =============================================================================
# Load the plugin before parsing keybinds/layouts that depend on it
plugin = /run/current-system/sw/lib/libhy3.so
plugin = /run/current-system/sw/lib/libhyprexpo.so
# =============================================================================
# MONITORS
# =============================================================================
monitor=,preferred,auto,1
# =============================================================================
# PROGRAMS
# =============================================================================
$terminal = ghostty --gtk-single-instance=false
$fileManager = dolphin
$menu = rofi -show drun -show-icons
$runMenu = rofi -show run
# =============================================================================
# ENVIRONMENT VARIABLES
# =============================================================================
env = XCURSOR_SIZE,24
env = QT_QPA_PLATFORMTHEME,qt5ct
# Used by ~/.config/hypr/scripts/* to keep workspace IDs bounded.
env = HYPR_MAX_WORKSPACE,9
# =============================================================================
# INPUT CONFIGURATION
# =============================================================================
input {
kb_layout = us
kb_variant =
kb_model =
kb_options =
kb_rules =
follow_mouse = 1
touchpad {
natural_scroll = no
}
sensitivity = 0
}
# Cursor warping behavior
cursor {
persistent_warps = true
}
# =============================================================================
# GENERAL SETTINGS
# =============================================================================
general {
gaps_in = 5
gaps_out = 10
border_size = 0
col.active_border = rgba(edb443ee) rgba(33ccffee) 45deg
col.inactive_border = rgba(595959aa)
# Use hy3 layout for XMonad-like dynamic tiling
layout = hy3
allow_tearing = false
}
# =============================================================================
# DECORATION
# =============================================================================
decoration {
rounding = 5
blur {
enabled = true
size = 3
passes = 1
}
# Fade inactive windows (like XMonad's fadeInactive)
active_opacity = 1.0
inactive_opacity = 0.9
}
# =============================================================================
# ANIMATIONS
# =============================================================================
animations {
enabled = yes
# Hyprland supports bezier curves, not true spring physics.
# Use a mild overshoot plus GNOME-like window animation style.
bezier = overshoot, 0.05, 0.9, 0.1, 1.1
bezier = smoothOut, 0.36, 1, 0.3, 1
bezier = smoothInOut, 0.42, 0, 0.58, 1
bezier = linear, 0, 0, 1, 1
# SPEED is in deciseconds (e.g. 6 == 600ms).
animation = windows, 1, 6, overshoot, gnomed
animation = windowsIn, 1, 6, overshoot, gnomed
animation = windowsOut, 1, 5, smoothInOut, gnomed
animation = windowsMove, 1, 6, smoothOut
animation = border, 0
animation = borderangle, 0
animation = fade, 1, 5, smoothOut
animation = workspaces, 1, 6, smoothOut, slidefade 15%
animation = specialWorkspace, 1, 6, smoothOut, slidevert
}
# =============================================================================
# MASTER LAYOUT CONFIGURATION
# =============================================================================
master {
new_status = slave
mfact = 0.5
orientation = left
}
# Dwindle layout (alternative - binary tree like i3)
dwindle {
pseudotile = yes
preserve_split = yes
}
# =============================================================================
# WORKSPACE RULES (SMART GAPS)
# =============================================================================
# Replace no_gaps_when_only (removed in newer Hyprland)
# Remove gaps when there's only one visible tiled window (ignore special workspaces)
workspace = w[tv1]s[false], gapsout:0, gapsin:0
workspace = f[1]s[false], gapsout:0, gapsin:0
# Group/tabbed window configuration (built-in alternative to hy3 tabs)
group {
col.border_active = rgba(edb443ff)
col.border_inactive = rgba(091f2eff)
groupbar {
enabled = true
font_size = 12
height = 22
col.active = rgba(edb443ff)
col.inactive = rgba(091f2eff)
text_color = rgba(091f2eff)
}
}
# =============================================================================
# HY3/HYPREXPO PLUGIN CONFIG
# =============================================================================
plugin {
hy3 {
# Disable autotile to get XMonad-like manual control
autotile {
enable = false
}
# Tab configuration
tabs {
height = 22
padding = 6
render_text = true
text_font = "Sans"
text_height = 10
text_padding = 3
col.active = rgba(edb443ff)
col.inactive = rgba(091f2eff)
col.urgent = rgba(ff0000ff)
col.text.active = rgba(091f2eff)
col.text.inactive = rgba(ffffffff)
col.text.urgent = rgba(ffffffff)
}
}
hyprexpo {
# Always include workspace 1 in the overview grid
workspace_method = first 1
# Only show workspaces with windows
skip_empty = true
# Show numeric workspace labels in the expo grid
show_workspace_numbers = true
# 3 columns -> 3x3 grid when 9 workspaces are visible
columns = 3
}
}
# =============================================================================
# MISC
# =============================================================================
misc {
force_default_wallpaper = 0
disable_hyprland_logo = true
}
# =============================================================================
# BINDS OPTIONS
# =============================================================================
binds {
# Keep workspace history so "previous" can toggle back reliably.
allow_workspace_cycles = true
workspace_back_and_forth = true
}
# =============================================================================
# WINDOW RULES
# =============================================================================
# Float dialogs
windowrule = match:class ^()$, match:title ^()$, float on
windowrule = match:title ^(Picture-in-Picture)$, float on
windowrule = match:title ^(Open File)$, float on
windowrule = match:title ^(Save File)$, float on
windowrule = match:title ^(Confirm)$, float on
# Rumno OSD/notifications: treat as an overlay, not a "real" managed window.
# (Matches both class and title because rumno may set either depending on backend.)
windowrule = match:class ^(.*[Rr]umno.*)$, float on
windowrule = match:class ^(.*[Rr]umno.*)$, pin on
windowrule = match:class ^(.*[Rr]umno.*)$, center on
windowrule = match:class ^(.*[Rr]umno.*)$, decorate off
windowrule = match:class ^(.*[Rr]umno.*)$, no_shadow on
windowrule = match:title ^(.*[Rr]umno.*)$, float on
windowrule = match:title ^(.*[Rr]umno.*)$, pin on
windowrule = match:title ^(.*[Rr]umno.*)$, center on
windowrule = match:title ^(.*[Rr]umno.*)$, decorate off
windowrule = match:title ^(.*[Rr]umno.*)$, no_shadow on
# Scratchpad sizing handled by hyprscratch exec rules (see hyprland.nix)
# Using hyprscratch rules instead of windowrule to avoid affecting child windows (e.g. Slack meets)
# =============================================================================
# KEY BINDINGS
# =============================================================================
# Modifier keys
$mainMod = SUPER
$modAlt = SUPER ALT
$hyper = SUPER CTRL ALT
# -----------------------------------------------------------------------------
# Program Launching
# -----------------------------------------------------------------------------
bind = $mainMod, P, exec, $menu
bind = $mainMod SHIFT, P, exec, $runMenu
bind = $mainMod SHIFT, Return, exec, $terminal
# -----------------------------------------------------------------------------
# Overview (Hyprexpo)
# -----------------------------------------------------------------------------
bind = $mainMod, TAB, hyprexpo:expo, toggle
bind = $mainMod SHIFT, TAB, hyprexpo:expo, bring
bind = $mainMod, Q, killactive,
bind = $mainMod SHIFT, C, killactive,
bind = $mainMod SHIFT, Q, exit,
# Emacs-everywhere (like XMonad's emacs-everywhere)
bind = $mainMod, E, exec, emacsclient --eval '(emacs-everywhere)'
bind = $mainMod, V, exec, wl-paste | xdotool type --file -
# Chrome/Browser (raise or spawn like XMonad's bindBringAndRaise)
bind = $modAlt, C, exec, ~/.config/hypr/scripts/raise-or-run.sh google-chrome google-chrome-stable
# -----------------------------------------------------------------------------
# SCRATCHPADS (managed by hyprscratch daemon with auto-dismiss)
# -----------------------------------------------------------------------------
bind = $modAlt, E, exec, hyprscratch toggle element
bind = $modAlt, G, exec, hyprscratch toggle gmail
bind = $modAlt, H, exec, hyprscratch toggle htop
bind = $modAlt, M, exec, hyprscratch toggle messages
bind = $modAlt, K, exec, hyprscratch toggle slack
bind = $modAlt, S, exec, hyprscratch toggle spotify
bind = $modAlt, T, exec, hyprscratch toggle transmission
bind = $modAlt, V, exec, hyprscratch toggle volume
bind = $modAlt, grave, exec, hyprscratch toggle dropdown
# Hidden workspace (like XMonad's NSP)
bind = $mainMod, X, movetoworkspace, special:NSP
bind = $mainMod SHIFT, X, togglespecialworkspace, NSP
# -----------------------------------------------------------------------------
# DIRECTIONAL NAVIGATION (WASD - like XMonad Navigation2D)
# Using hy3 dispatchers for proper tree-based navigation
# -----------------------------------------------------------------------------
# Focus movement (Mod + WASD) - hy3:movefocus navigates the tree
bind = $mainMod, W, hy3:movefocus, u
bind = $mainMod, S, hy3:movefocus, d
bind = $mainMod, A, hy3:movefocus, l
bind = $mainMod, D, hy3:movefocus, r
# Move windows (Mod + Shift + WASD) - hy3:movewindow with once=true for swapping
bind = $mainMod SHIFT, W, exec, ~/.config/hypr/scripts/movewindow-follow-cursor.sh u once
bind = $mainMod SHIFT, S, exec, ~/.config/hypr/scripts/movewindow-follow-cursor.sh d once
bind = $mainMod SHIFT, A, exec, ~/.config/hypr/scripts/movewindow-follow-cursor.sh l once
bind = $mainMod SHIFT, D, exec, ~/.config/hypr/scripts/movewindow-follow-cursor.sh r once
# Resize windows (Mod + Ctrl + WASD)
binde = $mainMod CTRL, W, resizeactive, 0 -50
binde = $mainMod CTRL, S, resizeactive, 0 50
binde = $mainMod CTRL, A, resizeactive, -50 0
binde = $mainMod CTRL, D, resizeactive, 50 0
# Screen/Monitor focus (Hyper + WASD)
bind = $hyper, W, focusmonitor, u
bind = $hyper, S, focusmonitor, d
bind = $hyper, A, focusmonitor, l
bind = $hyper, D, focusmonitor, r
# Move window to monitor and follow (Hyper + Shift + WASD)
bind = $hyper SHIFT, W, movewindow, mon:u
bind = $hyper SHIFT, S, movewindow, mon:d
bind = $hyper SHIFT, A, movewindow, mon:l
bind = $hyper SHIFT, D, movewindow, mon:r
# Shift to empty workspace on screen direction (Super + Ctrl + Shift + WASD)
# Like XMonad's shiftToEmptyOnScreen
bind = $mainMod CTRL SHIFT, W, exec, ~/.config/hypr/scripts/shift-to-empty-on-screen.sh u
bind = $mainMod CTRL SHIFT, S, exec, ~/.config/hypr/scripts/shift-to-empty-on-screen.sh d
bind = $mainMod CTRL SHIFT, A, exec, ~/.config/hypr/scripts/shift-to-empty-on-screen.sh l
bind = $mainMod CTRL SHIFT, D, exec, ~/.config/hypr/scripts/shift-to-empty-on-screen.sh r
# -----------------------------------------------------------------------------
# LAYOUT CONTROL (XMonad-like with hy3)
# -----------------------------------------------------------------------------
# Create groups with different orientations (like XMonad layouts)
# hy3:makegroup creates a split/tab group from focused window
bind = $mainMod, Space, hy3:changegroup, toggletab
bind = $mainMod SHIFT, Space, hy3:changegroup, opposite
# Create specific group types
bind = $mainMod, H, hy3:makegroup, h
bind = $mainMod SHIFT, V, hy3:makegroup, v
# Mod+Ctrl+Space mirrors Mod+Space (tabs instead of fullscreen)
bind = $mainMod CTRL, Space, hy3:changegroup, toggletab
# Change group type (cycle h -> v -> tab)
bind = $mainMod, slash, hy3:changegroup, h
bind = $mainMod SHIFT, slash, hy3:changegroup, v
# Tab navigation (like XMonad's focus next/prev in tabbed)
bind = $mainMod, bracketright, hy3:focustab, r, wrap
bind = $mainMod, bracketleft, hy3:focustab, l, wrap
# Move window within tab group (hy3 has no movetab dispatcher)
bind = $mainMod SHIFT, bracketright, hy3:movewindow, r, visible
bind = $mainMod SHIFT, bracketleft, hy3:movewindow, l, visible
# Expand focus to parent group (like XMonad's focus parent)
bind = $mainMod, grave, hy3:expand, expand
bind = $mainMod SHIFT, grave, hy3:expand, base
# Fullscreen (like XMonad's NBFULL toggle)
bind = $mainMod, F, fullscreen, 0
bind = $mainMod SHIFT, F, fullscreen, 1
# Toggle floating
bind = $mainMod, T, togglefloating,
# Resize split ratio (hy3 uses resizeactive for splits)
binde = $mainMod, comma, resizeactive, -50 0
binde = $mainMod, period, resizeactive, 50 0
# Equalize window sizes on workspace (hy3)
bind = $mainMod SHIFT, equal, hy3:equalize, workspace
# Kill group - removes the focused window from its group
bind = $mainMod, N, hy3:killactive
# hy3:setswallow - set a window to swallow newly spawned windows
bind = $mainMod CTRL, M, hy3:setswallow, toggle
# Minimize/unminimize (via special workspace)
bind = $mainMod, M, exec, ~/.config/hypr/scripts/minimize-active.sh minimized
bind = $mainMod SHIFT, M, exec, ~/.config/hypr/scripts/unminimize-last.sh minimized
# Minimized "picker" mode:
# Open the minimized special workspace, focus a window, press Enter to restore it.
bind = $modAlt, Return, exec, ~/.config/hypr/scripts/minimized-mode.sh minimized
submap = minimized
bind = , Return, exec, ~/.config/hypr/scripts/unminimize-last.sh minimized; hyprctl dispatch submap reset
bind = , Escape, exec, ~/.config/hypr/scripts/minimized-cancel.sh minimized
bind = $modAlt, Return, exec, ~/.config/hypr/scripts/minimized-cancel.sh minimized
# Optional: basic focus navigation inside the picker.
bind = , H, movefocus, l
bind = , J, movefocus, d
bind = , K, movefocus, u
bind = , L, movefocus, r
bind = , left, movefocus, l
bind = , down, movefocus, d
bind = , up, movefocus, u
bind = , right, movefocus, r
submap = reset
# -----------------------------------------------------------------------------
# WORKSPACE CONTROL
# -----------------------------------------------------------------------------
# Switch workspaces (1-9 only) on the currently focused monitor.
bind = $mainMod, 1, focusworkspaceoncurrentmonitor, 1
bind = $mainMod, 2, focusworkspaceoncurrentmonitor, 2
bind = $mainMod, 3, focusworkspaceoncurrentmonitor, 3
bind = $mainMod, 4, focusworkspaceoncurrentmonitor, 4
bind = $mainMod, 5, focusworkspaceoncurrentmonitor, 5
bind = $mainMod, 6, focusworkspaceoncurrentmonitor, 6
bind = $mainMod, 7, focusworkspaceoncurrentmonitor, 7
bind = $mainMod, 8, focusworkspaceoncurrentmonitor, 8
bind = $mainMod, 9, focusworkspaceoncurrentmonitor, 9
# Move window to workspace
bind = $mainMod SHIFT, 1, movetoworkspace, 1
bind = $mainMod SHIFT, 2, movetoworkspace, 2
bind = $mainMod SHIFT, 3, movetoworkspace, 3
bind = $mainMod SHIFT, 4, movetoworkspace, 4
bind = $mainMod SHIFT, 5, movetoworkspace, 5
bind = $mainMod SHIFT, 6, movetoworkspace, 6
bind = $mainMod SHIFT, 7, movetoworkspace, 7
bind = $mainMod SHIFT, 8, movetoworkspace, 8
bind = $mainMod SHIFT, 9, movetoworkspace, 9
# Move and follow to workspace (like XMonad's shiftThenView)
bind = $mainMod CTRL, 1, movetoworkspacesilent, 1
bind = $mainMod CTRL, 1, focusworkspaceoncurrentmonitor, 1
bind = $mainMod CTRL, 2, movetoworkspacesilent, 2
bind = $mainMod CTRL, 2, focusworkspaceoncurrentmonitor, 2
bind = $mainMod CTRL, 3, movetoworkspacesilent, 3
bind = $mainMod CTRL, 3, focusworkspaceoncurrentmonitor, 3
bind = $mainMod CTRL, 4, movetoworkspacesilent, 4
bind = $mainMod CTRL, 4, focusworkspaceoncurrentmonitor, 4
bind = $mainMod CTRL, 5, movetoworkspacesilent, 5
bind = $mainMod CTRL, 5, focusworkspaceoncurrentmonitor, 5
bind = $mainMod CTRL, 6, movetoworkspacesilent, 6
bind = $mainMod CTRL, 6, focusworkspaceoncurrentmonitor, 6
bind = $mainMod CTRL, 7, movetoworkspacesilent, 7
bind = $mainMod CTRL, 7, focusworkspaceoncurrentmonitor, 7
bind = $mainMod CTRL, 8, movetoworkspacesilent, 8
bind = $mainMod CTRL, 8, focusworkspaceoncurrentmonitor, 8
bind = $mainMod CTRL, 9, movetoworkspacesilent, 9
bind = $mainMod CTRL, 9, focusworkspaceoncurrentmonitor, 9
# Toggle to the previous workspace on the current monitor using Hyprland's
# built-in per-monitor workspace history.
bind = $mainMod, backslash, workspace, previous_per_monitor
# Swap current workspace with another (like XMonad's swapWithCurrent)
bind = $hyper, 5, exec, ~/.config/hypr/scripts/swap-workspaces.sh
# Go to next empty workspace (like XMonad's moveTo Next emptyWS)
bind = $hyper, E, exec, ~/.config/hypr/scripts/workspace-goto-empty.sh
# Move to next screen (like XMonad's shiftToNextScreenX)
bind = $mainMod, Z, focusmonitor, +1
bind = $mainMod SHIFT, Z, movewindow, mon:+1
# Shift to empty workspace and view (like XMonad's shiftToEmptyAndView)
bind = $mainMod SHIFT, H, exec, ~/.config/hypr/scripts/workspace-move-to-empty.sh
# -----------------------------------------------------------------------------
# WINDOW MANAGEMENT
# -----------------------------------------------------------------------------
# Go to window (rofi window switcher with icons)
bind = $mainMod, G, exec, ~/.config/hypr/scripts/go-to-window.sh
# Bring window (move to current workspace)
bind = $mainMod, B, exec, ~/.config/hypr/scripts/bring-window.sh
# Replace window (swap focused with selected - like XMonad's myReplaceWindow)
bind = $mainMod SHIFT, B, exec, ~/.config/hypr/scripts/replace-window.sh
# Gather windows of same class (like XMonad's gatherThisClass)
bind = $hyper, G, exec, ~/.config/hypr/scripts/gather-class.sh
# Focus next window of different class (like XMonad's focusNextClass)
bind = $mainMod, apostrophe, exec, ~/.config/hypr/scripts/focus-next-class.sh
# -----------------------------------------------------------------------------
# MEDIA KEYS
# -----------------------------------------------------------------------------
# Volume control (matching XMonad: Mod+I=up, Mod+K=down, Mod+U=mute)
binde = , XF86AudioRaiseVolume, exec, set_volume --unmute --change-volume +5
binde = , XF86AudioLowerVolume, exec, set_volume --unmute --change-volume -5
bind = , XF86AudioMute, exec, set_volume --toggle-mute
binde = $mainMod, I, exec, set_volume --unmute --change-volume +5
binde = $mainMod, K, exec, set_volume --unmute --change-volume -5
bind = $mainMod, U, exec, set_volume --toggle-mute
# Media player controls (matching XMonad: Mod+;=play, Mod+L=next, Mod+J=prev)
bind = $mainMod, semicolon, exec, playerctl play-pause
bind = , XF86AudioPlay, exec, playerctl play-pause
bind = , XF86AudioPause, exec, playerctl play-pause
bind = $mainMod, L, exec, playerctl next
bind = , XF86AudioNext, exec, playerctl next
bind = $mainMod, J, exec, playerctl previous
bind = , XF86AudioPrev, exec, playerctl previous
# Mute current window (like XMonad's toggle_mute_current_window)
bind = $hyper SHIFT, Q, exec, toggle_mute_current_window.sh
bind = $hyper CTRL, Q, exec, toggle_mute_current_window.sh only
# Brightness control
binde = , XF86MonBrightnessUp, exec, brightness.sh up
binde = , XF86MonBrightnessDown, exec, brightness.sh down
# -----------------------------------------------------------------------------
# UTILITY BINDINGS
# -----------------------------------------------------------------------------
bind = $hyper, V, exec, cliphist list | rofi -dmenu -p "Clipboard" | cliphist decode | wl-copy
bind = $hyper, P, exec, rofi-pass
bind = $hyper, H, exec, grim -g "$(slurp)" - | swappy -f -
bind = $hyper, C, exec, shell_command.sh
bind = $hyper, X, exec, rofi_command.sh
bind = $hyper SHIFT, L, exec, hyprlock
bind = $hyper, K, exec, rofi_kill_process.sh
bind = $hyper SHIFT, K, exec, rofi_kill_all.sh
bind = $hyper, R, exec, rofi-systemd
bind = $hyper, slash, exec, toggle_taffybar
bind = $hyper, 9, exec, start_synergy.sh
bind = $hyper, I, exec, rofi_select_input.hs
bind = $hyper, backslash, exec, /home/imalison/dotfiles/dotfiles/lib/functions/mpg341cx_input toggle
bind = $hyper, O, exec, rofi_paswitch
bind = $hyper, comma, exec, rofi_wallpaper.sh
bind = $hyper, Y, exec, rofi_agentic_skill
# Reload config
bind = $mainMod, R, exec, hyprctl reload
# -----------------------------------------------------------------------------
# MOUSE BINDINGS
# -----------------------------------------------------------------------------
bindm = $mainMod, mouse:272, movewindow
bindm = $mainMod, mouse:273, resizewindow
# Scroll through workspaces
bind = $mainMod, mouse_down, exec, ~/.config/hypr/scripts/workspace-scroll.sh +1
bind = $mainMod, mouse_up, exec, ~/.config/hypr/scripts/workspace-scroll.sh -1
# =============================================================================
# AUTOSTART
# =============================================================================
# Wire Hyprland into Home Manager's standard user-session targets.
# `graphical-session.target` pulls in most tray/SNI applets (which in turn pull in `tray.target`).
# Keep the systemd user manager in sync with the current Hyprland session before
# starting any session-bound units. Separate `exec-once` commands race.
exec-once = sh -lc 'export IMALISON_SESSION_TYPE=wayland; dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XAUTHORITY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP XDG_SESSION_TYPE IMALISON_SESSION_TYPE; systemctl --user start graphical-session.target hyprland-session.target'
# Force a fresh daemon after compositor restarts so hyprscratch doesn't keep a stale socket.
exec-once = systemctl --user restart hyprscratch.service
exec-once = hypridle
# Clipboard history daemon
exec-once = wl-paste --type text --watch cliphist store
exec-once = wl-paste --type image --watch cliphist store

View File

@@ -3,9 +3,8 @@ local mod_alt = "SUPER + ALT"
local hyper = "SUPER + CTRL + ALT"
local terminal = "ghostty --gtk-single-instance=false"
local shell_ui_command = "hypr_shell_ui"
local launcher_command = shell_ui_command .. " launcher"
local run_menu = shell_ui_command .. " run"
local menu = "rofi -show drun -show-icons"
local run_menu = "rofi -show run"
local max_workspace = 9
local scratchpad_top_margin = 60
@@ -13,19 +12,14 @@ local columns_layout = "nStack"
local monocle_layout = "monocle"
local minimized_workspace = "special:minimized"
local current_layout = columns_layout
local enable_nstack = true
local enable_hyprexpo = true
local enable_hyprwinview = true
local enable_workspace_history = true
local configure_nstack_plugin_from_lua = false
local workspace_layouts = {}
local minimized_windows = {}
local tabbed_workspace_groups = {}
local window_picker_mode = nil
local window_picker_candidates = {}
local stack_update_timer = nil
local monocle_notice = nil
local scratchpad_pending = {}
local monitor_workspace_history = {}
local scratchpads = {
htop = {
@@ -42,8 +36,7 @@ local scratchpads = {
},
element = {
command = "element-desktop",
classes = { "Element", "electron" },
title = "Element",
class = "Element",
},
slack = {
command = "slack",
@@ -58,6 +51,16 @@ local scratchpads = {
class = "com.mitchellh.ghostty.dropdown",
dropdown = true,
},
gmail = {
command = "google-chrome-stable --new-window https://mail.google.com/mail/u/0/#inbox",
class = "google-chrome",
title = "Gmail",
},
messages = {
command = "google-chrome-stable --new-window https://messages.google.com/web/conversations",
class = "google-chrome",
title = "Messages",
},
}
local function command_line_contains(needle)
@@ -81,13 +84,6 @@ local function exec(command)
return hl.dsp.exec_cmd(command)
end
local function window_selector(window)
if not window or not window.address then
return nil
end
return "address:" .. tostring(window.address)
end
local function hyprexpo(action)
return function()
if hl.plugin and hl.plugin.hyprexpo and hl.plugin.hyprexpo.expo then
@@ -104,40 +100,8 @@ local function hyprexpo(action)
end
end
local function hyprwinview(action)
return function()
if hl.plugin and hl.plugin.hyprwinview and hl.plugin.hyprwinview.overview then
hl.plugin.hyprwinview.overview(action)
else
hl.notification.create({
text = "hyprwinview is not loaded",
duration = 1800,
icon = "warning",
color = "rgba(edb443ff)",
font_size = 13,
})
end
end
end
local function workspacehistory(action, arg)
return function()
if hl.plugin and hl.plugin.workspacehistory and hl.plugin.workspacehistory[action] then
hl.plugin.workspacehistory[action](arg)
else
hl.notification.create({
text = "workspacehistory is not loaded",
duration = 1800,
icon = "warning",
color = "rgba(edb443ff)",
font_size = 13,
})
end
end
end
local function apply_nstack_config()
if verify_config or not enable_nstack or not configure_nstack_plugin_from_lua then
if verify_config then
return
end
@@ -162,7 +126,7 @@ local function apply_nstack_config()
end
local function apply_hyprexpo_config()
if verify_config or not enable_hyprexpo then
if verify_config then
return
end
@@ -174,7 +138,6 @@ local function apply_hyprexpo_config()
bg_col = "rgba(111111ff)",
workspace_method = "center current",
skip_empty = false,
max_workspace = max_workspace,
show_workspace_numbers = true,
workspace_number_color = "rgba(edb443ff)",
gesture_distance = 200,
@@ -183,60 +146,6 @@ local function apply_hyprexpo_config()
})
end
local function apply_hyprwinview_config()
if verify_config or not enable_hyprwinview then
return
end
hl.config({
plugin = {
hyprwinview = {
gap_size = 24,
margin = 48,
background = "rgba(10101499)",
background_blur = 0,
border_col = "rgba(ffffff33)",
hover_border_col = "rgba(66ccffee)",
border_size = 3,
window_order = "application",
show_app_icon = 1,
app_icon_size = 48,
app_icon_theme_source = "auto",
app_icon_position = "bottom right",
app_icon_margin_x = 12,
app_icon_margin_y = 12,
app_icon_margin_relative_x = 0.0,
app_icon_margin_relative_y = 0.0,
app_icon_offset_x = 0,
app_icon_offset_y = 0,
app_icon_backplate_col = "rgba(00000066)",
app_icon_backplate_padding = 6,
animation = "workspace_zoom",
animation_in_ms = 180,
animation_out_ms = 140,
animation_scale = 0.94,
animation_stagger_ms = 16,
animation_stagger_max_ms = 120,
},
},
})
if hl.plugin and hl.plugin.hyprwinview and hl.plugin.hyprwinview.configure then
hl.plugin.hyprwinview.configure({
keys = {
left = { "a", "h", "left" },
right = { "d", "l", "right" },
up = { "w", "k", "up" },
down = { "s", "j", "down" },
go = { "return", "enter", "space", "g", "f" },
bring = { "b", "shift+return", "shift+space" },
bring_replace = { "shift + b" },
close = { "escape", "q" },
},
})
end
end
local function active_workspace()
return hl.get_active_workspace()
end
@@ -295,22 +204,9 @@ local function lower_contains(value, needle)
return value:find(needle, 1, true) ~= nil
end
local function lower_contains_any(value, needles)
if type(needles) ~= "table" then
return lower_contains(value, needles)
end
for _, needle in ipairs(needles) do
if lower_contains(value, needle) then
return true
end
end
return false
end
local function scratchpad_window_matches(window, def)
return window
and lower_contains_any(window.class, def.classes or def.class)
and lower_contains(window.class, def.class)
and lower_contains(window.title, def.title)
end
@@ -323,27 +219,6 @@ local function is_scratchpad_window(window)
return false
end
local function matching_scratchpad_name(window)
for name, def in pairs(scratchpads) do
if scratchpad_window_matches(window, def) then
return name
end
end
return nil
end
local function same_workspace(left, right)
if not left or not right then
return false
end
if left.name and right.name and tostring(left.name) == tostring(right.name) then
return true
end
return left.id and right.id and left.id == right.id
end
local function is_minimized_workspace(workspace)
if not workspace then
return false
@@ -386,71 +261,6 @@ local function tiled_window_count(workspace)
return #tiled_windows(workspace)
end
local function sort_windows_by_focus_history(windows)
table.sort(windows, function(left, right)
return (left.focus_history_id or 0) < (right.focus_history_id or 0)
end)
end
local function window_address_set(windows)
local addresses = {}
for _, window in ipairs(windows) do
if window and window.address then
addresses[window.address] = true
end
end
return addresses
end
local function window_address_in_set(window, addresses)
return window and window.address and addresses[window.address] or false
end
local function numeric_component(value, key, index)
if type(value) ~= "table" then
return 0
end
return tonumber(value[key] or value[index]) or 0
end
local function window_center(window)
local at = window and window.at or {}
local size = window and window.size or {}
return numeric_component(at, "x", 1) + numeric_component(size, "x", 1) / 2,
numeric_component(at, "y", 2) + numeric_component(size, "y", 2) / 2
end
local function window_distance_squared(window, x, y)
local wx, wy = window_center(window)
local dx = wx - x
local dy = wy - y
return dx * dx + dy * dy
end
local function grouping_direction(window, anchor)
local wx, wy = window_center(window)
local ax, ay = window_center(anchor)
local dx = wx - ax
local dy = wy - ay
if math.abs(dx) >= math.abs(dy) then
return dx >= 0 and "left" or "right"
end
return dy >= 0 and "up" or "down"
end
local function grouping_directions(window, anchor)
local primary = grouping_direction(window, anchor)
local directions = { primary }
for _, direction in ipairs({ "left", "right", "up", "down" }) do
if direction ~= primary then
directions[#directions + 1] = direction
end
end
return directions
end
local function workspace_window_count(workspace_id)
local workspace = hl.get_workspace(tostring(workspace_id))
if not workspace then
@@ -484,7 +294,7 @@ local function find_empty_workspace(target_monitor, exclude_id)
end
local function update_nstack_count()
if not enable_nstack or current_layout ~= columns_layout then
if current_layout ~= columns_layout then
return
end
@@ -493,7 +303,6 @@ local function update_nstack_count()
if count == 0 then
return
end
count = math.max(count, 2)
hl.dsp.layout("setstackcount " .. tostring(count))()
end
@@ -577,16 +386,8 @@ local function toggle_columns_monocle()
end
end
local function active_group_size()
local window = hl.get_active_window()
return window and window.group and window.group.size or 0
end
local function monocle_next()
local window = hl.get_active_window()
if window and window.group and window.group.size and window.group.size > 1 then
hl.dsp.group.next({ window = window_selector(window) })()
elseif current_layout == monocle_layout then
if current_layout == monocle_layout then
hl.dsp.layout("cyclenext")()
update_monocle_notice()
else
@@ -595,10 +396,7 @@ local function monocle_next()
end
local function monocle_prev()
local window = hl.get_active_window()
if window and window.group and window.group.size and window.group.size > 1 then
hl.dsp.group.prev({ window = window_selector(window) })()
elseif current_layout == monocle_layout then
if current_layout == monocle_layout then
hl.dsp.layout("cycleprev")()
update_monocle_notice()
else
@@ -607,7 +405,7 @@ local function monocle_prev()
end
local function focus_direction(direction)
if active_group_size() > 1 or current_layout == monocle_layout then
if current_layout == monocle_layout then
if direction == "up" or direction == "left" then
monocle_prev()
else
@@ -619,189 +417,46 @@ local function focus_direction(direction)
hl.dsp.focus({ direction = direction })()
end
local function swap_direction(direction)
if enable_nstack and current_layout == columns_layout and active_group_size() <= 1 then
hl.dsp.layout("swapdirection " .. direction)()
return
end
hl.dsp.window.swap({ direction = direction })()
end
local function focus_workspace(workspace_id)
hl.dsp.focus({ workspace = tostring(workspace_id), on_current_monitor = true })()
end
local function monitor_key(monitor)
if not monitor then
return "unknown"
end
return tostring(monitor.name or monitor.id or "unknown")
end
local function remember_workspace_for_monitor(workspace)
workspace = workspace or active_workspace()
if not workspace or not workspace.id or workspace.id < 1 then
return
end
local key = monitor_key(workspace.monitor or hl.get_active_monitor())
local history = monitor_workspace_history[key] or {}
if history.current ~= workspace.id then
history.previous = history.current
history.current = workspace.id
end
monitor_workspace_history[key] = history
end
local function focus_previous_workspace_for_monitor()
local key = monitor_key(hl.get_active_monitor())
local history = monitor_workspace_history[key]
if history and history.previous then
focus_workspace(history.previous)
else
hl.dsp.focus({ workspace = "previous_per_monitor" })()
end
end
local function move_window_to_workspace(workspace_id, follow, window)
local target_window = window or hl.get_active_window()
local target_selector = window_selector(target_window)
hl.dsp.window.move({ workspace = tostring(workspace_id), follow = false, window = target_selector })()
hl.dsp.window.move({ workspace = tostring(workspace_id), follow = follow, window = window })()
if follow then
focus_workspace(workspace_id)
if target_selector then
hl.dsp.focus({ window = target_selector })()
end
end
end
local function notify_tabbed_group(text)
hl.notification.create({
text = text,
duration = 1800,
icon = "info",
color = "rgba(edb443ff)",
font_size = 13,
})
end
local function active_workspace_tiled_group_candidates(workspace)
local candidates = tiled_windows(workspace)
sort_windows_by_focus_history(candidates)
return candidates
end
local function move_window_into_group(window, anchor)
local selector = window_selector(window)
if not selector then
return false
end
for _, direction in ipairs(grouping_directions(window, anchor)) do
hl.dsp.focus({ window = selector })()
hl.dsp.window.move({ into_group = direction, window = selector })()
local active = hl.get_active_window()
if active and active.group and active.group.size and active.group.size > 1 then
return true
end
end
return false
end
local function find_tabbed_group_anchor(state)
local active = hl.get_active_window()
if active and active.group and active.group.size and active.group.size > 1 then
return active
end
if not state then
return nil
end
for _, window in ipairs(hl.get_windows()) do
if window and window.address == state.anchor and window.group and window.group.size and window.group.size > 1 then
return window
end
end
return nil
end
local function restore_workspace_tabbed_group()
local key = workspace_key()
local anchor = find_tabbed_group_anchor(tabbed_workspace_groups[key])
local anchor_selector = window_selector(anchor)
if not anchor_selector then
tabbed_workspace_groups[key] = nil
set_layout(columns_layout)
notify_tabbed_group("No tabbed group to restore")
return
end
hl.dsp.focus({ window = anchor_selector })()
hl.dsp.group.toggle({ window = anchor_selector })()
tabbed_workspace_groups[key] = nil
set_layout(columns_layout)
schedule_nstack_count_update()
end
local function gather_workspace_into_tabbed_group()
local workspace = active_workspace()
if not is_normal_workspace(workspace) then
return
end
local key = workspace_key(workspace)
if tabbed_workspace_groups[key] or active_group_size() > 1 then
restore_workspace_tabbed_group()
return
end
local candidates = active_workspace_tiled_group_candidates(workspace)
if #candidates <= 1 then
set_layout(columns_layout)
return
end
local candidate_addresses = window_address_set(candidates)
local focused = hl.get_active_window()
local anchor = nil
if focused and not focused.floating and not focused.group and window_address_in_set(focused, candidate_addresses) then
anchor = focused
end
if not anchor then
for _, window in ipairs(candidates) do
if not window.group then
anchor = window
break
end
end
end
local anchor_selector = window_selector(anchor)
if not anchor_selector then
notify_tabbed_group("Current tiled windows are already grouped")
return
end
set_layout(columns_layout)
hl.dsp.focus({ window = anchor_selector })()
hl.dsp.group.toggle({ window = anchor_selector })()
local group_windows = {}
for _, window in ipairs(candidates) do
if window ~= anchor and not window.group then
group_windows[#group_windows + 1] = window
end
end
local anchor_x, anchor_y = window_center(anchor)
table.sort(group_windows, function(left, right)
return window_distance_squared(left, anchor_x, anchor_y) < window_distance_squared(right, anchor_x, anchor_y)
end)
local grouped_count = 1
for _, window in ipairs(group_windows) do
if move_window_into_group(window, anchor) then
grouped_count = grouped_count + 1
end
end
if grouped_count <= 1 then
hl.dsp.focus({ window = anchor_selector })()
hl.dsp.group.toggle({ window = anchor_selector })()
notify_tabbed_group("Unable to group tiled windows")
return
elseif grouped_count < #candidates then
notify_tabbed_group("Grouped " .. tostring(grouped_count) .. " of " .. tostring(#candidates) .. " tiled windows")
end
tabbed_workspace_groups[key] = {
anchor = anchor.address,
windows = candidate_addresses,
}
hl.dsp.focus({ window = anchor_selector })()
end
local function force_columns_layout()
if active_group_size() > 1 or tabbed_workspace_groups[workspace_key()] then
restore_workspace_tabbed_group()
else
set_layout(columns_layout)
end
end
@@ -884,7 +539,7 @@ local function move_window_to_monitor(direction, follow)
end
local original_monitor = hl.get_active_monitor()
hl.dsp.window.move({ monitor = direction, follow = follow, window = window_selector(window) })()
hl.dsp.window.move({ monitor = direction, follow = follow, window = window })()
if not follow and original_monitor then
hl.dsp.focus({ monitor = original_monitor })()
@@ -1048,16 +703,15 @@ local function apply_scratchpad_geometry(name, window, target_monitor)
x = monitor.x + math.floor((monitor.width - width) / 2)
y = monitor.y + scratchpad_top_margin
end
local selector = window_selector(window)
hl.dsp.window.float({ action = "enable", window = selector })()
hl.dsp.window.tag({ tag = "+scratchpad", window = selector })()
hl.dsp.window.tag({ tag = "+scratchpad-" .. name, window = selector })()
hl.dsp.window.resize({ x = width, y = height, relative = false, window = selector })()
hl.dsp.window.move({ x = x, y = y, relative = false, window = selector })()
hl.dsp.window.float({ action = "enable", window = window })()
hl.dsp.window.tag({ tag = "+scratchpad", window = window })()
hl.dsp.window.tag({ tag = "+scratchpad-" .. name, window = window })()
hl.dsp.window.resize({ x = width, y = height, relative = false, window = window })()
hl.dsp.window.move({ x = x, y = y, relative = false, window = window })()
if def.dropdown then
hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = selector })()
hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = selector })()
hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = window })()
hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = window })()
end
end
@@ -1080,35 +734,13 @@ local function show_scratchpad_window(name, window, workspace, target_monitor)
remove_minimized_window(window)
move_window_to_workspace(workspace.id, false, window)
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
schedule_scratchpad_geometry(name, window, target_monitor or hl.get_active_monitor())
end
local function scratchpad_is_visible(window)
local workspace = active_workspace()
return workspace and window and same_workspace(window.workspace, workspace)
end
-- Active scratchpads are scratchpad windows visible on the active workspace.
-- Invoking a different scratchpad replaces that active set.
local function active_scratchpad_windows(except_name)
local windows = {}
for _, window in ipairs(hl.get_windows()) do
local name = matching_scratchpad_name(window)
if name and name ~= except_name and scratchpad_is_visible(window) then
windows[#windows + 1] = {
name = name,
window = window,
}
end
end
return windows
end
local function hide_active_scratchpads(except_name)
for _, active in ipairs(active_scratchpad_windows(except_name)) do
hide_scratchpad_window(active.name, active.window)
end
return workspace and window and window.workspace == workspace
end
local function adopt_matching_scratchpad_window(window)
@@ -1188,29 +820,29 @@ local function activate_window_picker_candidate(index)
end
if mode == "go" then
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
return
end
local workspace = active_workspace()
if mode == "bring" and workspace then
move_window_to_workspace(workspace.id, false, window)
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
return
end
if mode == "minimized" and workspace then
remove_minimized_window(window)
restore_minimized_window(window, workspace)
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
return
end
if mode == "replace" then
local focused = hl.get_active_window()
if focused and focused ~= window then
hl.dsp.window.swap({ target = window_selector(window), window = window_selector(focused) })()
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.window.swap({ target = window, window = focused })()
hl.dsp.focus({ window = window })()
end
end
end
@@ -1307,7 +939,7 @@ local function focus_next_class()
local next_class = classes[(current_index % #classes) + 1]
local target = first_by_class[next_class]
if target then
hl.dsp.focus({ window = window_selector(target) })()
hl.dsp.focus({ window = target })()
end
end
@@ -1346,7 +978,7 @@ local function raise_or_spawn(class_fragment, command)
local fragment = string.lower(class_fragment)
for _, window in ipairs(hl.get_windows()) do
if is_normal_window(window) and window.class and string.find(string.lower(window.class), fragment, 1, true) then
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
return
end
end
@@ -1376,7 +1008,7 @@ local function restore_last_minimized()
local window = table.remove(minimized_windows)
if window and window.address and is_minimized_window(window) then
restore_minimized_window(window, workspace)
hl.dsp.focus({ window = window_selector(window) })()
hl.dsp.focus({ window = window })()
return
end
end
@@ -1442,7 +1074,6 @@ local function toggle_scratchpad(name)
local windows = matching_scratchpad_windows(name)
if #windows == 0 then
hide_active_scratchpads(name)
scratchpad_pending[name] = {
monitor = hl.get_active_monitor(),
workspace = active_workspace(),
@@ -1464,7 +1095,6 @@ local function toggle_scratchpad(name)
hide_scratchpad_window(name, window)
end
else
hide_active_scratchpads(name)
local workspace = active_workspace()
local target_monitor = hl.get_active_monitor()
for _, window in ipairs(windows) do
@@ -1473,18 +1103,10 @@ local function toggle_scratchpad(name)
end
end
if enable_nstack then
hl.plugin.load("/run/current-system/sw/lib/libhyprNStack.so")
end
if enable_hyprexpo and not verify_config then
hl.plugin.load("/run/current-system/sw/lib/libhyprNStack.so")
if not verify_config then
hl.plugin.load("/run/current-system/sw/lib/libhyprexpo.so")
end
if enable_hyprwinview and not verify_config then
hl.plugin.load("/run/current-system/sw/lib/libhyprwinview.so")
end
if enable_workspace_history and not verify_config then
hl.plugin.load("/run/current-system/sw/lib/libhypr-workspace-history.so")
end
hl.env("XCURSOR_SIZE", "24")
hl.env("HYPRCURSOR_SIZE", "24")
@@ -1536,28 +1158,19 @@ hl.config({
workspace_back_and_forth = true,
},
group = {
group_on_movetoworkspace = false,
col = {
border_active = "rgba(edb443ff)",
border_inactive = "rgba(091f2eff)",
},
groupbar = {
enabled = true,
blur = true,
font_size = 13,
gradients = true,
height = 26,
indicator_gap = 0,
indicator_height = 1,
rounding = 5,
gradient_rounding = 5,
text_padding = 8,
font_size = 12,
height = 22,
col = {
active = "rgba(edb443ff)",
inactive = "rgba(101820f2)",
inactive = "rgba(091f2eff)",
},
text_color = "rgba(091018ff)",
text_color_inactive = "rgba(f2f5f7ff)",
text_color = "rgba(091f2eff)",
},
},
misc = {
@@ -1572,50 +1185,15 @@ hl.curve("smoothOut", { type = "bezier", points = { { 0.36, 1 }, { 0.3, 1 } } })
hl.curve("smoothInOut", { type = "bezier", points = { { 0.42, 0 }, { 0.58, 1 } } })
hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } })
local animations = {
{ leaf = "global", enabled = true, speed = 8, bezier = "default" },
{ leaf = "windows", enabled = true, speed = 6, bezier = "overshoot", style = "gnomed" },
{ leaf = "windowsIn", enabled = true, speed = 6, bezier = "overshoot", style = "gnomed" },
{ leaf = "windowsOut", enabled = true, speed = 5, bezier = "smoothInOut", style = "gnomed" },
{ leaf = "windowsMove", enabled = true, speed = 6, bezier = "smoothOut" },
{ leaf = "border", enabled = false },
{ leaf = "borderangle", enabled = false },
{ leaf = "fade", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeIn", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeOut", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeSwitch", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeShadow", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeGlow", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeDim", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeLayers", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeLayersIn", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeLayersOut", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadePopups", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadePopupsIn", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadePopupsOut", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "fadeDpms", enabled = true, speed = 5, bezier = "smoothOut" },
{ leaf = "layers", enabled = true, speed = 5, bezier = "smoothOut", style = "fade" },
{ leaf = "layersIn", enabled = true, speed = 5, bezier = "smoothOut", style = "fade" },
{ leaf = "layersOut", enabled = true, speed = 5, bezier = "smoothOut", style = "fade" },
{ leaf = "workspaces", enabled = true, speed = 6, bezier = "smoothOut", style = "slidefade 15%" },
{ leaf = "workspacesIn", enabled = true, speed = 6, bezier = "smoothOut", style = "slidefade 15%" },
{ leaf = "workspacesOut", enabled = true, speed = 6, bezier = "smoothOut", style = "slidefade 15%" },
{ leaf = "specialWorkspace", enabled = true, speed = 6, bezier = "smoothOut", style = "slidevert" },
{ leaf = "specialWorkspaceIn", enabled = true, speed = 6, bezier = "smoothOut", style = "slidevert" },
{ leaf = "specialWorkspaceOut", enabled = true, speed = 6, bezier = "smoothOut", style = "slidevert" },
{ leaf = "zoomFactor", enabled = true, speed = 7, bezier = "smoothOut" },
{ leaf = "monitorAdded", enabled = true, speed = 5, bezier = "smoothOut" },
}
for _, animation in ipairs(animations) do
hl.animation(animation)
end
hl.animation({ leaf = "windows", enabled = true, speed = 6, bezier = "overshoot", style = "gnomed" })
hl.animation({ leaf = "windowsIn", enabled = true, speed = 6, bezier = "overshoot", style = "gnomed" })
hl.animation({ leaf = "windowsOut", enabled = true, speed = 5, bezier = "smoothInOut", style = "gnomed" })
hl.animation({ leaf = "windowsMove", enabled = true, speed = 6, bezier = "smoothOut" })
hl.animation({ leaf = "border", enabled = false })
hl.animation({ leaf = "borderangle", enabled = false })
hl.animation({ leaf = "fade", enabled = true, speed = 5, bezier = "smoothOut" })
hl.animation({ leaf = "workspaces", enabled = true, speed = 6, bezier = "smoothOut", style = "slidefade 15%" })
hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 6, bezier = "smoothOut", style = "slidevert" })
local function apply_rules()
if verify_config then
@@ -1630,10 +1208,6 @@ local function apply_rules()
hl.window_rule({ match = { title = "^(Open File)$" }, float = true })
hl.window_rule({ match = { title = "^(Save File)$" }, float = true })
hl.window_rule({ match = { title = "^(Confirm)$" }, float = true })
hl.window_rule({
match = { class = "^(com\\.mitchellh\\.ghostty\\.dropdown)$" },
animation = "slide top",
})
hl.window_rule({
match = { class = "^(.*[Rr]umno.*)$" },
float = true,
@@ -1652,23 +1226,27 @@ local function apply_rules()
})
end
bind(main_mod .. " + P", exec(launcher_command))
apply_rules()
bind(main_mod .. " + P", exec(menu))
bind(main_mod .. " + SHIFT + P", exec(run_menu))
bind(hyper .. " + SHIFT + N", exec(shell_ui_command .. " control-center"))
bind(hyper .. " + CTRL + N", exec(shell_ui_command .. " settings"))
bind(main_mod .. " + SHIFT + Return", exec(terminal))
bind(main_mod .. " + Q", exec("hyprctl reload"))
bind(main_mod .. " + Q", hl.dsp.window.close())
bind(main_mod .. " + SHIFT + C", hl.dsp.window.close())
bind(main_mod .. " + SHIFT + Q", hl.dsp.exit())
bind(main_mod .. " + E", exec("emacsclient --eval '(emacs-everywhere)'"))
bind(main_mod .. " + V", exec("wl-paste | xdotool type --file -"))
bind(main_mod .. " + Tab", hyprwinview("toggle"))
bind(main_mod .. " + SHIFT + Tab", hyprwinview("toggle other-workspaces"))
bind("ALT + Tab", hyprexpo("toggle"))
bind("ALT + SHIFT + Tab", hyprexpo("bring"))
bind(main_mod .. " + G", exec(shell_ui_command .. " window go"))
bind(main_mod .. " + B", exec(shell_ui_command .. " window bring"))
bind(main_mod .. " + SHIFT + B", exec(shell_ui_command .. " window replace"))
bind(main_mod .. " + Tab", hyprexpo("toggle"))
bind(main_mod .. " + SHIFT + Tab", hyprexpo("bring"))
bind(main_mod .. " + G", function()
enter_window_picker("go")
end)
bind(main_mod .. " + B", function()
enter_window_picker("bring")
end)
bind(main_mod .. " + SHIFT + B", function()
enter_window_picker("replace")
end)
bind(main_mod .. " + W", function()
focus_direction("up")
@@ -1683,18 +1261,10 @@ bind(main_mod .. " + D", function()
focus_direction("right")
end)
bind(main_mod .. " + SHIFT + W", function()
swap_direction("up")
end)
bind(main_mod .. " + SHIFT + S", function()
swap_direction("down")
end)
bind(main_mod .. " + SHIFT + A", function()
swap_direction("left")
end)
bind(main_mod .. " + SHIFT + D", function()
swap_direction("right")
end)
bind(main_mod .. " + SHIFT + W", hl.dsp.window.swap({ direction = "up" }))
bind(main_mod .. " + SHIFT + S", hl.dsp.window.swap({ direction = "down" }))
bind(main_mod .. " + SHIFT + A", hl.dsp.window.swap({ direction = "left" }))
bind(main_mod .. " + SHIFT + D", hl.dsp.window.swap({ direction = "right" }))
bind(main_mod .. " + CTRL + W", function()
move_window_to_monitor("u", false)
@@ -1768,11 +1338,17 @@ bind(hyper .. " + SHIFT + D", function()
move_window_to_monitor("r", true)
end)
bind(main_mod .. " + Space", gather_workspace_into_tabbed_group)
bind(main_mod .. " + SHIFT + Space", force_columns_layout)
bind(main_mod .. " + CTRL + Space", gather_workspace_into_tabbed_group)
bind(main_mod .. " + Space", toggle_columns_monocle)
bind(main_mod .. " + SHIFT + Space", function()
set_layout(columns_layout)
end)
bind(main_mod .. " + CTRL + Space", function()
set_layout(monocle_layout)
end)
bind(main_mod .. " + bracketright", monocle_next)
bind(main_mod .. " + bracketleft", monocle_prev)
bind(main_mod .. " + F", hl.dsp.window.fullscreen({ mode = "fullscreen" }))
bind(main_mod .. " + SHIFT + F", hl.dsp.window.fullscreen({ mode = "maximized" }))
bind(main_mod .. " + T", hl.dsp.window.float())
bind(main_mod .. " + M", minimize_active_window)
bind(main_mod .. " + SHIFT + M", restore_last_minimized)
@@ -1795,9 +1371,15 @@ bind(main_mod .. " + SHIFT + X", hl.dsp.workspace.toggle_special("NSP"))
bind(mod_alt .. " + E", function()
toggle_scratchpad("element")
end)
bind(mod_alt .. " + G", function()
toggle_scratchpad("gmail")
end)
bind(mod_alt .. " + H", function()
toggle_scratchpad("htop")
end)
bind(mod_alt .. " + M", function()
toggle_scratchpad("messages")
end)
bind(mod_alt .. " + K", function()
toggle_scratchpad("slack")
end)
@@ -1830,9 +1412,7 @@ for i = 1, 9 do
end)
end
bind(main_mod .. " + backslash", workspacehistory("cycle", 1))
bind(main_mod .. " + slash", workspacehistory("cycle", -1))
bind(main_mod .. " + Escape", workspacehistory("cancel"))
bind(main_mod .. " + backslash", focus_previous_workspace_for_monitor)
bind(main_mod .. " + Z", hl.dsp.focus({ monitor = "+1" }))
bind(main_mod .. " + SHIFT + Z", hl.dsp.window.move({ monitor = "+1" }))
bind(main_mod .. " + mouse_down", function()
@@ -1874,7 +1454,6 @@ bind(hyper .. " + R", exec("rofi-systemd"))
bind(hyper .. " + slash", exec("toggle_taffybar"))
bind(hyper .. " + I", exec("rofi_select_input.hs"))
bind(hyper .. " + backslash", exec("/home/imalison/dotfiles/dotfiles/lib/functions/mpg341cx_input toggle"))
bind(hyper .. " + SHIFT + backslash", workspacehistory("debug"))
bind(hyper .. " + O", exec("rofi_paswitch"))
bind(hyper .. " + comma", exec("rofi_wallpaper.sh"))
bind(hyper .. " + Y", exec("rofi_agentic_skill"))
@@ -1886,20 +1465,17 @@ bind(main_mod .. " + mouse:273", hl.dsp.window.resize())
hl.on("hyprland.start", function()
apply_nstack_config()
apply_hyprexpo_config()
apply_hyprwinview_config()
apply_rules()
hl.exec_cmd("sh -lc 'export IMALISON_SESSION_TYPE=wayland; dbus-update-activation-environment --systemd XDG_RUNTIME_DIR WAYLAND_DISPLAY DISPLAY XAUTHORITY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP XDG_SESSION_TYPE IMALISON_SESSION_TYPE; systemctl --user start graphical-session.target hyprland-session.target'")
hl.exec_cmd("sh -lc 'export IMALISON_SESSION_TYPE=wayland; dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY XAUTHORITY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP XDG_SESSION_TYPE IMALISON_SESSION_TYPE; systemctl --user start graphical-session.target hyprland-session.target'")
hl.exec_cmd("hypridle")
hl.exec_cmd("wl-paste --type text --watch cliphist store")
hl.exec_cmd("wl-paste --type image --watch cliphist store")
remember_workspace_for_monitor()
write_layout_state()
schedule_nstack_count_update()
end)
hl.on("config.reloaded", apply_nstack_config)
hl.on("config.reloaded", apply_hyprexpo_config)
hl.on("config.reloaded", apply_hyprwinview_config)
hl.on("config.reloaded", apply_rules)
hl.on("window.open", schedule_nstack_count_update)
hl.on("window.destroy", schedule_nstack_count_update)
@@ -1907,6 +1483,7 @@ hl.on("window.kill", schedule_nstack_count_update)
hl.on("window.move_to_workspace", schedule_nstack_count_update)
hl.on("workspace.active", sync_layout_for_active_workspace)
hl.on("monitor.focused", sync_layout_for_active_workspace)
hl.on("workspace.active", remember_workspace_for_monitor)
hl.on("window.open", update_monocle_notice)
hl.on("window.destroy", update_monocle_notice)

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Bring window to current workspace (like XMonad's bringWindow)
# Uses rofi with icons to select a window, then moves it here.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/window-icon-map.sh"
CURRENT_WS=$(hyprctl activeworkspace -j | jq -r '.id')
# Get windows on OTHER workspaces as TSV
WINDOW_DATA=$(hyprctl clients -j | jq -r --argjson cws "$CURRENT_WS" '
.[] | select(.workspace.id >= 0 and .workspace.id != $cws)
| [.address, .class, (.title | gsub("\t"; " ")), (.workspace.id | tostring)]
| @tsv')
if [ -z "$WINDOW_DATA" ]; then
notify-send "Bring Window" "No windows on other workspaces"
exit 0
fi
addresses=()
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT
while IFS=$'\t' read -r address class title ws_id; do
icon=$(icon_for_class "$class")
addresses+=("$address")
printf '%-24s %s WS:%s\0icon\x1f%s\n' \
"$class" "$title" "$ws_id" "$icon"
done <<< "$WINDOW_DATA" > "$TMPFILE"
INDEX=$(rofi -dmenu -i -show-icons -p "Bring window" -format i < "$TMPFILE") || exit 0
if [ -n "$INDEX" ] && [ -n "${addresses[$INDEX]:-}" ]; then
ADDRESS="${addresses[$INDEX]}"
hyprctl dispatch movetoworkspace "$CURRENT_WS,address:$ADDRESS"
hyprctl dispatch focuswindow "address:$ADDRESS"
fi

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Cycle between master and dwindle layouts
# Like XMonad's NextLayout
set -euo pipefail
CURRENT=$(hyprctl getoption general:layout -j | jq -r '.str')
if [ "$CURRENT" = "master" ]; then
hyprctl keyword general:layout dwindle
notify-send "Layout" "Switched to Dwindle (binary tree)"
else
hyprctl keyword general:layout master
notify-send "Layout" "Switched to Master (XMonad-like)"
fi

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
# Print an "empty" workspace id within 1..$HYPR_MAX_WORKSPACE (default 9).
#
# Preference order (lowest id wins within each tier):
# 1. Workspace exists on the target monitor and has 0 windows
# 2. Workspace id does not exist at all (will be created on dispatch)
# 3. Workspace exists (elsewhere) and has 0 windows
#
# Usage:
# find-empty-workspace.sh [monitor] [exclude_id]
max_ws="${HYPR_MAX_WORKSPACE:-9}"
monitor="${1:-}"
exclude_id="${2:-}"
if [[ -z "${monitor}" ]]; then
monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)"
fi
if [[ -z "${monitor}" || "${monitor}" == "null" ]]; then
exit 1
fi
workspaces_json="$(hyprctl workspaces -j 2>/dev/null || echo '[]')"
unused_candidate=""
elsewhere_empty_candidate=""
for i in $(seq 1 "${max_ws}"); do
if [[ -n "${exclude_id}" && "${i}" == "${exclude_id}" ]]; then
continue
fi
exists="$(jq -r --argjson id "${i}" '[.[] | select(.id == $id)] | length' <<<"${workspaces_json}")"
if [[ "${exists}" == "0" ]]; then
if [[ -z "${unused_candidate}" ]]; then
unused_candidate="${i}"
fi
continue
fi
windows="$(jq -r --argjson id "${i}" '([.[] | select(.id == $id) | .windows] | .[0]) // 0' <<<"${workspaces_json}")"
if [[ "${windows}" != "0" ]]; then
continue
fi
ws_monitor="$(jq -r --argjson id "${i}" '([.[] | select(.id == $id) | .monitor] | .[0]) // ""' <<<"${workspaces_json}")"
if [[ "${ws_monitor}" == "${monitor}" ]]; then
printf '%s\n' "${i}"
exit 0
fi
if [[ -z "${elsewhere_empty_candidate}" ]]; then
elsewhere_empty_candidate="${i}"
fi
done
if [[ -n "${unused_candidate}" ]]; then
printf '%s\n' "${unused_candidate}"
exit 0
fi
if [[ -n "${elsewhere_empty_candidate}" ]]; then
printf '%s\n' "${elsewhere_empty_candidate}"
exit 0
fi
exit 1

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Focus next window of a different class (like XMonad's focusNextClass)
set -euo pipefail
# Get focused window class
FOCUSED_CLASS=$(hyprctl activewindow -j | jq -r '.class')
FOCUSED_ADDR=$(hyprctl activewindow -j | jq -r '.address')
if [ "$FOCUSED_CLASS" = "null" ] || [ -z "$FOCUSED_CLASS" ]; then
# No focused window, just focus any window
hyprctl dispatch cyclenext
exit 0
fi
# Get all unique classes
ALL_CLASSES=$(hyprctl clients -j | jq -r '[.[] | select(.workspace.id >= 0) | .class] | unique | .[]')
# Get sorted list of classes
CLASSES_ARRAY=()
while IFS= read -r class; do
CLASSES_ARRAY+=("$class")
done <<< "$ALL_CLASSES"
# Find current class index and get next class
CURRENT_INDEX=-1
for i in "${!CLASSES_ARRAY[@]}"; do
if [ "${CLASSES_ARRAY[$i]}" = "$FOCUSED_CLASS" ]; then
CURRENT_INDEX=$i
break
fi
done
if [ $CURRENT_INDEX -eq -1 ] || [ ${#CLASSES_ARRAY[@]} -le 1 ]; then
# Only one class or class not found
exit 0
fi
# Get next class (wrapping around)
NEXT_INDEX=$(( (CURRENT_INDEX + 1) % ${#CLASSES_ARRAY[@]} ))
NEXT_CLASS="${CLASSES_ARRAY[$NEXT_INDEX]}"
# Find first window of next class
NEXT_WINDOW=$(hyprctl clients -j | jq -r ".[] | select(.class == \"$NEXT_CLASS\" and .workspace.id >= 0) | .address" | head -1)
if [ -n "$NEXT_WINDOW" ]; then
hyprctl dispatch focuswindow "address:$NEXT_WINDOW"
fi

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Gather all windows of the same class as focused window (like XMonad's gatherThisClass)
set -euo pipefail
# Get focused window class
FOCUSED_CLASS=$(hyprctl activewindow -j | jq -r '.class')
CURRENT_WS=$(hyprctl activeworkspace -j | jq -r '.id')
if [ "$FOCUSED_CLASS" = "null" ] || [ -z "$FOCUSED_CLASS" ]; then
notify-send "Gather Class" "No focused window"
exit 0
fi
# Find all windows with same class on other workspaces
WINDOWS=$(hyprctl clients -j | jq -r ".[] | select(.class == \"$FOCUSED_CLASS\" and .workspace.id != $CURRENT_WS and .workspace.id >= 0) | .address")
if [ -z "$WINDOWS" ]; then
notify-send "Gather Class" "No other windows of class '$FOCUSED_CLASS'"
exit 0
fi
# Move each window to current workspace
COUNT=0
for ADDR in $WINDOWS; do
hyprctl dispatch movetoworkspace "$CURRENT_WS,address:$ADDR"
COUNT=$((COUNT + 1))
done
notify-send "Gather Class" "Gathered $COUNT windows of class '$FOCUSED_CLASS'"

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Go to a window selected via rofi (with icons from desktop entries).
# Replaces "rofi -show window" which doesn't work well on Wayland.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/window-icon-map.sh"
# Get all windows on regular workspaces as TSV
WINDOW_DATA=$(hyprctl clients -j | jq -r '
.[] | select(.workspace.id >= 0)
| [.address, .class, (.title | gsub("\t"; " ")), (.workspace.id | tostring)]
| @tsv')
[ -n "$WINDOW_DATA" ] || exit 0
addresses=()
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT
while IFS=$'\t' read -r address class title ws_id; do
icon=$(icon_for_class "$class")
addresses+=("$address")
printf '%-24s %s WS:%s\0icon\x1f%s\n' \
"$class" "$title" "$ws_id" "$icon"
done <<< "$WINDOW_DATA" > "$TMPFILE"
INDEX=$(rofi -dmenu -i -show-icons -p "Go to window" -format i < "$TMPFILE") || exit 0
if [ -n "$INDEX" ] && [ -n "${addresses[$INDEX]:-}" ]; then
hyprctl dispatch focuswindow "address:${addresses[$INDEX]}"
fi

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Minimize the active window by moving it to a special workspace without
# toggling that special workspace open.
#
# Usage: minimize-active.sh <name>
# Example: minimize-active.sh minimized
set -euo pipefail
NAME="${1:-minimized}"
NAME="${NAME#special:}"
if ! command -v hyprctl >/dev/null 2>&1; then
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
# We could parse plain output, but jq should exist in this setup; if it
# doesn't, fail soft.
exit 0
fi
ACTIVE_JSON="$(hyprctl -j activewindow 2>/dev/null || true)"
ADDR="$(printf '%s' "$ACTIVE_JSON" | jq -r '.address // empty')"
if [ -z "$ADDR" ] || [ "$ADDR" = "null" ]; then
exit 0
fi
# If the minimized special workspace is currently visible, closing it after the
# move keeps the window hidden (what "minimize" usually means).
MONITOR_ID="$(printf '%s' "$ACTIVE_JSON" | jq -r '.monitor // empty')"
SPECIAL_OPEN="$(
hyprctl -j monitors 2>/dev/null \
| jq -r --arg n "special:$NAME" --argjson mid "${MONITOR_ID:-0}" '
.[]
| select(.id == $mid)
| (.specialWorkspace.name // "")
| select(. == $n)
' \
| head -n 1 \
|| true
)"
hyprctl dispatch movetoworkspacesilent "special:${NAME},address:${ADDR}" >/dev/null 2>&1 || true
if [ -n "$SPECIAL_OPEN" ]; then
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null 2>&1 || true
fi
exit 0

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Exit minimized picker mode:
# - Hide the minimized special workspace on the active monitor (if visible)
# - Reset the submap
#
# Usage: minimized-cancel.sh <name>
set -euo pipefail
NAME="${1:-minimized}"
NAME="${NAME#special:}"
SPECIAL_WS="special:${NAME}"
if ! command -v hyprctl >/dev/null 2>&1; then
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
exit 0
fi
MONITOR_ID="$(hyprctl -j activeworkspace 2>/dev/null | jq -r '.monitorID // empty' || true)"
if [ -z "$MONITOR_ID" ] || [ "$MONITOR_ID" = "null" ]; then
MONITOR_ID=0
fi
OPEN="$(
hyprctl -j monitors 2>/dev/null \
| jq -r --argjson mid "$MONITOR_ID" '.[] | select(.id == $mid) | (.specialWorkspace.name // "")' \
| head -n 1 \
|| true
)"
if [ "$OPEN" = "$SPECIAL_WS" ]; then
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null 2>&1 || true
fi
hyprctl dispatch submap reset >/dev/null 2>&1 || true
exit 0

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Enter a "picker" mode for minimized windows:
# - Ensure the minimized special workspace is visible on the active monitor
# - Switch Hyprland into a submap so Enter restores and Escape cancels
#
# Usage: minimized-mode.sh <name>
set -euo pipefail
NAME="${1:-minimized}"
NAME="${NAME#special:}"
SPECIAL_WS="special:${NAME}"
if ! command -v hyprctl >/dev/null 2>&1; then
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
exit 0
fi
MONITOR_ID="$(hyprctl -j activeworkspace 2>/dev/null | jq -r '.monitorID // empty' || true)"
if [ -z "$MONITOR_ID" ] || [ "$MONITOR_ID" = "null" ]; then
MONITOR_ID=0
fi
OPEN="$(
hyprctl -j monitors 2>/dev/null \
| jq -r --argjson mid "$MONITOR_ID" '.[] | select(.id == $mid) | (.specialWorkspace.name // "")' \
| head -n 1 \
|| true
)"
# Ensure it's visible (but don't toggle it off if already open).
if [ "$OPEN" != "$SPECIAL_WS" ]; then
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null 2>&1 || true
fi
hyprctl dispatch submap minimized >/dev/null 2>&1 || true
exit 0

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Move the active window in a direction and warp the cursor to keep its
# relative position inside the moved window.
set -euo pipefail
export PATH="/run/current-system/sw/bin:${PATH}"
if [[ $# -lt 1 ]]; then
echo "usage: $0 <dir> [mode]" >&2
exit 1
fi
dir="$1"
mode="${2:-}"
if ! command -v hyprctl >/dev/null; then
exit 0
fi
move_window() {
if [[ -n "$mode" ]]; then
hyprctl dispatch hy3:movewindow "$dir, $mode" >/dev/null 2>&1 || true
else
hyprctl dispatch hy3:movewindow "$dir" >/dev/null 2>&1 || true
fi
}
win_json="$(hyprctl -j activewindow 2>/dev/null || true)"
cur_json="$(hyprctl -j cursorpos 2>/dev/null || true)"
if [[ -z "$win_json" || "$win_json" == "null" || -z "$cur_json" || "$cur_json" == "null" ]]; then
move_window
exit 0
fi
win_x="$(jq -er '.at[0]' <<<"$win_json" 2>/dev/null || true)"
win_y="$(jq -er '.at[1]' <<<"$win_json" 2>/dev/null || true)"
win_w="$(jq -er '.size[0]' <<<"$win_json" 2>/dev/null || true)"
win_h="$(jq -er '.size[1]' <<<"$win_json" 2>/dev/null || true)"
cur_x="$(jq -er '.x' <<<"$cur_json" 2>/dev/null || true)"
cur_y="$(jq -er '.y' <<<"$cur_json" 2>/dev/null || true)"
if [[ ! "$win_x" =~ ^-?[0-9]+$ || ! "$win_y" =~ ^-?[0-9]+$ || ! "$win_w" =~ ^-?[0-9]+$ || ! "$win_h" =~ ^-?[0-9]+$ || ! "$cur_x" =~ ^-?[0-9]+$ || ! "$cur_y" =~ ^-?[0-9]+$ ]]; then
move_window
exit 0
fi
rel_x=$((cur_x - win_x))
rel_y=$((cur_y - win_y))
move_window
win_json="$(hyprctl -j activewindow 2>/dev/null || true)"
if [[ -z "$win_json" || "$win_json" == "null" ]]; then
exit 0
fi
win_x="$(jq -er '.at[0]' <<<"$win_json" 2>/dev/null || true)"
win_y="$(jq -er '.at[1]' <<<"$win_json" 2>/dev/null || true)"
win_w="$(jq -er '.size[0]' <<<"$win_json" 2>/dev/null || true)"
win_h="$(jq -er '.size[1]' <<<"$win_json" 2>/dev/null || true)"
if [[ ! "$win_x" =~ ^-?[0-9]+$ || ! "$win_y" =~ ^-?[0-9]+$ || ! "$win_w" =~ ^-?[0-9]+$ || ! "$win_h" =~ ^-?[0-9]+$ ]]; then
exit 0
fi
if ((rel_x < 0)); then
rel_x=0
elif ((rel_x > win_w)); then
rel_x=$win_w
fi
if ((rel_y < 0)); then
rel_y=0
elif ((rel_y > win_h)); then
rel_y=$win_h
fi
new_x=$((win_x + rel_x))
new_y=$((win_y + rel_y))
hyprctl dispatch movecursor "$new_x" "$new_y" >/dev/null 2>&1 || true

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Raise existing window or run command (like XMonad's raiseNextMaybe)
# Usage: raise-or-run.sh <class-pattern> <command>
set -euo pipefail
CLASS_PATTERN="$1"
COMMAND="$2"
# Find windows matching the class pattern
MATCHING=$(hyprctl clients -j | jq -r ".[] | select(.class | test(\"$CLASS_PATTERN\"; \"i\")) | .address" | head -1)
if [ -n "$MATCHING" ]; then
# Window exists, focus it
hyprctl dispatch focuswindow "address:$MATCHING"
else
# No matching window, run the command
exec $COMMAND
fi

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Replace focused window with selected window (like XMonad's myReplaceWindow)
# Swaps the positions of focused window and selected window
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/window-icon-map.sh"
FOCUSED=$(hyprctl activewindow -j | jq -r '.address')
if [ "$FOCUSED" = "null" ] || [ -z "$FOCUSED" ]; then
notify-send "Replace Window" "No focused window"
exit 0
fi
# Get all windows except focused as TSV
WINDOW_DATA=$(hyprctl clients -j | jq -r --arg focused "$FOCUSED" '
.[] | select(.workspace.id >= 0 and .address != $focused)
| [.address, .class, (.title | gsub("\t"; " ")), (.workspace.id | tostring)]
| @tsv')
if [ -z "$WINDOW_DATA" ]; then
notify-send "Replace Window" "No other windows available"
exit 0
fi
addresses=()
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT
while IFS=$'\t' read -r address class title ws_id; do
icon=$(icon_for_class "$class")
addresses+=("$address")
printf '%-24s %s WS:%s\0icon\x1f%s\n' \
"$class" "$title" "$ws_id" "$icon"
done <<< "$WINDOW_DATA" > "$TMPFILE"
INDEX=$(rofi -dmenu -i -show-icons -p "Replace with" -format i < "$TMPFILE") || exit 0
if [ -n "$INDEX" ] && [ -n "${addresses[$INDEX]:-}" ]; then
hyprctl dispatch hy3:movewindow "address:${addresses[$INDEX]}"
fi

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Shift window to empty workspace on screen in given direction
# Like XMonad's shiftToEmptyOnScreen
# Usage: shift-to-empty-on-screen.sh <direction: u|d|l|r>
set -euo pipefail
DIRECTION="$1"
max_ws="${HYPR_MAX_WORKSPACE:-9}"
# Track the current monitor so we can return
ORIG_MONITOR=$(hyprctl activeworkspace -j | jq -r '.monitor')
# Move focus to the screen in that direction
hyprctl dispatch focusmonitor "$DIRECTION"
# Get the monitor we're now on (target monitor)
MONITOR=$(hyprctl activeworkspace -j | jq -r '.monitor')
# If there is no monitor in that direction, bail
if [ "$MONITOR" = "$ORIG_MONITOR" ]; then
exit 0
fi
# Find an empty workspace within 1..$HYPR_MAX_WORKSPACE.
EMPTY_WS="$(~/.config/hypr/scripts/find-empty-workspace.sh "${MONITOR}" 2>/dev/null || true)"
if [[ -z "${EMPTY_WS}" ]]; then
# No empty workspace available within the cap; restore focus and bail.
hyprctl dispatch focusmonitor "$ORIG_MONITOR"
exit 0
fi
if (( EMPTY_WS < 1 || EMPTY_WS > max_ws )); then
hyprctl dispatch focusmonitor "$ORIG_MONITOR"
exit 0
fi
# Ensure the workspace exists on the target monitor
hyprctl dispatch workspace "$EMPTY_WS"
# Go back to original monitor and move the window (without following)
hyprctl dispatch focusmonitor "$ORIG_MONITOR"
hyprctl dispatch movetoworkspacesilent "$EMPTY_WS"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Swap the contents of the current workspace with another workspace.
# Intended to mirror XMonad's swapWithCurrent behavior.
set -euo pipefail
max_ws="${HYPR_MAX_WORKSPACE:-9}"
CURRENT_WS="$(hyprctl activeworkspace -j | jq -r '.id')"
if [[ -z "${CURRENT_WS}" || "${CURRENT_WS}" == "null" ]]; then
exit 0
fi
TARGET_WS="${1:-}"
if [[ -z "${TARGET_WS}" ]]; then
WS_LIST="$({
seq 1 "${max_ws}"
hyprctl workspaces -j | jq -r '.[].id' 2>/dev/null || true
} | awk 'NF {print $1}' | awk '!seen[$0]++' | sort -n)"
TARGET_WS="$(printf "%s\n" "${WS_LIST}" | rofi -dmenu -p "Swap with workspace")"
fi
if [[ -z "${TARGET_WS}" || "${TARGET_WS}" == "null" ]]; then
exit 0
fi
if [[ "${TARGET_WS}" == "${CURRENT_WS}" ]]; then
exit 0
fi
if ! [[ "${TARGET_WS}" =~ ^-?[0-9]+$ ]]; then
notify-send "Swap Workspace" "Invalid workspace: ${TARGET_WS}"
exit 1
fi
if (( TARGET_WS < 1 || TARGET_WS > max_ws )); then
notify-send "Swap Workspace" "Workspace out of range (1-${max_ws}): ${TARGET_WS}"
exit 1
fi
WINDOWS_CURRENT="$(hyprctl clients -j | jq -r --arg ws "${CURRENT_WS}" '.[] | select((.workspace.id|tostring) == $ws) | .address')"
WINDOWS_TARGET="$(hyprctl clients -j | jq -r --arg ws "${TARGET_WS}" '.[] | select((.workspace.id|tostring) == $ws) | .address')"
for ADDR in ${WINDOWS_CURRENT}; do
hyprctl dispatch movetoworkspace "${TARGET_WS},address:${ADDR}"
done
for ADDR in ${WINDOWS_TARGET}; do
hyprctl dispatch movetoworkspace "${CURRENT_WS},address:${ADDR}"
done

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Toggle a named Hyprland scratchpad, spawning it if needed.
# Usage: toggle-scratchpad.sh <name> <class_regex|-> <title_regex|-> <command...>
set -euo pipefail
if [ "$#" -lt 4 ]; then
echo "usage: $0 <name> <class_regex|-> <title_regex|-> <command...>" >&2
exit 1
fi
NAME="$1"
shift
CLASS_REGEX="$1"
shift
TITLE_REGEX="$1"
shift
COMMAND=("$@")
if [ "$CLASS_REGEX" = "-" ]; then
CLASS_REGEX=""
fi
if [ "$TITLE_REGEX" = "-" ]; then
TITLE_REGEX=""
fi
if [ -z "$CLASS_REGEX" ] && [ -z "$TITLE_REGEX" ]; then
echo "toggle-scratchpad: provide a class or title regex" >&2
exit 1
fi
MATCHING=$(hyprctl clients -j | jq -r --arg cre "$CLASS_REGEX" --arg tre "$TITLE_REGEX" '
.[]
| select(
(($cre == "") or (.class | test($cre; "i")))
and
(($tre == "") or (.title | test($tre; "i")))
)
| .address
')
if [ -z "$MATCHING" ]; then
"${COMMAND[@]}" &
else
while IFS= read -r ADDR; do
[ -n "$ADDR" ] || continue
hyprctl dispatch movetoworkspacesilent "special:$NAME,address:$ADDR"
done <<< "$MATCHING"
fi
hyprctl dispatch togglespecialworkspace "$NAME"

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Restore a minimized window by moving it out of a special workspace.
#
# Usage: unminimize-last.sh <name>
# Example: unminimize-last.sh minimized
set -euo pipefail
NAME="${1:-minimized}"
NAME="${NAME#special:}"
SPECIAL_WS="special:${NAME}"
if ! command -v hyprctl >/dev/null 2>&1; then
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
exit 0
fi
ACTIVE_JSON="$(hyprctl -j activewindow 2>/dev/null || true)"
ACTIVE_ADDR="$(printf '%s' "$ACTIVE_JSON" | jq -r '.address // empty')"
ACTIVE_WS="$(printf '%s' "$ACTIVE_JSON" | jq -r '.workspace.name // empty')"
MONITOR_ID="$(printf '%s' "$ACTIVE_JSON" | jq -r '.monitor // empty')"
# Destination is the normal active workspace for the active monitor.
DEST_WS="$(
hyprctl -j monitors 2>/dev/null \
| jq -r --argjson mid "${MONITOR_ID:-0}" '.[] | select(.id == $mid) | .activeWorkspace.name' \
| head -n 1 \
|| true
)"
if [ -z "$DEST_WS" ] || [ "$DEST_WS" = "null" ]; then
DEST_WS="$(hyprctl -j activeworkspace 2>/dev/null | jq -r '.name // empty' || true)"
fi
if [ -z "$DEST_WS" ] || [ "$DEST_WS" = "null" ]; then
exit 0
fi
# If we're focused on a minimized window already, restore that one.
ADDR=""
if [ "$ACTIVE_WS" = "$SPECIAL_WS" ] && [ -n "$ACTIVE_ADDR" ] && [ "$ACTIVE_ADDR" != "null" ]; then
ADDR="$ACTIVE_ADDR"
else
# Otherwise, restore the "most recent" minimized window we can find.
# focusHistoryID tends to have 0 as most recent; pick the smallest value.
ADDR="$(
hyprctl -j clients 2>/dev/null \
| jq -r --arg sw "$SPECIAL_WS" '
[ .[]
| select(.workspace.name == $sw)
| { addr: .address, fh: (.focusHistoryID // 999999999) }
]
| sort_by(.fh)
| (.[0].addr // empty)
' \
| head -n 1 \
|| true
)"
fi
if [ -z "$ADDR" ] || [ "$ADDR" = "null" ]; then
exit 0
fi
hyprctl dispatch movetoworkspacesilent "${DEST_WS},address:${ADDR}" >/dev/null 2>&1 || true
hyprctl dispatch focuswindow "address:${ADDR}" >/dev/null 2>&1 || true
# If the minimized special workspace is currently visible, close it so we don't
# leave things in a special state after a restore.
SPECIAL_OPEN="$(
hyprctl -j monitors 2>/dev/null \
| jq -r --arg n "$SPECIAL_WS" --argjson mid "${MONITOR_ID:-0}" '
.[]
| select(.id == $mid)
| (.specialWorkspace.name // "")
| select(. == $n)
' \
| head -n 1 \
|| true
)"
if [ -n "$SPECIAL_OPEN" ]; then
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null 2>&1 || true
fi
exit 0

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Source this file to get icon_for_class function.
# Builds a mapping from window class → freedesktop icon name
# by scanning .desktop files for StartupWMClass and Icon fields.
#
# Usage:
# source "$(dirname "$0")/window-icon-map.sh"
# icon=$(icon_for_class "google-chrome")
declare -A _WINDOW_ICON_MAP
_build_window_icon_map() {
local IFS=':'
local -a search_dirs=()
local dir
for dir in ${XDG_DATA_DIRS:-/run/current-system/sw/share:/usr/share:/usr/local/share}; do
[ -d "$dir/applications" ] && search_dirs+=("$dir/applications")
done
[ -d "$HOME/.local/share/applications" ] && search_dirs+=("$HOME/.local/share/applications")
[ ${#search_dirs[@]} -eq 0 ] && return
# Expand globs per-directory so the pattern works correctly
local -a desktop_files=()
for dir in "${search_dirs[@]}"; do
desktop_files+=("$dir"/*.desktop)
done
[ ${#desktop_files[@]} -eq 0 ] && return
# Single grep pass across all desktop files
local -A file_icons file_wmclass
local filepath line
while IFS=: read -r filepath line; do
case "$line" in
Icon=*)
[ -z "${file_icons[$filepath]:-}" ] && file_icons["$filepath"]="${line#Icon=}"
;;
StartupWMClass=*)
[ -z "${file_wmclass[$filepath]:-}" ] && file_wmclass["$filepath"]="${line#StartupWMClass=}"
;;
esac
done < <(grep -H '^Icon=\|^StartupWMClass=' "${desktop_files[@]}" 2>/dev/null)
# Build class → icon map
local icon wm_class bn name
for filepath in "${!file_icons[@]}"; do
icon="${file_icons[$filepath]}"
[ -n "$icon" ] || continue
wm_class="${file_wmclass[$filepath]:-}"
if [ -n "$wm_class" ]; then
_WINDOW_ICON_MAP["${wm_class,,}"]="$icon"
fi
bn="${filepath##*/}"
name="${bn%.desktop}"
_WINDOW_ICON_MAP["${name,,}"]="$icon"
done
}
_build_window_icon_map
icon_for_class() {
local class_lower="${1,,}"
echo "${_WINDOW_ICON_MAP[$class_lower]:-$class_lower}"
}

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cur_ws="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)"
monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)"
ws="$(
~/.config/hypr/scripts/find-empty-workspace.sh "${monitor}" "${cur_ws}" 2>/dev/null || true
)"
if [[ -z "${ws}" ]]; then
exit 0
fi
hyprctl dispatch workspace "${ws}" >/dev/null 2>&1 || true

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cur_ws="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)"
monitor="$(hyprctl activeworkspace -j | jq -r '.monitor' 2>/dev/null || true)"
ws="$(
~/.config/hypr/scripts/find-empty-workspace.sh "${monitor}" "${cur_ws}" 2>/dev/null || true
)"
if [[ -z "${ws}" ]]; then
exit 0
fi
hyprctl dispatch movetoworkspace "${ws}" >/dev/null 2>&1 || true

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
max_ws="${HYPR_MAX_WORKSPACE:-9}"
delta="${1:-}"
case "${delta}" in
+1|-1) ;;
next) delta="+1" ;;
prev) delta="-1" ;;
*)
exit 2
;;
esac
cur="$(hyprctl activeworkspace -j | jq -r '.id' 2>/dev/null || true)"
if ! [[ "${cur}" =~ ^[0-9]+$ ]]; then
exit 0
fi
if (( cur < 1 )); then
cur=1
elif (( cur > max_ws )); then
cur="${max_ws}"
fi
if [[ "${delta}" == "+1" ]]; then
if (( cur >= max_ws )); then
nxt=1
else
nxt=$((cur + 1))
fi
else
if (( cur <= 1 )); then
nxt="${max_ws}"
else
nxt=$((cur - 1))
fi
fi
hyprctl dispatch workspace "${nxt}" >/dev/null 2>&1 || true

View File

@@ -1,23 +0,0 @@
cmake_minimum_required(VERSION 3.27)
project(hypr-workspace-history
DESCRIPTION "Workspace history cycling plugin for Hyprland"
VERSION 0.1.0
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(hypr-workspace-history SHARED src/main.cpp)
find_package(PkgConfig REQUIRED)
pkg_check_modules(deps REQUIRED IMPORTED_TARGET
hyprland
wayland-server
xkbcommon
)
target_link_libraries(hypr-workspace-history PRIVATE rt PkgConfig::deps)
install(TARGETS hypr-workspace-history)

View File

@@ -1,406 +0,0 @@
#define WLR_USE_UNSTABLE
#include <hyprland/src/Compositor.hpp>
#include <hyprland/src/debug/log/Logger.hpp>
#include <hyprland/src/desktop/Workspace.hpp>
#include <hyprland/src/desktop/state/FocusState.hpp>
#include <hyprland/src/devices/IKeyboard.hpp>
#include <hyprland/src/event/EventBus.hpp>
#include <hyprland/src/helpers/Monitor.hpp>
#include <hyprland/src/managers/KeybindManager.hpp>
#include <hyprland/src/plugins/PluginAPI.hpp>
#include <lua.hpp>
#include <algorithm>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <vector>
inline HANDLE PHANDLE = nullptr;
namespace {
constexpr int MAX_WORKSPACE = 9;
struct SCycleState {
std::string monitorKey;
int originalWorkspace = 0;
int previewWorkspace = 0;
std::vector<int> history;
size_t nextIndex = 0;
};
class CWorkspaceHistory {
public:
void seedActiveWorkspaces() {
if (!g_pCompositor)
return;
for (const auto& monitor : g_pCompositor->m_monitors) {
if (monitor && monitor->m_activeWorkspace)
remember(monitor->m_activeWorkspace);
}
writeDebug("seed");
}
void observe(PHLWORKSPACE workspace) {
const auto workspaceId = workspaceID(workspace);
if (!workspaceId) {
writeDebug("observe-skipped-non-normal-workspace");
return;
}
const auto key = monitorKey(workspace);
if (!m_cycle || m_cycle->monitorKey != key) {
remember(workspace);
return;
}
if (contains(m_cycle->history, *workspaceId)) {
m_cycle->previewWorkspace = *workspaceId;
writeDebug("cycle-observe-preview");
return;
}
m_cycle.reset();
remember(workspace);
writeDebug("remember-after-cycle-abandoned");
}
SDispatchResult cycle(int direction) {
auto* cycle = startCycle();
if (!cycle)
return {};
if (cycle->history.size() < 2) {
writeDebug("cycle-skipped-short-history");
return {};
}
const auto target = cycle->history[cycle->nextIndex];
cycle->previewWorkspace = target;
const auto result = focusWorkspace(target);
if (!result.success) {
writeDebug("cycle-focus-failed");
return result;
}
cycle->nextIndex = wrappedIndex(cycle->nextIndex, direction, cycle->history.size());
writeDebug(std::string("cycle-preview-") + std::to_string(target));
return {};
}
SDispatchResult commit() {
if (!m_cycle) {
writeDebug("commit-skipped-no-cycle");
return {};
}
auto cycle = *m_cycle;
m_cycle.reset();
m_histories[cycle.monitorKey] = promote(cycle.history, cycle.previewWorkspace);
writeDebug("commit");
return {};
}
SDispatchResult cancel() {
if (!m_cycle) {
writeDebug("cancel-skipped-no-cycle");
return {};
}
const auto original = m_cycle->originalWorkspace;
m_cycle.reset();
focusWorkspace(original);
writeDebug("cancel");
return {};
}
void onKey(IKeyboard::SKeyEvent event) {
if (!m_cycle || event.state != WL_KEYBOARD_KEY_STATE_RELEASED || !g_pKeybindManager)
return;
if (g_pKeybindManager->keycodeToModifier(event.keycode + 8) == HL_MODIFIER_META)
commit();
}
std::string snapshot(const std::string& reason) const {
std::stringstream out;
out << "reason=" << reason << "\n";
const auto monitor = Desktop::focusState() ? Desktop::focusState()->monitor() : nullptr;
const auto workspace = monitor ? monitor->m_activeWorkspace : nullptr;
out << "active_monitor=" << monitorKey(monitor) << "\n";
out << "active_workspace=" << (workspace ? std::to_string(workspace->m_id) : "?") << "\n";
for (const auto& [key, history] : m_histories) {
out << "history." << key << "=";
for (size_t i = 0; i < history.size(); ++i) {
if (i > 0)
out << ",";
out << history[i];
}
out << "\n";
}
if (m_cycle) {
out << "cycle.monitor=" << m_cycle->monitorKey << "\n";
out << "cycle.original=" << m_cycle->originalWorkspace << "\n";
out << "cycle.preview=" << m_cycle->previewWorkspace << "\n";
out << "cycle.next_index=" << (m_cycle->nextIndex + 1) << "\n";
out << "cycle.history=";
for (size_t i = 0; i < m_cycle->history.size(); ++i) {
if (i > 0)
out << ",";
out << m_cycle->history[i];
}
out << "\n";
} else {
out << "cycle=none\n";
}
return out.str();
}
void showDebug() const {
HyprlandAPI::addNotification(PHANDLE, snapshot("notification"), CHyprColor{0.4, 0.8, 1.0, 1.0}, 6000);
}
private:
std::map<std::string, std::vector<int>> m_histories;
std::optional<SCycleState> m_cycle;
static std::optional<int> workspaceID(PHLWORKSPACE workspace) {
if (!workspace || workspace->m_id < 1 || workspace->m_id > MAX_WORKSPACE)
return std::nullopt;
return workspace->m_id;
}
static std::string monitorKey(PHLMONITOR monitor) {
if (!monitor)
return "unknown";
if (!monitor->m_name.empty())
return monitor->m_name;
return std::to_string(monitor->m_id);
}
static std::string monitorKey(PHLWORKSPACE workspace) {
if (!workspace || !workspace->m_monitor)
return monitorKey(Desktop::focusState() ? Desktop::focusState()->monitor() : nullptr);
return monitorKey(workspace->m_monitor.lock());
}
static bool contains(const std::vector<int>& history, int workspace) {
return std::ranges::contains(history, workspace);
}
static std::vector<int> promote(std::vector<int> history, int workspace) {
std::erase(history, workspace);
history.insert(history.begin(), workspace);
return history;
}
static size_t wrappedIndex(size_t current, int direction, size_t size) {
const auto signedSize = static_cast<int>(size);
auto next = static_cast<int>(current) + direction;
next = ((next % signedSize) + signedSize) % signedSize;
return static_cast<size_t>(next);
}
PHLWORKSPACE activeWorkspace() const {
const auto monitor = Desktop::focusState() ? Desktop::focusState()->monitor() : nullptr;
return monitor ? monitor->m_activeWorkspace : nullptr;
}
void remember(PHLWORKSPACE workspace) {
const auto workspaceId = workspaceID(workspace);
if (!workspaceId) {
writeDebug("remember-skipped-non-normal-workspace");
return;
}
const auto key = monitorKey(workspace);
auto& history = m_histories[key];
const bool changed = history.empty() || history.front() != *workspaceId;
history = promote(history, *workspaceId);
if (changed)
writeDebug("remember");
}
SCycleState* startCycle() {
if (m_cycle)
return &*m_cycle;
const auto workspace = activeWorkspace();
remember(workspace);
const auto workspaceId = workspaceID(workspace);
if (!workspaceId) {
writeDebug("cycle-start-skipped-non-normal-workspace");
return nullptr;
}
const auto key = monitorKey(workspace);
auto history = promote(m_histories[key], *workspaceId);
if (history.size() < 2) {
writeDebug("cycle-start-skipped-short-history");
return nullptr;
}
m_cycle = SCycleState{
.monitorKey = key,
.originalWorkspace = *workspaceId,
.previewWorkspace = *workspaceId,
.history = history,
.nextIndex = 1,
};
writeDebug("cycle-start");
return &*m_cycle;
}
static SDispatchResult focusWorkspace(int workspace) {
if (!g_pKeybindManager)
return {.success = false, .error = "keybind manager is unavailable"};
const auto dispatcher = g_pKeybindManager->m_dispatchers.find("focusworkspaceoncurrentmonitor");
if (dispatcher == g_pKeybindManager->m_dispatchers.end())
return {.success = false, .error = "focusworkspaceoncurrentmonitor dispatcher is unavailable"};
return dispatcher->second(std::to_string(workspace));
}
static std::optional<std::string> runtimePath(const std::string& name) {
const auto runtimeDir = std::getenv("XDG_RUNTIME_DIR");
if (!runtimeDir)
return std::nullopt;
return std::string(runtimeDir) + "/" + name;
}
void writeDebug(const std::string& reason) const {
const auto statePath = runtimePath("hyprland-workspace-history-state");
const auto logPath = runtimePath("hyprland-workspace-history.log");
if (!statePath || !logPath)
return;
const auto body = snapshot(reason);
std::ofstream state(*statePath, std::ios::trunc);
if (state)
state << body;
std::ofstream log(*logPath, std::ios::app);
if (log) {
const auto now = std::time(nullptr);
log << "--- " << std::put_time(std::localtime(&now), "%Y-%m-%d %H:%M:%S") << " ---\n";
log << body;
}
}
};
CWorkspaceHistory g_workspaceHistory;
SDispatchResult dispatchCycle(std::string arg) {
int direction = 1;
if (arg == "-1" || arg == "previous" || arg == "prev" || arg == "reverse")
direction = -1;
return g_workspaceHistory.cycle(direction);
}
SDispatchResult dispatchCommit(std::string) {
return g_workspaceHistory.commit();
}
SDispatchResult dispatchCancel(std::string) {
return g_workspaceHistory.cancel();
}
SDispatchResult dispatchDebug(std::string) {
g_workspaceHistory.showDebug();
return {};
}
int luaCycle(lua_State* L) {
const auto result = g_workspaceHistory.cycle(static_cast<int>(luaL_optinteger(L, 1, 1)));
if (!result.success)
return luaL_error(L, "%s", result.error.c_str());
return 0;
}
int luaCommit(lua_State* L) {
const auto result = g_workspaceHistory.commit();
if (!result.success)
return luaL_error(L, "%s", result.error.c_str());
return 0;
}
int luaCancel(lua_State* L) {
const auto result = g_workspaceHistory.cancel();
if (!result.success)
return luaL_error(L, "%s", result.error.c_str());
return 0;
}
int luaDebug(lua_State*) {
g_workspaceHistory.showDebug();
return 0;
}
void failNotification(const std::string& reason) {
HyprlandAPI::addNotification(PHANDLE, "[workspace-history] " + reason, CHyprColor{1.0, 0.2, 0.2, 1.0}, 5000);
}
}
APICALL EXPORT std::string PLUGIN_API_VERSION() {
return HYPRLAND_API_VERSION;
}
APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
PHANDLE = handle;
const std::string hash = __hyprland_api_get_hash();
const std::string clientHash = __hyprland_api_get_client_hash();
if (hash != clientHash) {
failNotification("version mismatch between Hyprland headers and running Hyprland");
throw std::runtime_error("[workspace-history] version mismatch");
}
static auto workspaceHook = Event::bus()->m_events.workspace.active.listen([](PHLWORKSPACE workspace) { g_workspaceHistory.observe(workspace); });
static auto keyboardHook = Event::bus()->m_events.input.keyboard.key.listen([](IKeyboard::SKeyEvent event, Event::SCallbackInfo&) { g_workspaceHistory.onKey(event); });
static auto startHook = Event::bus()->m_events.start.listen([] { g_workspaceHistory.seedActiveWorkspaces(); });
static auto reloadHook = Event::bus()->m_events.config.reloaded.listen([] { g_workspaceHistory.seedActiveWorkspaces(); });
static auto monitorHook = Event::bus()->m_events.monitor.focused.listen([](PHLMONITOR monitor) {
if (monitor && monitor->m_activeWorkspace)
g_workspaceHistory.observe(monitor->m_activeWorkspace);
});
HyprlandAPI::addDispatcherV2(PHANDLE, "workspacehistory:cycle", ::dispatchCycle);
HyprlandAPI::addDispatcherV2(PHANDLE, "workspacehistory:commit", ::dispatchCommit);
HyprlandAPI::addDispatcherV2(PHANDLE, "workspacehistory:cancel", ::dispatchCancel);
HyprlandAPI::addDispatcherV2(PHANDLE, "workspacehistory:debug", ::dispatchDebug);
HyprlandAPI::addLuaFunction(PHANDLE, "workspacehistory", "cycle", ::luaCycle);
HyprlandAPI::addLuaFunction(PHANDLE, "workspacehistory", "commit", ::luaCommit);
HyprlandAPI::addLuaFunction(PHANDLE, "workspacehistory", "cancel", ::luaCancel);
HyprlandAPI::addLuaFunction(PHANDLE, "workspacehistory", "debug", ::luaDebug);
g_workspaceHistory.seedActiveWorkspaces();
HyprlandAPI::addNotification(PHANDLE, "[workspace-history] Initialized", CHyprColor{0.2, 1.0, 0.2, 1.0}, 3000);
return {"hypr-workspace-history", "Workspace history cycling with modifier-release commits", "Ivan Malison", "0.1.0"};
}
APICALL EXPORT void PLUGIN_EXIT() {
g_workspaceHistory.commit();
}

View File

@@ -136,11 +136,11 @@
"xmonad-contrib": "xmonad-contrib"
},
"locked": {
"lastModified": 1777525523,
"narHash": "sha256-/LGaCcX6BgXRYpWnRp9CNgAgy7lmbQsubi4RwHJgnTI=",
"ref": "refs/heads/master",
"rev": "23dbc827adca706b28df7404624ae3f5e800b04f",
"revCount": 2296,
"lastModified": 1777319252,
"narHash": "sha256-mPft6i8ReJAvW2LdylFI6FF6NFGa1HMa3RNbisfAsbc=",
"ref": "refs/heads/codex/fix-gdk-backend-strut-detection",
"rev": "c2cee23fc57384cd322d589944129e6c31d4f0fd",
"revCount": 2288,
"type": "git",
"url": "file:///home/imalison/dotfiles/dotfiles/config/taffybar/taffybar"
},

View File

@@ -121,6 +121,13 @@
(oa: {
doHaddock = false;
doCheck = false;
# Legacy fix for older GHC (harmless on newer)
postPatch = (oa.postPatch or "") + ''
substituteInPlace src/System/Taffybar/DBus/Client/Util.hs \
--replace-fail "import Control.Monad (forM)" \
"import Control.Monad (forM)
import Control.Applicative (liftA2)"
'';
# Needed for gi-gtk-layer-shell (introspection data).
librarySystemDepends = (oa.librarySystemDepends or []) ++ [ pkgs.gtk-layer-shell ];
});

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 174 148.18">
<path
fill="#d97757"
d="m 105.01,322.07 29.14,-16.35 0.49,-1.42 -0.49,-0.79 h -1.42 l -4.87,-0.3 -16.65,-0.45 -14.44,-0.6 -13.99,-0.75 -3.52,-0.75 -3.3,-4.35 0.34,-2.17 2.96,-1.99 4.24,0.37 9.37,0.64 14.06,0.97 10.2,0.6 15.11,1.57 h 2.4 l 0.34,-0.97 -0.82,-0.6 -0.64,-0.6 -14.55,-9.86 -15.75,-10.42 -8.25,-6 -4.46,-3.04 -2.25,-2.85 -0.97,-6.22 4.05,-4.46 5.44,0.37 1.39,0.37 5.51,4.24 11.77,9.11 15.37,11.32 2.25,1.87 0.9,-0.64 0.11,-0.45 -1.01,-1.69 -8.36,-15.11 -8.92,-15.37 -3.97,-6.37 -1.05,-3.82 c -0.37,-1.57 -0.64,-2.89 -0.64,-4.5 l 4.61,-6.26 2.55,-0.82 6.15,0.82 2.59,2.25 3.82,8.74 6.19,13.76 9.6,18.71 2.81,5.55 1.5,5.14 0.56,1.57 h 0.97 v -0.9 l 0.79,-10.54 1.46,-12.94 1.42,-16.65 0.49,-4.69 2.32,-5.62 4.61,-3.04 3.6,1.72 2.96,4.24 -0.41,2.74 -1.76,11.44 -3.45,17.92 -2.25,12 h 1.31 l 1.5,-1.5 6.07,-8.06 10.2,-12.75 4.5,-5.06 5.25,-5.59 3.37,-2.66 h 6.37 l 4.69,6.97 -2.1,7.2 -6.56,8.32 -5.44,7.05 -7.8,10.5 -4.87,8.4 0.45,0.67 1.16,-0.11 17.62,-3.75 9.52,-1.72 11.36,-1.95 5.14,2.4 0.56,2.44 -2.02,4.99 -12.15,3 -14.25,2.85 -21.22,5.02 -0.26,0.19 0.3,0.37 9.56,0.9 4.09,0.22 h 10.01 l 18.64,1.39 4.87,3.22 2.92,3.94 -0.49,3 -7.5,3.82 -10.12,-2.4 -23.62,-5.62 -8.1,-2.02 h -1.12 v 0.67 l 6.75,6.6 12.37,11.17 15.49,14.4 0.79,3.56 -1.99,2.81 -2.1,-0.3 -13.61,-10.24 -5.25,-4.61 -11.89,-10.01 h -0.79 v 1.05 l 2.74,4.01 14.47,21.75 0.75,6.67 -1.05,2.17 -3.75,1.31 -4.12,-0.75 -8.47,-11.89 -8.74,-13.39 -7.05,-12 -0.86,0.49 -4.16,44.81 -1.95,2.29 -4.5,1.72 -3.75,-2.85 -1.99,-4.61 1.99,-9.11 2.4,-11.89 1.95,-9.45 1.76,-11.74 1.05,-3.9 -0.07,-0.26 -0.86,0.11 -8.85,12.15 -13.46,18.19 -10.65,11.4 -2.55,1.01 -4.42,-2.29 0.41,-4.09 2.47,-3.64 14.74,-18.75 8.89,-11.62 5.74,-6.71 -0.04,-0.97 h -0.34 l -39.15,25.42 -6.97,0.9 -3,-2.81 0.37,-4.61 1.42,-1.5 11.77,-8.1 -0.04,0.04 z"
transform="translate(-75.96,-223.53)"
shape-rendering="optimizeQuality" />
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 158.7128 157.296">
<!-- Generator: Adobe Illustrator 29.2.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 116) -->
<path fill="#e7e4ee" d="M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -40,16 +40,6 @@
-GtkLabel-justify: left;
}
/* Compact logo column for stacked AI usage sections. */
.usage-section.icon-label > .icon {
min-width: 22px;
padding-right: 8px;
}
.usage-section.icon-label > .label {
padding-right: 0px;
}
/* Compact two-line RAM/SWAP widget: reduce icon padding a bit. */
.ram-swap .icon-label > .icon {
/* Different glyphs have different visual widths; fix the icon column width

View File

@@ -1,5 +1,4 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
@@ -47,21 +46,11 @@ import System.Taffybar.SimpleConfig
import System.Taffybar.Util (getPixbufFromFilePath, maybeTCombine, postGUIASync, (<|||>))
import System.Taffybar.Widget
import qualified System.Taffybar.Widget.ASUS as ASUS
import System.Taffybar.Widget.AnthropicUsage
( AnthropicUsageDisplayMode (AnthropicUsageDisplayRemaining),
AnthropicUsageStackConfig (..),
anthropicUsageStackNewWith,
defaultAnthropicUsageStackConfig,
)
import System.Taffybar.Widget.AnthropicUsage (anthropicUsageStackNew)
import System.Taffybar.Widget.CPUMonitor (cpuMonitorNew)
import System.Taffybar.Widget.Generic.Graph (GraphConfig (..), GraphDirection (..), GraphStyle (..), defaultGraphConfig)
import qualified System.Taffybar.Widget.NetworkManager as NetworkManager
import System.Taffybar.Widget.OpenAIUsage
( OpenAIUsageDisplayMode (OpenAIUsageDisplayRemaining),
OpenAIUsageStackConfig (..),
defaultOpenAIUsageStackConfig,
openAIUsageStackNewWith,
)
import System.Taffybar.Widget.OpenAIUsage (openAIUsageStackNew)
import qualified System.Taffybar.Widget.PulseAudio as PulseAudio
import System.Taffybar.Widget.SNIMenu (withNmAppletMenu)
import System.Taffybar.Widget.SNITray
@@ -76,7 +65,7 @@ import System.Taffybar.Widget.SNITray.PrioritizedCollapsible
sniTrayPrioritizedCollapsibleNewFromParams,
)
import qualified System.Taffybar.Widget.ScreenLock as ScreenLock
import System.Taffybar.Widget.Util (backgroundLoop, buildContentsBox, buildIconLabelBox, loadPixbufByName, pixbufNewFromFileAtScaleByHeight, widgetSetClassGI)
import System.Taffybar.Widget.Util (backgroundLoop, buildContentsBox, buildIconLabelBox, loadPixbufByName, widgetSetClassGI)
import qualified System.Taffybar.Widget.Wlsunset as Wlsunset
import qualified System.Taffybar.Widget.Workspaces as Workspaces
import System.Taffybar.WindowIcon (pixBufFromColor)
@@ -518,10 +507,10 @@ simplifiedScreensaverWidget =
then return False
else case button of
1 -> do
void $ spawnCommand "/home/imalison/dotfiles/dotfiles/lib/bin/hypr-screensaver toggle >/dev/null 2>&1"
void $ spawnCommand "hypr-screensaver toggle >/dev/null 2>&1"
return True
3 -> do
void $ spawnCommand "/home/imalison/dotfiles/dotfiles/lib/bin/hypr-screensaver stop >/dev/null 2>&1"
void $ spawnCommand "hypr-screensaver stop >/dev/null 2>&1"
return True
_ -> return False
Gtk.widgetShowAll ebox
@@ -562,40 +551,13 @@ wakeupDebugWidget :: TaffyIO Gtk.Widget
wakeupDebugWidget =
decorateWithClassAndBoxM "wakeup-debug" wakeupDebugWidgetNew
usageLogoWidget :: FilePath -> Text -> IO Gtk.Widget
usageLogoWidget iconFile tooltip = do
iconPath <- getUserConfigFile "taffybar" ("icons/" <> iconFile)
iconWidget <-
pixbufNewFromFileAtScaleByHeight 18 iconPath >>= \case
Right pixbuf -> Gtk.toWidget =<< Gtk.imageNewFromPixbuf (Just pixbuf)
Left _ -> Gtk.toWidget =<< Gtk.labelNew (Just "?")
Gtk.widgetSetTooltipText iconWidget (Just tooltip)
widgetSetClassGI iconWidget "usage-logo"
usageSectionWidget :: Text -> FilePath -> Text -> TaffyIO Gtk.Widget -> TaffyIO Gtk.Widget
usageSectionWidget klass iconFile tooltip stackBuilder =
decorateWithClassAndBoxM klass $ do
stack <- stackBuilder
liftIO $ do
iconWidget <- usageLogoWidget iconFile tooltip
section <- buildIconLabelBox iconWidget stack
widgetSetClassGI section "usage-section"
openAIUsageWidget :: TaffyIO Gtk.Widget
openAIUsageWidget =
usageSectionWidget "openai-usage" "openai-symbol.svg" "OpenAI usage" $
openAIUsageStackNewWith
defaultOpenAIUsageStackConfig
{ openAIUsageStackDefaultDisplayMode = OpenAIUsageDisplayRemaining
}
decorateWithClassAndBoxM "openai-usage" openAIUsageStackNew
anthropicUsageWidget :: TaffyIO Gtk.Widget
anthropicUsageWidget =
usageSectionWidget "anthropic-usage" "claude-symbol.svg" "Anthropic usage" $
anthropicUsageStackNewWith
defaultAnthropicUsageStackConfig
{ anthropicUsageStackDefaultDisplayMode = AnthropicUsageDisplayRemaining
}
decorateWithClassAndBoxM "anthropic-usage" anthropicUsageStackNew
sniPriorityVisibilityThresholdDefault :: Int
sniPriorityVisibilityThresholdDefault = 0

View File

@@ -232,19 +232,27 @@ getWorkspaceDmenu = myDmenu (workspaces myConfig)
-- Selectors
isGmailTitle t = isInfixOf "@gmail.com" t && isInfixOf "Gmail" t
isMessagesTitle = isPrefixOf "Messages"
isChromeClass = isInfixOf "chrome"
noSpecialChromeTitles = helper <$> title
where helper t = not $ any ($ t) [isGmailTitle, isMessagesTitle]
chromeSelectorBase = isChromeClass <$> className
chromeSelector = chromeSelectorBase
chromeSelector = chromeSelectorBase <&&> noSpecialChromeTitles
elementSelector = className =? "Element"
emacsSelector = className =? "Emacs"
gmailSelector = chromeSelectorBase <&&> fmap isGmailTitle title
messagesSelector = chromeSelectorBase <&&> isMessagesTitle <$> title
slackSelector = className =? "Slack"
spotifySelector = className =? "Spotify"
transmissionSelector = fmap (isPrefixOf "Transmission") title
volumeSelector = className =? "Pavucontrol"
virtualClasses =
[ (chromeSelector, "Chrome")
[ (gmailSelector, "Gmail")
, (messagesSelector, "Messages")
, (chromeSelector, "Chrome")
, (transmissionSelector, "Transmission")
]
@@ -253,7 +261,11 @@ virtualClasses =
chromeCommand = "google-chrome-stable"
elementCommand = "element-desktop"
emacsCommand = "emacsclient -c"
gmailCommand =
"google-chrome-stable --new-window https://mail.google.com/mail/u/0/#inbox"
htopCommand = "ghostty --title=htop -e htop"
messagesCommand =
"google-chrome-stable --new-window https://messages.google.com/web/conversations"
slackCommand = "slack"
spotifyCommand = "spotify"
transmissionCommand = "transmission-gtk"
@@ -801,7 +813,9 @@ nearFullFloat = customFloating $ W.RationalRect l t w h
scratchpads =
[ NS "element" elementCommand elementSelector nearFullFloat
, NS "gmail" gmailCommand gmailSelector nearFullFloat
, NS "htop" htopCommand (title =? "htop") nearFullFloat
, NS "messages" messagesCommand messagesSelector nearFullFloat
, NS "slack" slackCommand slackSelector nearFullFloat
, NS "spotify" spotifyCommand spotifySelector nearFullFloat
, NS "transmission" transmissionCommand transmissionSelector nearFullFloat
@@ -1011,7 +1025,9 @@ addKeys conf@XConfig { modMask = modm } =
-- ScratchPads
[ ((modalt, xK_e), doScratchpad "element")
, ((modalt, xK_g), doScratchpad "gmail")
, ((modalt, xK_h), doScratchpad "htop")
, ((modalt, xK_m), doScratchpad "messages")
, ((modalt, xK_k), doScratchpad "slack")
, ((modalt, xK_s), doScratchpad "spotify")
, ((modalt, xK_t), doScratchpad "transmission")

View File

@@ -1,93 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
state_file="${IM_DESKTOP_SHELL_UI_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/imalison/desktop-shell-ui}"
default_shell_ui="${IM_HYPRLAND_SHELL_UI:-taffybar}"
normalize_shell_ui() {
case "${1:-}" in
noctalia)
printf '%s\n' "noctalia"
;;
taffybar|rofi)
printf '%s\n' "taffybar"
;;
*)
return 1
;;
esac
}
current_shell_ui() {
local configured=""
if [[ -r "$state_file" ]]; then
IFS= read -r configured < "$state_file" || true
fi
normalize_shell_ui "$configured" 2>/dev/null \
|| normalize_shell_ui "$default_shell_ui" 2>/dev/null \
|| printf '%s\n' "taffybar"
}
write_shell_ui() {
local shell_ui="$1"
mkdir -p "$(dirname "$state_file")"
printf '%s\n' "$shell_ui" > "$state_file"
}
apply_shell_ui() {
local shell_ui="$1"
export IM_HYPRLAND_SHELL_UI="$shell_ui"
systemctl --user import-environment IM_HYPRLAND_SHELL_UI 2>/dev/null || true
case "$shell_ui" in
noctalia)
systemctl --user stop taffybar.service 2>/dev/null || true
systemctl --user reset-failed taffybar.service 2>/dev/null || true
systemctl --user start noctalia-shell.service
;;
taffybar)
systemctl --user stop noctalia-shell.service 2>/dev/null || true
systemctl --user reset-failed noctalia-shell.service 2>/dev/null || true
systemctl --user start taffybar.service
;;
esac
}
set_shell_ui() {
local shell_ui
shell_ui="$(normalize_shell_ui "${1:-}")" || {
echo "usage: desktop_shell_ui set {taffybar|noctalia}" >&2
exit 2
}
write_shell_ui "$shell_ui"
apply_shell_ui "$shell_ui"
}
case "${1:-current}" in
current)
current_shell_ui
;;
set)
set_shell_ui "${2:-}"
;;
toggle)
case "$(current_shell_ui)" in
noctalia) set_shell_ui taffybar ;;
*) set_shell_ui noctalia ;;
esac
;;
apply)
apply_shell_ui "$(current_shell_ui)"
;;
exec-condition)
[[ "$(current_shell_ui)" == "$(normalize_shell_ui "${2:-}")" ]]
;;
*)
echo "usage: desktop_shell_ui {current|set|toggle|apply|exec-condition}" >&2
exit 2
;;
esac

View File

@@ -2,48 +2,27 @@
set -euo pipefail
script_path="$(readlink -f "${BASH_SOURCE[0]}")"
state_dir="${XDG_RUNTIME_DIR:-/tmp}/hypr-screensaver"
pid_file="$state_dir/mpvpaper.pid"
event_log="$state_dir/events.log"
mkdir -p "$state_dir"
title_prefix="hypr-screensaver:"
screensaver_dir="${HYPR_SCREENSAVER_DIR:-/var/lib/syncthing/sync/Screensaver}"
screensaver_use_dir="${HYPR_SCREENSAVER_USE_DIR:-$screensaver_dir/use}"
usage() {
cat <<'EOF'
Usage: hypr-screensaver <start|stop|toggle|status|session>
Commands:
start Launch the screensaver as a Wayland layer-shell overlay.
stop Stop any running screensaver overlay.
start Launch the screensaver on every Hyprland monitor.
stop Stop any running screensaver windows.
toggle Start if stopped, otherwise stop.
status Exit 0 if the screensaver overlay is running, otherwise exit 1.
session Compatibility alias for start.
status Exit 0 if any screensaver window is running, otherwise exit 1.
session Run the configured screensaver payload for one monitor.
By default, start chooses a random media file from:
/var/lib/syncthing/sync/Screensaver/use
Populate that directory with symlinks to generated screensaver loops you want
in rotation. You can override the source with HYPR_SCREENSAVER_SOURCE, for
example:
The default payload is an mpv-rendered lavfi animation. You can override the
source with HYPR_SCREENSAVER_SOURCE, for example:
HYPR_SCREENSAVER_SOURCE='/path/to/video.mp4'
HYPR_SCREENSAVER_SOURCE='av://lavfi:mandelbrot=s=2560x1440:r=60'
You can also override the rotation directory:
HYPR_SCREENSAVER_USE_DIR='/path/to/use'
Layer-shell/output overrides:
HYPR_SCREENSAVER_OUTPUT='ALL'
HYPR_SCREENSAVER_LAYER='overlay'
HDR handling defaults to matching Hyprland's monitor color-management preset.
Only monitors with preset "hdr" or "hdredid" get HDR colorspace hints. Override
with:
HYPR_SCREENSAVER_HDR_MODE=auto
HYPR_SCREENSAVER_HDR_MODE=sdr
HYPR_SCREENSAVER_HDR_MODE=hdr
EOF
}
@@ -51,11 +30,19 @@ monitors_json() {
hyprctl -j monitors
}
log_event() {
printf '%s %s\n' "$(date --iso-8601=seconds)" "$*" >>"$event_log"
monitor_names() {
monitors_json | jq -r '.[].name'
}
legacy_screensaver_window_pids() {
monitor_specs() {
monitors_json | jq -c '.[] | { name, width, height }'
}
focused_monitor() {
monitors_json | jq -r '.[] | select(.focused) | .name'
}
screensaver_window_pids() {
hyprctl -j clients 2>/dev/null | jq -r --arg prefix "$title_prefix" '
.[]
| select((.title // "") | startswith($prefix))
@@ -65,170 +52,106 @@ legacy_screensaver_window_pids() {
is_running() {
local pid
if [ -f "$pid_file" ]; then
for pid in $(screensaver_window_pids); do
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
done
shopt -s nullglob
local pid_file
for pid_file in "$state_dir"/*.pid; do
pid="$(<"$pid_file")"
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
rm -f "$pid_file"
fi
done
return 1
}
default_source() {
local size width height
size="$(
monitors_json 2>/dev/null \
| jq -r 'max_by((.width // 0) * (.height // 0)) | "\(.width // 1920)x\(.height // 1080)"' 2>/dev/null \
|| true
)"
size="${size:-1920x1080}"
width="${size%x*}"
height="${size#*x}"
local width="$1"
local height="$2"
printf 'av://lavfi:life=s=%sx%s:r=60:mold=10:ratio=0.065:death_color=#101414:life_color=#7dd3fc:mold_color=#1e3a5f,format=yuv420p' \
"$width" "$height"
}
random_source() {
[ -d "$screensaver_use_dir" ] || return 1
local -a candidates=()
local candidate
while IFS= read -r -d '' candidate; do
candidates+=("$candidate")
done < <(
find -L "$screensaver_use_dir" -maxdepth 1 -type f \
\( \
-iname '*.mp4' -o \
-iname '*.mkv' -o \
-iname '*.mov' -o \
-iname '*.webm' -o \
-iname '*.gif' -o \
-iname '*.png' -o \
-iname '*.jpg' -o \
-iname '*.jpeg' \
\) \
-print0
)
[ "${#candidates[@]}" -gt 0 ] || return 1
printf '%s\n' "${candidates[$((RANDOM % ${#candidates[@]}))]}"
}
screensaver_uses_hdr() {
local mode="${HYPR_SCREENSAVER_HDR_MODE:-auto}"
case "$mode" in
hdr)
return 0
;;
sdr)
return 1
;;
auto)
monitors_json 2>/dev/null \
| jq -e 'any(.[]; (.colorManagementPreset // "srgb") == "hdr" or (.colorManagementPreset // "srgb") == "hdredid")' >/dev/null 2>&1
;;
*)
printf 'Invalid HYPR_SCREENSAVER_HDR_MODE=%s; expected auto, sdr, or hdr\n' "$mode" >&2
return 1
;;
esac
}
mpv_color_options() {
if screensaver_uses_hdr; then
printf '%s ' \
target-colorspace-hint=yes \
target-colorspace-hint-mode=source
return
fi
printf '%s ' \
target-colorspace-hint=no \
target-prim=bt.709 \
target-trc=srgb \
target-gamut=bt.709 \
target-peak=80 \
inverse-tone-mapping=no
}
mpv_options() {
if [ -n "${HYPR_SCREENSAVER_MPV_OPTIONS:-}" ]; then
printf '%s\n' "$HYPR_SCREENSAVER_MPV_OPTIONS"
return
fi
printf '%s %s\n' \
"no-audio loop-file=inf osc=no osd-level=0 input-default-bindings=no terminal=no image-display-duration=inf keep-open=yes" \
"$(mpv_color_options)"
}
run_mpvpaper() {
if command -v mpvpaper >/dev/null 2>&1; then
exec mpvpaper "$@"
fi
exec nix shell nixpkgs#mpvpaper --command mpvpaper "$@"
}
start() {
local source output layer options pid
local current_monitor spec monitor width height pid
if is_running; then
log_event "start ignored: already running pid=$(<"$pid_file")"
exit 0
fi
stop
current_monitor="$(focused_monitor || true)"
source="${HYPR_SCREENSAVER_SOURCE:-}"
if [ -z "$source" ]; then
source="$(random_source || true)"
fi
if [ -z "$source" ]; then
source="$(default_source)"
fi
while IFS= read -r spec; do
monitor="$(jq -r '.name' <<<"$spec")"
width="$(jq -r '.width' <<<"$spec")"
height="$(jq -r '.height' <<<"$spec")"
[ -n "$monitor" ] || continue
HYPR_SCREENSAVER_MONITOR="$monitor" \
HYPR_SCREENSAVER_WIDTH="$width" \
HYPR_SCREENSAVER_HEIGHT="$height" \
"$script_path" session >/dev/null 2>&1 &
pid=$!
printf '%s\n' "$pid" > "$state_dir/${monitor}.pid"
sleep 0.15
done < <(monitor_specs)
output="${HYPR_SCREENSAVER_OUTPUT:-ALL}"
layer="${HYPR_SCREENSAVER_LAYER:-overlay}"
options="$(mpv_options)"
log_event "start output=$output layer=$layer source=$source"
(
exec </dev/null
run_mpvpaper --layer "$layer" --mpv-options "$options" "$output" "$source"
) >>"$state_dir/mpvpaper.log" 2>&1 &
pid=$!
printf '%s\n' "$pid" > "$pid_file"
sleep 0.2
if ! kill -0 "$pid" 2>/dev/null; then
rm -f "$pid_file"
log_event "start failed: process exited early pid=$pid"
return 1
if [ -n "$current_monitor" ]; then
hyprctl dispatch focusmonitor "$current_monitor" >/dev/null 2>&1 || true
fi
log_event "start ok pid=$pid"
}
stop() {
local pid legacy_pid
local pid pid_file
if [ -f "$pid_file" ]; then
pid="$(<"$pid_file")"
log_event "stop pid=$pid"
for pid in $(screensaver_window_pids); do
kill "$pid" >/dev/null 2>&1 || true
done
shopt -s nullglob
for pid_file in "$state_dir"/*.pid; do
pid="$(<"$pid_file")"
kill "$pid" >/dev/null 2>&1 || true
pkill -TERM -P "$pid" >/dev/null 2>&1 || true
rm -f "$pid_file"
else
log_event "stop with no pid file"
done
}
session() {
local monitor="${HYPR_SCREENSAVER_MONITOR:?missing HYPR_SCREENSAVER_MONITOR}"
local width="${HYPR_SCREENSAVER_WIDTH:-1920}"
local height="${HYPR_SCREENSAVER_HEIGHT:-1080}"
local source="${HYPR_SCREENSAVER_SOURCE:-$(default_source "$width" "$height")}"
local -a mpv_args=(
--no-config
--really-quiet
--fullscreen
--fs-screen-name="$monitor"
--screen-name="$monitor"
--force-window=immediate
--border=no
--title-bar=no
--ontop
--keep-open=yes
--loop-file=inf
--audio=no
--osc=no
--osd-level=0
--input-default-bindings=no
--wayland-app-id=hypr-screensaver
--title="${title_prefix}${monitor}"
--image-display-duration=inf
"$source"
)
if command -v mpv >/dev/null 2>&1; then
exec mpv "${mpv_args[@]}"
fi
for legacy_pid in $(legacy_screensaver_window_pids); do
log_event "stop legacy pid=$legacy_pid"
kill "$legacy_pid" >/dev/null 2>&1 || true
done
exec nix shell nixpkgs#mpv --command mpv "${mpv_args[@]}"
}
status() {
@@ -253,7 +176,7 @@ case "${1:-}" in
status
;;
session)
start
session
;;
""|-h|--help|help)
usage

View File

@@ -1,254 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
PROMPTS = {
"go": "Go to window",
"bring": "Bring window",
"replace": "Replace with",
}
def ensure_hyprland_instance():
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
return
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
hypr_dir = runtime / "hypr"
if not hypr_dir.is_dir():
return
sockets = []
for socket in hypr_dir.glob("*/.socket.sock"):
try:
sockets.append((socket.stat().st_mtime, socket.parent.name))
except OSError:
pass
if sockets:
os.environ["HYPRLAND_INSTANCE_SIGNATURE"] = max(sockets)[1]
def run_json(*args):
result = subprocess.run(args, check=False, text=True, capture_output=True)
if result.returncode != 0 or not result.stdout.strip():
return None
return json.loads(result.stdout)
def dispatch_lua(expression):
return subprocess.run(["hyprctl", "dispatch", expression], check=False).returncode == 0
def dispatch_legacy(*args):
return subprocess.run(["hyprctl", "dispatch", *args], check=False).returncode == 0
def normal_workspace(window):
workspace = window.get("workspace") or {}
workspace_id = workspace.get("id")
return isinstance(workspace_id, int) and workspace_id >= 0
def window_address(window):
address = window.get("address") or ""
return address if isinstance(address, str) and address else None
def clean_text(value):
return str(value or "").replace("\t", " ").replace("\n", " ").strip()
def app_dirs():
seen = set()
for base in os.environ.get("XDG_DATA_DIRS", "/run/current-system/sw/share:/usr/share:/usr/local/share").split(":"):
path = Path(base) / "applications"
if path.is_dir() and path not in seen:
seen.add(path)
yield path
local = Path.home() / ".local/share/applications"
if local.is_dir() and local not in seen:
yield local
def parse_desktop_file(path):
icon = None
wm_class = None
try:
with path.open(errors="ignore") as handle:
for raw_line in handle:
line = raw_line.strip()
if not icon and line.startswith("Icon="):
icon = line.removeprefix("Icon=").strip()
elif not wm_class and line.startswith("StartupWMClass="):
wm_class = line.removeprefix("StartupWMClass=").strip()
if icon and wm_class:
break
except OSError:
return None, None
return icon, wm_class
def icon_map():
mapping = {}
for directory in app_dirs():
for desktop_file in directory.glob("*.desktop"):
icon, wm_class = parse_desktop_file(desktop_file)
if not icon:
continue
mapping.setdefault(desktop_file.stem.lower(), icon)
if wm_class:
mapping.setdefault(wm_class.lower(), icon)
return mapping
def icon_for(mapping, class_name):
class_key = clean_text(class_name).lower()
return mapping.get(class_key, class_key or "application-x-executable")
def rofi_index(entries, prompt):
menu = b"".join(entry.encode("utf-8", errors="replace") for entry in entries)
result = subprocess.run(
["rofi", "-dmenu", "-i", "-show-icons", "-p", prompt, "-format", "i"],
input=menu,
check=False,
capture_output=True,
)
if result.returncode != 0:
return None
try:
return int(result.stdout.decode("utf-8").strip())
except ValueError:
return None
def candidates(mode, clients, active_workspace, active_window):
current_ws = (active_workspace or {}).get("id")
focused = window_address(active_window or {}) if active_window else None
filtered = []
for window in clients:
if not normal_workspace(window):
continue
address = window_address(window)
if not address:
continue
workspace_id = (window.get("workspace") or {}).get("id")
if mode == "bring" and workspace_id == current_ws:
continue
if mode == "replace" and address == focused:
continue
filtered.append(window)
filtered.sort(
key=lambda window: (
(window.get("workspace") or {}).get("id", 9999),
window.get("focusHistoryID", 999999),
clean_text(window.get("class")).lower(),
clean_text(window.get("title")).lower(),
)
)
return filtered
def menu_entry(window, icons):
class_name = clean_text(window.get("class"))
title = clean_text(window.get("title"))
workspace_id = (window.get("workspace") or {}).get("id", "?")
label = f"{class_name[:24]:<24} {title} WS:{workspace_id}"
icon = icon_for(icons, class_name)
return f"{label}\0icon\x1f{icon}\n"
def focus_window(address):
if dispatch_lua(f'hl.dsp.focus({{ window = "address:{address}" }})'):
return
dispatch_legacy("focuswindow", f"address:{address}")
def move_to_workspace(address, workspace_id):
workspace = str(workspace_id)
if dispatch_lua(f'hl.dsp.window.move({{ workspace = "{workspace}", window = "address:{address}" }})'):
return
dispatch_legacy("movetoworkspace", f"{workspace},address:{address}")
def swap_with_focused(target_address, focused_address):
if dispatch_lua(
f'hl.dsp.window.swap({{ target = "address:{target_address}", window = "address:{focused_address}" }})'
):
return
dispatch_legacy("hy3:movewindow", f"address:{target_address}")
def activate(mode, window, active_workspace, active_window):
address = window_address(window)
if not address:
return
if mode == "go":
focus_window(address)
return
if mode == "bring":
current_ws = (active_workspace or {}).get("id")
if current_ws is not None:
move_to_workspace(address, current_ws)
focus_window(address)
return
if mode == "replace":
focused = window_address(active_window or {})
if focused and focused != address:
swap_with_focused(address, focused)
focus_window(address)
def main():
parser = argparse.ArgumentParser(description="Rofi window go/bring/replace for Hyprland.")
parser.add_argument("mode", choices=sorted(PROMPTS))
parser.add_argument("--print-candidates", action="store_true")
parser.add_argument("--select-index", type=int)
args = parser.parse_args()
ensure_hyprland_instance()
clients = run_json("hyprctl", "clients", "-j") or []
active_workspace = run_json("hyprctl", "activeworkspace", "-j") or {}
active_window = run_json("hyprctl", "activewindow", "-j") or {}
windows = candidates(args.mode, clients, active_workspace, active_window)
if args.print_candidates:
print(json.dumps(windows, indent=2, sort_keys=True))
return 0
if not windows:
return 0
if args.select_index is None:
icons = icon_map()
entries = [menu_entry(window, icons) for window in windows]
index = rofi_index(entries, PROMPTS[args.mode])
else:
index = args.select_index
if index is None or index < 0 or index >= len(windows):
return 0
activate(args.mode, windows[index], active_workspace, active_window)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v desktop_shell_ui >/dev/null 2>&1; then
shell_ui="$(desktop_shell_ui current 2>/dev/null || true)"
else
shell_ui=""
fi
shell_ui="${shell_ui:-${IM_HYPRLAND_SHELL_UI:-taffybar}}"
run_noctalia() {
noctalia-shell ipc --any-display call "$@"
}
run_launcher() {
case "$shell_ui" in
noctalia)
run_noctalia launcher toggle
;;
taffybar|rofi)
exec rofi -show drun -show-icons
;;
*)
echo "unknown IM_HYPRLAND_SHELL_UI: $shell_ui" >&2
exit 2
;;
esac
}
run_window_picker() {
local mode="${1:-}"
case "$mode" in
go|bring|replace) ;;
*)
echo "usage: hypr_shell_ui window {go|bring|replace}" >&2
exit 2
;;
esac
if [[ "$shell_ui" == "noctalia" && "${IM_HYPRLAND_NOCTALIA_WINDOW_PICKER:-0}" == "1" ]]; then
# Future Noctalia launcher-provider hook. Until that plugin exists or if it
# fails to load, keep the existing rofi picker as the working path.
run_noctalia "plugin:hypr-window-picker" "$mode" 2>/dev/null && exit 0
fi
exec hypr_rofi_window "$mode"
}
case "${1:-}" in
launcher)
run_launcher
;;
run)
exec rofi -show run
;;
control-center)
if [[ "$shell_ui" == "noctalia" ]]; then
run_noctalia controlCenter toggle || true
fi
exit 0
;;
settings)
if [[ "$shell_ui" == "noctalia" ]]; then
run_noctalia settings toggle || true
fi
exit 0
;;
window)
shift
run_window_picker "$@"
;;
*)
echo "usage: hypr_shell_ui {launcher|run|control-center|settings|window}" >&2
exit 2
;;
esac

View File

@@ -1,275 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
CONFIG_PATH = Path.home() / ".config" / "roborock-control" / "config.json"
PYTHON_ENV_EXPR = (
"with import <nixpkgs> {}; "
"python313.withPackages (ps: [ ps.python-roborock ps.pyyaml ps.pyshark ])"
)
COMMON_COMMANDS = {
"start": ("app_start", None),
"pause": ("app_pause", None),
"stop": ("app_stop", None),
"dock": ("app_charge", None),
"find": ("find_me", None),
"dust": ("app_start_collect_dust", None),
"stop-dust": ("app_stop_collect_dust", None),
"wash": ("app_start_wash", None),
"stop-wash": ("app_stop_wash", None),
}
def load_config():
if not CONFIG_PATH.exists():
return {}
return json.loads(CONFIG_PATH.read_text())
def save_config(config):
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
def roborock_args(*args):
if os.getenv("ROBOROCK_CONTROL_RUNNER") == "direct":
return ["roborock", *args]
return [
"nix",
"shell",
"--impure",
"--expr",
PYTHON_ENV_EXPR,
"--command",
"roborock",
*args,
]
def run_roborock(*args, capture=False, check=True):
kwargs = {
"text": True,
"check": check,
}
if capture:
kwargs["stdout"] = subprocess.PIPE
kwargs["stderr"] = subprocess.PIPE
return subprocess.run(roborock_args(*args), **kwargs)
def configured_device_id(args, config):
return args.device_id or os.getenv("ROBOROCK_DEVICE_ID") or config.get("device_id")
def infer_single_device_id():
result = run_roborock("list-devices", capture=True)
devices = json.loads(result.stdout)
if len(devices) != 1:
names = ", ".join(sorted(devices)) or "none"
raise SystemExit(
"Could not infer a default device. "
f"Found {len(devices)} devices: {names}. "
"Pass --device-id or run 'roborock-control config --device-id <id>'."
)
return next(iter(devices.values()))
def get_device_id(args, config):
return configured_device_id(args, config) or infer_single_device_id()
def handle_config(args):
config = load_config()
changed = False
if args.clear:
config = {}
changed = True
if args.device_id is not None:
config["device_id"] = args.device_id
changed = True
if args.email is not None:
config["email"] = args.email
changed = True
if changed:
save_config(config)
print(json.dumps(config, indent=2, sort_keys=True))
def handle_login(args):
config = load_config()
email = args.email or os.getenv("ROBOROCK_EMAIL") or config.get("email")
if not email:
raise SystemExit(
"Email is required. Pass --email or run "
"'roborock-control config --email <address>'."
)
cli_args = ["login", "--email", email]
if args.reauth:
cli_args.append("--reauth")
if args.password_command:
password = subprocess.check_output(args.password_command, shell=True, text=True).strip()
cli_args.extend(["--password", password])
run_roborock(*cli_args)
def handle_devices(args):
if args.refresh:
run_roborock("discover")
run_roborock("list-devices")
def command_with_device(args, upstream_command):
config = load_config()
return [upstream_command, "--device_id", get_device_id(args, config)]
def handle_home(args):
cli_args = command_with_device(args, "home")
if args.refresh:
cli_args.append("--refresh")
run_roborock(*cli_args)
def handle_maps(args):
run_roborock(*command_with_device(args, "maps"))
def handle_rooms(args):
run_roborock(*command_with_device(args, "rooms"))
def handle_map_data(args):
cli_args = command_with_device(args, "map-data")
if args.include_path:
cli_args.append("--include_path")
run_roborock(*cli_args)
def handle_map_image(args):
run_roborock(
*command_with_device(args, "map-image"),
"--output-file",
args.output_file,
)
def handle_command(args):
config = load_config()
params = args.params
cli_args = [
"command",
"--device_id",
get_device_id(args, config),
"--cmd",
args.command_name,
]
if params is not None:
cli_args.extend(["--params", params])
run_roborock(*cli_args)
def handle_common_command(args):
command_name, params = COMMON_COMMANDS[args.action]
config = load_config()
cli_args = [
"command",
"--device_id",
get_device_id(args, config),
"--cmd",
command_name,
]
if params is not None:
cli_args.extend(["--params", params])
run_roborock(*cli_args)
def handle_status(args):
config = load_config()
run_roborock("status", "--device_id", get_device_id(args, config))
def handle_upstream(args):
run_roborock(*args.args)
def build_parser():
parser = argparse.ArgumentParser(
description="Control Roborock vacuums through python-roborock."
)
parser.add_argument(
"--device-id",
help="Roborock device id. Defaults to ROBOROCK_DEVICE_ID, saved config, or the only cached device.",
)
subparsers = parser.add_subparsers(dest="subcommand", required=True)
config_parser = subparsers.add_parser("config", help="Show or update saved defaults")
config_parser.add_argument("--device-id")
config_parser.add_argument("--email")
config_parser.add_argument("--clear", action="store_true")
config_parser.set_defaults(func=handle_config)
login_parser = subparsers.add_parser("login", help="Login with email code or password")
login_parser.add_argument("--email")
login_parser.add_argument("--reauth", action="store_true")
login_parser.add_argument(
"--password-command",
help="Shell command that prints the Roborock password, e.g. 'pass show path/to/login'.",
)
login_parser.set_defaults(func=handle_login)
devices_parser = subparsers.add_parser("devices", help="List cached devices")
devices_parser.add_argument("--refresh", action="store_true", help="Refresh discovery first")
devices_parser.set_defaults(func=handle_devices)
subparsers.add_parser("status", help="Show vacuum status").set_defaults(func=handle_status)
home_parser = subparsers.add_parser("home", help="Discover and cache home layout")
home_parser.add_argument("--refresh", action="store_true")
home_parser.set_defaults(func=handle_home)
subparsers.add_parser("maps", help="Show map metadata").set_defaults(func=handle_maps)
subparsers.add_parser("rooms", help="Show room metadata").set_defaults(func=handle_rooms)
map_data_parser = subparsers.add_parser("map-data", help="Show parsed map data as JSON")
map_data_parser.add_argument("--include-path", action="store_true")
map_data_parser.set_defaults(func=handle_map_data)
map_image_parser = subparsers.add_parser("map-image", help="Save the map image")
map_image_parser.add_argument("output_file")
map_image_parser.set_defaults(func=handle_map_image)
raw_parser = subparsers.add_parser("raw", help="Send a raw python-roborock command")
raw_parser.add_argument("command_name")
raw_parser.add_argument("params", nargs="?")
raw_parser.set_defaults(func=handle_command)
upstream_parser = subparsers.add_parser("cli", help="Pass arguments to the upstream CLI")
upstream_parser.add_argument("args", nargs=argparse.REMAINDER)
upstream_parser.set_defaults(func=handle_upstream)
for action in sorted(COMMON_COMMANDS):
subparsers.add_parser(action).set_defaults(func=handle_common_command, action=action)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except subprocess.CalledProcessError as error:
if error.stdout:
print(error.stdout, end="")
if error.stderr:
print(error.stderr, end="", file=sys.stderr)
raise SystemExit(error.returncode)
if __name__ == "__main__":
main()

View File

@@ -69,7 +69,6 @@
multiplexerAliases = import ../../shared/multiplexer-aliases.nix;
excludedTopLevelEntries = [
"codex"
"config"
];

View File

@@ -5,10 +5,9 @@
...
}: let
cfg = config.myModules.codexGeneratedSkills;
oos = config.lib.file.mkOutOfStoreSymlink;
in {
options.myModules.codexGeneratedSkills = {
enable = lib.mkEnableOption "Codex home setup";
enable = lib.mkEnableOption "generated Codex skill setup";
codexHome = lib.mkOption {
type = lib.types.str;
@@ -16,12 +15,6 @@ in {
description = "Codex home directory.";
};
worktreeCodexDir = lib.mkOption {
type = lib.types.str;
default = "${config.home.homeDirectory}/dotfiles/dotfiles/codex";
description = "Codex dotfiles directory in the live worktree.";
};
skillsDir = lib.mkOption {
type = lib.types.str;
default = "${cfg.codexHome}/skills";
@@ -36,67 +29,6 @@ in {
};
config = lib.mkIf cfg.enable {
home.file = {
".codex/.gitignore" = {
force = true;
source = oos "${cfg.worktreeCodexDir}/.gitignore";
};
".codex/AGENTS.md" = {
force = true;
source = oos "${cfg.worktreeCodexDir}/AGENTS.md";
};
".codex/skills" = {
force = true;
source = oos "${cfg.worktreeCodexDir}/skills";
};
};
home.activation.prepareCodexDirectory = lib.hm.dag.entryBefore ["checkLinkTargets"] ''
codex_home=${lib.escapeShellArg cfg.codexHome}
worktree_codex=${lib.escapeShellArg cfg.worktreeCodexDir}
if [ -L "$codex_home" ] && [ "$(readlink "$codex_home")" = "$worktree_codex" ]; then
rm -f "$codex_home"
mkdir -p "$codex_home"
elif [ ! -e "$codex_home" ]; then
mkdir -p "$codex_home"
elif [ ! -d "$codex_home" ]; then
echo "Skipping Codex setup because $codex_home is not a directory" >&2
exit 1
fi
'';
home.activation.generateCodexConfig = lib.hm.dag.entryAfter ["writeBoundary"] ''
codex_home=${lib.escapeShellArg cfg.codexHome}
base=${lib.escapeShellArg "${cfg.worktreeCodexDir}/config.toml"}
local_config=${lib.escapeShellArg "${cfg.worktreeCodexDir}/config.local.toml"}
target="$codex_home/config.toml"
if [ ! -r "$base" ]; then
echo "Missing shared Codex config at $base" >&2
exit 1
fi
mkdir -p "$codex_home"
tmp="$(mktemp "$codex_home/config.toml.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
chmod 600 "$tmp"
cat "$base" > "$tmp"
if [ -r "$local_config" ]; then
printf '\n' >> "$tmp"
cat "$local_config" >> "$tmp"
fi
if [ -e "$target" ] && cmp -s "$tmp" "$target"; then
rm -f "$tmp"
else
mv -f "$tmp" "$target"
fi
'';
home.activation.setupCodexGeneratedSkills = lib.hm.dag.entryAfter ["writeBoundary"] ''
codex_home=${lib.escapeShellArg cfg.codexHome}
skills_dir=${lib.escapeShellArg cfg.skillsDir}

View File

@@ -29,14 +29,12 @@
./laptop.nix
./nix.nix
./notifications-tray-icon.nix
./noctalia.nix
./nvidia.nix
./options.nix
./plasma.nix
./postgres.nix
./rabbitmq.nix
./quickshell.nix
./remote-hyprland.nix
./secrets.nix
./ssh.nix
./sni.nix

View File

@@ -6,20 +6,7 @@
makeEnable,
...
}:
let
cfg = config.myModules.desktop;
desktopShellUi = pkgs.writeShellApplication {
name = "desktop_shell_ui";
runtimeInputs = [
pkgs.bash
pkgs.coreutils
pkgs.systemd
];
text = ''
exec ${../dotfiles/lib/bin/desktop_shell_ui} "$@"
'';
};
enabledModule = makeEnable config "myModules.desktop" true {
makeEnable config "myModules.desktop" true {
services.greenclip.enable = true;
imports = [
./fonts.nix
@@ -53,11 +40,8 @@ let
enable = true;
};
environment.sessionVariables = {
# This is for the benefit of VSCODE running natively in wayland
NIXOS_OZONE_WL = "1";
IM_HYPRLAND_SHELL_UI = cfg.shellUi;
};
# This is for the benefit of VSCODE running natively in wayland
environment.sessionVariables.NIXOS_OZONE_WL = "1";
system.activationScripts.playwrightChromeCompat.text = lib.optionalString (pkgs.stdenv.hostPlatform.system == "x86_64-linux") ''
# Playwright's Chrome channel lookup expects the FHS path below.
@@ -103,8 +87,6 @@ let
environment.systemPackages = with pkgs;
[
desktopShellUi
# Appearance
adwaita-icon-theme
hicolor-icon-theme
@@ -187,18 +169,4 @@ let
]
else []
);
};
in
enabledModule
// {
options = lib.recursiveUpdate enabledModule.options {
myModules.desktop.shellUi = lib.mkOption {
type = lib.types.enum [ "noctalia" "taffybar" ];
default = "taffybar";
description = ''
Desktop shell UI used by Hyprland-oriented bindings. This controls
the active shell service and Hyprland launcher/window picker dispatch.
'';
};
};
}

View File

@@ -1,8 +1,5 @@
{
config,
lib,
...
}: let
{ config, lib, ... }:
let
# Replicate the useful part of rcm/rcup:
# - dotfiles live in ~/dotfiles/dotfiles (no leading dots in the repo)
# - links in $HOME add a leading '.' to the first path component
@@ -19,9 +16,6 @@
srcDotfiles = ../dotfiles;
excludedTop = [
# Managed by nix-shared/home-manager/codex-generated-skills.nix so
# config.toml can be generated from shared and machine-local fragments.
"codex"
# Managed by Nix directly (PATH/fpath), not meant to appear as ~/.lib.
"lib"
# Avoid colliding with HM-generated xdg.configFile entries for now.
@@ -30,25 +24,27 @@
"emacs.d"
];
firstComponent = rel: let
parts = lib.splitString "/" rel;
in
lib.elemAt parts 0;
firstComponent = rel:
let parts = lib.splitString "/" rel;
in lib.elemAt parts 0;
isExcluded = rel: lib.elem (firstComponent rel) excludedTop;
listFilesRec = dir: let
entries = builtins.readDir dir;
names = builtins.attrNames entries;
go = name: let
ty = entries.${name};
path = dir + "/${name}";
listFilesRec = dir:
let
entries = builtins.readDir dir;
names = builtins.attrNames entries;
go = name:
let
ty = entries.${name};
path = dir + "/${name}";
in
if ty == "directory" then
map (p: "${name}/${p}") (listFilesRec path)
else
[ name ];
in
if ty == "directory"
then map (p: "${name}/${p}") (listFilesRec path)
else [name];
in
lib.concatLists (map go names);
lib.concatLists (map go names);
managedRelFiles =
lib.filter (rel: !(isExcluded rel)) (listFilesRec srcDotfiles);
@@ -57,10 +53,9 @@
lib.nameValuePair ".${rel}" {
source = oos "${worktreeDotfiles}/${rel}";
};
in {
imports = [
../nix-shared/home-manager/codex-generated-skills.nix
];
in
{
imports = [ ../nix-shared/home-manager/codex-generated-skills.nix ];
home.file =
builtins.listToAttrs (map mkManaged managedRelFiles);
@@ -79,4 +74,5 @@ in {
echo "Skipping ~/.emacs.d relink because it is not a symlink" >&2
fi
'';
}

View File

@@ -10,8 +10,6 @@ makeEnable config "myModules.extra" false {
signal-desktop
gource
gimp
kef
roborock-control
texlive.combined.scheme-full
tor
yt-dlp

823
nixos/flake.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -93,27 +93,37 @@
# Hyprland and plugins from official flakes for proper plugin compatibility
hyprland = {
url = "git+https://github.com/hyprwm/Hyprland?submodules=1";
url = "git+https://github.com/hyprwm/Hyprland?submodules=1&ref=refs/tags/v0.53.0";
};
hyprland-lua-config = {
# Experimental Lua config branch from PR 13817.
url = "git+https://github.com/hyprwm/Hyprland?submodules=1&ref=refs/pull/13817/head";
};
hyprNStack = {
url = "github:colonelpanic8/hyprNStack?ref=hyprland-lua-integration";
url = "github:colonelpanic8/hyprNStack";
inputs = {
hyprland.follows = "hyprland";
hyprland.follows = "hyprland-lua-config";
nixpkgs.follows = "nixpkgs";
};
};
hyprland-plugins-lua = {
url = "github:colonelpanic8/hyprland-plugins?ref=codex/fix-main-ci";
hy3 = {
url = "github:outfoxxed/hy3?ref=hl0.53.0";
inputs.hyprland.follows = "hyprland";
};
hyprwinview = {
url = "github:colonelpanic8/hyprwinview";
hyprland-plugins = {
url = "github:hyprwm/hyprland-plugins?ref=v0.53.0";
inputs.hyprland.follows = "hyprland";
};
hyprland-plugins-lua = {
url = "github:colonelpanic8/hyprland-plugins?ref=hyprexpo-lua-hyprland";
inputs.hyprland.follows = "hyprland-lua-config";
};
hyprscratch = {
url = "github:colonelpanic8/hyprscratch/reapply-rules-on-toggle";
inputs.nixpkgs.follows = "nixpkgs";
@@ -151,11 +161,26 @@
};
};
taffybar = {
# Use a remote, lockfile-pinned input so rebuilds are reproducible across
# machines. For local development, use `nixos-rebuild --override-input taffybar path:...`.
url = "github:taffybar/taffybar";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
xmonad.follows = "xmonad";
xmonad-contrib.follows = "xmonad-contrib";
weeder-nix.url = "github:NorfairKing/weeder-nix";
weeder-nix.inputs.pre-commit-hooks.inputs.nixpkgs.follows = "nixpkgs";
};
};
imalison-taffybar = {
url = "path:../dotfiles/config/taffybar";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
taffybar.follows = "taffybar";
xmonad.follows = "xmonad";
};
};
@@ -206,11 +231,6 @@
inputs.nixpkgs.follows = "nixpkgs";
};
noctalia = {
url = "github:noctalia-dev/noctalia-shell";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs @ {
@@ -218,6 +238,7 @@
nixpkgs,
nixos-hardware,
home-manager,
taffybar,
xmonad,
nixtheplanet,
xmonad-contrib,
@@ -226,6 +247,8 @@
agenix,
imalison-taffybar,
hyprland,
hy3,
hyprland-plugins,
org-agenda-api,
flake-utils,
...
@@ -420,7 +443,6 @@
"https://colonelpanic8-dotfiles.cachix.org"
"https://codex-cli.cachix.org"
"https://claude-code.cachix.org"
"https://noctalia.cachix.org"
];
extra-trusted-substituters = [
"https://ai.cachix.org"
@@ -442,7 +464,6 @@
"colonelpanic8-dotfiles.cachix.org-1:O6GF3nptpeMFapX29okzO92eSWXR36zqW6ZF2C8P0eQ="
"codex-cli.cachix.org-1:1Br3H1hHoRYG22n//cGKJOk3cQXgYobUel6O8DgSing="
"claude-code.cachix.org-1:YeXf2aNu7UTX8Vwrze0za1WEDS+4DuI2kVeWEE4fsRk="
"noctalia.cachix.org-1:pCOR47nnMEo5thcxNDtzWpOxNFQsBRglJzxWPp3dkU4="
];
};
nixosConfigurations =
@@ -476,26 +497,6 @@
containerLib = import ../org-agenda-api/container.nix {
inherit pkgs system tangledConfig org-agenda-api orgApiRev dotfilesRev;
};
hyprlandPkgs = import nixpkgs {
inherit system;
overlays = [ hyprland.overlays.hyprland-packages ];
};
hyprWorkspaceHistory = hyprlandPkgs.hyprlandPlugins.mkHyprlandPlugin {
pluginName = "hypr-workspace-history";
version = "0.1.0";
src = builtins.path {
path = ../dotfiles/config/hypr/workspace-history-plugin;
name = "hypr-workspace-history-source";
};
inherit (hyprland.packages.${system}.hyprland) nativeBuildInputs;
meta = {
description = "Workspace history cycling plugin for Hyprland";
license = lib.licenses.bsd3;
platforms = lib.platforms.linux;
};
};
in {
packages = {
colonelpanic-org-agenda-api = containerLib.containers.colonelpanic;
@@ -503,21 +504,16 @@
} // lib.optionalAttrs pkgs.stdenv.isLinux {
hyprNStack = inputs.hyprNStack.packages.${system}.hyprNStack;
hyprexpo-lua = inputs.hyprland-plugins-lua.packages.${system}.hyprexpo;
hyprwinview = inputs.hyprwinview.packages.${system}.hyprwinview;
hypr-workspace-history = hyprWorkspaceHistory;
};
checks = lib.optionalAttrs pkgs.stdenv.isLinux {
hyprNStack = inputs.hyprNStack.packages.${system}.hyprNStack;
hyprexpo-lua = inputs.hyprland-plugins-lua.packages.${system}.hyprexpo;
hyprwinview = inputs.hyprwinview.packages.${system}.hyprwinview;
hypr-workspace-history = hyprWorkspaceHistory;
hyprland-config-syntax = pkgs.runCommand "hyprland-config-syntax" {
hyprland-lua-config-syntax = pkgs.runCommand "hyprland-lua-config-syntax" {
nativeBuildInputs = [ pkgs.lua5_4 ];
} ''
cp ${../dotfiles/config/hypr/hyprland.lua} hyprland.lua
luac -p hyprland.lua
if grep -n 'hyprctl' hyprland.lua | grep -v 'hyprctl reload' | grep -v 'hyprctl dispatch hyprwinview:overview'; then
luac -p ${../dotfiles/config/hypr/hyprland.lua}
if grep -n 'hyprctl' ${../dotfiles/config/hypr/hyprland.lua} | grep -v 'hyprctl reload'; then
echo "hyprland.lua should not shell out to hyprctl for window/workspace manipulation" >&2
exit 1
fi
@@ -626,7 +622,7 @@
end,
}
dofile("./hyprland.lua")
dofile("${../dotfiles/config/hypr/hyprland.lua}")
for _, callback in ipairs(callbacks) do
callback()
@@ -634,8 +630,8 @@
LUA
touch "$out"
'';
hyprland-verify-config = let
hyprlandPackage = inputs.hyprland.packages.${system}.hyprland;
hyprland-lua-verify-config = let
hyprlandPackage = inputs.hyprland-lua-config.packages.${system}.hyprland;
hyprNStackPackage = inputs.hyprNStack.packages.${system}.hyprNStack;
in pkgs.runCommand "hyprland-lua-verify-config" {} ''
cp ${../dotfiles/config/hypr/hyprland.lua} hyprland.lua

View File

@@ -6,152 +6,6 @@
...
}: let
mimeMap = desktopId: mimeTypes: lib.genAttrs mimeTypes (_: [desktopId]);
browser = "google-chrome.desktop";
imageViewer = "org.kde.gwenview.desktop";
pdfViewer = "okularApplication_pdf.desktop";
comicViewer = "okularApplication_comicbook.desktop";
djvuViewer = "okularApplication_djvu.desktop";
ebookViewer = "okularApplication_epub.desktop";
mobiViewer = "okularApplication_mobi.desktop";
xpsViewer = "okularApplication_xps.desktop";
mediaPlayer = "vlc.desktop";
archiveManager = "org.gnome.FileRoller.desktop";
fileManager = "thunar.desktop";
wordProcessor = "writer.desktop";
spreadsheet = "calc.desktop";
presentation = "impress.desktop";
defaultApplications =
(mimeMap imageViewer [
"image/avif"
"image/bmp"
"image/gif"
"image/heic"
"image/heif"
"image/jpeg"
"image/jxl"
"image/png"
"image/svg+xml"
"image/svg+xml-compressed"
"image/tiff"
"image/vnd.microsoft.icon"
"image/webp"
])
// (mimeMap pdfViewer [
"application/pdf"
"application/x-bzpdf"
"application/x-gzpdf"
])
// (mimeMap comicViewer [
"application/x-cb7"
"application/x-cbr"
"application/x-cbt"
"application/x-cbz"
])
// (mimeMap djvuViewer [
"image/vnd.djvu"
])
// (mimeMap ebookViewer [
"application/epub+zip"
])
// (mimeMap mobiViewer [
"application/x-mobipocket-ebook"
])
// (mimeMap xpsViewer [
"application/oxps"
"application/vnd.ms-xpsdocument"
])
// (mimeMap mediaPlayer [
"application/ogg"
"audio/flac"
"audio/mp4"
"audio/mpeg"
"audio/ogg"
"audio/opus"
"audio/webm"
"audio/wav"
"audio/x-flac"
"audio/x-wav"
"video/mp4"
"video/ogg"
"video/quicktime"
"video/webm"
"video/x-matroska"
"video/x-msvideo"
])
// (mimeMap archiveManager [
"application/bzip2"
"application/gzip"
"application/vnd.rar"
"application/x-7z-compressed"
"application/x-bzip"
"application/x-compressed-tar"
"application/x-gzip"
"application/x-rar"
"application/x-rar-compressed"
"application/x-tar"
"application/x-xz"
"application/x-zip-compressed"
"application/zip"
"application/zstd"
])
// (mimeMap wordProcessor [
"application/msword"
"application/rtf"
"application/vnd.ms-word"
"application/vnd.oasis.opendocument.text"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
])
// (mimeMap spreadsheet [
"application/vnd.ms-excel"
"application/vnd.oasis.opendocument.spreadsheet"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"text/csv"
"text/tab-separated-values"
])
// (mimeMap presentation [
"application/mspowerpoint"
"application/vnd.ms-powerpoint"
"application/vnd.oasis.opendocument.presentation"
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
"application/vnd.openxmlformats-officedocument.presentationml.slideshow"
])
// (mimeMap fileManager [
"inode/directory"
])
// (mimeMap browser [
"application/rdf+xml"
"application/rss+xml"
"application/xhtml+xml"
"application/xhtml_xml"
"application/xml"
"text/html"
"text/xml"
"x-scheme-handler/about"
"x-scheme-handler/http"
"x-scheme-handler/https"
"x-scheme-handler/unknown"
])
// {
"x-scheme-handler/element" = ["element-desktop.desktop"];
"x-scheme-handler/magnet" = ["transmission-gtk.desktop"];
};
mimeAppsListText = let
formatApplications = applications:
lib.concatStringsSep "\n" (
lib.mapAttrsToList (
mimeType: desktopIds: "${mimeType}=${lib.concatStringsSep ";" desktopIds};"
)
applications
);
in ''
[Added Associations]
${formatApplications defaultApplications}
[Default Applications]
${formatApplications defaultApplications}
[Removed Associations]
'';
in {
# Automatic garbage collection of old home-manager generations
nix.gc = {
@@ -173,44 +27,148 @@ in {
static_history = []
'';
xdg.configFile."zellij/config.kdl".source =
config.lib.file.mkOutOfStoreSymlink
"${config.home.homeDirectory}/dotfiles/dotfiles/config/zellij/config.kdl";
xdg.configFile."menus/applications.menu" = lib.mkIf nixos.config.myModules.desktop.enable {
source = "${pkgs.kdePackages.plasma-workspace}/etc/xdg/menus/plasma-applications.menu";
};
xdg.mimeApps = lib.mkIf nixos.config.myModules.desktop.enable (
let
browser = "google-chrome.desktop";
imageViewer = "org.kde.gwenview.desktop";
pdfViewer = "okularApplication_pdf.desktop";
comicViewer = "okularApplication_comicbook.desktop";
djvuViewer = "okularApplication_djvu.desktop";
ebookViewer = "okularApplication_epub.desktop";
mobiViewer = "okularApplication_mobi.desktop";
xpsViewer = "okularApplication_xps.desktop";
mediaPlayer = "vlc.desktop";
archiveManager = "org.gnome.FileRoller.desktop";
fileManager = "thunar.desktop";
wordProcessor = "writer.desktop";
spreadsheet = "calc.desktop";
presentation = "impress.desktop";
xdg.configFile."kde-mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.configFile."none+xmonad-mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.configFile."xmonad-mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.configFile."hyprland-mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.dataFile."mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.dataFile."applications/kde-mimeapps.list" = lib.mkIf nixos.config.myModules.desktop.enable {
text = mimeAppsListText;
};
xdg.mimeApps = lib.mkIf nixos.config.myModules.desktop.enable {
enable = true;
associations.added = defaultApplications;
inherit defaultApplications;
};
defaultApplications =
(mimeMap imageViewer [
"image/avif"
"image/bmp"
"image/gif"
"image/heic"
"image/heif"
"image/jpeg"
"image/jxl"
"image/png"
"image/svg+xml"
"image/svg+xml-compressed"
"image/tiff"
"image/vnd.microsoft.icon"
"image/webp"
])
// (mimeMap pdfViewer [
"application/pdf"
"application/x-bzpdf"
"application/x-gzpdf"
])
// (mimeMap comicViewer [
"application/x-cb7"
"application/x-cbr"
"application/x-cbt"
"application/x-cbz"
])
// (mimeMap djvuViewer [
"image/vnd.djvu"
])
// (mimeMap ebookViewer [
"application/epub+zip"
])
// (mimeMap mobiViewer [
"application/x-mobipocket-ebook"
])
// (mimeMap xpsViewer [
"application/oxps"
"application/vnd.ms-xpsdocument"
])
// (mimeMap mediaPlayer [
"application/ogg"
"audio/flac"
"audio/mp4"
"audio/mpeg"
"audio/ogg"
"audio/opus"
"audio/webm"
"audio/wav"
"audio/x-flac"
"audio/x-wav"
"video/mp4"
"video/ogg"
"video/quicktime"
"video/webm"
"video/x-matroska"
"video/x-msvideo"
])
// (mimeMap archiveManager [
"application/bzip2"
"application/gzip"
"application/vnd.rar"
"application/x-7z-compressed"
"application/x-bzip"
"application/x-compressed-tar"
"application/x-gzip"
"application/x-rar"
"application/x-rar-compressed"
"application/x-tar"
"application/x-xz"
"application/x-zip-compressed"
"application/zip"
"application/zstd"
])
// (mimeMap wordProcessor [
"application/msword"
"application/rtf"
"application/vnd.ms-word"
"application/vnd.oasis.opendocument.text"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
])
// (mimeMap spreadsheet [
"application/vnd.ms-excel"
"application/vnd.oasis.opendocument.spreadsheet"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"text/csv"
"text/tab-separated-values"
])
// (mimeMap presentation [
"application/mspowerpoint"
"application/vnd.ms-powerpoint"
"application/vnd.oasis.opendocument.presentation"
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
"application/vnd.openxmlformats-officedocument.presentationml.slideshow"
])
// (mimeMap fileManager [
"inode/directory"
])
// (mimeMap browser [
"application/rdf+xml"
"application/rss+xml"
"application/xhtml+xml"
"application/xhtml_xml"
"application/xml"
"text/html"
"text/xml"
"x-scheme-handler/about"
"x-scheme-handler/http"
"x-scheme-handler/https"
"x-scheme-handler/unknown"
])
// {
"x-scheme-handler/element" = ["element-desktop.desktop"];
"x-scheme-handler/magnet" = ["transmission-gtk.desktop"];
};
in {
enable = true;
associations.added = defaultApplications;
inherit defaultApplications;
}
);
home.activation.refreshChromeDesktopMimeCache = lib.hm.dag.entryAfter ["writeBoundary"] ''
applications_dir="$HOME/.local/share/applications"
@@ -222,7 +180,6 @@ in {
do
if [ -f "$desktop_file" ]; then
${pkgs.gnused}/bin/sed -i \
-e 's,application/pdf;,,g' \
-e 's,image/gif;,,g' \
-e 's,image/jpeg;,,g' \
-e 's,image/png;,,g' \
@@ -231,34 +188,10 @@ in {
fi
done
for desktop_file in "$applications_dir"/okular*.desktop "$applications_dir"/vlc*.desktop; do
if [ -f "$desktop_file" ]; then
${pkgs.gnused}/bin/sed -i \
-e 's,image/avif;,,g' \
-e 's,image/bmp;,,g' \
-e 's,image/gif;,,g' \
-e 's,image/heic;,,g' \
-e 's,image/heif;,,g' \
-e 's,image/jpeg;,,g' \
-e 's,image/jxl;,,g' \
-e 's,image/png;,,g' \
-e 's,image/svg+xml;,,g' \
-e 's,image/svg+xml-compressed;,,g' \
-e 's,image/tiff;,,g' \
-e 's,image/vnd.microsoft.icon;,,g' \
-e 's,image/webp;,,g' \
"$desktop_file"
fi
done
${pkgs.desktop-file-utils}/bin/update-desktop-database "$applications_dir" >/dev/null 2>&1 || true
fi
'';
home.activation.refreshKdeServiceCache = lib.hm.dag.entryAfter ["refreshChromeDesktopMimeCache"] ''
${pkgs.kdePackages.kservice}/bin/kbuildsycoca6 --noincremental >/dev/null 2>&1 || true
'';
xsession = {
enable = true;
preferStatusNotifierItems = true;

View File

@@ -1,93 +1,27 @@
{
config,
pkgs,
lib,
makeEnable,
inputs,
...
}:
{ config, pkgs, lib, makeEnable, inputs, ... }:
let
cfg = config.myModules.hyprland;
system = pkgs.stdenv.hostPlatform.system;
hyprlandInput = inputs.hyprland;
hyprlandPluginPackages = [
enableExternalPluginPackages = !cfg.useLuaConfigBranch;
hyprlandInput =
if cfg.useLuaConfigBranch
then inputs.hyprland-lua-config
else inputs.hyprland;
luaPluginPackages = lib.optionals cfg.useLuaConfigBranch [
inputs.hyprNStack.packages.${system}.hyprNStack
inputs.hyprland-plugins-lua.packages.${system}.hyprexpo
inputs.hyprwinview.packages.${system}.hyprwinview
inputs.self.packages.${system}.hypr-workspace-history
];
hyprRofiWindow = pkgs.writeShellApplication {
name = "hypr_rofi_window";
runtimeInputs = [
pkgs.python3
pkgs.rofi
hyprlandInput.packages.${system}.hyprland
hyprexpoPatched = inputs.hyprland-plugins.packages.${system}.hyprexpo.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [
./patches/hyprexpo-pr-612-workspace-numbers.patch
./patches/hyprexpo-pr-616-bring-mode.patch
];
text = ''
exec python3 ${../dotfiles/lib/bin/hypr_rofi_window} "$@"
'';
};
hyprShellUi = pkgs.writeShellApplication {
name = "hypr_shell_ui";
runtimeInputs = [
pkgs.rofi
hyprRofiWindow
inputs.noctalia.packages.${system}.default
];
text = ''
exec ${../dotfiles/lib/bin/hypr_shell_ui} "$@"
'';
};
hyprscratchSettings = {
daemon_options = "clean";
global_options = "";
global_rules = "float;size monitor_w*0.95 monitor_h*0.95;center";
htop = {
command = "alacritty --class htop-scratch --title htop -e htop";
class = "htop-scratch";
};
volume = {
command = "pavucontrol";
class = "org.pulseaudio.pavucontrol";
};
spotify = {
command = "spotify";
class = "spotify";
};
element = {
command = "element-desktop";
class = "Element";
};
slack = {
command = "slack";
class = "Slack";
};
transmission = {
command = "transmission-gtk";
class = "transmission-gtk";
};
dropdown = {
command = "ghostty --config-file=/home/imalison/.config/ghostty/dropdown";
class = "com.mitchellh.ghostty.dropdown";
options = "persist";
rules = "float;size monitor_w monitor_h*0.5;move 0 60;noborder;noshadow;animation slide";
};
};
});
enabledModule = makeEnable config "myModules.hyprland" true {
# Install both shell service units so `desktop_shell_ui set ...` can switch
# between them at runtime without a NixOS rebuild.
myModules.noctalia.enable = lib.mkDefault true;
myModules.taffybar.enable = lib.mkDefault true;
myModules.taffybar.enable = true;
# Needed for hyprlock authentication without PAM fallback warnings.
security.pam.services.hyprlock = { };
security.pam.services.hyprlock = {};
# DDC/CI monitor control for keyboard-driven input switching.
hardware.i2c = {
@@ -105,106 +39,157 @@ let
home-manager.sharedModules = [
inputs.hyprscratch.homeModules.default
(
{ config, lib, ... }:
let
hyprConfig =
name:
config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/dotfiles/dotfiles/config/hypr/${name}";
in
{
services.kanshi = {
enable = true;
systemdTarget = "graphical-session.target";
settings = [
{
# USB-C connector names can move between DP-* ports across docks/reboots.
# Match the ultrawide by make/model and allow the serial field to vary.
profile.name = "ultrawide-usbc-desk";
profile.outputs = [
{
criteria = "eDP-1";
status = "enable";
mode = "2560x1600@240Hz";
position = "0,0";
scale = 1.0;
}
{
criteria = "Microstep MPG341CX OLED *";
status = "enable";
mode = "3440x1440@240Hz";
position = "2560,0";
scale = 1.0;
}
];
}
{
# When the laptop panel is unavailable (e.g. lid-closed docked use),
# still drive the ultrawide at its full refresh rate.
profile.name = "ultrawide-only";
profile.outputs = [
{
criteria = "Microstep MPG341CX OLED *";
status = "enable";
mode = "3440x1440@240Hz";
position = "0,0";
scale = 1.0;
}
];
}
];
};
({ config, ... }: {
xdg.configFile."hypr" = {
force = true;
source =
if cfg.useLuaConfigBranch
then ../dotfiles/config/hypr
else config.lib.file.mkOutOfStoreSymlink "${config.home.homeDirectory}/dotfiles/dotfiles/config/hypr";
};
programs.hyprscratch = {
enable = false;
settings = { };
};
services.kanshi = {
enable = true;
systemdTarget = "graphical-session.target";
settings = [
{
# USB-C connector names can move between DP-* ports across docks/reboots.
# Match the ultrawide by make/model and allow the serial field to vary.
profile.name = "ultrawide-usbc-desk";
profile.outputs = [
{
criteria = "eDP-1";
status = "enable";
mode = "2560x1600@240Hz";
position = "0,0";
scale = 1.0;
}
{
criteria = "Microstep MPG341CX OLED *";
status = "enable";
mode = "3440x1440@240Hz";
position = "2560,0";
scale = 1.0;
}
];
}
{
# When the laptop panel is unavailable (e.g. lid-closed docked use),
# still drive the ultrawide at its full refresh rate.
profile.name = "ultrawide-only";
profile.outputs = [
{
criteria = "Microstep MPG341CX OLED *";
status = "enable";
mode = "3440x1440@240Hz";
position = "0,0";
scale = 1.0;
}
];
}
];
};
xdg.configFile."hyprscratch/config.conf" = lib.mkIf false {
text = lib.hm.generators.toHyprconf {
attrs = hyprscratchSettings;
programs.hyprscratch = {
enable = !cfg.useLuaConfigBranch;
settings = {
daemon_options = "clean";
global_options = "";
global_rules = "float;size monitor_w*0.95 monitor_h*0.95;center";
htop = {
command = "alacritty --class htop-scratch --title htop -e htop";
class = "htop-scratch";
};
volume = {
command = "pavucontrol";
class = "org.pulseaudio.pavucontrol";
};
spotify = {
command = "spotify";
class = "spotify";
};
element = {
command = "element-desktop";
class = "Element";
};
slack = {
command = "slack";
class = "Slack";
};
transmission = {
command = "transmission-gtk";
class = "transmission-gtk";
};
dropdown = {
command = "ghostty --config-file=/home/imalison/.config/ghostty/dropdown";
class = "com.mitchellh.ghostty.dropdown";
options = "persist";
rules = "float;size monitor_w monitor_h*0.5;move 0 60;noborder;noshadow;animation slide";
};
gmail = {
command = "google-chrome-stable --new-window https://mail.google.com/mail/u/0/#inbox";
class = "google-chrome";
title = "Gmail";
};
messages = {
command = "google-chrome-stable --new-window https://messages.google.com/web/conversations";
class = "google-chrome";
title = "Messages";
};
};
xdg.configFile."hypr/hyprland.lua" = {
force = true;
source = hyprConfig "hyprland.lua";
};
xdg.configFile."hypr/hypridle.conf".source = hyprConfig "hypridle.conf";
xdg.configFile."hypr/hyprlock.conf".source = hyprConfig "hyprlock.conf";
xdg.configFile."hypr/scripts".enable = false;
}
)
};
})
];
# Hyprland-specific packages
environment.systemPackages =
with pkgs;
[
# Hyprland utilities
hyprpaper # Wallpaper
hypridle # Idle daemon
hyprlock # Screen locker
hyprcursor # Cursor themes
wl-clipboard # Clipboard for Wayland
wtype # Wayland input typing
cliphist # Clipboard history
grim # Screenshot utility
slurp # Region selection
swappy # Screenshot annotation
nwg-displays # GUI monitor arrangement
mpvpaper # Layer-shell video screensaver payload
ddcutil # Monitor input switching over DDC/CI
environment.systemPackages = with pkgs; [
# Hyprland utilities
hyprpaper # Wallpaper
hypridle # Idle daemon
hyprlock # Screen locker
hyprcursor # Cursor themes
wl-clipboard # Clipboard for Wayland
wtype # Wayland input typing
cliphist # Clipboard history
grim # Screenshot utility
slurp # Region selection
swappy # Screenshot annotation
nwg-displays # GUI monitor arrangement
mpv # Graphical screensaver payload
ddcutil # Monitor input switching over DDC/CI
# For scripts
hyprRofiWindow
hyprShellUi
jq
]
++ hyprlandPluginPackages;
# For scripts
jq
] ++ luaPluginPackages ++ lib.optionals enableExternalPluginPackages [
# External plugin packages are pinned to the stable 0.53 stack.
# Keep hy3 on the stable stack; the Lua branch uses hyprNStack and the
# forked Lua-compatible hyprexpo input instead.
inputs.hy3.packages.${system}.hy3
hyprexpoPatched
];
};
in
enabledModule
enabledModule // {
options = lib.recursiveUpdate enabledModule.options {
myModules.hyprland.useLuaConfigBranch = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Use the experimental Hyprland PR 13817 Lua-config branch for the
Hyprland package itself. The experimental package set excludes hy3, and
includes the Lua-branch builds of hyprNStack and hyprexpo instead. When
a sibling `hyprland.lua` is present, the Lua config manager picks it
before `hyprland.conf`.
'';
};
};
}

View File

@@ -184,11 +184,16 @@
type = "Application";
categories = [ "Network" "WebBrowser" ];
mimeType = [
"application/pdf"
"application/rdf+xml"
"application/rss+xml"
"application/xhtml+xml"
"application/xhtml_xml"
"application/xml"
"image/gif"
"image/jpeg"
"image/png"
"image/webp"
"text/html"
"text/xml"
"x-scheme-handler/http"

View File

@@ -63,7 +63,6 @@
# Disable the old multi-node railbird k3s setup
myModules.railbird-k3s.enable = false;
myModules."keepbook-sync".enable = true;
myModules.remote-hyprland.enable = true;
# Mirror the old biskcomp "Syncthing hosting" pattern: serve the synced railbird tree over HTTPS with autoindex.
services.nginx.virtualHosts."syncthing.railbird.ai" = {

View File

@@ -113,9 +113,6 @@
codex = inputs.codex-cli-nix.packages.${prev.stdenv.hostPlatform.system}.default;
claude-code = inputs.claude-code-nix.packages.${prev.stdenv.hostPlatform.system}.default;
git-sync-rs = inputs.git-sync-rs.packages.${prev.stdenv.hostPlatform.system}.default;
kef = final.callPackage ./packages/kef {};
pykefcontrol = final.python3Packages.callPackage ./packages/pykefcontrol {};
roborock-control = final.callPackage ./packages/roborock-control {};
})
]
++ (

View File

@@ -1,87 +0,0 @@
{
config,
inputs,
lib,
makeEnable,
pkgs,
...
}:
let
system = pkgs.stdenv.hostPlatform.system;
noctaliaPackage = inputs.noctalia.packages.${system}.default;
waitForWayland = pkgs.writeShellScript "noctalia-wait-for-wayland" ''
runtime_dir="''${XDG_RUNTIME_DIR:-/run/user/$(${pkgs.coreutils}/bin/id -u)}"
for _ in $(${pkgs.coreutils}/bin/seq 1 50); do
if [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -S "$runtime_dir/$WAYLAND_DISPLAY" ]; then
exit 0
fi
${pkgs.coreutils}/bin/sleep 0.1
done
echo "Wayland socket not ready: WAYLAND_DISPLAY=''${WAYLAND_DISPLAY:-<unset>} XDG_RUNTIME_DIR=$runtime_dir" >&2
exit 1
'';
in
makeEnable config "myModules.noctalia" false {
environment.systemPackages = [
noctaliaPackage
];
# Noctalia's battery widget talks to UPower. Hosts that deliberately do not
# have batteries can still override this back to false.
services.upower.enable = lib.mkDefault true;
home-manager.sharedModules = [
inputs.noctalia.homeModules.default
({ lib, ... }: {
programs.noctalia-shell = {
enable = true;
# This module provides the Hyprland-scoped service below.
systemd.enable = false;
};
home.activation.noctaliaLauncherOverviewLayer =
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
settings_file="$HOME/.config/noctalia/settings.json"
settings_tmp="$(${pkgs.coreutils}/bin/mktemp)"
${pkgs.coreutils}/bin/mkdir -p "$(${pkgs.coreutils}/bin/dirname "$settings_file")"
if [ -e "$settings_file" ] && ${lib.getExe pkgs.jq} -e . "$settings_file" >/dev/null 2>&1; then
${lib.getExe pkgs.jq} \
'.appLauncher = (.appLauncher // {}) | .appLauncher.overviewLayer = true' \
"$settings_file" > "$settings_tmp"
${pkgs.coreutils}/bin/mv "$settings_tmp" "$settings_file"
else
${pkgs.coreutils}/bin/printf '%s\n' \
'{' \
' "appLauncher": {' \
' "overviewLayer": true' \
' }' \
'}' > "$settings_file"
${pkgs.coreutils}/bin/rm -f "$settings_tmp"
fi
'';
systemd.user.services.noctalia-shell = {
Unit = {
Description = "Noctalia Shell";
Documentation = "https://docs.noctalia.dev";
PartOf = [ "hyprland-session.target" ];
After = [ "hyprland-session.target" ];
};
Service = {
ExecCondition = "/run/current-system/sw/bin/desktop_shell_ui exec-condition noctalia";
ExecStartPre = "${waitForWayland}";
ExecStart = "${lib.getExe noctaliaPackage} --no-duplicate";
Restart = "on-failure";
RestartSec = 1;
};
Install.WantedBy = [ "hyprland-session.target" ];
};
})
];
}

View File

@@ -1,30 +0,0 @@
{
lib,
python3,
python3Packages,
writeShellApplication,
}:
let
pykefcontrol = python3Packages.callPackage ../pykefcontrol {};
python = python3.withPackages (ps: [
pykefcontrol
ps.zeroconf
]);
in
writeShellApplication {
name = "kef";
runtimeInputs = [ python ];
text = ''
exec python ${./kef.py} "$@"
'';
meta = {
description = "Command-line controller for KEF W2 speakers";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ imalison ];
mainProgram = "kef";
};
}

View File

@@ -1,251 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
import threading
import time
import requests
from pykefcontrol.kef_connector import KefConnector
from zeroconf import IPVersion, ServiceBrowser, ServiceListener, Zeroconf
SOURCES = ("wifi", "bluetooth", "tv", "optic", "coaxial", "analog", "standby")
DISCOVERY_SERVICE_TYPES = (
"_kef-info._tcp.local.",
"_sues800device._tcp.local.",
"_http._tcp.local.",
)
class KefDiscoveryListener(ServiceListener):
def __init__(self):
self._lock = threading.Lock()
self.speakers = {}
def add_service(self, zeroconf, service_type, name):
self._record_service(zeroconf, service_type, name)
def update_service(self, zeroconf, service_type, name):
self._record_service(zeroconf, service_type, name)
def remove_service(self, zeroconf, service_type, name):
return None
def _record_service(self, zeroconf, service_type, name):
info = zeroconf.get_service_info(service_type, name, timeout=1000)
if info is None:
return
properties = {
key.decode("utf-8", errors="replace"): (
value.decode("utf-8", errors="replace") if value is not None else ""
)
for key, value in info.properties.items()
}
addresses = [
address
for address in info.parsed_addresses(IPVersion.V4Only)
if not address.startswith("127.")
]
if not addresses:
return
is_kef = (
service_type in ("_kef-info._tcp.local.", "_sues800device._tcp.local.")
or properties.get("manufacturer", "").lower() == "kef"
or "kef" in name.lower()
or "ls50" in name.lower()
)
if not is_kef:
return
key = addresses[0]
with self._lock:
existing = self.speakers.get(key, {})
self.speakers[key] = {
**existing,
"address": addresses[0],
"hostname": (info.server.rstrip(".") if info.server else None)
or existing.get("hostname"),
"name": properties.get("name")
or properties.get("fn")
or existing.get("name")
or strip_service_name(name),
"model": properties.get("modelName")
or properties.get("model")
or properties.get("mn")
or existing.get("model"),
"version": properties.get("version") or existing.get("version"),
"service": existing.get("service") or service_type.removesuffix(".local."),
}
def strip_service_name(name):
for suffix in DISCOVERY_SERVICE_TYPES:
if name.endswith("." + suffix):
return name[: -(len(suffix) + 1)]
return name.rstrip(".")
def discover_speakers(timeout):
listener = KefDiscoveryListener()
zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
try:
browsers = [
ServiceBrowser(zeroconf, service_type, listener)
for service_type in DISCOVERY_SERVICE_TYPES
]
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if listener.speakers:
time.sleep(0.25)
break
time.sleep(0.05)
return sorted(
listener.speakers.values(),
key=lambda speaker: (
speaker.get("name") or "",
speaker.get("address") or "",
),
)
finally:
for browser in locals().get("browsers", []):
browser.cancel()
zeroconf.close()
def discover_host(timeout):
speakers = discover_speakers(timeout)
if not speakers:
raise SystemExit(
"Could not discover a KEF speaker. Pass --host <speaker-ip> or set KEF_HOST."
)
if len(speakers) > 1:
choices = ", ".join(
f"{speaker.get('name') or 'KEF'} at {speaker['address']}" for speaker in speakers
)
raise SystemExit(f"Multiple KEF speakers discovered: {choices}. Pass --host explicitly.")
return speakers[0]["address"]
def connector(args):
host = (
args.host
or os.environ.get("KEF_HOST")
or os.environ.get("KEF_IP")
or discover_host(args.discovery_timeout)
)
return KefConnector(host, model=args.model)
def print_status(speaker):
info = {
"name": speaker.speaker_name,
"model": speaker.speaker_model,
"firmware": speaker.firmware_version,
"status": speaker.status,
"source": speaker.source,
"volume": speaker.volume,
"playing": speaker.is_playing,
}
try:
song = speaker.get_song_information()
if any(song.values()):
info["song"] = song
except (KeyError, IndexError, TypeError, requests.RequestException):
pass
print(json.dumps(info, indent=2, sort_keys=True))
def bounded_volume(value):
return max(0, min(100, value))
def main():
parser = argparse.ArgumentParser(prog="kef", description="Control KEF W2 speakers.")
parser.add_argument(
"--host",
help="Speaker IP address. Defaults to KEF_HOST/KEF_IP, then mDNS discovery.",
)
parser.add_argument(
"--model",
default="LS50W2",
help="Speaker model passed to pykefcontrol. Default: LS50W2.",
)
parser.add_argument(
"--discovery-timeout",
default=2.0,
type=float,
help="Seconds to wait for mDNS discovery when --host/KEF_HOST is unset.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("discover", help="List discovered KEF speakers as JSON.")
subparsers.add_parser("status", help="Print speaker status as JSON.")
subparsers.add_parser("on", help="Power on.")
subparsers.add_parser("standby", help="Put the speaker in standby.")
subparsers.add_parser("mute", help="Mute by setting volume to 0.")
volume = subparsers.add_parser("volume", help="Get or set volume.")
volume.add_argument("level", nargs="?", type=int, help="Volume level, 0-100.")
up = subparsers.add_parser("up", help="Increase volume.")
up.add_argument("step", nargs="?", default=3, type=int)
down = subparsers.add_parser("down", help="Decrease volume.")
down.add_argument("step", nargs="?", default=3, type=int)
source = subparsers.add_parser("source", help="Get or set source.")
source.add_argument("name", nargs="?", choices=SOURCES)
subparsers.add_parser("play-pause", help="Toggle play/pause.")
subparsers.add_parser("next", help="Next track.")
subparsers.add_parser("previous", help="Previous track.")
args = parser.parse_args()
if args.command == "discover":
print(json.dumps(discover_speakers(args.discovery_timeout), indent=2, sort_keys=True))
return 0
speaker = connector(args)
try:
if args.command == "status":
print_status(speaker)
elif args.command == "on":
speaker.power_on()
elif args.command == "standby":
speaker.shutdown()
elif args.command == "mute":
speaker.volume = 0
elif args.command == "volume":
if args.level is None:
print(speaker.volume)
else:
speaker.volume = bounded_volume(args.level)
elif args.command == "up":
speaker.volume = bounded_volume(speaker.volume + args.step)
elif args.command == "down":
speaker.volume = bounded_volume(speaker.volume - args.step)
elif args.command == "source":
if args.name is None:
print(speaker.source)
else:
speaker.source = args.name
elif args.command == "play-pause":
speaker.toggle_play_pause()
elif args.command == "next":
speaker.next_track()
elif args.command == "previous":
speaker.previous_track()
except requests.RequestException as error:
print(f"kef: failed to reach speaker: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,37 +0,0 @@
{
lib,
buildPythonPackage,
fetchPypi,
hatchling,
aiohttp,
requests,
}:
buildPythonPackage rec {
pname = "pykefcontrol";
version = "0.9.2";
pyproject = true;
src = fetchPypi {
inherit pname version;
hash = "sha256-3kGhN+E7driiE6ePyF0EZOEnUhTm07sxHCKdzrn/MxM=";
};
build-system = [
hatchling
];
dependencies = [
aiohttp
requests
];
pythonImportsCheck = [ "pykefcontrol" ];
meta = {
description = "Python library for controlling KEF LS50WII, LSX II, and LS60 speakers";
homepage = "https://github.com/N0ciple/pykefcontrol";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ imalison ];
};
}

View File

@@ -1,30 +0,0 @@
{
lib,
python3,
writeShellApplication,
}:
let
python = python3.withPackages (ps: [
ps.python-roborock
ps.pyshark
ps.pyyaml
]);
in
writeShellApplication {
name = "roborock-control";
runtimeInputs = [ python ];
text = ''
export ROBOROCK_CONTROL_RUNNER=direct
exec python ${../../../dotfiles/lib/bin/roborock-control} "$@"
'';
meta = {
description = "Command-line controller for Roborock vacuums";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ imalison ];
mainProgram = "roborock-control";
};
}

View File

@@ -0,0 +1,228 @@
From aaefc0ff0bc4348de04f311ad0101da44c62ae94 Mon Sep 17 00:00:00 2001
From: Ivan Malison <IvanMalison@gmail.com>
Date: Wed, 4 Feb 2026 00:54:52 -0800
Subject: [PATCH 1/2] hyprexpo: optionally render workspace numbers
---
hyprexpo/README.md | 3 +-
hyprexpo/main.cpp | 2 +
hyprexpo/overview.cpp | 109 ++++++++++++++++++++++++++++++++++++++++++
hyprexpo/overview.hpp | 4 ++
4 files changed, 117 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 97bd1d4..aac2e97 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,8 @@ gap_size | number | gap between desktops | `5`
bg_col | color | color in gaps (between desktops) | `rgb(000000)`
workspace_method | [center/first] [workspace] | position of the desktops | `center current`
skip_empty | boolean | whether the grid displays workspaces sequentially by id using selector "r" (`false`) or skips empty workspaces using selector "m" (`true`) | `false`
+show_workspace_numbers | boolean | show numeric labels for workspaces | `false`
+workspace_number_color | color | color of workspace number labels | `rgb(ffffff)`
gesture_distance | number | how far is the max for the gesture | `300`
### Keywords
@@ -57,4 +59,3 @@ off | hides the overview
disable | same as `off`
on | displays the overview
enable | same as `on`
-
diff --git a/main.cpp b/main.cpp
index 883fd82..ff9f380 100644
--- a/main.cpp
+++ b/main.cpp
@@ -239,6 +239,8 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:bg_col", Hyprlang::INT{0xFF111111});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:workspace_method", Hyprlang::STRING{"center current"});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:skip_empty", Hyprlang::INT{0});
+ HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:show_workspace_numbers", Hyprlang::INT{0});
+ HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:workspace_number_color", Hyprlang::INT{0xFFFFFFFF});
HyprlandAPI::addConfigValue(PHANDLE, "plugin:hyprexpo:gesture_distance", Hyprlang::INT{200});
diff --git a/overview.cpp b/overview.cpp
index 5721948..926a9f8 100644
--- a/overview.cpp
+++ b/overview.cpp
@@ -1,5 +1,8 @@
#include "overview.hpp"
#include <any>
+#include <algorithm>
+#include <cmath>
+#include <pango/pangocairo.h>
#define private public
#include <hyprland/src/render/Renderer.hpp>
#include <hyprland/src/Compositor.hpp>
@@ -15,6 +18,86 @@
#undef private
#include "OverviewPassElement.hpp"
+static Vector2D renderLabelTexture(SP<CTexture> out, const std::string& text, const CHyprColor& color, int fontSizePx) {
+ if (!out || text.empty() || fontSizePx <= 0)
+ return {};
+
+ auto measureSurface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 1, 1);
+ auto measureCairo = cairo_create(measureSurface);
+
+ PangoLayout* measureLayout = pango_cairo_create_layout(measureCairo);
+ pango_layout_set_text(measureLayout, text.c_str(), -1);
+ auto* fontDesc = pango_font_description_from_string("Sans Bold");
+ pango_font_description_set_size(fontDesc, fontSizePx * PANGO_SCALE);
+ pango_layout_set_font_description(measureLayout, fontDesc);
+ pango_font_description_free(fontDesc);
+
+ PangoRectangle inkRect, logicalRect;
+ pango_layout_get_extents(measureLayout, &inkRect, &logicalRect);
+
+ const int textW = std::max(1, (int)std::ceil(logicalRect.width / (double)PANGO_SCALE));
+ const int textH = std::max(1, (int)std::ceil(logicalRect.height / (double)PANGO_SCALE));
+
+ g_object_unref(measureLayout);
+ cairo_destroy(measureCairo);
+ cairo_surface_destroy(measureSurface);
+
+ const int pad = std::max(4, (int)std::round(fontSizePx * 0.35));
+ const int width = textW + pad * 2;
+ const int height = textH + pad * 2;
+
+ auto surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
+ auto cairo = cairo_create(surface);
+
+ // Clear the pixmap
+ cairo_save(cairo);
+ cairo_set_operator(cairo, CAIRO_OPERATOR_CLEAR);
+ cairo_paint(cairo);
+ cairo_restore(cairo);
+
+ // Background for legibility
+ cairo_set_source_rgba(cairo, 0.0, 0.0, 0.0, 0.55);
+ cairo_rectangle(cairo, 0, 0, width, height);
+ cairo_fill(cairo);
+
+ PangoLayout* layout = pango_cairo_create_layout(cairo);
+ pango_layout_set_text(layout, text.c_str(), -1);
+ fontDesc = pango_font_description_from_string("Sans Bold");
+ pango_font_description_set_size(fontDesc, fontSizePx * PANGO_SCALE);
+ pango_layout_set_font_description(layout, fontDesc);
+ pango_font_description_free(fontDesc);
+
+ pango_layout_get_extents(layout, &inkRect, &logicalRect);
+ const double xOffset = (width - logicalRect.width / (double)PANGO_SCALE) / 2.0;
+ const double yOffset = (height - logicalRect.height / (double)PANGO_SCALE) / 2.0;
+
+ cairo_set_source_rgba(cairo, color.r, color.g, color.b, color.a);
+ cairo_move_to(cairo, xOffset, yOffset);
+ pango_cairo_show_layout(cairo, layout);
+
+ g_object_unref(layout);
+
+ cairo_surface_flush(surface);
+
+ const auto DATA = cairo_image_surface_get_data(surface);
+ out->allocate();
+ glBindTexture(GL_TEXTURE_2D, out->m_texID);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+
+#ifndef GLES2
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_BLUE);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_RED);
+#endif
+
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, DATA);
+
+ cairo_destroy(cairo);
+ cairo_surface_destroy(surface);
+
+ return {width, height};
+}
+
static void damageMonitor(WP<Hyprutils::Animation::CBaseAnimatedVariable> thisptr) {
g_pOverview->damage();
}
@@ -34,11 +117,14 @@ COverview::COverview(PHLWORKSPACE startedOn_, bool swipe_) : startedOn(startedOn
static auto* const* PGAPS = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:gap_size")->getDataStaticPtr();
static auto* const* PCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:bg_col")->getDataStaticPtr();
static auto* const* PSKIP = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:skip_empty")->getDataStaticPtr();
+ static auto* const* PSHOWNUM = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:show_workspace_numbers")->getDataStaticPtr();
+ static auto* const* PNUMCOL = (Hyprlang::INT* const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:workspace_number_color")->getDataStaticPtr();
static auto const* PMETHOD = (Hyprlang::STRING const*)HyprlandAPI::getConfigValue(PHANDLE, "plugin:hyprexpo:workspace_method")->getDataStaticPtr();
SIDE_LENGTH = **PCOLUMNS;
GAP_WIDTH = **PGAPS;
BG_COLOR = **PCOL;
+ showWorkspaceNumbers = **PSHOWNUM;
// process the method
bool methodCenter = true;
@@ -126,6 +212,17 @@ COverview::COverview(PHLWORKSPACE startedOn_, bool swipe_) : startedOn(startedOn
Vector2D tileRenderSize = (pMonitor->m_size - Vector2D{GAP_WIDTH * pMonitor->m_scale, GAP_WIDTH * pMonitor->m_scale} * (SIDE_LENGTH - 1)) / SIDE_LENGTH;
CBox monbox{0, 0, tileSize.x * 2, tileSize.y * 2};
+ if (showWorkspaceNumbers) {
+ const CHyprColor numberColor = **PNUMCOL;
+ const int fontSizePx = std::max(12, (int)std::round(tileRenderSize.y * pMonitor->m_scale * 0.22));
+ for (auto& image : images) {
+ if (image.workspaceID == WORKSPACE_INVALID)
+ continue;
+ image.labelTex = makeShared<CTexture>();
+ image.labelSizePx = renderLabelTexture(image.labelTex, std::to_string(image.workspaceID), numberColor, fontSizePx);
+ }
+ }
+
if (!ENABLE_LOWRES)
monbox = {{0, 0}, pMonitor->m_pixelSize};
@@ -452,6 +549,18 @@ void COverview::fullRender() {
texbox.round();
CRegion damage{0, 0, INT16_MAX, INT16_MAX};
g_pHyprOpenGL->renderTextureInternal(images[x + y * SIDE_LENGTH].fb.getTexture(), texbox, {.damage = &damage, .a = 1.0});
+
+ if (showWorkspaceNumbers) {
+ auto& image = images[x + y * SIDE_LENGTH];
+ if (image.workspaceID != WORKSPACE_INVALID && image.labelTex && image.labelTex->m_texID != 0 && image.labelSizePx.x > 0 && image.labelSizePx.y > 0) {
+ const Vector2D labelSize = image.labelSizePx / pMonitor->m_scale;
+ const float margin = std::max(4.0, tileRenderSize.y * 0.05);
+ CBox labelBox = {x * tileRenderSize.x + x * GAPSIZE + margin, y * tileRenderSize.y + y * GAPSIZE + margin, labelSize.x, labelSize.y};
+ labelBox.scale(pMonitor->m_scale).translate(pos->value());
+ labelBox.round();
+ g_pHyprOpenGL->renderTexture(image.labelTex, labelBox, {.a = 1.0});
+ }
+ }
}
}
}
diff --git a/overview.hpp b/overview.hpp
index 4b02400..1f6bf3c 100644
--- a/overview.hpp
+++ b/overview.hpp
@@ -8,6 +8,7 @@
#include <hyprland/src/helpers/AnimatedVariable.hpp>
#include <hyprland/src/managers/HookSystemManager.hpp>
#include <vector>
+class CTexture;
// saves on resources, but is a bit broken rn with blur.
// hyprland's fault, but cba to fix.
@@ -58,6 +59,8 @@ class COverview {
int64_t workspaceID = -1;
PHLWORKSPACE pWorkspace;
CBox box;
+ SP<CTexture> labelTex;
+ Vector2D labelSizePx;
};
Vector2D lastMousePosLocal = Vector2D{};
@@ -81,6 +84,7 @@ class COverview {
bool swipe = false;
bool swipeWasCommenced = false;
+ bool showWorkspaceNumbers = false;
friend class COverviewPassElement;
};
--
2.52.0

View File

@@ -0,0 +1,147 @@
From edc05ce88f79ceda0cdcb9aa68ec371b1af323de Mon Sep 17 00:00:00 2001
From: Ivan Malison <IvanMalison@gmail.com>
Date: Mon, 16 Feb 2026 21:50:16 -0800
Subject: [PATCH 2/2] hyprexpo: add bring selection mode
---
hyprexpo/README.md | 1 +
hyprexpo/main.cpp | 50 +++++++++++++++++++++++++++++++++++++++++++
hyprexpo/overview.cpp | 12 +++++++++--
hyprexpo/overview.hpp | 3 ++-
4 files changed, 63 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index aac2e97..084f02b 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ Here are a list of options you can use:
| --- | --- |
toggle | displays if hidden, hide if displayed
select | selects the hovered desktop
+bring | brings a window from the hovered desktop to the current desktop
off | hides the overview
disable | same as `off`
on | displays the overview
diff --git a/main.cpp b/main.cpp
index ff9f380..78bac24 100644
--- a/main.cpp
+++ b/main.cpp
@@ -65,6 +65,47 @@ static void hkAddDamageB(void* thisptr, const pixman_region32_t* rg) {
g_pOverview->onDamageReported();
}
+static PHLWINDOW windowToBringFromWorkspace(const PHLWORKSPACE& workspace) {
+ if (!workspace)
+ return nullptr;
+
+ for (auto it = g_pCompositor->m_windows.rbegin(); it != g_pCompositor->m_windows.rend(); ++it) {
+ const auto& w = *it;
+ if (!w || w->m_workspace != workspace || !w->m_isMapped || w->isHidden())
+ continue;
+
+ return w;
+ }
+
+ return nullptr;
+}
+
+static SDispatchResult bringWindowFromWorkspace(int64_t sourceWorkspaceID) {
+ if (sourceWorkspaceID == WORKSPACE_INVALID)
+ return {.success = false, .error = "selected workspace is empty"};
+
+ const auto FOCUSSTATE = Desktop::focusState();
+ const auto MONITOR = FOCUSSTATE->monitor();
+ if (!MONITOR || !MONITOR->m_activeWorkspace)
+ return {.success = false, .error = "no active monitor/workspace"};
+
+ if (sourceWorkspaceID == MONITOR->activeWorkspaceID())
+ return {};
+
+ const auto SOURCEWORKSPACE = g_pCompositor->getWorkspaceByID(sourceWorkspaceID);
+ if (!SOURCEWORKSPACE)
+ return {.success = false, .error = "selected workspace is not open"};
+
+ const auto WINDOW = windowToBringFromWorkspace(SOURCEWORKSPACE);
+ if (!WINDOW)
+ return {.success = false, .error = "selected workspace has no mapped windows"};
+
+ g_pCompositor->moveWindowToWorkspaceSafe(WINDOW, MONITOR->m_activeWorkspace);
+ FOCUSSTATE->fullWindowFocus(WINDOW);
+ g_pCompositor->warpCursorTo(WINDOW->middle());
+ return {};
+}
+
static SDispatchResult onExpoDispatcher(std::string arg) {
if (g_pOverview && g_pOverview->m_isSwiping)
@@ -77,6 +118,15 @@ static SDispatchResult onExpoDispatcher(std::string arg) {
}
return {};
}
+ if (arg == "bring") {
+ if (g_pOverview) {
+ g_pOverview->selectHoveredWorkspace();
+ const auto BRINGRESULT = bringWindowFromWorkspace(g_pOverview->selectedWorkspaceID());
+ g_pOverview->close(false);
+ return BRINGRESULT;
+ }
+ return {};
+ }
if (arg == "toggle") {
if (g_pOverview)
g_pOverview->close();
diff --git a/overview.cpp b/overview.cpp
index 926a9f8..45ee982 100644
--- a/overview.cpp
+++ b/overview.cpp
@@ -343,6 +343,14 @@ void COverview::selectHoveredWorkspace() {
closeOnID = x + y * SIDE_LENGTH;
}
+int64_t COverview::selectedWorkspaceID() const {
+ const int ID = closeOnID == -1 ? openedID : closeOnID;
+ if (ID < 0 || ID >= (int)images.size())
+ return WORKSPACE_INVALID;
+
+ return images[ID].workspaceID;
+}
+
void COverview::redrawID(int id, bool forcelowres) {
if (!pMonitor)
return;
@@ -451,7 +459,7 @@ void COverview::onDamageReported() {
g_pCompositor->scheduleFrameForMonitor(pMonitor.lock());
}
-void COverview::close() {
+void COverview::close(bool switchToSelection) {
if (closing)
return;
@@ -471,7 +479,7 @@ void COverview::close() {
redrawAll();
- if (TILE.workspaceID != pMonitor->activeWorkspaceID()) {
+ if (switchToSelection && TILE.workspaceID != pMonitor->activeWorkspaceID()) {
pMonitor->setSpecialWorkspace(0);
// If this tile's workspace was WORKSPACE_INVALID, move to the next
diff --git a/overview.hpp b/overview.hpp
index 1f6bf3c..ca59f32 100644
--- a/overview.hpp
+++ b/overview.hpp
@@ -33,8 +33,9 @@ class COverview {
void onSwipeEnd();
// close without a selection
- void close();
+ void close(bool switchToSelection = true);
void selectHoveredWorkspace();
+ int64_t selectedWorkspaceID() const;
bool blockOverviewRendering = false;
bool blockDamageReporting = false;
--
2.52.0

View File

@@ -1,200 +0,0 @@
{ config, lib, pkgs, makeEnable, ... }:
let
cfg = config.myModules.remote-hyprland;
hyprlandPackage = config.programs.hyprland.package;
geometry = "${toString cfg.width}x${toString cfg.height}@${toString cfg.refreshRate}";
monitorRule = "${cfg.output},${geometry},0x0,${toString cfg.scale}";
remoteHyprlandStartVnc = pkgs.writeShellScript "remote-hyprland-start-vnc" ''
set -euo pipefail
export WAYLAND_DISPLAY=${cfg.socket}
export XDG_CURRENT_DESKTOP=Hyprland
export XDG_SESSION_DESKTOP=Hyprland
export XDG_SESSION_TYPE=wayland
for _ in $(${pkgs.coreutils}/bin/seq 1 50); do
if ${hyprlandPackage}/bin/hyprctl -j monitors >/dev/null 2>&1; then
break
fi
${pkgs.coreutils}/bin/sleep 0.1
done
# Give wayvnc a stable output name instead of relying on Hyprland's
# fallback HEADLESS-* naming.
${hyprlandPackage}/bin/hyprctl output create headless ${cfg.output} >/dev/null 2>&1 || true
${hyprlandPackage}/bin/hyprctl keyword monitor '${monitorRule}' >/dev/null 2>&1 || true
exec ${pkgs.wayvnc}/bin/wayvnc \
--log-level=info \
--output ${cfg.output} \
${cfg.bindAddress} ${toString cfg.port}
'';
remoteHyprlandConfig = pkgs.writeText "remote-hyprland.conf" ''
monitor=${monitorRule}
monitor=,${geometry},0x0,${toString cfg.scale}
env = XDG_CURRENT_DESKTOP,Hyprland
env = XDG_SESSION_DESKTOP,Hyprland
env = XDG_SESSION_TYPE,wayland
input {
kb_layout = us
follow_mouse = 1
}
general {
gaps_in = 4
gaps_out = 8
border_size = 2
layout = dwindle
}
decoration {
rounding = 4
}
dwindle {
pseudotile = true
preserve_split = true
}
misc {
disable_hyprland_logo = true
disable_splash_rendering = true
force_default_wallpaper = 0
}
$mainMod = SUPER
bind = $mainMod, Return, exec, ${cfg.terminalCommand}
bind = $mainMod, D, exec, ${pkgs.rofi}/bin/rofi -show drun
bind = $mainMod, Q, killactive
bind = $mainMod SHIFT, M, exit
bind = $mainMod, H, movefocus, l
bind = $mainMod, J, movefocus, d
bind = $mainMod, K, movefocus, u
bind = $mainMod, L, movefocus, r
bind = $mainMod SHIFT, H, movewindow, l
bind = $mainMod SHIFT, J, movewindow, d
bind = $mainMod SHIFT, K, movewindow, u
bind = $mainMod SHIFT, L, movewindow, r
bind = $mainMod, 1, workspace, 1
bind = $mainMod, 2, workspace, 2
bind = $mainMod, 3, workspace, 3
bind = $mainMod, 4, workspace, 4
bind = $mainMod, 5, workspace, 5
bind = $mainMod, 6, workspace, 6
bind = $mainMod, 7, workspace, 7
bind = $mainMod, 8, workspace, 8
bind = $mainMod, 9, workspace, 9
bind = $mainMod SHIFT, 1, movetoworkspace, 1
bind = $mainMod SHIFT, 2, movetoworkspace, 2
bind = $mainMod SHIFT, 3, movetoworkspace, 3
bind = $mainMod SHIFT, 4, movetoworkspace, 4
bind = $mainMod SHIFT, 5, movetoworkspace, 5
bind = $mainMod SHIFT, 6, movetoworkspace, 6
bind = $mainMod SHIFT, 7, movetoworkspace, 7
bind = $mainMod SHIFT, 8, movetoworkspace, 8
bind = $mainMod SHIFT, 9, movetoworkspace, 9
exec-once = ${remoteHyprlandStartVnc}
exec-once = ${cfg.terminalCommand}
'';
enabledModule = makeEnable config "myModules.remote-hyprland" false {
myModules.hyprland.enable = true;
users.manageLingering = true;
users.users.${cfg.user}.linger = true;
environment.systemPackages = [ pkgs.wayvnc ];
home-manager.users.${cfg.user}.systemd.user.services.remote-hyprland = {
Unit = {
Description = "Headless Hyprland session for remote VNC access";
After = [ "default.target" ];
};
Service = {
ExecStart = "${hyprlandPackage}/bin/Hyprland --socket ${cfg.socket} --config ${remoteHyprlandConfig}";
Restart = "on-failure";
RestartSec = 5;
Environment = [
"XDG_CURRENT_DESKTOP=Hyprland"
"XDG_SESSION_DESKTOP=Hyprland"
"XDG_SESSION_TYPE=wayland"
"WAYLAND_DISPLAY=${cfg.socket}"
];
};
Install = {
WantedBy = [ "default.target" ];
};
};
};
in
enabledModule // {
options = lib.recursiveUpdate enabledModule.options {
myModules.remote-hyprland = {
user = lib.mkOption {
type = lib.types.str;
default = "imalison";
description = "User account that owns the remote Hyprland session.";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for wayvnc to bind. Keep localhost when using SSH or Tailscale forwarding.";
};
port = lib.mkOption {
type = lib.types.port;
default = 5900;
description = "TCP port for wayvnc.";
};
socket = lib.mkOption {
type = lib.types.str;
default = "wayland-remote-hyprland";
description = "Wayland socket name used by the remote Hyprland instance.";
};
output = lib.mkOption {
type = lib.types.str;
default = "remote";
description = "Stable Hyprland headless output name captured by wayvnc.";
};
width = lib.mkOption {
type = lib.types.ints.positive;
default = 1920;
description = "Remote output width.";
};
height = lib.mkOption {
type = lib.types.ints.positive;
default = 1080;
description = "Remote output height.";
};
refreshRate = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = "Remote output refresh rate.";
};
scale = lib.mkOption {
type = lib.types.number;
default = 1;
description = "Remote output scale.";
};
terminalCommand = lib.mkOption {
type = lib.types.str;
default = "${pkgs.ghostty}/bin/ghostty --gtk-single-instance=false";
description = "Command launched for the default terminal binding and initial window.";
};
};
};
}

View File

@@ -2,49 +2,47 @@
makeEnable,
config,
...
}:
let
}: let
shared = import ../nix-shared/syncthing.nix;
inherit (shared) devices allDevices;
in
makeEnable config "myModules.syncthing" true {
system.activationScripts.syncthingPermissions = {
text = ''
mkdir -p /var/lib/syncthing/sync
mkdir -p /var/lib/syncthing/sync/Screensaver/use
mkdir -p /var/lib/syncthing/railbird
chown -R syncthing:syncthing /var/lib/syncthing
chmod -R 2770 /var/lib/syncthing
'';
};
systemd.services.syncthing = {
serviceConfig = {
AmbientCapabilities = "CAP_CHOWN";
CapabilityBoundingSet = "CAP_CHOWN";
makeEnable config "myModules.syncthing" true {
system.activationScripts.syncthingPermissions = {
text = ''
chown -R syncthing:syncthing /var/lib/syncthing
chmod -R 2770 /var/lib/syncthing
mkdir -p /var/lib/syncthing/sync
mkdir -p /var/lib/syncthing/railbird
'';
};
};
services.syncthing = {
enable = true;
settings = {
inherit devices;
folders = {
sync = {
path = "~/sync";
devices = allDevices;
ignorePerms = true;
copyOwnershipFromParent = true;
};
railbird = {
path = "~/railbird";
devices = allDevices;
ignorePerms = true;
copyOwnershipFromParent = true;
};
};
options = {
relaysEnabled = true;
localAnnounceEnabled = true;
systemd.services.syncthing = {
serviceConfig = {
AmbientCapabilities = "CAP_CHOWN";
CapabilityBoundingSet = "CAP_CHOWN";
};
};
};
}
services.syncthing = {
enable = true;
settings = {
inherit devices;
folders = {
sync = {
path = "~/sync";
devices = allDevices;
ignorePerms = true;
copyOwnershipFromParent = true;
};
railbird = {
path = "~/railbird";
devices = allDevices;
ignorePerms = true;
copyOwnershipFromParent = true;
};
};
options = {
relaysEnabled = true;
localAnnounceEnabled = true;
};
};
};
}

View File

@@ -56,29 +56,41 @@ let
exec ${taffybarPackage}/bin/taffybar "$@"
'';
skipTaffybarInOtherShells = pkgs.writeShellScript "skip-taffybar-in-other-shells" ''
skipTaffybarInKde = pkgs.writeShellScript "skip-taffybar-in-kde" ''
current_desktop="''${XDG_CURRENT_DESKTOP:-}"
desktop_session="''${DESKTOP_SESSION:-}"
case "''${current_desktop}:''${desktop_session}" in
*KDE*|*kde*|*Plasma*|*plasma*) exit 1 ;;
*) exit 0 ;;
esac
exit 0
'';
taffybarExecCondition = pkgs.writeShellScript "taffybar-exec-condition" ''
${skipTaffybarInOtherShells} || exit 1
if [ -x /run/current-system/sw/bin/desktop_shell_ui ]; then
exec /run/current-system/sw/bin/desktop_shell_ui exec-condition taffybar
fi
exit 0
'';
in
makeEnable config "myModules.taffybar" false {
myModules.sni.enable = true;
nixpkgs.overlays = with inputs; (
if builtins.isList taffybar.overlays
then taffybar.overlays
else builtins.attrValues taffybar.overlays
) ++ [
# status-notifier-item's test suite spawns `dbus-daemon`; ensure it's on PATH.
(final: prev: {
haskellPackages = prev.haskellPackages.override (old: {
overrides =
final.lib.composeExtensions (old.overrides or (_: _: {}))
(hself: hsuper: {
status-notifier-item = hsuper.status-notifier-item.overrideAttrs (oldAttrs: {
checkInputs = (oldAttrs.checkInputs or []) ++ [ prev.dbus ];
# The test suite assumes a system-wide /etc/dbus-1/session.conf,
# which isn't present in Nix sandboxes.
doCheck = false;
});
});
});
})
];
environment.systemPackages = [
taffybarPackage
];
@@ -109,7 +121,7 @@ makeEnable config "myModules.taffybar" false {
rmdir --ignore-fail-on-non-empty "$HOME/.config/systemd/user/taffybar.service.d" 2>/dev/null || true
'';
systemd.user.services.taffybar.Service = {
ExecCondition = "${taffybarExecCondition}";
ExecCondition = "${skipTaffybarInKde}";
ExecStart = lib.mkForce "${taffybarStart}";
# Temporary startup debugging: keep a plain-text log outside journald so
# the next login/startup leaves easy-to-inspect tray traces behind.

View File

@@ -1,6 +1,6 @@
{ config, pkgs, inputs, lib, makeEnable, ... }:
{ config, pkgs, inputs, makeEnable, ... }:
makeEnable config "myModules.xmonad" true {
myModules.taffybar.enable = lib.mkDefault (config.myModules.desktop.shellUi == "taffybar");
myModules.taffybar.enable = true;
nixpkgs.overlays = with inputs; [
xmonad.overlay