Cleanup of old configs.
This commit is contained in:
@@ -1,256 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// GENERAL & SYSTEM SETTINGS
|
|
||||||
// Wiki: https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
|
|
||||||
// ============================================================================
|
|
||||||
config-notification {
|
|
||||||
disable-failed
|
|
||||||
}
|
|
||||||
|
|
||||||
hotkey-overlay {
|
|
||||||
skip-at-startup
|
|
||||||
}
|
|
||||||
|
|
||||||
prefer-no-csd
|
|
||||||
|
|
||||||
screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png"
|
|
||||||
|
|
||||||
debug {
|
|
||||||
honor-xdg-activation-with-invalid-serial
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ENVIRONMENT VARIABLES
|
|
||||||
// ============================================================================
|
|
||||||
environment {
|
|
||||||
XDG_CURRENT_DESKTOP "niri"
|
|
||||||
QT_QPA_PLATFORM "wayland;xcb"
|
|
||||||
ELECTRON_OZONE_PLATFORM_HINT "auto"
|
|
||||||
QT_QPA_PLATFORMTHEME "gtk3"
|
|
||||||
QT_QPA_PLATFORMTHEME_QT6 "gtk3"
|
|
||||||
TERMINAL "alacritty"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AUTOSTART / SPAWN AT STARTUP
|
|
||||||
// ============================================================================
|
|
||||||
spawn-at-startup "dms" "run"
|
|
||||||
|
|
||||||
// testing
|
|
||||||
spawn-sh-at-startup "python3 /home/jason/Projects/dotfiles/config/niri/scripts/niri_tilemod.py --num_stack 1"
|
|
||||||
|
|
||||||
// Export the session env to D-Bus so xdg-desktop-portal works
|
|
||||||
spawn-at-startup "dbus-update-activation-environment" "--all"
|
|
||||||
|
|
||||||
// Audio stack
|
|
||||||
spawn-at-startup "pipewire"
|
|
||||||
|
|
||||||
// PolicyKit authentication agent (verify path: xbps-query -f mate-polkit | grep -i agent)
|
|
||||||
spawn-at-startup "sh" "-c" "/usr/libexec/polkit-mate-authentication-agent-1 &"
|
|
||||||
|
|
||||||
// Clipboard history backing store for Mod+V
|
|
||||||
spawn-at-startup "sh" "-c" "wl-paste --watch cliphist store"
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INPUT & GESTURES
|
|
||||||
// Wiki: https://github.com/YaLTeR/niri/wiki/Configuration:-Input
|
|
||||||
// ============================================================================
|
|
||||||
input {
|
|
||||||
keyboard {
|
|
||||||
xkb {
|
|
||||||
// If this section is empty, niri will fetch xkb settings
|
|
||||||
// from org.freedesktop.locale1. You can control these using
|
|
||||||
// localectl set-x11-keymap.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable numlock on startup, omitting this setting disables it.
|
|
||||||
numlock
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus windows and outputs automatically when moving the mouse into them.
|
|
||||||
focus-follows-mouse max-scroll-amount="95%"
|
|
||||||
}
|
|
||||||
|
|
||||||
gestures {
|
|
||||||
hot-corners {
|
|
||||||
off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// LAYOUT & VISUAL APPEARANCE
|
|
||||||
// Wiki: https://github.com/YaLTeR/niri/wiki/Configuration:-Layout
|
|
||||||
// ============================================================================
|
|
||||||
layout {
|
|
||||||
background-color "transparent"
|
|
||||||
|
|
||||||
// When to center a column when changing focus: "never", "always", or "on-overflow"
|
|
||||||
center-focused-column "never"
|
|
||||||
|
|
||||||
// Custom widths toggled by "switch-preset-column-width" (Mod+R)
|
|
||||||
preset-column-widths {
|
|
||||||
proportion 0.5
|
|
||||||
proportion 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
default-column-width { proportion 0.5; }
|
|
||||||
|
|
||||||
border {
|
|
||||||
off
|
|
||||||
width 4
|
|
||||||
active-color "#707070" // Neutral gray
|
|
||||||
inactive-color "#d0d0d0" // Light gray
|
|
||||||
urgent-color "#cc4444" // Softer red
|
|
||||||
}
|
|
||||||
|
|
||||||
shadow {
|
|
||||||
softness 30
|
|
||||||
spread 5
|
|
||||||
offset x=0 y=5
|
|
||||||
color "#0007"
|
|
||||||
}
|
|
||||||
|
|
||||||
struts {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
overview {
|
|
||||||
workspace-shadow {
|
|
||||||
off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ANIMATIONS
|
|
||||||
// ============================================================================
|
|
||||||
animations {
|
|
||||||
workspace-switch {
|
|
||||||
spring damping-ratio=0.80 stiffness=523 epsilon=0.0001
|
|
||||||
}
|
|
||||||
window-open {
|
|
||||||
duration-ms 150
|
|
||||||
curve "ease-out-expo"
|
|
||||||
}
|
|
||||||
window-close {
|
|
||||||
duration-ms 150
|
|
||||||
curve "ease-out-quad"
|
|
||||||
}
|
|
||||||
horizontal-view-movement {
|
|
||||||
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
|
|
||||||
}
|
|
||||||
window-movement {
|
|
||||||
spring damping-ratio=0.75 stiffness=323 epsilon=0.0001
|
|
||||||
}
|
|
||||||
window-resize {
|
|
||||||
spring damping-ratio=0.85 stiffness=423 epsilon=0.0001
|
|
||||||
}
|
|
||||||
config-notification-open-close {
|
|
||||||
spring damping-ratio=0.65 stiffness=923 epsilon=0.001
|
|
||||||
}
|
|
||||||
screenshot-ui-open {
|
|
||||||
duration-ms 200
|
|
||||||
curve "ease-out-quad"
|
|
||||||
}
|
|
||||||
overview-open-close {
|
|
||||||
spring damping-ratio=0.85 stiffness=800 epsilon=0.0001
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WINDOW & LAYER RULES
|
|
||||||
// Wiki: https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules
|
|
||||||
// ============================================================================
|
|
||||||
layer-rule {
|
|
||||||
match namespace="^quickshell$"
|
|
||||||
place-within-backdrop true
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"^org\.gnome\."#
|
|
||||||
draw-border-with-background false
|
|
||||||
geometry-corner-radius 12
|
|
||||||
clip-to-geometry true
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"^gnome-control-center$"#
|
|
||||||
match app-id=r#"^pavucontrol$"#
|
|
||||||
match app-id=r#"^nm-connection-editor$"#
|
|
||||||
default-column-width { proportion 0.5; }
|
|
||||||
open-floating false
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"^org\.gnome\.Calculator$"#
|
|
||||||
match app-id=r#"^gnome-calculator$"#
|
|
||||||
match app-id=r#"^galculator$"#
|
|
||||||
match app-id=r#"^blueman-manager$"#
|
|
||||||
match app-id=r#"^org\.gnome\.Nautilus$"#
|
|
||||||
match app-id=r#"^xdg-desktop-portal$"#
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id="Alacritty"
|
|
||||||
match app-id="kitty"
|
|
||||||
draw-border-with-background false
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
|
|
||||||
match app-id="zoom"
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force the Steam client, Friends List, and settings windows to float
|
|
||||||
window-rule {
|
|
||||||
match app-id="steam"
|
|
||||||
open-floating true
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id="steam"
|
|
||||||
match title="Friends List"
|
|
||||||
open-floating true
|
|
||||||
default-floating-position x=10 y=10 relative-to="bottom-right"
|
|
||||||
}
|
|
||||||
|
|
||||||
window-rule {
|
|
||||||
match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"#
|
|
||||||
default-floating-position x=10 y=10 relative-to="top-right"
|
|
||||||
open-focused false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically fullscreen games launched via Proton
|
|
||||||
window-rule {
|
|
||||||
// Proton games typically use "steam_app_<id>" as their app-id.
|
|
||||||
// The r#"..."# syntax tells niri to evaluate this as a regular expression.
|
|
||||||
match app-id=r#"^steam_app_\d+$"#
|
|
||||||
open-fullscreen true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// NAVIGATION & KEYBINDINGS
|
|
||||||
// ============================================================================
|
|
||||||
// Override to disable super+tab
|
|
||||||
recent-windows {
|
|
||||||
binds {
|
|
||||||
Alt+Tab { next-window scope="output"; }
|
|
||||||
Alt+Shift+Tab { previous-window scope="output"; }
|
|
||||||
Alt+grave { next-window filter="app-id"; }
|
|
||||||
Alt+Shift+grave { previous-window filter="app-id"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INCLUDES (DMS MANAGEMENT)
|
|
||||||
// ============================================================================
|
|
||||||
// @NOTE: only dms file we could edit
|
|
||||||
include optional=true "dms/binds.kdl"
|
|
||||||
|
|
||||||
// @NOTE: Non-editable files below all managed by dms
|
|
||||||
include optional=true "dms/colors.kdl"
|
|
||||||
include optional=true "dms/layout.kdl"
|
|
||||||
include optional=true "dms/alttab.kdl"
|
|
||||||
include optional=true "dms/outputs.kdl"
|
|
||||||
include optional=true "dms/cursor.kdl"
|
|
||||||
include optional=true "dms/input.kdl"
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
binds {
|
|
||||||
// ========================================================================
|
|
||||||
// SYSTEM & DMS APPLICATION LAUNCHERS
|
|
||||||
// ========================================================================
|
|
||||||
// Screenshots
|
|
||||||
Alt+Print hotkey-overlay-title="Screenshot: Window" { spawn "dms" "screenshot" "window"; }
|
|
||||||
Alt+XF86Launch1 hotkey-overlay-title="Screenshot: Window" { spawn "dms" "screenshot" "window"; }
|
|
||||||
Ctrl+Print hotkey-overlay-title="Screenshot: Full Screen" { spawn "dms" "screenshot" "full"; }
|
|
||||||
Ctrl+XF86Launch1 hotkey-overlay-title="Screenshot: Full Screen" { spawn "dms" "screenshot" "full"; }
|
|
||||||
Mod+P hotkey-overlay-title="Screenshot: Region" { spawn "dms" "screenshot"; }
|
|
||||||
|
|
||||||
// Launchers & Overlays
|
|
||||||
Mod+D hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
|
|
||||||
Mod+space hotkey-overlay-title="Spotlight Bar: Toggle" { spawn "dms" "ipc" "call" "spotlight-bar" "toggle"; }
|
|
||||||
Mod+Y hotkey-overlay-title="Browse Wallpapers" { spawn "dms" "ipc" "call" "dash" "toggle" "wallpaper"; }
|
|
||||||
Super+Backspace hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; }
|
|
||||||
Mod+Alt+L hotkey-overlay-title="Lock Screen" { spawn "dms" "ipc" "call" "lock" "lock"; }
|
|
||||||
|
|
||||||
// Utilities & Tools
|
|
||||||
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" { spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; }
|
|
||||||
Mod+M hotkey-overlay-title="Task Manager" { spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; }
|
|
||||||
Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; }
|
|
||||||
Mod+S hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; }
|
|
||||||
Mod+V hotkey-overlay-title="Clipboard Manager" { spawn "dms" "ipc" "call" "clipboard" "toggle"; }
|
|
||||||
Mod+Comma hotkey-overlay-title="Settings" { spawn "dms" "ipc" "call" "settings" "focusOrToggle"; }
|
|
||||||
|
|
||||||
// Configuration Helpers
|
|
||||||
Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" { spawn "dms" "ipc" "call" "workspace-rename" "open"; }
|
|
||||||
Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; }
|
|
||||||
|
|
||||||
// Audio Control (MPRIS)
|
|
||||||
Ctrl+XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "mpris" "decrement" "3"; }
|
|
||||||
Ctrl+XF86AudioRaiseVolume allow-when-locked=true { spawn "dms" "ipc" "call" "mpris" "increment" "3"; }
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// WINDOW & COLUMN FOCUS
|
|
||||||
// ========================================================================
|
|
||||||
// Focus Columns (Horizontal)
|
|
||||||
Mod+H { focus-column-left; }
|
|
||||||
Mod+Left { focus-column-left; }
|
|
||||||
Mod+L { focus-column-right; }
|
|
||||||
Mod+Right { focus-column-right; }
|
|
||||||
Mod+Home { focus-column-first; }
|
|
||||||
Mod+End { focus-column-last; }
|
|
||||||
|
|
||||||
// Focus Windows within Column (Vertical)
|
|
||||||
Mod+J { focus-window-down; }
|
|
||||||
Mod+Down { focus-window-down; }
|
|
||||||
Mod+K { focus-window-up; }
|
|
||||||
Mod+Up { focus-window-up; }
|
|
||||||
|
|
||||||
// Mouse Wheel Focus Navigation
|
|
||||||
Mod+WheelScrollLeft { focus-column-left; }
|
|
||||||
Mod+WheelScrollRight { focus-column-right; }
|
|
||||||
Mod+Shift+WheelScrollUp { focus-column-left; }
|
|
||||||
Mod+Shift+WheelScrollDown { focus-column-right; }
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// WINDOW & COLUMN MANIPULATION
|
|
||||||
// ========================================================================
|
|
||||||
// Close Window
|
|
||||||
// Mod+Q repeat=false { close-window; }
|
|
||||||
Mod+Q { spawn-sh "bash /home/jason/Projects/dotfiles/config/niri/scripts/niri_close_helper.sh"; }
|
|
||||||
|
|
||||||
// Move Columns (Horizontal)
|
|
||||||
Mod+Shift+H { move-column-left; }
|
|
||||||
Mod+Shift+Left { move-column-left; }
|
|
||||||
Mod+Shift+L { move-column-right; }
|
|
||||||
Mod+Shift+Right { move-column-right; }
|
|
||||||
Mod+Ctrl+Home { move-column-to-first; }
|
|
||||||
Mod+Ctrl+End { move-column-to-last; }
|
|
||||||
Mod+Ctrl+WheelScrollLeft { move-column-left; }
|
|
||||||
Mod+Ctrl+WheelScrollRight { move-column-right; }
|
|
||||||
Mod+Ctrl+Shift+WheelScrollUp { move-column-left; }
|
|
||||||
Mod+Ctrl+Shift+WheelScrollDown { move-column-right; }
|
|
||||||
|
|
||||||
// Move Windows within Column (Vertical)
|
|
||||||
Mod+Shift+J { move-window-down; }
|
|
||||||
Mod+Shift+Down { move-window-down; }
|
|
||||||
Mod+Shift+K { move-window-up; }
|
|
||||||
Mod+Shift+Up { move-window-up; }
|
|
||||||
|
|
||||||
// Sizing & Layout Constraints
|
|
||||||
Mod+Equal { set-column-width "+10%"; }
|
|
||||||
Mod+Minus { set-column-width "-10%"; }
|
|
||||||
Mod+Shift+Equal { set-window-height "+10%"; }
|
|
||||||
Mod+Shift+Minus { set-window-height "-10%"; }
|
|
||||||
Mod+R { switch-preset-column-width; }
|
|
||||||
Mod+Shift+R { switch-preset-window-height; }
|
|
||||||
Mod+Ctrl+R { reset-window-height; }
|
|
||||||
Mod+Ctrl+F { expand-column-to-available-width; }
|
|
||||||
Mod+F { maximize-column; }
|
|
||||||
Mod+Shift+F { fullscreen-window; }
|
|
||||||
|
|
||||||
// Positioning, Floating & Tiling States
|
|
||||||
Mod+C { center-column; }
|
|
||||||
Mod+Ctrl+C { center-visible-columns; }
|
|
||||||
Mod+W { toggle-column-tabbed-display; }
|
|
||||||
Mod+Shift+T { toggle-window-floating; }
|
|
||||||
Mod+Shift+V { switch-focus-between-floating-and-tiling; }
|
|
||||||
Mod+BracketLeft { consume-or-expel-window-left; }
|
|
||||||
Mod+BracketRight { consume-or-expel-window-right; }
|
|
||||||
Mod+Period { expel-window-from-column; }
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// WORKSPACE MANAGEMENT
|
|
||||||
// ========================================================================
|
|
||||||
// Switch Workspace Focus
|
|
||||||
Mod+Page_Up { focus-workspace-up; }
|
|
||||||
Mod+I { focus-workspace-up; }
|
|
||||||
Mod+Page_Down { focus-workspace-down; }
|
|
||||||
Mod+U { focus-workspace-down; }
|
|
||||||
Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; }
|
|
||||||
Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; }
|
|
||||||
|
|
||||||
// Switch Workspace by Index
|
|
||||||
Mod+1 { focus-workspace 1; }
|
|
||||||
Mod+2 { focus-workspace 2; }
|
|
||||||
Mod+3 { focus-workspace 3; }
|
|
||||||
Mod+4 { focus-workspace 4; }
|
|
||||||
|
|
||||||
// Move Focused Column to Workspace
|
|
||||||
Mod+Ctrl+Up { move-column-to-workspace-up; }
|
|
||||||
Mod+Ctrl+I { move-column-to-workspace-up; }
|
|
||||||
Mod+Ctrl+Down { move-column-to-workspace-down; }
|
|
||||||
Mod+Ctrl+U { move-column-to-workspace-down; }
|
|
||||||
Mod+Ctrl+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; }
|
|
||||||
Mod+Ctrl+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; }
|
|
||||||
|
|
||||||
// Move Focused Column to Workspace Index
|
|
||||||
Mod+Shift+1 { move-column-to-workspace 1; }
|
|
||||||
Mod+Shift+2 { move-column-to-workspace 2; }
|
|
||||||
Mod+Shift+3 { move-column-to-workspace 3; }
|
|
||||||
Mod+Shift+4 { move-column-to-workspace 4; }
|
|
||||||
|
|
||||||
// Move Entire Workspace Position
|
|
||||||
Mod+Shift+Page_Up { move-workspace-up; }
|
|
||||||
Mod+Shift+I { move-workspace-up; }
|
|
||||||
Mod+Shift+Page_Down { move-workspace-down; }
|
|
||||||
Mod+Shift+U { move-workspace-down; }
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// COMPOSITOR & SESSION CONTROLS
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
Mod+T hotkey-overlay-title="Open Terminal" { spawn "alacritty"; }
|
|
||||||
Mod+O repeat=false { toggle-overview; }
|
|
||||||
Mod+Tab repeat=false { toggle-overview; }
|
|
||||||
Mod+Slash { show-hotkey-overlay; }
|
|
||||||
Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; }
|
|
||||||
Mod+Shift+P { power-off-monitors; }
|
|
||||||
Mod+Shift+E { quit; }
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Parse 'close left' flag if present
|
|
||||||
ENABLE_CLOSE_LEFT=false
|
|
||||||
if [[ $# -gt 0 ]]; then
|
|
||||||
if [[ $1 == "-l" || $1 == "-left" || $1 == "--left" ]]; then
|
|
||||||
ENABLE_CLOSE_LEFT=true
|
|
||||||
else
|
|
||||||
notify-send "close_helper.sh" "Flag error!\nProvide '-l' or '--left' to enable close-left mode"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ----- Handle special cases -----
|
|
||||||
|
|
||||||
# Bail if we don't have a focused window
|
|
||||||
# -> Will also happen when overview is open, so try to close anyways
|
|
||||||
CURR_WIN_INFO=$(niri msg -j focused-window)
|
|
||||||
WINID_TO_CLOSE=$(jq .id <<< $CURR_WIN_INFO)
|
|
||||||
if [[ $WINID_TO_CLOSE == "null" ]]; then
|
|
||||||
niri msg action close-window
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Close the window if floating or has no workspace
|
|
||||||
# -> No workspace can happen if window is being dragged, for example
|
|
||||||
WSPACE_ID=$(jq .workspace_id <<< $CURR_WIN_INFO)
|
|
||||||
IS_FLOATING=$(jq .is_floating <<< $CURR_WIN_INFO)
|
|
||||||
if $IS_FLOATING || [[ $WSPACE_ID == "null" ]]; then
|
|
||||||
niri msg action close-window --id $WINID_TO_CLOSE
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If we're in the first column, close normally (nothing else to do)
|
|
||||||
COL_IDX=$(jq .layout.pos_in_scrolling_layout[0] <<< $CURR_WIN_INFO)
|
|
||||||
if [[ $COL_IDX -eq 1 ]]; then
|
|
||||||
niri msg action close-window --id $WINID_TO_CLOSE
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If we're in a stacked column, close normally (let niri handle focus change within column)
|
|
||||||
ALL_WIN_INFO=$(niri msg -j windows | jq --argjson wsid $WSPACE_ID 'map(select(.workspace_id == $wsid))')
|
|
||||||
NUM_ROWS_IN_COL=$(jq --argjson cidx $COL_IDX 'map(select(.layout.pos_in_scrolling_layout[0] == $cidx)) | length' <<< $ALL_WIN_INFO)
|
|
||||||
if [[ $NUM_ROWS_IN_COL -gt 1 ]]; then
|
|
||||||
niri msg action close-window --id $WINID_TO_CLOSE
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If there are exactly 2 (un-stacked) windows, close normally
|
|
||||||
# -> Odd detail, but needed to preserve 'always-center-single-column' behavior
|
|
||||||
NUM_WINS=$(jq length <<< $ALL_WIN_INFO)
|
|
||||||
if [[ $NUM_WINS -eq 2 ]]; then
|
|
||||||
niri msg action close-window --id $WINID_TO_CLOSE
|
|
||||||
exit
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ----- End of special cases -----
|
|
||||||
|
|
||||||
# Figure out which window (left or right) to focus after closing
|
|
||||||
LAST_COL_IDX=$(jq '[.[].layout.pos_in_scrolling_layout[0]] | max' <<< $ALL_WIN_INFO)
|
|
||||||
if $ENABLE_CLOSE_LEFT || [[ $COL_IDX -eq $LAST_COL_IDX ]]; then
|
|
||||||
niri msg action focus-column-left
|
|
||||||
else
|
|
||||||
niri msg action focus-column-right
|
|
||||||
fi
|
|
||||||
AFTERCLOSE_WIN_ID=$(niri msg -j focused-window | jq .id)
|
|
||||||
|
|
||||||
# Close and force view re-alignment to get rid of empty space on the right
|
|
||||||
niri msg action close-window --id $WINID_TO_CLOSE
|
|
||||||
niri msg action focus-column-first
|
|
||||||
niri msg action focus-window --id $AFTERCLOSE_WIN_ID
|
|
||||||
@@ -1,816 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Imports
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from time import sleep, perf_counter
|
|
||||||
from socket import socket, AF_UNIX, SOCK_STREAM
|
|
||||||
from signal import signal, SIGTERM
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
# Potentially missing lib (requires python v3.11+, but v3.10 is still considered supported!)
|
|
||||||
HAS_TOML_LIB = True
|
|
||||||
try:
|
|
||||||
import tomllib
|
|
||||||
except ImportError:
|
|
||||||
HAS_TOML_LIB = False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Args
|
|
||||||
|
|
||||||
# Define script arguments
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Script which makes niri tile windows up to a certain stack height, for a given range of columns",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-cfg",
|
|
||||||
"--config_path",
|
|
||||||
type=str,
|
|
||||||
default="~/.config/niri/tilemod_config.toml",
|
|
||||||
help="Path to .json or .toml config file (default: '~/.config/niri/tilemod_config.toml')",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-n",
|
|
||||||
"--num_stack",
|
|
||||||
default=2,
|
|
||||||
type=int,
|
|
||||||
help="Number of windows to stack in columns (default 2). Use 0 or 1 to disable stacking",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-b",
|
|
||||||
"--column_bounds",
|
|
||||||
nargs=2,
|
|
||||||
default=[2, 2],
|
|
||||||
type=int,
|
|
||||||
metavar="column-index",
|
|
||||||
help="Column start/end range where tiling should occur (default: 2 2), niri indexing starts at 1",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-rtl",
|
|
||||||
"--right_to_left",
|
|
||||||
action="store_true",
|
|
||||||
help="Open windows in right-to-left order",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-m",
|
|
||||||
"--apply_to_moved_windows",
|
|
||||||
action="store_true",
|
|
||||||
help="When a window is moved into another workspace, treat it as a newly spawned window",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--autostack_last_column_only",
|
|
||||||
action="store_true",
|
|
||||||
help="Only enable auto-stacking when the new window is opened in the last-most column",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--autostack_into_row_gaps",
|
|
||||||
nargs="?", # 0 or 1 arg
|
|
||||||
default=False,
|
|
||||||
const=True,
|
|
||||||
choices=["strict"],
|
|
||||||
metavar="'strict' : optional",
|
|
||||||
help="If set, windows can auto-stack into columns that are not full height (e.g. to fill gaps). Include 'strict' to only stack windows that would fit the gap",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--action_maximize",
|
|
||||||
type=str,
|
|
||||||
default="MaximizeColumn",
|
|
||||||
choices=["MaximizeColumn", "MaximizeWindowToEdges", "FullscreenWindow"],
|
|
||||||
help="Set which maximization action to use (default: MaximizeColumn)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_outer_stack",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable auto-stacking to the right if column is available (or left, when RTL is enabled)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_maximize_solos",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable auto-maximize of first window opened on a workspace",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_maximize_on_close",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable re-maximize when only one window remains",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_collapse_on_open",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable collapse of solo maximized window when opening a second window",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_config_reload",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable ability to reload tiling config by opening the overview",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-d",
|
|
||||||
"--delay_startup_ms",
|
|
||||||
default=250,
|
|
||||||
type=int,
|
|
||||||
help="Number of milliseconds to delay before listening to niri IPC (default: 500)",
|
|
||||||
)
|
|
||||||
parser.add_argument("-k", "--config_printout", action="store_true", help="Print out the active config (in terminal)")
|
|
||||||
parser.add_argument("--notify_unexpected", action="store_true", help="Enable notifications for unexpected behavior")
|
|
||||||
|
|
||||||
# Get script configs
|
|
||||||
args, unknown_args = parser.parse_known_args()
|
|
||||||
ARG_CONFIG_PATH = Path(args.config_path).expanduser()
|
|
||||||
ARG_NUM_STACK = args.num_stack
|
|
||||||
ARG_COLUMN_TILING_BOUNDS = args.column_bounds
|
|
||||||
ARG_ALLOW_OUTER_STACK = not args.no_outer_stack
|
|
||||||
ARG_ENABLE_RTL = args.right_to_left
|
|
||||||
ARG_MAXIMIZE_SOLOS_ON_OPEN = not args.no_maximize_solos
|
|
||||||
ARG_MAXIMIZE_SOLOS_ON_CLOSE = not args.no_maximize_on_close
|
|
||||||
ARG_COLLAPSE_SOLOS_ON_OPEN = not args.no_collapse_on_open
|
|
||||||
ARG_APPLY_TO_MOVED_WINDOWS = args.apply_to_moved_windows
|
|
||||||
ARG_AUTOSTACK_LAST_ONLY = args.autostack_last_column_only
|
|
||||||
ARG_AUTOSTACK_INTO_GAPS = args.autostack_into_row_gaps
|
|
||||||
ARG_ACTION_MAXIMIZE = args.action_maximize
|
|
||||||
STARTUP_DELAY_MS = args.delay_startup_ms
|
|
||||||
ENABLE_CONFIG_RELOAD = not args.no_config_reload
|
|
||||||
RUN_CONFIG_PRINTOUT = args.config_printout
|
|
||||||
NOTIFY_UNEXPECTED = args.notify_unexpected
|
|
||||||
|
|
||||||
# Handle startup delay (prevent listening to niri during potentially busy startup)
|
|
||||||
if STARTUP_DELAY_MS > 0:
|
|
||||||
sleep(STARTUP_DELAY_MS / 1000)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Classes
|
|
||||||
|
|
||||||
|
|
||||||
class NiriIPC:
|
|
||||||
"""Helper used to read/write from niri IPC"""
|
|
||||||
|
|
||||||
def __init__(self, socket_path: str, buffer_size: int = 4096):
|
|
||||||
niri_socket = socket(AF_UNIX, SOCK_STREAM)
|
|
||||||
niri_socket.connect(socket_path)
|
|
||||||
|
|
||||||
self._writer = niri_socket.makefile("w", buffering=buffer_size)
|
|
||||||
self._reader = niri_socket.makefile("r", buffering=buffer_size)
|
|
||||||
self._socket = niri_socket
|
|
||||||
self._batched_actions_list = []
|
|
||||||
self._batched_action_names = []
|
|
||||||
|
|
||||||
def _parse_response(self) -> tuple[bool, dict | str]:
|
|
||||||
|
|
||||||
# Parse 'Ok' response
|
|
||||||
resp_str = self._reader.readline()
|
|
||||||
resp_dict = json.loads(resp_str)
|
|
||||||
resp_data = resp_dict.get("Ok", None)
|
|
||||||
is_ok = resp_data is not None
|
|
||||||
if is_ok:
|
|
||||||
return is_ok, resp_data
|
|
||||||
|
|
||||||
# Handle 'Err' response
|
|
||||||
resp_data = resp_dict.get("Err", None)
|
|
||||||
return is_ok, resp_data
|
|
||||||
|
|
||||||
def batch_action(self, action: str, **kwargs):
|
|
||||||
"""Queue up an action, without executing immediately. Use .commit_batch() to execute"""
|
|
||||||
action_json_str = json.dumps({"Action": {action: kwargs}}, indent=None, separators=(",", ":"))
|
|
||||||
self._batched_actions_list.append(action_json_str)
|
|
||||||
self._batched_action_names.append(action)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def commit_batch(self) -> list[tuple[str, bool, str]]:
|
|
||||||
|
|
||||||
# Bail if no actions have been batched
|
|
||||||
num_actions = len(self._batched_actions_list)
|
|
||||||
if num_actions == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Write single (batched) action string
|
|
||||||
batched_action_str = "\n".join(self._batched_actions_list)
|
|
||||||
batched_names = self._batched_action_names
|
|
||||||
self._batched_actions_list = []
|
|
||||||
self._batched_action_names = []
|
|
||||||
self._writer.write(batched_action_str)
|
|
||||||
self._writer.write("\n")
|
|
||||||
self._writer.flush()
|
|
||||||
|
|
||||||
# Read response for each batch item
|
|
||||||
return [(name, self._parse_response()) for name in batched_names]
|
|
||||||
|
|
||||||
def message(self, message: str) -> tuple[bool, dict | str]:
|
|
||||||
self._writer.write(f'"{message}"\n')
|
|
||||||
self._writer.flush()
|
|
||||||
return self._parse_response()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
self._writer.close()
|
|
||||||
self._reader.close()
|
|
||||||
self._socket.close()
|
|
||||||
|
|
||||||
def read_event_stream(self) -> tuple[str, dict]:
|
|
||||||
|
|
||||||
# Try to request event stream
|
|
||||||
ok_stream, evt_resp = self.message("EventStream")
|
|
||||||
if not ok_stream:
|
|
||||||
raise ConnectionError(f"Error starting EventStream\n{evt_resp}")
|
|
||||||
|
|
||||||
# Read responses (delimited by '\n')
|
|
||||||
self._writer.close()
|
|
||||||
for line in self._reader:
|
|
||||||
for evt_message in line.splitlines():
|
|
||||||
msg_json = json.loads(evt_message)
|
|
||||||
evt_name = tuple(msg_json.keys())[0]
|
|
||||||
evt_data = msg_json.get(evt_name, {})
|
|
||||||
yield evt_name, evt_data
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class WindowSizeState(Enum):
|
|
||||||
NOT_MAXIMIZED = 0
|
|
||||||
MAX_COLUMN = 1
|
|
||||||
MAX_TO_EDGES = 2
|
|
||||||
FULLSCREEN = 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Helpers
|
|
||||||
|
|
||||||
|
|
||||||
def get_tiling_config(tiling_configs_dict: dict, workspace_dict: dict, outputs_dict: dict) -> tuple[str, dict]:
|
|
||||||
"""Helper used to select tiling config. Checks workspace properties before monitor properties!"""
|
|
||||||
|
|
||||||
# Get workspace/monitor-specific config
|
|
||||||
match_key, augment_cfg = None, {}
|
|
||||||
for section_key, section_properties_dict in (("workspaces", workspace_dict), ("outputs", outputs_dict)):
|
|
||||||
for prop_name, property_entries in tiling_configs_dict[section_key].items():
|
|
||||||
if prop_name not in section_properties_dict.keys():
|
|
||||||
continue
|
|
||||||
curr_prop_value = str(section_properties_dict[prop_name])
|
|
||||||
if curr_prop_value in property_entries:
|
|
||||||
augment_cfg = property_entries[curr_prop_value]
|
|
||||||
match_key = f"{section_key}.{prop_name}.{curr_prop_value}"
|
|
||||||
break
|
|
||||||
|
|
||||||
# Break outer loop (e.g. if we find a workspace match first, ignore monitor match)
|
|
||||||
if match_key is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fill in missing match key
|
|
||||||
if match_key is None:
|
|
||||||
match_key = "default"
|
|
||||||
|
|
||||||
# Return specific tiling config
|
|
||||||
default_cfg = tiling_configs_dict["default"]
|
|
||||||
return match_key, {**default_cfg, **augment_cfg}
|
|
||||||
|
|
||||||
|
|
||||||
def load_user_config(config_path: Path, has_tomllib: bool = False, raise_errors: bool = True) -> tuple[bool, dict]:
|
|
||||||
"""Helper used to load .toml or .json config file, with basic error handling"""
|
|
||||||
|
|
||||||
# Initilize outputs
|
|
||||||
ok_config, loaded_config = False, {}
|
|
||||||
|
|
||||||
config_file_ext = config_path.suffix.lower()
|
|
||||||
if config_file_ext == ".toml":
|
|
||||||
if not has_tomllib:
|
|
||||||
notify("Error, no support for .toml config\n(requires python 3.11+)")
|
|
||||||
raise ModuleNotFoundError("No support for .toml file (requires python 3.11+). Please use .json instead")
|
|
||||||
try:
|
|
||||||
with open(config_path, "rb") as in_file:
|
|
||||||
loaded_config = tomllib.load(in_file)
|
|
||||||
ok_config = True
|
|
||||||
except tomllib.TOMLDecodeError as err:
|
|
||||||
notify("Error parsing toml config!\nRun in terminal to see more details")
|
|
||||||
if raise_errors:
|
|
||||||
raise err
|
|
||||||
|
|
||||||
elif config_file_ext == ".json":
|
|
||||||
try:
|
|
||||||
with open(config_path, "r") as in_file:
|
|
||||||
loaded_config = json.load(in_file)
|
|
||||||
ok_config = True
|
|
||||||
except json.JSONDecodeError as err:
|
|
||||||
notify("Error parsing json config!\nRun in terminal to see more details")
|
|
||||||
if raise_errors:
|
|
||||||
raise err
|
|
||||||
|
|
||||||
return ok_config, loaded_config
|
|
||||||
|
|
||||||
|
|
||||||
def update_working_config(base_config: dict[str, dict], update_config: dict[str, dict]) -> dict[str, dict]:
|
|
||||||
"""Helper used to update a base config with (potentially sparse) update data"""
|
|
||||||
new_config_dict = {}
|
|
||||||
for key in base_config.keys():
|
|
||||||
new_config_dict[key] = {**base_config[key], **update_config.get(key, {})}
|
|
||||||
return new_config_dict
|
|
||||||
|
|
||||||
|
|
||||||
def notify(message: str, timeout_ms: int | None = None) -> None:
|
|
||||||
notify_title = f"{Path(__file__).name}"
|
|
||||||
subprocess.run(["notify-send", notify_title, message, *([] if timeout_ms is None else ["-t", str(timeout_ms)])])
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def get_tiled_workspace_windows(multi_window_dict: dict[int, dict], workspace_id: int) -> dict[int, dict]:
|
|
||||||
"""
|
|
||||||
Instead of checking the 'is_floating' state to determine if a window is tiled,
|
|
||||||
we check that it has valid column/row indexing, which should generalize better for edge cases
|
|
||||||
See: https://github.com/heyoeyo/niri_tweaks/issues/18
|
|
||||||
"""
|
|
||||||
is_tiled_in_wspace = lambda win_info: (
|
|
||||||
(win_info["workspace_id"] == workspace_id) and (win_info["layout"]["pos_in_scrolling_layout"] is not None)
|
|
||||||
)
|
|
||||||
return {win_id: win_info for win_id, win_info in multi_window_dict.items() if is_tiled_in_wspace(win_info)}
|
|
||||||
|
|
||||||
|
|
||||||
def get_focused_id(multi_info_dict: dict[int, dict]) -> int | None:
|
|
||||||
focused_id = None
|
|
||||||
for data in multi_info_dict.values():
|
|
||||||
if data["is_focused"]:
|
|
||||||
focused_id = data["id"]
|
|
||||||
break
|
|
||||||
return focused_id
|
|
||||||
|
|
||||||
|
|
||||||
def get_missing_size_state(
|
|
||||||
single_window_dict: dict,
|
|
||||||
monitor_info: dict,
|
|
||||||
max_w_threshold: float = 0.8,
|
|
||||||
) -> WindowSizeState:
|
|
||||||
"""Helper used to guess at missing maximization/fullscreen window state"""
|
|
||||||
|
|
||||||
monitor_w = monitor_info["logical"]["width"]
|
|
||||||
monitor_h = monitor_info["logical"]["height"]
|
|
||||||
win_w_px, win_h_px = single_window_dict["layout"]["tile_size"]
|
|
||||||
gap_w, gap_h = (monitor_w - int(win_w_px)), (monitor_h - int(win_h_px))
|
|
||||||
|
|
||||||
win_state = WindowSizeState.NOT_MAXIMIZED
|
|
||||||
if (gap_w < 5) and (gap_h < 5):
|
|
||||||
win_state = WindowSizeState.FULLSCREEN
|
|
||||||
elif (gap_w < 5) or (gap_h < 5):
|
|
||||||
win_state = WindowSizeState.MAX_TO_EDGES
|
|
||||||
elif (win_w_px / monitor_w) > max_w_threshold:
|
|
||||||
win_state = WindowSizeState.MAX_COLUMN
|
|
||||||
|
|
||||||
return win_state
|
|
||||||
|
|
||||||
|
|
||||||
def get_rows_per_column(multi_window_dict: dict[int, dict]) -> dict[int, int]:
|
|
||||||
"""Assumes tiled windows only! Gets rows per column as a dictionary, format: {column_index: row_count}"""
|
|
||||||
|
|
||||||
col_count_per_idx = {}
|
|
||||||
for win_info in multi_window_dict.values():
|
|
||||||
col_idx = win_info["layout"]["pos_in_scrolling_layout"][0]
|
|
||||||
if col_idx not in col_count_per_idx.keys():
|
|
||||||
col_count_per_idx[col_idx] = 0
|
|
||||||
col_count_per_idx[col_idx] += 1
|
|
||||||
|
|
||||||
return col_count_per_idx
|
|
||||||
|
|
||||||
|
|
||||||
def get_col_index(single_window_dict: dict) -> int:
|
|
||||||
"""Helper used to simplify access to column x-index. Assumes a tiled window!"""
|
|
||||||
return single_window_dict["layout"]["pos_in_scrolling_layout"][0]
|
|
||||||
|
|
||||||
|
|
||||||
def sigterm_to_interrupt(signum, frame):
|
|
||||||
raise InterruptedError
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Parse config
|
|
||||||
|
|
||||||
# Define base/default config, based on script args
|
|
||||||
base_configs_dict = {
|
|
||||||
"default": {
|
|
||||||
"num_stack": ARG_NUM_STACK,
|
|
||||||
"column_bounds": ARG_COLUMN_TILING_BOUNDS,
|
|
||||||
"maximize_solos_on_open": ARG_MAXIMIZE_SOLOS_ON_OPEN,
|
|
||||||
"maximize_solos_on_close": ARG_MAXIMIZE_SOLOS_ON_CLOSE,
|
|
||||||
"collapse_solos_on_open": ARG_COLLAPSE_SOLOS_ON_OPEN,
|
|
||||||
"allow_outer_stack": ARG_ALLOW_OUTER_STACK,
|
|
||||||
"apply_to_moved_windows": ARG_APPLY_TO_MOVED_WINDOWS,
|
|
||||||
"autostack_last_column_only": ARG_AUTOSTACK_LAST_ONLY,
|
|
||||||
"autostack_into_row_gaps": ARG_AUTOSTACK_INTO_GAPS,
|
|
||||||
"right_to_left": ARG_ENABLE_RTL,
|
|
||||||
"action_maximize": ARG_ACTION_MAXIMIZE,
|
|
||||||
"disabled": False,
|
|
||||||
},
|
|
||||||
"outputs": {},
|
|
||||||
"workspaces": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to load/parse from config file
|
|
||||||
last_config_modify_time = 0
|
|
||||||
all_configs_dict = {**base_configs_dict}
|
|
||||||
if ARG_CONFIG_PATH.exists():
|
|
||||||
last_config_modify_time = ARG_CONFIG_PATH.stat().st_mtime
|
|
||||||
_, loaded_config = load_user_config(ARG_CONFIG_PATH, HAS_TOML_LIB, raise_errors=True)
|
|
||||||
all_configs_dict = update_working_config(base_configs_dict, loaded_config)
|
|
||||||
print("", "Loaded config file:", ARG_CONFIG_PATH, sep="\n")
|
|
||||||
else:
|
|
||||||
print("", "No config file found:", ARG_CONFIG_PATH, "-> Will use default config", sep="\n")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% Setup
|
|
||||||
|
|
||||||
# Set up separate connections for sending commands during event stream
|
|
||||||
SCRIPTNAME = Path(__file__).name
|
|
||||||
PATH_SOCKET = os.environ["NIRI_SOCKET"]
|
|
||||||
if not os.path.exists(PATH_SOCKET):
|
|
||||||
raise FileNotFoundError(f"Cannot connect to niri socket: {PATH_SOCKET}")
|
|
||||||
event_stream_reader = NiriIPC(PATH_SOCKET)
|
|
||||||
niri = NiriIPC(PATH_SOCKET)
|
|
||||||
print("", f"({SCRIPTNAME}) - Opened niri IPC connection", sep="\n")
|
|
||||||
|
|
||||||
# Check versioning
|
|
||||||
response_ok, response_version = niri.message("Version")
|
|
||||||
expected_version = "26.04"
|
|
||||||
if (not response_ok) or (expected_version not in response_version.get("Version")):
|
|
||||||
version_msg = f"Expecting version {expected_version}, got: {response_version.get('Version', None)}"
|
|
||||||
if NOTIFY_UNEXPECTED:
|
|
||||||
sleep(1)
|
|
||||||
notify(version_msg)
|
|
||||||
print("", version_msg, sep="\n")
|
|
||||||
|
|
||||||
# Initialize global state
|
|
||||||
is_overview_open: bool = False
|
|
||||||
focused_win_id: int | None = None
|
|
||||||
focused_wspace_id: int | None = None
|
|
||||||
all_win_dict: dict[int, dict] = {}
|
|
||||||
all_wspace_dict: dict[int, dict] = {}
|
|
||||||
all_monitor_dict: dict[str, dict] = {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------------------------------------
|
|
||||||
# %% *** IPC listening loop ***
|
|
||||||
|
|
||||||
# Convert SIGTERM into python interrupt error for graceful shutdown
|
|
||||||
signal(SIGTERM, sigterm_to_interrupt)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Print out active config and close. Used for debugging
|
|
||||||
if RUN_CONFIG_PRINTOUT:
|
|
||||||
_, response_focused_output = niri.message("FocusedOutput")
|
|
||||||
_, response_workspaces = niri.message("Workspaces")
|
|
||||||
curr_wspace_dict = [info for info in response_workspaces["Workspaces"] if info["is_focused"]][0]
|
|
||||||
curr_monitor_dict = response_focused_output["FocusedOutput"]
|
|
||||||
match_key, config = get_tiling_config(all_configs_dict, curr_wspace_dict, curr_monitor_dict)
|
|
||||||
print("", f"Tiling config: {match_key}", sep="\n")
|
|
||||||
print(json.dumps(config, indent=2) if not config["disabled"] else "***DISABLED***", flush=True)
|
|
||||||
raise KeyboardInterrupt()
|
|
||||||
|
|
||||||
# For all event docs, see: https://niri-wm.github.io/niri/niri_ipc/enum.Event.html
|
|
||||||
timestamp_ms = int(1000 * perf_counter())
|
|
||||||
for evt_name, evt_data in event_stream_reader.read_event_stream():
|
|
||||||
|
|
||||||
# Uncomment for debugging
|
|
||||||
# new_timestamp = int(1000 * perf_counter())
|
|
||||||
# time_elapsed_ms, timestamp_ms = (new_timestamp - timestamp_ms), new_timestamp
|
|
||||||
# if time_elapsed_ms > 250:
|
|
||||||
# print("", f"Time elapsed (ms): {time_elapsed_ms}", sep="\n", flush=True)
|
|
||||||
# print("EVENT:", evt_name, flush=True)
|
|
||||||
# print(evt_data)
|
|
||||||
|
|
||||||
# Reset event state
|
|
||||||
new_win_dict = None
|
|
||||||
closed_win_dict = None
|
|
||||||
is_win_move_wspace_event = False
|
|
||||||
|
|
||||||
# Handle events
|
|
||||||
if "WindowFocusChanged" == evt_name:
|
|
||||||
evt_win_id = evt_data["id"]
|
|
||||||
if focused_win_id is not None:
|
|
||||||
all_win_dict[focused_win_id]["is_focused"] = False
|
|
||||||
if evt_win_id is not None:
|
|
||||||
all_win_dict[evt_win_id]["is_focused"] = True
|
|
||||||
focused_win_id = evt_win_id
|
|
||||||
|
|
||||||
elif "WindowFocusTimestampChanged" == evt_name:
|
|
||||||
# Very noisy event but not needed, so skip it. Usage would look like:
|
|
||||||
# all_win_dict[evt_data["id"]]["focus_timestamp"] = evt_data["focus_timestamp"]
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif "WorkspaceActiveWindowChanged" == evt_name:
|
|
||||||
# Very noisy event but not needed, so skip it. Usage would look like:
|
|
||||||
# all_wspace_dict[evt_data["workspace_id"]]["active_window_id"] = evt_data["active_window_id"]
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif "WindowOpenedOrChanged" == evt_name:
|
|
||||||
# Confusing event! This triggers when a new window is opened but...
|
|
||||||
# - also triggers if certain window properties change (e.g. title, is_floating, workspace_id)
|
|
||||||
# - does NOT trigger for all property changes (e.g. is_focused, layout)
|
|
||||||
# - will trigger on drag-start/end with floating windows
|
|
||||||
evt_win_data = evt_data["window"]
|
|
||||||
evt_win_id = evt_win_data["id"]
|
|
||||||
|
|
||||||
# Check for new window vs. moved window events
|
|
||||||
is_new_window = evt_win_id not in all_win_dict.keys()
|
|
||||||
if not is_new_window:
|
|
||||||
is_win_move_wspace_event = evt_win_data["workspace_id"] != all_win_dict[evt_win_id]["workspace_id"]
|
|
||||||
if is_new_window or is_win_move_wspace_event:
|
|
||||||
new_win_dict = evt_win_data
|
|
||||||
|
|
||||||
# Update focus, if needed
|
|
||||||
if evt_data["window"]["is_focused"]:
|
|
||||||
if focused_win_id is not None:
|
|
||||||
all_win_dict[focused_win_id]["is_focused"] = False
|
|
||||||
focused_win_id = evt_win_id
|
|
||||||
|
|
||||||
# Record new/updated window data
|
|
||||||
all_win_dict[evt_win_id] = evt_win_data
|
|
||||||
|
|
||||||
elif "WindowClosed" == evt_name:
|
|
||||||
evt_win_id = evt_data["id"]
|
|
||||||
if focused_win_id == evt_win_id:
|
|
||||||
focused_win_id = None
|
|
||||||
closed_win_dict = all_win_dict.pop(evt_win_id)
|
|
||||||
|
|
||||||
elif "WindowUrgencyChanged" == evt_name:
|
|
||||||
all_win_dict[evt_data["id"]]["is_urgent"] = evt_data["urgent"]
|
|
||||||
|
|
||||||
elif "WindowLayoutsChanged" == evt_name:
|
|
||||||
for evt_win_id, evt_new_layout in evt_data["changes"]:
|
|
||||||
all_win_dict[evt_win_id]["layout"] = evt_new_layout
|
|
||||||
|
|
||||||
elif "WorkspaceActivated" == evt_name:
|
|
||||||
if evt_data["focused"]:
|
|
||||||
evt_wspace_id = evt_data["id"]
|
|
||||||
if focused_wspace_id is not None:
|
|
||||||
all_wspace_dict[focused_wspace_id]["is_focused"] = False
|
|
||||||
if evt_wspace_id is not None:
|
|
||||||
all_wspace_dict[evt_wspace_id]["is_focused"] = True
|
|
||||||
focused_wspace_id = evt_wspace_id
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif "WorkspaceUrgencyChanged" == evt_name:
|
|
||||||
all_wspace_dict[evt_data["id"]]["is_urgent"] = evt_data["urgent"]
|
|
||||||
|
|
||||||
elif "OverviewOpenedOrClosed" == evt_name:
|
|
||||||
is_overview_open = evt_data["is_open"]
|
|
||||||
|
|
||||||
# Reload config if file has changed
|
|
||||||
if ENABLE_CONFIG_RELOAD and ARG_CONFIG_PATH.exists():
|
|
||||||
new_config_modify_time = ARG_CONFIG_PATH.stat().st_mtime
|
|
||||||
if new_config_modify_time > last_config_modify_time:
|
|
||||||
last_config_modify_time = new_config_modify_time
|
|
||||||
ok_config, loaded_config = load_user_config(ARG_CONFIG_PATH, HAS_TOML_LIB, raise_errors=False)
|
|
||||||
if ok_config:
|
|
||||||
all_configs_dict = update_working_config(base_configs_dict, loaded_config)
|
|
||||||
notify("Config reloaded!", timeout_ms=2500)
|
|
||||||
pass
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif "WorkspacesChanged" == evt_name:
|
|
||||||
# Signals full re-write of workspace data. Happens on startup or when workspaces are added/removed
|
|
||||||
# Event data format: {"workspaces": [... list of workspace dicts ...]}
|
|
||||||
all_wspace_dict = {data["id"]: data for data in evt_data["workspaces"]}
|
|
||||||
focused_wspace_id = get_focused_id(all_wspace_dict)
|
|
||||||
|
|
||||||
elif "WindowsChanged" == evt_name:
|
|
||||||
# Signals full re-write of window data. Happens on startup (only?)
|
|
||||||
# Event data format: {"windows": [... list of window dicts ...]}
|
|
||||||
all_win_dict = {data["id"]: data for data in evt_data["windows"]}
|
|
||||||
focused_win_id = get_focused_id(all_win_dict)
|
|
||||||
|
|
||||||
elif "ConfigLoaded" == evt_name:
|
|
||||||
# We'll re-read monitor info on config changes (not clear if there is an event for attaching monitors?)
|
|
||||||
ok_monitors, outputs_data = niri.message("Outputs")
|
|
||||||
if not ok_monitors:
|
|
||||||
notify("Error getting monitor data...")
|
|
||||||
continue
|
|
||||||
all_monitor_dict = outputs_data["Outputs"]
|
|
||||||
|
|
||||||
elif evt_name in (
|
|
||||||
"CastStartedOrChanged",
|
|
||||||
"CastStopped",
|
|
||||||
"CastsChanged",
|
|
||||||
"KeyboardLayoutSwitched",
|
|
||||||
"KeyboardLayoutsChanged",
|
|
||||||
"ScreenshotCaptured",
|
|
||||||
):
|
|
||||||
# Do nothing
|
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("Unknown event:", evt_name)
|
|
||||||
if NOTIFY_UNEXPECTED:
|
|
||||||
notify("Unknown evemt: {evt_name}")
|
|
||||||
|
|
||||||
# .............................................................................................................
|
|
||||||
# %% Window management
|
|
||||||
|
|
||||||
# Handle post-processing after window close
|
|
||||||
if closed_win_dict is not None:
|
|
||||||
|
|
||||||
# Ignore closing of floating/untiled windows (don't affect tiling)
|
|
||||||
is_untiled = closed_win_dict["layout"]["pos_in_scrolling_layout"] is None
|
|
||||||
if is_untiled:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get location info of closed window
|
|
||||||
curr_wspace_id = closed_win_dict["workspace_id"]
|
|
||||||
curr_wspace_dict = all_wspace_dict.get(curr_wspace_id, None)
|
|
||||||
if curr_wspace_dict is None:
|
|
||||||
# Can happen when a window is closed (remotely) on a dynamic workspace
|
|
||||||
# -> The workspace is removed before the window, so we don't have a valid id!
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get active config
|
|
||||||
curr_monitor_dict = all_monitor_dict[curr_wspace_dict["output"]]
|
|
||||||
_, config = get_tiling_config(all_configs_dict, curr_wspace_dict, curr_monitor_dict)
|
|
||||||
if config["disabled"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle re-maximizing solo windows
|
|
||||||
tile_win_dict = get_tiled_workspace_windows(all_win_dict, curr_wspace_id)
|
|
||||||
if len(tile_win_dict) == 1 and config["maximize_solos_on_close"]:
|
|
||||||
solo_id = tuple(tile_win_dict.keys())[0]
|
|
||||||
solo_win_size_state = get_missing_size_state(tile_win_dict[solo_id], curr_monitor_dict)
|
|
||||||
if solo_win_size_state == WindowSizeState.NOT_MAXIMIZED:
|
|
||||||
if focused_win_id != solo_id:
|
|
||||||
niri.batch_action("FocusWindow", id=solo_id)
|
|
||||||
niri.batch_action(config["action_maximize"])
|
|
||||||
niri.commit_batch()
|
|
||||||
pass
|
|
||||||
|
|
||||||
# *** New window event ***
|
|
||||||
if new_win_dict is not None:
|
|
||||||
|
|
||||||
# Get window location data
|
|
||||||
new_win_id = new_win_dict["id"]
|
|
||||||
curr_wspace_id = new_win_dict["workspace_id"]
|
|
||||||
if curr_wspace_id is None: # This happens when dragging windows!
|
|
||||||
curr_wspace_id = focused_wspace_id
|
|
||||||
curr_wspace_dict = all_wspace_dict[curr_wspace_id]
|
|
||||||
curr_monitor_dict = all_monitor_dict[curr_wspace_dict["output"]]
|
|
||||||
|
|
||||||
# Ignore floats/un-tiled or windows that seem to be created with window rules (don't want to interfere)
|
|
||||||
new_win_size_state = get_missing_size_state(new_win_dict, curr_monitor_dict)
|
|
||||||
is_already_maximized = new_win_size_state != WindowSizeState.NOT_MAXIMIZED
|
|
||||||
is_on_another_wspace = curr_wspace_id != focused_wspace_id
|
|
||||||
is_untiled = new_win_dict["layout"]["pos_in_scrolling_layout"] is None
|
|
||||||
if is_untiled or is_already_maximized or is_on_another_wspace:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get active tiling config (can be different for workspaces/monitors)
|
|
||||||
_, config = get_tiling_config(all_configs_dict, curr_wspace_dict, curr_monitor_dict)
|
|
||||||
if config["disabled"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip behavior if the window is 'new' because it moved workspaces, if configured
|
|
||||||
if is_win_move_wspace_event and not config["apply_to_moved_windows"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get (tiled-only) windows on workspace of new window, since that's all we care about
|
|
||||||
tile_win_dict = get_tiled_workspace_windows(all_win_dict, curr_wspace_id)
|
|
||||||
num_tile_wins = len(tile_win_dict)
|
|
||||||
|
|
||||||
# Important note (as of v26.04):
|
|
||||||
# When a new window appears, it will report itself as being in row 1 of the
|
|
||||||
# column to the right of the current focused window, even if there is already
|
|
||||||
# a window there (i.e it will report a duplicated column/row index).
|
|
||||||
# For example if we have windows A and B but are focused on A, like: [A-focused] [B]
|
|
||||||
# and spawn a window 'C' like: [A] [C-focused] [B], then:
|
|
||||||
# [A] has xy: (1,1), [B] *still* has xy: (2,1) and [C] *also* has xy: (2,1)
|
|
||||||
# -> This will mess up neighbouring window checks if we're not careful!
|
|
||||||
|
|
||||||
# For clarity, get stacking config
|
|
||||||
col_tiling_bounds = config["column_bounds"]
|
|
||||||
num_stack = config["num_stack"]
|
|
||||||
enable_rtl = config["right_to_left"]
|
|
||||||
enable_strict_fill_gaps = config["autostack_into_row_gaps"] == "strict"
|
|
||||||
enable_fill_gaps = enable_strict_fill_gaps or (config["autostack_into_row_gaps"] == True)
|
|
||||||
|
|
||||||
# Try to handle toml formatting errors on column indexing
|
|
||||||
if not isinstance(col_tiling_bounds, list) or len(col_tiling_bounds) != 2:
|
|
||||||
notify(f"Invalid column_bounds: {col_tiling_bounds}\nMust be a list of two numbers", timeout_ms=2500)
|
|
||||||
col_tiling_bounds = [0, 1_000_000]
|
|
||||||
|
|
||||||
# Get rows per column count on current workspace
|
|
||||||
new_win_col_idx = get_col_index(new_win_dict)
|
|
||||||
rows_per_column_dict = get_rows_per_column(tile_win_dict)
|
|
||||||
rows_per_column_dict[new_win_col_idx] -= 1 # Don't count the new window itself
|
|
||||||
if rows_per_column_dict[new_win_col_idx] == 0:
|
|
||||||
rows_per_column_dict.pop(new_win_col_idx)
|
|
||||||
|
|
||||||
# Figure out the surrounding column indices
|
|
||||||
side_col_iter = [(new_win_col_idx - 1, -1)]
|
|
||||||
if config["allow_outer_stack"]:
|
|
||||||
side_col_iter.append((new_win_col_idx, +1))
|
|
||||||
|
|
||||||
# Adjust column index checks if we're in right-to-left mode
|
|
||||||
if enable_rtl:
|
|
||||||
if config["allow_outer_stack"]:
|
|
||||||
side_col_iter[1] = (new_win_col_idx - 2, -3)
|
|
||||||
max_col_idx = max(rows_per_column_dict.keys(), default=0)
|
|
||||||
col_tiling_bounds = [max_col_idx - idx + 1 for idx in col_tiling_bounds]
|
|
||||||
|
|
||||||
# Disable stacking checks if we're not in the last-most column with last-only config
|
|
||||||
if config["autostack_last_column_only"]:
|
|
||||||
rightmost_col_idx = max(rows_per_column_dict.keys(), default=1)
|
|
||||||
is_opened_last_col = (new_win_col_idx > rightmost_col_idx) if not enable_rtl else (new_win_col_idx == 2)
|
|
||||||
if not is_opened_last_col:
|
|
||||||
side_col_iter = []
|
|
||||||
|
|
||||||
# Check if we need to shift into another column
|
|
||||||
start_colidx, end_colidx = sorted(col_tiling_bounds)
|
|
||||||
need_shift_amt = 0
|
|
||||||
for side_col_idx, shift_amt in side_col_iter:
|
|
||||||
if side_col_idx not in rows_per_column_dict.keys():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for auto-stacking within column bounds
|
|
||||||
num_side_rows = rows_per_column_dict[side_col_idx]
|
|
||||||
if (num_side_rows < num_stack) and (start_colidx <= side_col_idx <= end_colidx):
|
|
||||||
need_shift_amt = shift_amt
|
|
||||||
break
|
|
||||||
|
|
||||||
# Special check for filling row gaps (e.g. if a single window is not filling column height)
|
|
||||||
if enable_fill_gaps and num_side_rows == 1:
|
|
||||||
is_neighbour = lambda info: (info["id"] != new_win_id) and (get_col_index(info) == side_col_idx)
|
|
||||||
nb_win_info = (info for info in tile_win_dict.values() if is_neighbour(info))
|
|
||||||
nb_col_height = sum(info["layout"]["tile_size"][1] for info in nb_win_info)
|
|
||||||
monitor_height = curr_monitor_dict["logical"]["height"]
|
|
||||||
if enable_strict_fill_gaps:
|
|
||||||
new_win_height = new_win_dict["layout"]["tile_size"][1]
|
|
||||||
if (nb_col_height + new_win_height) <= monitor_height:
|
|
||||||
need_shift_amt = shift_amt
|
|
||||||
break
|
|
||||||
elif nb_col_height < (monitor_height * 0.8):
|
|
||||||
need_shift_amt = shift_amt
|
|
||||||
break
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Handle shifting
|
|
||||||
is_focus_on_new_win = focused_win_id == new_win_id
|
|
||||||
if need_shift_amt != 0:
|
|
||||||
shift_cmd = "ConsumeOrExpelWindowLeft" if need_shift_amt < 0 else "ConsumeOrExpelWindowRight"
|
|
||||||
for _ in range(abs(need_shift_amt) - 1):
|
|
||||||
niri.batch_action(shift_cmd, id=new_win_id)
|
|
||||||
niri.batch_action(shift_cmd, id=new_win_id)
|
|
||||||
|
|
||||||
elif enable_rtl:
|
|
||||||
# Handle right-to-left movement (no stacking)
|
|
||||||
if is_focus_on_new_win:
|
|
||||||
niri.batch_action("MoveColumnLeft")
|
|
||||||
niri.batch_action("FocusColumnRight")
|
|
||||||
niri.batch_action("FocusColumnLeft")
|
|
||||||
else:
|
|
||||||
niri.batch_action("ConsumeOrExpelWindowLeft", id=new_win_id)
|
|
||||||
niri.batch_action("ConsumeOrExpelWindowLeft", id=new_win_id)
|
|
||||||
|
|
||||||
# Handle solo-window maximization
|
|
||||||
if num_tile_wins == 1 and config["maximize_solos_on_open"]:
|
|
||||||
if new_win_size_state == WindowSizeState.NOT_MAXIMIZED:
|
|
||||||
niri.batch_action("FocusWindow", id=new_win_id)
|
|
||||||
niri.batch_action(config["action_maximize"])
|
|
||||||
if not is_focus_on_new_win and (focused_win_id is not None):
|
|
||||||
niri.batch_action("FocusWindow", id=focused_win_id)
|
|
||||||
|
|
||||||
# Collapse maximized windows when no longer solo
|
|
||||||
if num_tile_wins == 2 and config["collapse_solos_on_open"]:
|
|
||||||
other_win_id = [win_id for win_id in tile_win_dict.keys() if win_id != new_win_id][0]
|
|
||||||
other_win_size_state = get_missing_size_state(tile_win_dict[other_win_id], curr_monitor_dict)
|
|
||||||
if other_win_size_state != WindowSizeState.NOT_MAXIMIZED:
|
|
||||||
niri.batch_action("FocusWindow", id=other_win_id)
|
|
||||||
if other_win_size_state == WindowSizeState.MAX_COLUMN:
|
|
||||||
niri.batch_action("MaximizeColumn")
|
|
||||||
elif other_win_size_state == WindowSizeState.MAX_TO_EDGES:
|
|
||||||
niri.batch_action("MaximizeWindowToEdges")
|
|
||||||
# -> Could collapse fullscreen state, but probably not desired...
|
|
||||||
niri.batch_action("FocusWindow", id=focused_win_id if focused_win_id is not None else new_win_id)
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Trigger all actions & report any errors
|
|
||||||
action_responses = niri.commit_batch()
|
|
||||||
for name_action, (ok_action, response_msg) in action_responses:
|
|
||||||
if not ok_action:
|
|
||||||
notify(f"Action error: {name_action}\n{response_msg}")
|
|
||||||
print("Action error!", action_responses, sep="\n")
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
except (KeyboardInterrupt, InterruptedError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
except ConnectionError as err:
|
|
||||||
notify(str(err))
|
|
||||||
raise err
|
|
||||||
|
|
||||||
except Exception as err:
|
|
||||||
notify(f"Unexpected error:\n{err}")
|
|
||||||
raise err
|
|
||||||
|
|
||||||
finally:
|
|
||||||
event_stream_reader.close()
|
|
||||||
niri.close()
|
|
||||||
print("", f"({SCRIPTNAME}) - Closed niri IPC connection", sep="\n")
|
|
||||||
Reference in New Issue
Block a user