Created
March 21, 2026 23:58
-
-
Save niteria/af6de2044204462d7978f68de2013cf2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| commit d0c445c801ec208cb79b37cf0c3461fb958d1352 | |
| Author: Bartosz Nitka <niteria@gmail.com> | |
| Date: Sun Mar 22 00:37:54 2026 +0100 | |
| feat(hyprland): implement smart directional moves with monitor spillover | |
| Replaces the previous ALT+SHIFT+Left/Right hy3 bindings with a new | |
| `hypr-smart-move` Go binary. | |
| The tool attempts a normal hy3 internal move first, then intelligently | |
| detects whether it succeeded by comparing normalized `hy3:debugnodes` | |
| output. If the window hit a monitor edge, it automatically performs | |
| `movewindow mon:<direction>` instead. | |
| This provides a much smoother experience when moving windows across | |
| monitors. Also includes significant cleanup of the keybindings file | |
| (removed stale comments and whitespace). | |
| diff --git a/omarchy-nix/modules/home-manager/hyprland/bindings.nix b/omarchy-nix/modules/home-manager/hyprland/bindings.nix | |
| index f6d9f43..cd643d1 100644 | |
| --- a/omarchy-nix/modules/home-manager/hyprland/bindings.nix | |
| +++ b/omarchy-nix/modules/home-manager/hyprland/bindings.nix | |
| @@ -5,6 +5,18 @@ | |
| }: | |
| let | |
| cfg = config.omarchy; | |
| + | |
| + hyprSmartMove = pkgs.buildGoModule { | |
| + pname = "hypr-smart-move"; | |
| + version = "1.0"; | |
| + src = ./.; | |
| + vendorHash = null; # no external deps | |
| + # Only build this one file | |
| + buildPhase = '' | |
| + go build -o $out/bin/hypr-smart-move ${./hypr-smart-move.go} | |
| + ''; | |
| + doCheck = false; | |
| + }; | |
| in | |
| { | |
| wayland.windowManager.hyprland.settings = { | |
| @@ -16,27 +28,24 @@ in | |
| "5, monitor:DP-1, persistent:true" | |
| "6, monitor:HDMI-A-2, persistent:true" | |
| ]; | |
| + | |
| bind = cfg.quick_app_bindings ++ [ | |
| "SUPER, space, exec, vicinae toggle" | |
| - # "SUPER, space, exec, walker" | |
| + # "SUPER, space, exec, walker" | |
| "ALT SHIFT, SPACE, exec, pkill -SIGUSR1 waybar" | |
| "SUPER CTRL, SPACE, exec, ~/.local/share/omarchy/bin/omarchy-bg-next" | |
| # "SUPER SHIFT CTRL, SPACE, exec, ~/.local/share/omarchy/bin/omarchy-theme-next" | |
| - | |
| "ALT SHIFT, Q, killactive," | |
| - | |
| # End active session | |
| "SUPER, ESCAPE, exec, hyprlock" | |
| "SUPER SHIFT, ESCAPE, exit," | |
| "SUPER CTRL, ESCAPE, exec, reboot" | |
| "SUPER SHIFT CTRL, ESCAPE, exec, systemctl poweroff" | |
| - | |
| # Move focus with mainMod + arrow keys | |
| "ALT, left, hy3:movefocus, l" | |
| "ALT, right, hy3:movefocus, r" | |
| "ALT, up, hy3:movefocus, u" | |
| "ALT, down, hy3:movefocus, d" | |
| - | |
| # Switch workspaces with mainMod + [0-9] | |
| "ALT, 1, workspace, 1" | |
| "ALT, 2, workspace, 2" | |
| @@ -48,7 +57,6 @@ in | |
| "ALT, 8, workspace, 8" | |
| "ALT, 9, workspace, 9" | |
| "ALT, 0, workspace, 10" | |
| - | |
| "SUPER, q, workspace, 1" | |
| "SUPER, w, workspace, 2" | |
| "SUPER, e, workspace, 3" | |
| @@ -59,7 +67,6 @@ in | |
| "SUPER, i, workspace, 8" | |
| "SUPER, o, workspace, 9" | |
| "SUPER, p, workspace, 10" | |
| - | |
| # Move active window to a workspace with mainMod + SHIFT + [0-9] | |
| "ALT SHIFT, 1, movetoworkspace, 1" | |
| "ALT SHIFT, 2, movetoworkspace, 2" | |
| @@ -71,7 +78,6 @@ in | |
| "ALT SHIFT, 8, movetoworkspace, 8" | |
| "ALT SHIFT, 9, movetoworkspace, 9" | |
| "ALT SHIFT, 0, movetoworkspace, 10" | |
| - | |
| "SUPER SHIFT, q, movetoworkspace, 1" | |
| "SUPER SHIFT, w, movetoworkspace, 2" | |
| "SUPER SHIFT, e, movetoworkspace, 3" | |
| @@ -82,40 +88,32 @@ in | |
| "SUPER SHIFT, i, movetoworkspace, 8" | |
| "SUPER SHIFT, o, movetoworkspace, 9" | |
| "SUPER SHIFT, p, movetoworkspace, 10" | |
| - | |
| - # Swap active window with the one next to it with mainMod + SHIFT + arrow keys | |
| - "ALT SHIFT, left, hy3:movewindow, l" | |
| - "ALT SHIFT, right, hy3:movewindow, r" | |
| + # === SMART DIRECTIONAL MOVE (now compiled Go) === | |
| + "ALT SHIFT, left, exec, ${hyprSmartMove}/bin/hypr-smart-move l" | |
| + "ALT SHIFT, right, exec, ${hyprSmartMove}/bin/hypr-smart-move r" | |
| "ALT SHIFT, up, hy3:movewindow, u" | |
| "ALT SHIFT, down, hy3:movewindow, d" | |
| - | |
| # Resize active window | |
| "ALT, minus, resizeactive, -100 0" | |
| "ALT, equal, resizeactive, 100 0" | |
| "ALT SHIFT, minus, resizeactive, 0 -100" | |
| "ALT SHIFT, equal, resizeactive, 0 100" | |
| - | |
| # Scroll through existing workspaces with mainMod + scroll | |
| "SUPER, mouse_down, workspace, e+1" | |
| "SUPER, mouse_up, workspace, e-1" | |
| - | |
| # Control Apple Display brightness | |
| "CTRL, F1, exec, ~/.local/share/omarchy/bin/apple-display-brightness -5000" | |
| "CTRL, F2, exec, ~/.local/share/omarchy/bin/apple-display-brightness +5000" | |
| "SHIFT CTRL, F2, exec, ~/.local/share/omarchy/bin/apple-display-brightness +60000" | |
| - | |
| # Super workspace floating layer | |
| "SUPER, S, togglespecialworkspace, magic" | |
| "SUPER SHIFT, S, movetoworkspace, special:magic" | |
| - | |
| # Screenshots with satty editing | |
| ", PRINT, exec, hyprshot -m region --clipboard-only && satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H%M%S').png" | |
| "SHIFT, PRINT, exec, hyprshot -m window --clipboard-only && satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H%M%S').png" | |
| "CTRL, PRINT, exec, hyprshot -m output --clipboard-only && satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H%M%S').png" | |
| - | |
| # Color picker | |
| "ALT, PRINT, exec, hyprpicker -a" | |
| - | |
| # Clipse | |
| "CTRL ALT, V, exec, ghostty --class clipse -e clipse" | |
| # Layout controls with hy3 | |
| @@ -125,19 +123,16 @@ in | |
| "ALT, S, hy3:changegroup, toggletab" | |
| "ALT, R, hy3:changefocus, raise" | |
| "ALT SHIFT, G, hy3:changegroup, opposite" | |
| - | |
| # This is the mic key on my keyboard | |
| "SUPER, C, exec, ~/.local/share/omarchy/bin/hypermodern-waytoggle -s 1,3 -t 4,6 -f DP-1" | |
| ]; | |
| bindm = [ | |
| - # Move/resize windows with mainMod + LMB/RMB and dragging | |
| "ALT, mouse:272, movewindow" | |
| "ALT, mouse:273, resizewindow" | |
| ]; | |
| bindel = [ | |
| - # Laptop multimedia keys for volume and LCD brightness | |
| ",XF86AudioRaiseVolume, exec, wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+" | |
| ",XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-" | |
| ",XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle" | |
| @@ -147,7 +142,6 @@ in | |
| ]; | |
| bindl = [ | |
| - # Requires playerctl | |
| ", XF86AudioNext, exec, playerctl next" | |
| ", XF86AudioPause, exec, playerctl play-pause" | |
| ", XF86AudioPlay, exec, playerctl play-pause" | |
| diff --git a/omarchy-nix/modules/home-manager/hyprland/hypr-smart-move.go b/omarchy-nix/modules/home-manager/hyprland/hypr-smart-move.go | |
| new file mode 100644 | |
| index 0000000..589d663 | |
| --- /dev/null | |
| +++ b/omarchy-nix/modules/home-manager/hyprland/hypr-smart-move.go | |
| @@ -0,0 +1,133 @@ | |
| +package main | |
| + | |
| +import ( | |
| + "bytes" | |
| + "context" | |
| + "encoding/json" | |
| + "flag" | |
| + "fmt" | |
| + "os" | |
| + "os/exec" | |
| + "path/filepath" | |
| + "regexp" | |
| + "strings" | |
| + "time" | |
| +) | |
| + | |
| +type Logger struct { | |
| + enabled bool | |
| +} | |
| + | |
| +func (l *Logger) log(msg string) { | |
| + if !l.enabled { | |
| + return | |
| + } | |
| + ts := time.Now().Format("2006-01-02 15:04:05") | |
| + logPath := filepath.Join(os.Getenv("HOME"), ".cache", "hypr-smart-move.log") | |
| + f, _ := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | |
| + if f != nil { | |
| + defer f.Close() | |
| + fmt.Fprintf(f, "=== %s ===\n%s\n=== END ===\n\n", ts, msg) | |
| + } | |
| +} | |
| + | |
| +func runHyprctl(args []string) (string, error) { | |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| + defer cancel() | |
| + | |
| + cmd := exec.CommandContext(ctx, "hyprctl", args...) | |
| + var stdout bytes.Buffer | |
| + cmd.Stdout = &stdout | |
| + | |
| + if err := cmd.Run(); err != nil { | |
| + return "", fmt.Errorf("hyprctl %v failed: %w", args, err) | |
| + } | |
| + return strings.TrimSpace(stdout.String()), nil | |
| +} | |
| + | |
| +func getActiveMonitor() int { | |
| + out, err := runHyprctl([]string{"activewindow", "-j"}) | |
| + if err != nil || out == "" { | |
| + return -1 | |
| + } | |
| + var data struct { | |
| + Monitor int `json:"monitor"` | |
| + } | |
| + if json.Unmarshal([]byte(out), &data) != nil { | |
| + return -1 | |
| + } | |
| + return data.Monitor | |
| +} | |
| + | |
| +func normalize(nodes, winID string) string { | |
| + protected := strings.ReplaceAll(nodes, winID, "__ACTIVE_WINDOW__") | |
| + re := regexp.MustCompile(`0x[0-9a-fA-F]+`) | |
| + normalized := re.ReplaceAllString(protected, "[ADDR]") | |
| + return strings.ReplaceAll(normalized, "__ACTIVE_WINDOW__", winID) | |
| +} | |
| + | |
| +func main() { | |
| + direction := "r" | |
| + enableLog := false | |
| + | |
| + flag.BoolVar(&enableLog, "log", false, "Enable logging") | |
| + flag.BoolVar(&enableLog, "L", false, "Enable logging (short)") | |
| + flag.Parse() | |
| + | |
| + // Positional argument (exactly like the original Python script) | |
| + if len(flag.Args()) > 0 { | |
| + direction = flag.Args()[0] | |
| + } | |
| + | |
| + dirArg := "r" | |
| + if direction == "l" || direction == "left" { | |
| + dirArg = "l" | |
| + } | |
| + | |
| + logger := &Logger{enabled: enableLog} | |
| + logger.log(fmt.Sprintf("Called with direction='%s' → '%s'", direction, dirArg)) | |
| + | |
| + winJSON, err := runHyprctl([]string{"activewindow", "-j"}) | |
| + if err != nil { | |
| + logger.log("Failed to get active window") | |
| + fmt.Fprintf(os.Stderr, "hypr-smart-move: %v\n", err) | |
| + return | |
| + } | |
| + | |
| + type Window struct { | |
| + Address string `json:"address"` | |
| + } | |
| + var win Window | |
| + if json.Unmarshal([]byte(winJSON), &win) != nil || win.Address == "" { | |
| + logger.log("No active window found") | |
| + return | |
| + } | |
| + | |
| + logger.log(fmt.Sprintf("Stable window ID: %s", win.Address)) | |
| + | |
| + beforeRaw, _ := runHyprctl([]string{"dispatch", "hy3:debugnodes"}) | |
| + beforeNorm := normalize(beforeRaw, win.Address) | |
| + | |
| + logger.log(fmt.Sprintf("→ hy3:movewindow %s", dirArg)) | |
| + _, _ = runHyprctl([]string{"dispatch", "hy3:movewindow", dirArg}) | |
| + | |
| + time.Sleep(100 * time.Millisecond) | |
| + | |
| + afterRaw, _ := runHyprctl([]string{"dispatch", "hy3:debugnodes"}) | |
| + afterNorm := normalize(afterRaw, win.Address) | |
| + | |
| + if beforeNorm == afterNorm { | |
| + logger.log("→ NORMALIZED TREE IDENTICAL → TRUE EDGE HIT") | |
| + oldMon := getActiveMonitor() | |
| + logger.log(fmt.Sprintf("→ Trying spill to mon:%s", dirArg)) | |
| + | |
| + _, _ = runHyprctl([]string{"dispatch", "movewindow", "mon:" + dirArg}) | |
| + time.Sleep(50 * time.Millisecond) | |
| + | |
| + if newMon := getActiveMonitor(); newMon != oldMon && newMon != -1 { | |
| + logger.log(fmt.Sprintf("→ Spill succeeded (monitor %d → %d)", oldMon, newMon)) | |
| + } | |
| + } else { | |
| + logger.log("→ Tree changed → internal move/tab cycle succeeded") | |
| + } | |
| +} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment