diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 0a6eb68..13700cf 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -1,13 +1,11 @@ name: dev-build on: - push: + pull_request: branches: - main - - dev paths: - "src/**" - workflow_dispatch: jobs: build: @@ -22,10 +20,7 @@ jobs: id: src_changes shell: pwsh run: | - $base = "${{ github.event.before }}" - if (-not $base -or $base -eq "0000000000000000000000000000000000000000") { - $base = "HEAD~1" - } + $base = "${{ github.event.pull_request.base.sha }}" $changed = git diff --name-only $base $env:GITHUB_SHA | Where-Object { $_ -like "src/*" } $hasChanges = $changed.Count -gt 0 "has_src_changes=$hasChanges" | Out-File -FilePath $env:GITHUB_OUTPUT -Append diff --git a/README.md b/README.md index ef3cd00..9f6c504 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A window manager written in AutoHotkey v2. The aim is a low-friction workflow: a single super modifier, mnemonic app keys, and fast window actions. Alt+Tab and Win+Tab still work, but you will hardly use them ## Contents -- [What This Does](#what-this-does) +- [Overview](#overview) - [Quick Start](#quick-start) - [Configuration](#configuration) - [Default Config Keys](#default-config-keys) @@ -17,19 +17,18 @@ The aim is a low-friction workflow: a single super modifier, mnemonic app keys, - [Layout](#layout) - [Third-Party](#third-party) -## What This Does +## Overview > [!NOTE] -> CapsLock is the default super key. +> CapsLock is the default super key, because who needs it? +### Features -Show the [Command Overlay](#command-overlay) when the super key is held. -![Alt text](docs/assets/command_overlay.png) -Jump focus to an app or launch it with `super + [letter]`. +Launch-or-focus a program with `super + [letter]`, or directionally change window focus with `alt + h/l/j/k` (left, right, down, up) and `alt + [` / `alt + ]` for back/forward in a stack. ![Alt text](docs/assets/focus.gif) -Cycle centered window widths with `super + space`. +Cycle centered window widths with `super + spacebar`. ![Alt text](docs/assets/center-cycle.gif) Maximizes/restores with `super + m`. @@ -44,12 +43,22 @@ Freely move a window with double tap super + h/j/k/l Resize edges with `super + shift + h/j/k/l`. ![Alt text](docs/assets/resize.gif) +Show the [Command Overlay](#command-overlay) when the super key is held. Disable through command mode. +![Alt text](docs/assets/command_overlay.png) + +Use the "window switcher" (like powertoys window walker) with `super + w`. +![Alt text](docs/assets/window_switcher.png) + Other -- `super + alt` send `ctrl + tab` (configurable via `global_hotkeys`) -- `super + c` to cycle through windows of the same app +- `super + alt` sends `ctrl + tab` (configurable via `global_hotkeys`) +- `super + c` cycle through windows of the same app +- `super + w` open Window Selector (fuzzy find open windows) +- `alt + h/l` move window focus left/right +- `alt + j/k` move window focus down/up (non-stacked) +- `alt + [` / `alt + ]` move window focus forward/back through stacked windows Enter Command Mode with `super + ;`. -- `r` to reload app/config +- `r` to reload program/config - `e` to open config file - `w` opens a new window for the active program, if the program supports it - `n` toggles the command overlay on or off @@ -60,7 +69,7 @@ Enter Command Mode with `super + ;`. ### Quick start -- Start the program and enter command mode with `super + ;`. +- Start the program and enter command mode with `super + ;`. The binary is not currently signed and you will be warned by Windows. Clone and use `main.ahk` directly as an alternative. - Press `e` to open the config file. You can also find it manually in `~/.config/be-there/config.json`. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. @@ -70,7 +79,9 @@ Enter Command Mode with `super + ;`. - `apps[].run_paths`: optional list of directories to search for the executable. - `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). - `window`: resize/move steps and hotkeys (including move mode). -- `window_manager`: grid size, margins, and ignored window classes. +- `window_selector`: Window Selector settings (hotkey, match fields, display limits). +- `window_manager`: grid size, margins, gaps, and ignored window classes. +- `directional_focus`: directional focus settings (stacked threshold, stack tolerance, topmost preference, last-stacked preference, frontmost guard, perpendicular overlap min, cross-monitor, debug). - `focus_border`: overlay appearance and update interval. - `helper`: command overlay settings. - `reload`: hotkey and file watch settings for config reload. @@ -93,9 +104,8 @@ Enter Command Mode with `super + ;`. - Use Refresh to update the list; Copy Selected/All or Export to save results. ## Known Limitations -- This has not been tested with multi-monitor setups. -- Dynamic grid ratios need to be added to support more screen sizes and resolutions for the window-snap function. -- Some apps (e.g., Discord) launch via `Update.exe` and keep versioned subfolders, which makes auto-resolution unreliable. +- This has not been tested with multi-monitor setups or much outside of ultra-wide monitors. +- Some apps (e.g., Discord) launch via `Update.exe` and keep versioned subfolders, which makes auto-resolution unreliable for launching or focusing more challenging. - For some apps that minimize or close to the system tray, it's recommended you disable that in the program. Otherwise you can try to set `apps[].run` to a stable full path (or use `run_paths`) in your config. - Windows with elevated permissions may ignore be-there hotkeys unless be-there is run as Administrator. diff --git a/be-there.ahk b/be-there.ahk index 6e51b95..37e8220 100644 --- a/be-there.ahk +++ b/be-there.ahk @@ -3,6 +3,8 @@ #Include src/lib/JXON.ahk #Include src/lib/config_loader.ahk +#Include src/lib/state_store.ahk +#Include src/lib/focus_or_run.ahk #Include src/lib/command_toast.ahk #Include src/lib/window_inspector.ahk @@ -17,6 +19,7 @@ if (config_errors.Length) { LogConfigErrors(config_errors, config_dir "\config.errors.log", config_path) ExitApp } +global AppState := LoadState() InitCommandToast() super_key := Config["super_key"] @@ -63,11 +66,14 @@ SetWinDelay(-1) window_nav_modifier := super_key #Include src/lib/window_manager.ahk -#Include src/lib/focus_or_run.ahk +#Include src/lib/directional_focus.ahk #Include src/lib/focus_border.ahk +#Include src/lib/window_walker.ahk #Include src/hotkeys/global_hotkey.ahk #Include src/hotkeys/apps.ahk #Include src/hotkeys/window.ahk +#Include src/hotkeys/directional_focus.ahk +#Include src/hotkeys/window_walker.ahk #Include src/hotkeys/unbound.ahk DefaultConfig() { @@ -99,6 +105,16 @@ DefaultConfig() { "cycle_app_windows_hotkey", "c", "center_width_cycle_hotkey", "Space" ), + "window_selector", Map( + "enabled", true, + "hotkey", "w", + "max_results", 12, + "title_preview_len", 60, + "match_title", true, + "match_exe", true, + "include_minimized", true, + "close_on_focus_loss", true + ), "window_manager", Map( "grid_size", 3, "margins", Map( @@ -106,18 +122,31 @@ DefaultConfig() { "left", 4, "right", 4 ), + "gap_px", 0, "exceptions_regex", "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHostIslandWindow)" ), + "directional_focus", Map( + "enabled", true, + "stacked_overlap_threshold", 0.5, + "stack_tolerance_px", 25, + "prefer_topmost", true, + "prefer_last_stacked", true, + "frontmost_guard_px", 200, + "perpendicular_overlap_min", 0.2, + "cross_monitor", false, + "debug_enabled", false + ), "focus_border", Map( "enabled", true, - "border_color", "0x357EC7", - "move_mode_color", "0x2ECC71", + "border_color", "#357EC7", + "move_mode_color", "#2ECC71", "border_thickness", 4, "corner_radius", 12, "update_interval_ms", 20 ), "helper", Map( - "enabled", true + "enabled", true, + "overlay_opacity", 200 ), "reload", Map( "enabled", true, diff --git a/config/config.example.json b/config/config.example.json index 777cc9d..1e77621 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -71,6 +71,16 @@ "cycle_app_windows_hotkey": "c", "center_width_cycle_hotkey": "Space" }, + "window_selector": { + "enabled": true, + "hotkey": "w", + "max_results": 12, + "title_preview_len": 60, + "match_title": true, + "match_exe": true, + "include_minimized": true, + "close_on_focus_loss": true + }, "window_manager": { "grid_size": 3, "margins": { @@ -78,18 +88,31 @@ "left": 4, "right": 4 }, + "gap_px": 0, "exceptions_regex": "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHostIslandWindow)" }, + "directional_focus": { + "enabled": true, + "stacked_overlap_threshold": 0.5, + "stack_tolerance_px": 25, + "prefer_topmost": true, + "prefer_last_stacked": true, + "frontmost_guard_px": 200, + "perpendicular_overlap_min": 0.2, + "cross_monitor": false, + "debug_enabled": false + }, "focus_border": { "enabled": true, - "border_color": "0x357EC7", - "move_mode_color": "0x2ECC71", + "border_color": "#357EC7", + "move_mode_color": "#2ECC71", "border_thickness": 4, "corner_radius": 12, "update_interval_ms": 20 }, "helper": { - "enabled": true + "enabled": true, + "overlay_opacity": 200 }, "reload": { "enabled": true, diff --git a/docs/assets/command_overlay.png b/docs/assets/command_overlay.png index 5e722d5..4676033 100644 Binary files a/docs/assets/command_overlay.png and b/docs/assets/command_overlay.png differ diff --git a/docs/assets/focus.gif b/docs/assets/focus.gif index fce4e32..f57723e 100644 Binary files a/docs/assets/focus.gif and b/docs/assets/focus.gif differ diff --git a/docs/assets/window_switcher.png b/docs/assets/window_switcher.png new file mode 100644 index 0000000..cdfc30d Binary files /dev/null and b/docs/assets/window_switcher.png differ diff --git a/justfile b/justfile new file mode 100644 index 0000000..66ed7c7 --- /dev/null +++ b/justfile @@ -0,0 +1,32 @@ +# Cross platform shebang: +shebang := if os() == 'windows' { + 'pwsh.exe' +} else { + '/usr/bin/env pwsh' +} + +# Set shell for non-Windows OSs: +set shell := ["pwsh", "-c"] + +# Set shell for Windows OSs: +set windows-shell := ["pwsh.exe", "-NoLogo", "-Command"] + + +@build: + ./tools/build_release.ps1 + +@start: + ./be-there.ahk + +@start_bin: + ./dist/be-there.exe + +compress_gifs path: + #!{{shebang}} + Get-ChildItem {{path}} -Filter *.gif | ForEach-Object { + $in = $_.FullName + $tmp = [System.IO.Path]::ChangeExtension($in, ".optimized.gif") + ffmpeg -y -i "$in" ` + -vf "fps=11,scale=720:-2:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=full[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3" ` + "$tmp" + } \ No newline at end of file diff --git a/src/hotkeys/directional_focus.ahk b/src/hotkeys/directional_focus.ahk new file mode 100644 index 0000000..b04d12f --- /dev/null +++ b/src/hotkeys/directional_focus.ahk @@ -0,0 +1,12 @@ +global Config + +if Config.Has("directional_focus") && Config["directional_focus"]["enabled"] { + Hotkey("!h", (*) => DirectionalFocus("left")) + Hotkey("!l", (*) => DirectionalFocus("right")) + Hotkey("!j", (*) => DirectionalFocus("down")) + Hotkey("!k", (*) => DirectionalFocus("up")) + Hotkey("![", (*) => DirectionalFocusStacked("prev")) + Hotkey("!]", (*) => DirectionalFocusStacked("next")) + Hotkey("!+d", (*) => ToggleDirectionalFocusDebug()) + Hotkey("!+s", (*) => SetLastStackedFromActive()) +} diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index 3e23f06..477b065 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -196,6 +196,25 @@ CenterWidthCycle(*) { mw := mx2 - mx1 mh := my2 - my1 + left_margin := Screen.left_margin + right_margin := Screen.right_margin + top_margin := Screen.top_margin + gap_px := Config["window_manager"]["gap_px"] + + mx1 += left_margin + mw := mw - left_margin - right_margin + mh := mh - top_margin + + if (gap_px > 0) { + mx1 += gap_px + my1 += gap_px + mw -= gap_px * 2 + mh -= gap_px * 2 + } + + if (mw <= 0 || mh <= 0) + return + if (state = 0) { ; center 1/3 w := mw / 3 @@ -207,14 +226,7 @@ CenterWidthCycle(*) { w := mw * 2 / 3 } - left_margin := Screen.left_margin - right_margin := Screen.right_margin - top_margin := Screen.top_margin - - mx1 += left_margin - mw := mw - left_margin - right_margin - mh := mh - top_margin - + w := Min(w, mw) x := mx1 + (mw - w) / 2 y := my1 + top_margin @@ -248,6 +260,14 @@ CloseWindow(*) { WinClose "ahk_id " hwnd } +MinimizeWindow(*) { + hwnd := WinExist("A") + if !hwnd + return + WinMinimize "ahk_id " hwnd + ActivateMostRecentWindow(hwnd) +} + CycleAppWindows(*) { hwnd := WinExist("A") if !hwnd @@ -295,6 +315,8 @@ Hotkey("q", CloseWindow) Hotkey(cycle_app_windows_hotkey, CycleAppWindows) HotIf +Hotkey("!-", MinimizeWindow) + if move_mode_enabled { HotIf Window.IsMoveMode Hotkey("h", (*) => MoveActiveWindow(-move_step, 0)) @@ -309,3 +331,22 @@ ExitMoveMode() { Window.SetMoveMode(false) UpdateCommandToastVisibility() } + +ActivateMostRecentWindow(exclude_hwnd := 0) { + z_list := WinGetList() + for _, hwnd in z_list { + if (hwnd = exclude_hwnd) + continue + if Window.IsException("ahk_id " hwnd) + continue + if (WinGetMinMax("ahk_id " hwnd) = -1) + continue + ex_style := WinGetExStyle("ahk_id " hwnd) + if (ex_style & 0x80) || (ex_style & 0x8000000) + continue + if !(WinGetStyle("ahk_id " hwnd) & 0x10000000) + continue + WinActivate "ahk_id " hwnd + return + } +} diff --git a/src/hotkeys/window_walker.ahk b/src/hotkeys/window_walker.ahk new file mode 100644 index 0000000..af6ed0e --- /dev/null +++ b/src/hotkeys/window_walker.ahk @@ -0,0 +1,6 @@ +global Config, super_key + +if Config.Has("window_selector") && Config["window_selector"]["enabled"] { + hotkey_name := Config["window_selector"]["hotkey"] + Hotkey(super_key " & " hotkey_name, (*) => WindowWalker.Show()) +} diff --git a/src/lib/command_toast.ahk b/src/lib/command_toast.ahk index 0a451e2..0ea7af2 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -1,12 +1,22 @@ -global Config +global Config, AppState global command_helper_enabled := false global command_toast_gui := "" global command_toast_text := "" global command_toast_visible := false +global command_toast_view_key := "" +global command_toast_apps_list := "" +global command_toast_actions_list := "" +global command_toast_image_list := "" +global command_toast_icon_cache := Map() +global command_toast_default_icon_index := 0 +global command_toast_last_mode := "" +global command_toast_normal_refresh_pending := false InitCommandToast() { - global Config, command_helper_enabled + global Config, AppState, command_helper_enabled command_helper_enabled := Config["helper"]["enabled"] + if (AppState is Map && AppState.Has("command_helper_enabled")) + command_helper_enabled := AppState["command_helper_enabled"] } UpdateCommandToastVisibility() { @@ -24,22 +34,39 @@ UpdateCommandToastVisibility() { } ShowCommandToast() { - global command_helper_enabled, command_toast_gui, command_toast_text, command_toast_visible + global command_helper_enabled, command_toast_gui, command_toast_visible, command_toast_view_key, command_toast_last_mode, command_toast_normal_refresh_pending if !command_helper_enabled return - text := BuildCommandToastText() - if (text = "") + model := BuildCommandToastModel() + if !(model is Map) || !model.Has("key") || (model["key"] = "") return - if !command_toast_gui { - command_toast_gui := Gui("+AlwaysOnTop -Caption +ToolWindow +Border", "be-there Command Overlay") - command_toast_gui.SetFont("s10", "Consolas") - command_toast_text := command_toast_gui.AddText("w420", text) - } else if (command_toast_text.Text != text) { - command_toast_text.Text := text + if (model["mode"] = "normal" && command_toast_last_mode != "normal") { + command_toast_view_key := "" + command_toast_normal_refresh_pending := true } + if !command_toast_gui || (command_toast_view_key != model["key"]) { + if command_toast_gui + command_toast_gui.Destroy() + command_toast_gui := "" + command_toast_text := "" + command_toast_apps_list := "" + command_toast_actions_list := "" + command_toast_image_list := "" + command_toast_icon_cache := Map() + command_toast_default_icon_index := 0 + CreateCommandToastGui(model) + command_toast_view_key := model["key"] + } else if (model["mode"] = "normal" && command_toast_normal_refresh_pending) { + ; defer refresh until after show to avoid flicker + } + + opacity := NormalizeOverlayOpacity() + if (opacity < 255) + WinSetTransparent(opacity, command_toast_gui) + command_toast_gui.Show("NoActivate") command_toast_gui.GetPos(&x, &y, &w, &h) GetCommandToastWorkArea(&left, &top, &right, &bottom) @@ -58,7 +85,90 @@ ShowCommandToast() { pos_x := Max(min_x, Min(pos_x, max_x)) pos_y := Max(min_y, Min(pos_y, max_y)) command_toast_gui.Show("NoActivate x" pos_x " y" pos_y) + if (model["mode"] = "normal" && command_toast_normal_refresh_pending) { + RefreshCommandToastIcons(model) + command_toast_normal_refresh_pending := false + } command_toast_visible := true + command_toast_last_mode := model["mode"] +} + +CreateCommandToastGui(model) { + global command_toast_gui, command_toast_text, command_toast_apps_list, command_toast_actions_list, command_toast_image_list + command_toast_gui := Gui("+AlwaysOnTop -Caption +ToolWindow +Border", "be-there Command Overlay") + command_toast_gui.MarginX := 12 + command_toast_gui.MarginY := 10 + opacity := NormalizeOverlayOpacity() + if (opacity < 255) + WinSetTransparent(opacity, command_toast_gui) + + command_toast_gui.SetFont("s10 w600", "Segoe UI") + command_toast_gui.AddText("xm", model["title"]) + + GetCommandToastWorkArea(&work_left, &work_top, &work_right, &work_bottom) + work_w := work_right - work_left + work_h := work_bottom - work_top + width_ratio := (work_w > 2000) ? 0.15 : 0.25 + overlay_width := Round(ClampValue(work_w * width_ratio, 380, 640)) + row_height := 22 + apps_rows_max := ClampValue(Floor((work_h * 0.22) / row_height), 6, 12) + actions_rows_max := ClampValue(Floor((work_h * 0.5) / row_height), 10, 26) + + if (model["mode"] = "normal") { + command_toast_gui.SetFont("s9 w600", "Segoe UI") + command_toast_gui.AddText("xm y+6", "Apps") + command_toast_gui.SetFont("s9", "Segoe UI") + + row_count := Max(1, Min(apps_rows_max, model["apps"].Length)) + command_toast_apps_list := command_toast_gui.AddListView("xm w" overlay_width " r" row_count " -Multi NoSortHdr", ["Key", "App"]) + command_toast_image_list := IL_Create(16) + command_toast_default_icon_index := EnsureDefaultAppIcon() + command_toast_apps_list.SetImageList(command_toast_image_list, 1) + apps_key_width := Round(ClampValue(overlay_width * 0.2, 80, 140)) + command_toast_apps_list.ModifyCol(1, apps_key_width) + command_toast_apps_list.ModifyCol(2, overlay_width - apps_key_width - 20) + + for _, app in model["apps"] { + icon_index := GetAppIconIndex(app["icon_path"]) + command_toast_apps_list.Add("Icon" icon_index, app["hotkey"], app["label"]) + } + + command_toast_gui.SetFont("s9", "Segoe UI") + rows := model["rows"] + row_count := Max(1, Min(actions_rows_max, rows.Length)) + command_toast_actions_list := command_toast_gui.AddListView("xm y+6 w" overlay_width " r" row_count " -Multi NoSortHdr", ["Key", "Action"]) + actions_key_width := Round(ClampValue(overlay_width * 0.45, 180, 320)) + command_toast_actions_list.ModifyCol(1, actions_key_width) + command_toast_actions_list.ModifyCol(2, overlay_width - actions_key_width - 20) + + for _, row in rows { + command_toast_actions_list.Add("", row["key"], row["desc"]) + } + return + } + + command_toast_gui.SetFont("s9", "Consolas") + command_toast_text := command_toast_gui.AddText("xm y+6 w" overlay_width, model["body_text"]) +} + +RefreshCommandToastIcons(model) { + global command_toast_apps_list, command_toast_image_list, command_toast_icon_cache, command_toast_default_icon_index + if !command_toast_apps_list + return + if !(model is Map) || !model.Has("apps") + return + + command_toast_icon_cache := Map() + command_toast_image_list := IL_Create(16) + command_toast_default_icon_index := EnsureDefaultAppIcon() + command_toast_apps_list.SetImageList(command_toast_image_list, 1) + command_toast_apps_list.Delete() + row_count := Max(1, Min(8, model["apps"].Length)) + command_toast_apps_list.Opt("r" row_count) + for _, app in model["apps"] { + icon_index := GetAppIconIndex(app["icon_path"]) + command_toast_apps_list.Add("Icon" icon_index, app["hotkey"], app["label"]) + } } HideCommandToast() { @@ -70,8 +180,12 @@ HideCommandToast() { } ToggleCommandHelper() { - global command_helper_enabled + global command_helper_enabled, AppState command_helper_enabled := !command_helper_enabled + if !(AppState is Map) + AppState := Map() + AppState["command_helper_enabled"] := command_helper_enabled + SaveState(AppState) status := command_helper_enabled ? "enabled" : "disabled" TrayTip("", "") TrayTip("be-there", "Command overlay " status, 2) @@ -108,58 +222,192 @@ ConvertMonitorHandleToNumber(handle) { } } -BuildCommandToastText() { +BuildCommandToastModel() { global Config is_command_mode := ReloadModeActive() is_move_mode := Window.IsMoveMode() - lines := [] - lines.Push("be-there") - lines.Push("") - key_width := 16 + key_width := 22 + model := Map() if is_move_mode { - lines.Push("Move Mode") + lines := [] lines.Push(FormatRow("h/j/k/l", "move window", key_width)) lines.Push(FormatRow(Config["window"]["move_mode"]["cancel_key"], "exit move mode", key_width)) - return StrJoin(lines, "`n") + model["mode"] := "move" + model["title"] := "Move Mode" + model["body_text"] := StrJoin(lines, "`n") + model["key"] := "move|" model["body_text"] + return model } if is_command_mode { - lines.Push("Command Mode") + lines := [] lines.Push(FormatRow("r", "reload config", key_width)) lines.Push(FormatRow("e", "open config file", key_width)) lines.Push(FormatRow("i", "window inspector", key_width)) - lines.Push(FormatRow("n", "toggle helper", key_width)) + lines.Push(FormatRow("n", "toggle command overlay", key_width)) lines.Push(FormatRow("w", "new window (active app)", key_width)) - lines.Push(FormatRow("Esc", "exit command mode", key_width)) - return StrJoin(lines, "`n") + lines.Push(FormatRow("Esc/super", "exit command mode", key_width)) + model["mode"] := "command" + model["title"] := "Command Mode" + model["body_text"] := StrJoin(lines, "`n") + model["key"] := "command|" model["body_text"] + return model } - lines.Push("Apps") - lines.Push(" " PadRight("Key", key_width) " App") - lines.Push(" " RepeatChar("-", key_width) " " RepeatChar("-", 16)) - for _, app in Config["apps"] { - lines.Push(" " PadRight(app["hotkey"], key_width) " " app["id"]) - } - lines.Push("") - lines.Push("Window") - lines.Push(FormatRow("arrows", "resize", key_width)) - lines.Push(FormatRow("shift+h/j/k/l", "resize center", key_width)) - lines.Push(FormatRow("ctrl+h/j/k/l", "move", key_width)) - lines.Push(FormatRow("m", "maximize", key_width)) - lines.Push(FormatRow("q", "close", key_width)) - lines.Push(FormatRow(Config["window"]["cycle_app_windows_hotkey"], "cycle app windows", key_width)) - lines.Push("") - lines.Push("Global Hotkeys") + model["mode"] := "normal" + model["title"] := "be-there" + model["apps"] := BuildAppRows() + model["rows"] := BuildCommandToastRows(key_width) + model["key"] := "normal|" BuildCommandToastRowsKey(model["rows"]) "|" BuildAppsKey(model["apps"]) + return model +} + +BuildCommandToastRows(key_width := 16) { + global Config + rows := [] + rows.Push(Map("key", "Window", "desc", "")) + rows.Push(Map("key", "super+arrows", "desc", "resize")) + rows.Push(Map("key", "super+shift+h/j/k/l", "desc", "resize center")) + rows.Push(Map("key", "super+ctrl+h/j/k/l", "desc", "move")) + rows.Push(Map("key", "super+m", "desc", "maximize")) + rows.Push(Map("key", "alt+-", "desc", "minimize")) + rows.Push(Map("key", "super+q", "desc", "close")) + rows.Push(Map("key", "super+" Config["window"]["cycle_app_windows_hotkey"], "desc", "cycle app windows")) + if Config.Has("window_selector") && Config["window_selector"]["enabled"] { + rows.Push(Map("key", "super+" Config["window_selector"]["hotkey"], "desc", "window selector")) + } + if Config.Has("directional_focus") && Config["directional_focus"]["enabled"] { + rows.Push(Map("key", "alt+h/l", "desc", "focus left/right")) + rows.Push(Map("key", "alt+j/k", "desc", "focus down/up")) + rows.Push(Map("key", "alt+[ / ]", "desc", "cycle stacked")) + } + rows.Push(Map("key", "", "desc", "")) + rows.Push(Map("key", "Global Hotkeys", "desc", "")) for _, hotkey_config in Config["global_hotkeys"] { if hotkey_config["enabled"] - lines.Push(FormatRow(hotkey_config["hotkey"], hotkey_config["send_keys"], key_width)) + rows.Push(Map("key", hotkey_config["hotkey"], "desc", hotkey_config["send_keys"])) + } + rows.Push(Map("key", "", "desc", "")) + rows.Push(Map("key", "Command Mode", "desc", "")) + rows.Push(Map("key", Config["reload"]["mode_hotkey"], "desc", "enter command mode")) + return rows +} + +BuildCommandToastRowsKey(rows) { + key := "" + for _, row in rows { + key .= row["key"] "|" row["desc"] "||" + } + return key +} + +BuildAppRows() { + global Config + rows := [] + for _, app in Config["apps"] { + icon_path := ResolveAppIconPath(app) + rows.Push(Map( + "hotkey", app["hotkey"], + "label", app["id"], + "icon_path", icon_path + )) + } + return rows +} + +BuildAppsKey(apps) { + key := "" + for _, app in apps { + key .= app["hotkey"] "|" app["label"] "|" app["icon_path"] "||" + } + return key +} + +ResolveAppIconPath(app) { + if !(app is Map) + return "" + if app.Has("win_title") { + path := FindAppWindowPath(app["win_title"]) + if (path != "") + return path + } + if app.Has("run") { + path := ResolveRunPath(app["run"], app) + if path && FileExist(path) + return path + } + return "" +} + +FindAppWindowPath(win_title) { + if !win_title + return "" + hwnds := WinGetList(win_title) + if (hwnds.Length = 0) + return "" + + for _, hwnd in hwnds { + if (InStr(win_title, "explorer.exe")) { + class_name := WinGetClass("ahk_id " hwnd) + if (class_name = "Progman" || class_name = "WorkerW" || class_name = "Shell_TrayWnd") + continue + } + ex_style := WinGetExStyle("ahk_id " hwnd) + if (ex_style & 0x80) || (ex_style & 0x8000000) + continue + if !(WinGetStyle("ahk_id " hwnd) & 0x10000000) + continue + try { + path := WinGetProcessPath("ahk_id " hwnd) + if path + return path + } catch { + } } - lines.Push("") - lines.Push("Command Mode") - lines.Push(FormatRow(Config["reload"]["mode_hotkey"], "enter command mode", key_width)) + return "" +} + +GetAppIconIndex(path) { + global command_toast_image_list, command_toast_icon_cache, command_toast_default_icon_index + if (path != "" && command_toast_icon_cache.Has(path)) + return command_toast_icon_cache[path] + + if (path = "" || !FileExist(path)) + return command_toast_default_icon_index + + icon_index := 0 + try icon_index := IL_Add(command_toast_image_list, path, 1) + if (!icon_index) + icon_index := command_toast_default_icon_index - return StrJoin(lines, "`n") + if (path != "") + command_toast_icon_cache[path] := icon_index + return icon_index +} + +EnsureDefaultAppIcon() { + global command_toast_image_list + icon_index := 0 + try icon_index := IL_Add(command_toast_image_list, "shell32.dll", 1) + if (!icon_index) + icon_index := 1 + return icon_index +} + +NormalizeOverlayOpacity() { + global Config + opacity := 255 + if Config.Has("helper") && Config["helper"].Has("overlay_opacity") + opacity := Config["helper"]["overlay_opacity"] + if !IsNumber(opacity) + opacity := 255 + opacity := Floor(opacity) + if (opacity < 0) + opacity := 0 + if (opacity > 255) + opacity := 255 + return opacity } StrJoin(items, sep) { @@ -188,3 +436,7 @@ RepeatChar(char, count) { output .= char return output } + +ClampValue(value, min_value, max_value) { + return Max(min_value, Min(value, max_value)) +} diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 7eeaead..ae536d3 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -52,6 +52,16 @@ ConfigSchema() { "cycle_app_windows_hotkey", "string", "center_width_cycle_hotkey", "string" ), + "window_selector", Map( + "enabled", "bool", + "hotkey", "string", + "max_results", "number", + "title_preview_len", "number", + "match_title", "bool", + "match_exe", "bool", + "include_minimized", "bool", + "close_on_focus_loss", "bool" + ), "window_manager", Map( "grid_size", "number", "margins", Map( @@ -59,8 +69,20 @@ ConfigSchema() { "left", "number", "right", "number" ), + "gap_px", "number", "exceptions_regex", "string" ), + "directional_focus", Map( + "enabled", "bool", + "stacked_overlap_threshold", "number", + "stack_tolerance_px", "number", + "prefer_topmost", "bool", + "prefer_last_stacked", "bool", + "frontmost_guard_px", "number", + "perpendicular_overlap_min", "number", + "cross_monitor", "bool", + "debug_enabled", "bool" + ), "focus_border", Map( "enabled", "bool", "border_color", "string", @@ -70,7 +92,8 @@ ConfigSchema() { "update_interval_ms", "number" ), "helper", Map( - "enabled", "bool" + "enabled", "bool", + "overlay_opacity", "number" ), "reload", Map( "enabled", "bool", diff --git a/src/lib/directional_focus.ahk b/src/lib/directional_focus.ahk new file mode 100644 index 0000000..9495829 --- /dev/null +++ b/src/lib/directional_focus.ahk @@ -0,0 +1,855 @@ +global Config +global directional_focus_history := Map() +global directional_focus_debug_enabled := false +global directional_focus_debug_gui := "" +global directional_focus_debug_text := "" +global directional_focus_debug_buffer := [] +global directional_focus_debug_limit := 5 +global directional_focus_last_history_target := 0 +global directional_focus_last_stacked_hwnd := 0 +global directional_focus_last_reason := "" +global directional_focus_last_guard_max := "" +global directional_focus_last_min_primary := "" +global directional_focus_last_frontmost_hwnd := "" +global directional_focus_last_frontmost_z := "" +global directional_focus_last_frontmost_list := "" +global directional_focus_last_scored_count := 0 +global directional_focus_last_filtered_count := 0 +global directional_focus_last_activated_hwnd := "" +global directional_focus_last_activation_match := "" +global directional_focus_last_selected_z := "" +global directional_focus_last_selected_primary := "" +global directional_focus_last_selected_overlap := "" +global directional_focus_last_filtered_list := "" + +DirectionalFocus(direction) { + if !Config.Has("directional_focus") || !Config["directional_focus"]["enabled"] + return + + active_hwnd := WinExist("A") + if !active_hwnd || Window.IsException("ahk_id " active_hwnd) + return + + WinGetPosEx(&ax, &ay, &aw, &ah, "ahk_id " active_hwnd) + if (aw <= 0 || ah <= 0) + return + + active := Map( + "hwnd", active_hwnd, + "x", ax, + "y", ay, + "w", aw, + "h", ah, + "cx", ax + aw / 2, + "cy", ay + ah / 2, + "area", aw * ah, + "monitor", Screen.FromWindow("ahk_id " active_hwnd) + ) + + ResetDirectionalDebugTracking() + ResetDirectionalDebugTracking() + threshold := Config["directional_focus"]["stacked_overlap_threshold"] + stack_tolerance := Config["directional_focus"]["stack_tolerance_px"] + prefer_topmost := Config["directional_focus"]["prefer_topmost"] + frontmost_guard := Config["directional_focus"]["frontmost_guard_px"] + overlap_min := Config["directional_focus"]["perpendicular_overlap_min"] + prefer_last_stacked := Config["directional_focus"]["prefer_last_stacked"] + cross_monitor := Config["directional_focus"]["cross_monitor"] + z_list := WinGetList() + z_map := BuildZOrderMapFromList(z_list) + + if (IsActiveStacked(active, threshold, stack_tolerance, cross_monitor)) + directional_focus_last_stacked_hwnd := active["hwnd"] + + candidates := GetDirectionalCandidates(active, threshold, stack_tolerance, cross_monitor, z_map) + if (candidates.Length = 0) + { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "none" + UpdateDirectionalFocusDebug("direction", direction, active, [], [], 0, z_map) + return + } + + disable_stateful := true + if !disable_stateful { + history_target := GetHistoryTarget(active["hwnd"], direction, active, threshold, stack_tolerance, cross_monitor) + directional_focus_last_history_target := history_target + if (history_target != 0 && CandidateListHas(candidates, history_target)) { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "history" + directional_focus_last_activated_hwnd := ActivateWindow(history_target) + directional_focus_last_activation_match := (directional_focus_last_activated_hwnd = history_target) + UpdateDirectionalFocusDebug("direction", direction, active, candidates, [], history_target, z_map) + if (IsStackedTarget(history_target, candidates, threshold, stack_tolerance)) + directional_focus_last_stacked_hwnd := history_target + return + } + + if prefer_last_stacked { + last_stacked := GetLastStackedCandidate(active, candidates, direction, threshold, stack_tolerance) + if (last_stacked != 0) { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "last_stacked" + directional_focus_last_activated_hwnd := ActivateWindow(last_stacked) + directional_focus_last_activation_match := (directional_focus_last_activated_hwnd = last_stacked) + UpdateDirectionalFocusDebug("direction", direction, active, candidates, [], last_stacked, z_map) + RecordDirectionalHistory(active["hwnd"], last_stacked, direction) + return + } + } + } + + ResetDirectionalDebugTracking() + best := FindDirectionalBest(active, candidates, direction, prefer_topmost, frontmost_guard, overlap_min, z_map) + if (best is Map) { + directional_focus_last_activated_hwnd := ActivateWindow(best["hwnd"]) + directional_focus_last_activation_match := (directional_focus_last_activated_hwnd = best["hwnd"]) + UpdateDirectionalFocusDebug("direction", direction, active, candidates, [], best["hwnd"], z_map) + RecordDirectionalHistory(active["hwnd"], best["hwnd"], direction) + if (IsStackedTarget(best["hwnd"], candidates, threshold, stack_tolerance)) + directional_focus_last_stacked_hwnd := best["hwnd"] + } else { + UpdateDirectionalFocusDebug("direction", direction, active, candidates, [], 0, z_map) + } +} + +DirectionalFocusStacked(direction) { + if !Config.Has("directional_focus") || !Config["directional_focus"]["enabled"] + return + + active_hwnd := WinExist("A") + if !active_hwnd || Window.IsException("ahk_id " active_hwnd) + return + + WinGetPosEx(&ax, &ay, &aw, &ah, "ahk_id " active_hwnd) + if (aw <= 0 || ah <= 0) + return + + active := Map( + "hwnd", active_hwnd, + "x", ax, + "y", ay, + "w", aw, + "h", ah, + "cx", ax + aw / 2, + "cy", ay + ah / 2, + "area", aw * ah, + "monitor", Screen.FromWindow("ahk_id " active_hwnd) + ) + + threshold := Config["directional_focus"]["stacked_overlap_threshold"] + stack_tolerance := Config["directional_focus"]["stack_tolerance_px"] + cross_monitor := Config["directional_focus"]["cross_monitor"] + z_map := BuildZOrderMap() + stacked := GetStackedWindows(active, threshold, stack_tolerance, cross_monitor, z_map) + if (stacked.Length < 2) + { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "stacked_none" + UpdateDirectionalFocusDebug("stacked", direction, active, [], stacked, 0, z_map) + return + } + + ordered := OrderByStableStack(stacked) + if (ordered.Length < 2) + { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "stacked_none" + UpdateDirectionalFocusDebug("stacked", direction, active, [], ordered, 0, z_map) + return + } + + current_index := 0 + for i, win in ordered { + if (win["hwnd"] = active_hwnd) { + current_index := i + break + } + } + if (current_index = 0) + { + ResetDirectionalDebugTracking() + directional_focus_last_reason := "stacked_none" + UpdateDirectionalFocusDebug("stacked", direction, active, [], ordered, 0, z_map) + return + } + + if (direction = "prev") + next_index := (current_index <= 1) ? ordered.Length : current_index - 1 + else + next_index := (current_index >= ordered.Length) ? 1 : current_index + 1 + + ResetDirectionalDebugTracking() + directional_focus_last_reason := "stacked_cycle" + directional_focus_last_activated_hwnd := ActivateWindow(ordered[next_index]["hwnd"]) + directional_focus_last_activation_match := (directional_focus_last_activated_hwnd = ordered[next_index]["hwnd"]) + UpdateDirectionalFocusDebug("stacked", direction, active, [], ordered, ordered[next_index]["hwnd"], z_map) + directional_focus_last_stacked_hwnd := ordered[next_index]["hwnd"] +} + +ToggleDirectionalFocusDebug() { + global directional_focus_debug_enabled + directional_focus_debug_enabled := !directional_focus_debug_enabled + UpdateDirectionalFocusDebug("toggle", "", "", [], [], 0, Map()) +} + +SetLastStackedFromActive() { + global directional_focus_last_stacked_hwnd + hwnd := WinExist("A") + if hwnd + directional_focus_last_stacked_hwnd := hwnd +} + +UpdateDirectionalFocusDebug(mode, direction, active, candidates, stacked, selected_hwnd, z_map) { + global Config, directional_focus_debug_enabled, directional_focus_debug_gui, directional_focus_debug_text + global directional_focus_debug_buffer, directional_focus_debug_limit + if !Config.Has("directional_focus") + return + if !Config["directional_focus"]["debug_enabled"] && !directional_focus_debug_enabled + return + + if !directional_focus_debug_gui { + directional_focus_debug_gui := Gui("+AlwaysOnTop +ToolWindow", "be-there Directional Focus Debug") + directional_focus_debug_gui.SetFont("s9", "Consolas") + directional_focus_debug_text := directional_focus_debug_gui.AddEdit("w720 r22 ReadOnly", "") + directional_focus_debug_gui.OnEvent("Close", (*) => directional_focus_debug_gui.Hide()) + } + + if (!directional_focus_debug_enabled && Config["directional_focus"]["debug_enabled"]) { + directional_focus_debug_enabled := true + } + + entry := BuildDirectionalDebugText(mode, direction, active, candidates, stacked, selected_hwnd, z_map) + directional_focus_debug_buffer.Push(entry) + while (directional_focus_debug_buffer.Length > directional_focus_debug_limit) + directional_focus_debug_buffer.RemoveAt(1) + + directional_focus_debug_text.Text := DirectionalStrJoin(directional_focus_debug_buffer, "`n`n" DirectionalRepeatChar("-", 48) "`n`n") + directional_focus_debug_gui.Show("NoActivate") +} + +BuildDirectionalDebugText(mode, direction, active, candidates, stacked, selected_hwnd, z_map) { + global directional_focus_last_history_target, directional_focus_last_stacked_hwnd + lines := [] + lines.Push("Directional Focus Debug") + lines.Push("mode=" mode " direction=" direction) + lines.Push("") + if (active is Map) { + lines.Push("Active: " FormatWindowLine(active, z_map)) + } else { + lines.Push("Active: (none)") + } + lines.Push("Selected hwnd: " (selected_hwnd ? Format("0x{:X}", selected_hwnd) : "")) + lines.Push("Activated hwnd: " (directional_focus_last_activated_hwnd ? Format("0x{:X}", directional_focus_last_activated_hwnd) : "")) + lines.Push("Activation match: " directional_focus_last_activation_match) + lines.Push("Reason: " directional_focus_last_reason) + if (mode = "direction") { + lines.Push("Selected z: " directional_focus_last_selected_z) + lines.Push("Selected primary: " directional_focus_last_selected_primary) + lines.Push("Selected overlap: " directional_focus_last_selected_overlap) + lines.Push("Min primary: " directional_focus_last_min_primary) + lines.Push("Guard max: " directional_focus_last_guard_max) + lines.Push("Scored count: " directional_focus_last_scored_count) + lines.Push("Filtered count: " directional_focus_last_filtered_count) + lines.Push("Frontmost pick: " (directional_focus_last_frontmost_hwnd ? Format("0x{:X}", directional_focus_last_frontmost_hwnd) : "")) + lines.Push("Frontmost z: " directional_focus_last_frontmost_z) + lines.Push("Frontmost list: " directional_focus_last_frontmost_list) + lines.Push("Filtered list: " directional_focus_last_filtered_list) + } + lines.Push("") + + if (candidates.Length) { + lines.Push("Candidates:") + for _, win in candidates { + dir_ok := IsInDirection(active, win, direction) + overlap := (direction = "left" || direction = "right") ? OverlapRatioVertical(active, win) : OverlapRatioHorizontal(active, win) + primary := (direction = "left" || direction = "right") ? Abs(active["cx"] - win["cx"]) : Abs(active["cy"] - win["cy"]) + overlap_min := Config["directional_focus"]["perpendicular_overlap_min"] + base_score := dir_ok ? DirectionScore(active, win, direction) : "n/a" + score := dir_ok ? base_score : "n/a" + lines.Push("- " FormatWindowLine(win, z_map) + " dir=" (dir_ok ? "y" : "n") + " primary=" Round(primary, 1) + " overlap=" Round(overlap, 3) + " min_overlap=" overlap_min + " score=" score) + } + } else { + lines.Push("Candidates: (none)") + } + lines.Push("") + + if (stacked.Length) { + lines.Push("Stacked:") + for _, win in stacked { + lines.Push("- " FormatWindowLine(win, z_map)) + } + } else { + lines.Push("Stacked: (none)") + } + + return DirectionalStrJoin(lines, "`n") +} + +FormatWindowLine(win, z_map) { + hwnd := win["hwnd"] + title := "" + exe := "" + try title := WinGetTitle("ahk_id " hwnd) + try exe := WinGetProcessName("ahk_id " hwnd) + z := win.Has("z") ? win["z"] : (z_map.Has(hwnd) ? z_map[hwnd] : 0) + return Format("0x{:X} z:{} {} [{}] x{} y{} w{} h{}", + hwnd, + z, + exe, + title, + Round(win["x"]), + Round(win["y"]), + Round(win["w"]), + Round(win["h"])) +} + +DirectionalStrJoin(items, sep) { + output := "" + for i, item in items { + if (i > 1) + output .= sep + output .= item + } + return output +} + +DirectionalRepeatChar(char, count) { + output := "" + loop count + output .= char + return output +} + +GetHistoryTarget(active_hwnd, direction, active, threshold, stack_tolerance, cross_monitor) { + global directional_focus_history + if !directional_focus_history.Has(active_hwnd) + return 0 + dir_map := directional_focus_history[active_hwnd] + if !(dir_map is Map) || !dir_map.Has(direction) + return 0 + target_hwnd := dir_map[direction] + if !target_hwnd + return 0 + if !WinExist("ahk_id " target_hwnd) + return 0 + if !IsDirectionalCandidate(target_hwnd, active, cross_monitor) + return 0 + target_info := BuildWindowInfo(target_hwnd) + if !(target_info is Map) + return 0 + if !IsInDirection(active, target_info, direction) + return 0 + if (IsStacked(active, target_info, threshold, stack_tolerance)) + return 0 + return target_hwnd +} + +RecordDirectionalHistory(from_hwnd, to_hwnd, direction) { + global directional_focus_history + if !from_hwnd || !to_hwnd + return + opposite := OppositeDirection(direction) + if (opposite = "") + return + if !directional_focus_history.Has(to_hwnd) + directional_focus_history[to_hwnd] := Map() + directional_focus_history[to_hwnd][opposite] := from_hwnd +} + +OppositeDirection(direction) { + if (direction = "left") + return "right" + if (direction = "right") + return "left" + if (direction = "up") + return "down" + if (direction = "down") + return "up" + return "" +} + +GetDirectionalCandidates(active, threshold, stack_tolerance, cross_monitor, z_map) { + list := [] + for _, hwnd in WinGetList() { + if (hwnd = active["hwnd"]) + continue + if !IsDirectionalCandidate(hwnd, active, cross_monitor) + continue + + win := BuildWindowInfo(hwnd, z_map) + if !(win is Map) + continue + + if (IsStacked(active, win, threshold, stack_tolerance)) + continue + + list.Push(win) + } + return list +} + +GetStackedWindows(active, threshold, stack_tolerance, cross_monitor, z_map := "") { + list := [] + for _, hwnd in WinGetList() { + if !IsDirectionalCandidate(hwnd, active, cross_monitor) + continue + + win := BuildWindowInfo(hwnd, z_map) + if !(win is Map) + continue + + if (IsStacked(active, win, threshold, stack_tolerance)) + list.Push(win) + } + return list +} + +IsDirectionalCandidate(hwnd, active, cross_monitor) { + if !hwnd + return false + if Window.IsException("ahk_id " hwnd) + return false + if (WinGetMinMax("ahk_id " hwnd) = -1) + return false + if !IsWindowVisible(hwnd) + return false + if !cross_monitor { + if (Screen.FromWindow("ahk_id " hwnd) != active["monitor"]) + return false + } + return true +} + +IsWindowVisible(hwnd) { + ex_style := WinGetExStyle("ahk_id " hwnd) + if (ex_style & 0x80) || (ex_style & 0x8000000) + return false + if !(WinGetStyle("ahk_id " hwnd) & 0x10000000) + return false + return true +} + +BuildWindowInfo(hwnd, z_map := "") { + WinGetPosEx(&x, &y, &w, &h, "ahk_id " hwnd) + if (w <= 0 || h <= 0) + return "" + info := Map( + "hwnd", hwnd, + "x", x, + "y", y, + "w", w, + "h", h, + "cx", x + w / 2, + "cy", y + h / 2, + "area", w * h, + "z", 999999 + ) + if (z_map is Map && z_map.Has(hwnd)) + info["z"] := z_map[hwnd] + return info +} + +FindDirectionalBest(active, candidates, direction, prefer_topmost := false, frontmost_guard := 0, overlap_min := 0, z_map := "") { + global directional_focus_last_reason, directional_focus_last_guard_max, directional_focus_last_min_primary + global directional_focus_last_frontmost_hwnd, directional_focus_last_frontmost_z, directional_focus_last_frontmost_list + global directional_focus_last_scored_count, directional_focus_last_filtered_count + global directional_focus_last_selected_z, directional_focus_last_selected_primary, directional_focus_last_selected_overlap + global directional_focus_last_filtered_list + scored := [] + min_primary := "" + + center_band := GetCenterBandBounds() + active_in_side_band := IsActiveInSideBand(active, center_band) + + for _, win in candidates { + if !IsInDirection(active, win, direction) + continue + + if ((direction = "up" || direction = "down") && active_in_side_band) { + if IsCandidateInCenterBand(win, center_band) + continue + } + + overlap := (direction = "left" || direction = "right") + ? OverlapRatioVertical(active, win) + : OverlapRatioHorizontal(active, win) + if (overlap < overlap_min) + continue + + primary := GetPrimaryDistance(active, win, direction) + base_score := DirectionScore(active, win, direction) + + if (min_primary = "" || primary < min_primary) + min_primary := primary + + z_value := win["z"] + if (z_map is Map && z_map.Has(win["hwnd"])) + z_value := z_map[win["hwnd"]] + scored.Push(Map( + "win", win, + "primary", primary, + "score", base_score, + "z", z_value, + "overlap", overlap + )) + } + + directional_focus_last_scored_count := scored.Length + if (scored.Length = 0) { + directional_focus_last_reason := "none" + directional_focus_last_guard_max := "" + directional_focus_last_min_primary := "" + directional_focus_last_frontmost_list := "" + directional_focus_last_filtered_count := 0 + directional_focus_last_selected_z := "" + directional_focus_last_selected_primary := "" + directional_focus_last_selected_overlap := "" + directional_focus_last_filtered_list := "" + return "" + } + + guard_max := "" + if (frontmost_guard > 0 && min_primary != "") + guard_max := min_primary + frontmost_guard + + filtered := [] + if (guard_max != "") { + for _, entry in scored { + if (entry["primary"] <= guard_max) + filtered.Push(entry) + } + } else { + filtered := scored + } + directional_focus_last_filtered_count := filtered.Length + filtered_list := [] + for _, entry in filtered { + filtered_list.Push(entry["win"]["hwnd"] ":" entry["z"] ":" Round(entry["primary"], 1) ":" Round(entry["overlap"], 3)) + } + directional_focus_last_filtered_list := DirectionalStrJoin(filtered_list, ", ") + + if (filtered.Length > 0) { + best_front := "" + frontmost_list := [] + for _, entry in filtered { + frontmost_list.Push(entry["win"]["hwnd"] ":" entry["z"]) + if (best_front = "" || entry["z"] < best_front["z"]) { + best_front := entry + } + } + if (best_front != "") { + directional_focus_last_reason := "frontmost_guard" + directional_focus_last_guard_max := guard_max + directional_focus_last_min_primary := min_primary + directional_focus_last_frontmost_hwnd := best_front["win"]["hwnd"] + directional_focus_last_frontmost_z := best_front["z"] + directional_focus_last_frontmost_list := DirectionalStrJoin(frontmost_list, ", ") + directional_focus_last_selected_z := best_front["z"] + directional_focus_last_selected_primary := best_front["primary"] + directional_focus_last_selected_overlap := best_front["overlap"] + return best_front["win"] + } + } + + best_entry := "" + best_score := "" + for _, entry in scored { + if (best_entry = "" || entry["score"] < best_score) { + best_entry := entry + best_score := entry["score"] + } else if (prefer_topmost && entry["score"] = best_score) { + if (entry["z"] < best_entry["z"]) + best_entry := entry + } + } + + directional_focus_last_reason := "score" + directional_focus_last_guard_max := guard_max + directional_focus_last_min_primary := min_primary + directional_focus_last_frontmost_hwnd := "" + directional_focus_last_frontmost_z := "" + directional_focus_last_frontmost_list := "" + directional_focus_last_filtered_list := "" + directional_focus_last_selected_z := best_entry["z"] + directional_focus_last_selected_primary := best_entry["primary"] + directional_focus_last_selected_overlap := best_entry["overlap"] + directional_focus_last_filtered_count := filtered.Length + return (best_entry != "") ? best_entry["win"] : "" +} + +GetCenterBandBounds() { + left := Screen.left + right := Screen.right + width := right - left + band_left := left + (width / 3) + band_right := left + (width * 2 / 3) + return Map( + "left", band_left, + "right", band_right + ) +} + +IsActiveInSideBand(active, center_band) { + if !(active is Map) + return false + return (active["cx"] < center_band["left"] || active["cx"] > center_band["right"]) +} + +IsCandidateInCenterBand(win, center_band) { + if !(win is Map) + return false + return (win["cx"] >= center_band["left"] && win["cx"] <= center_band["right"]) +} + +ActivateWindow(hwnd) { + if !hwnd + return 0 + WinActivate("ahk_id " hwnd) + try WinWaitActive("ahk_id " hwnd,, 0.2) + active_hwnd := WinGetID("A") + if (active_hwnd = hwnd) + return active_hwnd + + WinActivate("ahk_id " hwnd) + try WinWaitActive("ahk_id " hwnd,, 0.4) + return WinGetID("A") +} + +ResetDirectionalDebugTracking() { + global directional_focus_last_reason, directional_focus_last_guard_max, directional_focus_last_min_primary + global directional_focus_last_frontmost_hwnd, directional_focus_last_frontmost_z, directional_focus_last_frontmost_list + global directional_focus_last_scored_count, directional_focus_last_filtered_count + global directional_focus_last_activated_hwnd, directional_focus_last_activation_match + global directional_focus_last_selected_z, directional_focus_last_selected_primary, directional_focus_last_selected_overlap + global directional_focus_last_filtered_list + directional_focus_last_reason := "" + directional_focus_last_guard_max := "" + directional_focus_last_min_primary := "" + directional_focus_last_frontmost_hwnd := "" + directional_focus_last_frontmost_z := "" + directional_focus_last_frontmost_list := "" + directional_focus_last_scored_count := 0 + directional_focus_last_filtered_count := 0 + directional_focus_last_activated_hwnd := "" + directional_focus_last_activation_match := "" + directional_focus_last_selected_z := "" + directional_focus_last_selected_primary := "" + directional_focus_last_selected_overlap := "" + directional_focus_last_filtered_list := "" +} + +IsInDirection(active, win, direction) { + if (direction = "left") + return win["cx"] < active["cx"] - 1 + if (direction = "right") + return win["cx"] > active["cx"] + 1 + if (direction = "up") + return win["cy"] < active["cy"] - 1 + if (direction = "down") + return win["cy"] > active["cy"] + 1 + return false +} + +DirectionScore(active, win, direction) { + primary := 0 + overlap_ratio := 0 + + if (direction = "left" || direction = "right") { + primary := Abs(active["cx"] - win["cx"]) + overlap_ratio := OverlapRatioVertical(active, win) + } else { + primary := Abs(active["cy"] - win["cy"]) + overlap_ratio := OverlapRatioHorizontal(active, win) + } + + return primary + (1 - overlap_ratio) * 5000 +} + +GetPrimaryDistance(active, win, direction) { + if (direction = "left" || direction = "right") + return Abs(active["cx"] - win["cx"]) + return Abs(active["cy"] - win["cy"]) +} + +OverlapRatioVertical(a, b) { + overlap := OverlapLength(a["y"], a["y"] + a["h"], b["y"], b["y"] + b["h"]) + return overlap / Max(1, Min(a["h"], b["h"])) +} + +OverlapRatioHorizontal(a, b) { + overlap := OverlapLength(a["x"], a["x"] + a["w"], b["x"], b["x"] + b["w"]) + return overlap / Max(1, Min(a["w"], b["w"])) +} + +OverlapLength(a1, a2, b1, b2) { + return Max(0, Min(a2, b2) - Max(a1, b1)) +} + +IsStacked(a, b, threshold, tolerance_px := 0) { + if (tolerance_px > 0) { + if (Abs(a["cx"] - b["cx"]) <= tolerance_px && Abs(a["cy"] - b["cy"]) <= tolerance_px) + return true + if (Abs(a["x"] - b["x"]) <= tolerance_px + && Abs(a["y"] - b["y"]) <= tolerance_px + && Abs(a["w"] - b["w"]) <= tolerance_px + && Abs(a["h"] - b["h"]) <= tolerance_px) + return true + } + overlap_w := OverlapLength(a["x"], a["x"] + a["w"], b["x"], b["x"] + b["w"]) + overlap_h := OverlapLength(a["y"], a["y"] + a["h"], b["y"], b["y"] + b["h"]) + overlap_area := overlap_w * overlap_h + if (overlap_area <= 0) + return false + + min_area := Min(a["area"], b["area"]) + ratio := overlap_area / Max(1, min_area) + return ratio >= threshold +} + +BuildZOrderMap() { + z_list := WinGetList() + return BuildZOrderMapFromList(z_list) +} + +BuildZOrderMapFromList(z_list) { + z_map := Map() + for i, hwnd in z_list { + z_map[hwnd] := i + } + return z_map +} + +OrderByZ(z_list, windows) { + ordered := [] + window_map := Map() + for _, win in windows { + window_map[win["hwnd"]] := win + } + for _, hwnd in z_list { + if window_map.Has(hwnd) + ordered.Push(window_map[hwnd]) + } + return ordered +} + +OrderByZMap(z_map, windows) { + ordered := [] + if !(z_map is Map) + return ordered + + for _, win in windows { + if !win.Has("z") + win["z"] := z_map.Has(win["hwnd"]) ? z_map[win["hwnd"]] : 999999 + ordered.Push(win) + } + + count := ordered.Length + if (count < 2) + return ordered + + loop count - 1 { + i := A_Index + loop count - i { + j := A_Index + if (ordered[j]["z"] > ordered[j + 1]["z"]) { + temp := ordered[j] + ordered[j] := ordered[j + 1] + ordered[j + 1] := temp + } + } + } + return ordered +} + +OrderByStableStack(windows) { + ordered := [] + for _, win in windows + ordered.Push(win) + + count := ordered.Length + if (count < 2) + return ordered + + loop count - 1 { + i := A_Index + loop count - i { + j := A_Index + if (ordered[j]["hwnd"] > ordered[j + 1]["hwnd"]) { + temp := ordered[j] + ordered[j] := ordered[j + 1] + ordered[j + 1] := temp + } + } + } + return ordered +} + +CandidateListHas(candidates, hwnd) { + for _, win in candidates { + if (win["hwnd"] = hwnd) + return true + } + return false +} + +GetLastStackedCandidate(active, candidates, direction, threshold, stack_tolerance) { + global directional_focus_last_stacked_hwnd + if !directional_focus_last_stacked_hwnd + return 0 + + last_candidate := "" + for _, win in candidates { + if (win["hwnd"] = directional_focus_last_stacked_hwnd) { + last_candidate := win + break + } + } + if !(last_candidate is Map) + return 0 + + for _, win in candidates { + if (win["hwnd"] = last_candidate["hwnd"]) + continue + if (IsStacked(last_candidate, win, threshold, stack_tolerance)) + return AllowLastStackedForDirection(active, last_candidate, direction, threshold) ? last_candidate["hwnd"] : 0 + } + return 0 +} + +AllowLastStackedForDirection(active, last_candidate, direction, threshold) { + if !(active is Map) || !(last_candidate is Map) + return false + if (direction = "left" || direction = "right") + return OverlapRatioVertical(active, last_candidate) >= threshold + if (direction = "up" || direction = "down") + return OverlapRatioHorizontal(active, last_candidate) >= threshold + return false +} + +IsActiveStacked(active, threshold, stack_tolerance, cross_monitor) { + stacked := GetStackedWindows(active, threshold, stack_tolerance, cross_monitor) + return stacked.Length >= 2 +} + +IsStackedTarget(target_hwnd, candidates, threshold, stack_tolerance) { + for _, win in candidates { + if (win["hwnd"] = target_hwnd) + continue + if (IsStackedByHwnd(target_hwnd, win, threshold, stack_tolerance)) + return true + } + return false +} + +IsStackedByHwnd(target_hwnd, win, threshold, stack_tolerance) { + target_info := BuildWindowInfo(target_hwnd) + if !(target_info is Map) + return false + return IsStacked(target_info, win, threshold, stack_tolerance) +} diff --git a/src/lib/focus_border.ahk b/src/lib/focus_border.ahk index 519f972..2aea728 100644 --- a/src/lib/focus_border.ahk +++ b/src/lib/focus_border.ahk @@ -11,8 +11,8 @@ focus_config := Config["focus_border"] global focus_border_enabled := focus_config["enabled"] if focus_border_enabled { ; ------------- User Settings ------------- - border_color := Integer(focus_config["border_color"]) ; Hex color (0xRRGGBB) - move_mode_color := Integer(focus_config["move_mode_color"]) ; Hex color (0xRRGGBB) + border_color := ParseHexColor(focus_config["border_color"]) ; Hex color (#RRGGBB) + move_mode_color := ParseHexColor(focus_config["move_mode_color"]) ; Hex color (#RRGGBB) border_thickness := Integer(focus_config["border_thickness"]) ; Border thickness in pixels corner_radius := Integer(focus_config["corner_radius"]) ; Corner roundness in pixels update_interval := Integer(focus_config["update_interval_ms"]) ; How often (ms) to check/update active window @@ -136,3 +136,18 @@ FlashFocusBorder(color := 0xB0B0B0, duration_ms := 130) { current_color := flash_color } } + +ParseHexColor(value) { + if (value is Integer) + return value + if !(value is String) + return 0 + trimmed := Trim(value) + if (SubStr(trimmed, 1, 1) = "#") + trimmed := SubStr(trimmed, 2) + if (StrLen(trimmed) = 8 && RegExMatch(trimmed, "i)^0x[0-9a-f]{6}$")) + return Integer(trimmed) + if (StrLen(trimmed) = 6 && RegExMatch(trimmed, "i)^[0-9a-f]{6}$")) + return Integer("0x" trimmed) + return 0 +} diff --git a/src/lib/state_store.ahk b/src/lib/state_store.ahk new file mode 100644 index 0000000..cec2d8a --- /dev/null +++ b/src/lib/state_store.ahk @@ -0,0 +1,30 @@ +LoadState() { + state_path := GetStatePath() + if !FileExist(state_path) + return Map() + + try { + json_text := FileRead(state_path) + state := Jxon_Load(&json_text) + if (state is Map) + return state + } catch { + } + return Map() +} + +SaveState(state) { + if !(state is Map) + return + + state_path := GetStatePath() + DirCreate(GetConfigDir()) + state_text := Jxon_Dump(state, 2) + if FileExist(state_path) + FileDelete(state_path) + FileAppend(state_text, state_path) +} + +GetStatePath() { + return GetConfigDir() "\state.json" +} diff --git a/src/lib/window_manager.ahk b/src/lib/window_manager.ahk index 6f9dfc3..e669557 100644 --- a/src/lib/window_manager.ahk +++ b/src/lib/window_manager.ahk @@ -30,6 +30,7 @@ class Window */ static grid_size := Config["window_manager"]["grid_size"] static move_mode := false + static side_cycle_index := Map() @@ -115,17 +116,24 @@ class Window /** * get width and height of grid point closest to the window */ - loop Window.grid_size + grid_w_max := Max(Window.grid_size, 4) + grid_h_max := Window.grid_size + + loop grid_w_max { plot_width := screenWidth // A_Index ; screen width divided by 1, 2, 3, etc. - plot_height := screenHeight // A_Index ; screen height divided by 1, 2, 3, etc. diffW := Abs(plot_width - w) ; difference between grid plot width and window width - diffH := Abs(plot_height - h) ; difference between grid plot height and window height if diffW <= closest_in_width { ; if difference is less than the last difference calculated closest_in_width := diffW ; remember new value for next iteration grid_w := A_Index ; remember width in grid } + } + + loop grid_h_max + { + plot_height := screenHeight // A_Index ; screen height divided by 1, 2, 3, etc. + diffH := Abs(plot_height - h) ; difference between grid plot height and window height if diffH <= closest_in_height { ; if difference is less than the last difference calculated closest_in_height := diffH ; remember new value for next iteration @@ -185,11 +193,17 @@ class Window */ static MoveLeft(coords) { + side_max_width := 4 + if (coords.width > side_max_width) { + coords.width := side_max_width + coords.x := Min(coords.x, coords.width) + } + if --coords.x < this.min_grid ; if x-1 coord is out of grid bounds { coords.x := this.min_grid ; set x coord to minimum grid - if coords.width = this.grid_size ; if width is at max size + if coords.width = side_max_width ; if width is at max size { coords.y := 1 ; set y coord to top of screen @@ -199,9 +213,7 @@ class Window else ; if width is less than max size { WinGetPosEx(,, &w,, 'A') ; get window width - if w <= Screen.width // coords.width ; if window can get smaller (prevents gui guides from thinking window got smaller) - or Window.IsMaximized(coords) ; or window is maximized - coords.width := Min(++coords.width, this.grid_size) ; increase width of window if there is room + coords.width := Window.GetNextSideWidthFromCycle(w) } } Window.UpdatePosition(coords) ; update the window position @@ -210,9 +222,15 @@ class Window static MoveRight(coords) { + side_max_width := 4 + if (coords.width > side_max_width) { + coords.width := side_max_width + coords.x := Min(coords.x, coords.width) + } + if ++coords.x > coords.width ; if x+1 coord is greater than window width { - if coords.x > this.grid_size ; if x coord is out of grid bounds + if coords.x > side_max_width ; if x coord is out of grid bounds { coords.y := 1 ; set y coord to top of screen @@ -223,16 +241,89 @@ class Window else ; if x coord is within grid { WinGetPosEx(,, &w,, 'A') ; get window width - if w <= Screen.width // coords.width ; if window can get smaller (prevents gui guides from "thinking" window got smaller) - or Window.IsMaximized(coords) ; or window is maximized - coords.width := Min(++coords.width, this.grid_size) ; increase width of window if there is room - - else coords.x-- ; undo x increase so wrong gui guides aren't created in some scenarios + coords.width := Window.GetNextSideWidthFromCycle(w) + coords.x := coords.width } } Window.UpdatePosition(coords) ; update the window position } + static GetSideWidthDivisors() + { + return [4.0, 3.0, 2.0, 1.5] + } + + static GetNextSideWidthFromCycle(window_width) + { + hwnd := WinExist("A") + if !hwnd + return 2.0 + + screen_width := Screen.width + if (screen_width <= 0) + return 2.0 + + divisors := Window.GetSideWidthDivisors() + base_index := Window.GetClosestSideDivisorIndex(window_width, screen_width, divisors) + current_index := base_index + if Window.side_cycle_index.Has(hwnd) { + stored_index := Window.side_cycle_index[hwnd] + if (stored_index >= 1 && stored_index <= divisors.Length) { + if Window.IsWidthNearDivisor(window_width, screen_width, divisors[stored_index]) + current_index := stored_index + } + } + + next_index := current_index + 1 + if (next_index > divisors.Length) + next_index := 1 + + Window.side_cycle_index[hwnd] := next_index + return divisors[next_index] + } + + static GetClosestSideDivisorIndex(window_width, screen_width, divisors) + { + closest_index := 1 + closest_diff := Abs((screen_width / divisors[1]) - window_width) + for i, divisor in divisors { + diff := Abs((screen_width / divisor) - window_width) + if (diff < closest_diff) { + closest_diff := diff + closest_index := i + } + } + return closest_index + } + + static IsWidthNearDivisor(window_width, screen_width, divisor, tolerance_px := 12) + { + expected := screen_width / divisor + return Abs(window_width - expected) <= tolerance_px + } + + static GetNextSideWidthDivisor(window_width) + { + screen_width := Screen.width + if (screen_width <= 0) + return "" + + divisors := Window.GetSideWidthDivisors() + closest_index := 1 + closest_diff := Abs((screen_width / divisors[1]) - window_width) + for i, divisor in divisors { + diff := Abs((screen_width / divisor) - window_width) + if (diff < closest_diff) { + closest_diff := diff + closest_index := i + } + } + + if (closest_index < divisors.Length) + return divisors[closest_index + 1] + return divisors[1] + } + static MoveUp(coords) { @@ -331,24 +422,57 @@ class Window */ static Move(coords, hwnd := 'A') { - fractionX := Mod(100, coords.width) != 0 ; check if window / width isn't a whole number - fractionY := Mod(100, coords.height) != 0 ; check if window / height isn't a whole number - - x_pos := (coords.x - 1) * (100 // coords.width) ; get x position window should be in - y_pos := (coords.y - 1) * (100 // coords.height) ; get y position window should be in + width_is_int := IsInteger(coords.width) + height_is_int := IsInteger(coords.height) + + if width_is_int { + fractionX := Mod(100, coords.width) != 0 ; check if window / width isn't a whole number + x_pos := (coords.x - 1) * (100 // coords.width) ; get x position window should be in + width := (100 // coords.width) + ; 100 / window width, rounded down + (fractionX and (coords.x = coords.width) ? 1 : 0) ; add one if layout size isn't evenly divided by window and window is furthest right in the grid + } else { + width := 100 / coords.width + x_pos := (coords.x - 1) * width + } - width := (100 // coords.width) + ; 100 / window width, rounded down - (fractionX and (coords.x = coords.width) ? 1 : 0) ; add one if layout size isn't evenly divided by window and window is furthest right in the grid - height := (100 // coords.height) + ; 100 / window height, rounded down - (fractionY and (coords.y = coords.height) ? 1 : 0) ; add one if layout size isn't evenly divided by window and window is furthest bottom in the grid + if height_is_int { + fractionY := Mod(100, coords.height) != 0 ; check if window / height isn't a whole number + y_pos := (coords.y - 1) * (100 // coords.height) ; get y position window should be in + height := (100 // coords.height) + ; 100 / window height, rounded down + (fractionY and (coords.y = coords.height) ? 1 : 0) ; add one if layout size isn't evenly divided by window and window is furthest bottom in the grid + } else { + height := 100 / coords.height + y_pos := (coords.y - 1) * height + } WinRestore(hwnd) ; unmaximizes window if maximized + target_x := Screen.X_Pos_Percent(x_pos) + target_y := Screen.Y_Pos_Percent(y_pos) + target_w := Screen.Width_Percent(width) + target_h := Screen.Height_Percent(height) + + gap_px := Config["window_manager"]["gap_px"] + if (gap_px > 0) { + left_inset := gap_px + right_inset := gap_px + top_inset := gap_px + bottom_inset := gap_px + + target_x += left_inset + target_y += top_inset + target_w -= (left_inset + right_inset) + target_h -= (top_inset + bottom_inset) + + if (target_w <= 0 || target_h <= 0) + return + } + WinMoveEx( ; move window taking invisible borders into account - Screen.X_Pos_Percent(x_pos), ; move window x_pos to x% of the screen - Screen.Y_Pos_Percent(y_pos), ; move window y_pos to x% of the screen - Screen.Width_Percent(width), ; resize window width to x% - Screen.Height_Percent(height), ; resize window height to x% + target_x, ; move window x_pos to x% of the screen + target_y, ; move window y_pos to x% of the screen + target_w, ; resize window width to x% + target_h, ; resize window height to x% hwnd ; window to move ) diff --git a/src/lib/window_walker.ahk b/src/lib/window_walker.ahk new file mode 100644 index 0000000..8deaa8a --- /dev/null +++ b/src/lib/window_walker.ahk @@ -0,0 +1,412 @@ +global Config + +class WindowWalker +{ + static gui := "" + static search_edit := "" + static list_view := "" + static windows := [] + static filtered := [] + static focus_timer := "" + static nav_hotkeys_ready := false + static visible := false + static image_list := "" + static icon_cache := Map() + + static Show(*) + { + if !Config.Has("window_selector") || !Config["window_selector"]["enabled"] + return + + WindowWalker.EnsureGui() + WindowWalker.EnsureNavigationHotkeys() + WindowWalker.RefreshWindows() + WindowWalker.ApplyFilter() + WindowWalker.ShowCentered() + WindowWalker.visible := true + WindowWalker.search_edit.Focus() + WindowWalker.StartFocusWatch() + } + + static Hide(*) + { + if WindowWalker.gui { + WindowWalker.gui.Hide() + } + WindowWalker.visible := false + WindowWalker.StopFocusWatch() + } + + static IsActive(*) + { + return WindowWalker.visible && WindowWalker.gui && WinActive("ahk_id " WindowWalker.gui.Hwnd) + } + + static EnsureGui() + { + if WindowWalker.gui + return + + WindowWalker.gui := Gui("+AlwaysOnTop +ToolWindow -Caption +Border", "be-there Window Selector") + WindowWalker.gui.MarginX := 12 + WindowWalker.gui.MarginY := 10 + WindowWalker.gui.SetFont("s10", "Segoe UI") + + WindowWalker.search_edit := WindowWalker.gui.AddEdit("w560", "") + WindowWalker.search_edit.OnEvent("Change", (*) => WindowWalker.ApplyFilter()) + + WindowWalker.list_view := WindowWalker.gui.AddListView("w560 r10 -Multi", ["App", "Title"]) + WindowWalker.image_list := IL_Create(20) + WindowWalker.list_view.SetImageList(WindowWalker.image_list, 1) + WindowWalker.list_view.ModifyCol(1, 160) + WindowWalker.list_view.ModifyCol(2, 380) + WindowWalker.list_view.OnEvent("DoubleClick", (*) => WindowWalker.ActivateSelected()) + + WindowWalker.gui.OnEvent("Close", (*) => WindowWalker.Hide()) + } + + static ShowCentered() + { + WindowWalker.gui.Show("Hide AutoSize") + WindowWalker.gui.GetPos(&x, &y, &w, &h) + + WindowWalker.GetActiveWorkArea(&left, &top, &right, &bottom) + pos_x := left + (right - left - w) / 2 + pos_y := top + (bottom - top - h) / 2 + WindowWalker.gui.Show("x" pos_x " y" pos_y) + } + + static StartFocusWatch() + { + if !Config["window_selector"]["close_on_focus_loss"] + return + + if !WindowWalker.focus_timer + WindowWalker.focus_timer := ObjBindMethod(WindowWalker, "CheckFocus") + SetTimer(WindowWalker.focus_timer, 0) + SetTimer(WindowWalker.focus_timer, 120) + } + + static StopFocusWatch() + { + if WindowWalker.focus_timer + SetTimer(WindowWalker.focus_timer, 0) + } + + static CheckFocus() + { + if !WindowWalker.visible || !WindowWalker.gui + return + if !WinActive("ahk_id " WindowWalker.gui.Hwnd) + WindowWalker.Hide() + } + + static EnsureNavigationHotkeys() + { + if WindowWalker.nav_hotkeys_ready + return + + HotIf (*) => WindowWalker.IsActive() + Hotkey("Up", (*) => WindowWalker.MoveSelection(-1)) + Hotkey("Down", (*) => WindowWalker.MoveSelection(1)) + Hotkey("^k", (*) => WindowWalker.MoveSelection(-1)) + Hotkey("^j", (*) => WindowWalker.MoveSelection(1)) + Hotkey("Enter", (*) => WindowWalker.ActivateSelected()) + Hotkey("Esc", (*) => WindowWalker.Hide()) + HotIf + WindowWalker.nav_hotkeys_ready := true + } + + static MoveSelection(delta) + { + if !WindowWalker.list_view + return + + count := WindowWalker.list_view.GetCount() + if (count = 0) + return + + row := WindowWalker.list_view.GetNext(0, "F") + if (row = 0) + row := 1 + else { + row += delta + if (row < 1) + row := count + if (row > count) + row := 1 + } + + WindowWalker.list_view.Modify(0, "-Select -Focus") + WindowWalker.list_view.Modify(row, "Select Focus Vis") + } + + static ActivateSelected() + { + if !WindowWalker.list_view + return + + row := WindowWalker.list_view.GetNext(0, "F") + if (row = 0) + row := WindowWalker.list_view.GetNext(0, "S") + if (row = 0) + row := 1 + + if (row < 1 || row > WindowWalker.filtered.Length) + return + + hwnd := WindowWalker.filtered[row]["hwnd"] + if !hwnd + return + + WindowWalker.Hide() + if WinExist("ahk_id " hwnd) { + if (WinGetMinMax("ahk_id " hwnd) = -1) + WinRestore "ahk_id " hwnd + WinActivate "ahk_id " hwnd + } + } + + static RefreshWindows() + { + WindowWalker.windows := [] + win_list := WinGetList() + + for _, hwnd in win_list { + if WindowWalker.gui && (hwnd = WindowWalker.gui.Hwnd) + continue + + if !WindowWalker.IsWindowEligible(hwnd) + continue + + title := WinGetTitle("ahk_id " hwnd) + if (Trim(title) = "") + continue + + exe := WinGetProcessName("ahk_id " hwnd) + if (exe = "") + exe := "unknown" + exe_path := "" + try exe_path := WinGetProcessPath("ahk_id " hwnd) + + WindowWalker.windows.Push(Map( + "hwnd", hwnd, + "title", title, + "exe", exe, + "exe_path", exe_path, + "order", A_Index + )) + } + } + + static ApplyFilter() + { + if !WindowWalker.list_view || !WindowWalker.search_edit + return + + query := Trim(WindowWalker.search_edit.Text) + match_title := Config["window_selector"]["match_title"] + match_exe := Config["window_selector"]["match_exe"] + max_results := Config["window_selector"]["max_results"] + preview_len := Config["window_selector"]["title_preview_len"] + + matches := [] + for _, win in WindowWalker.windows { + match_text := WindowWalker.BuildMatchText(win, match_title, match_exe) + score := WindowWalker.FuzzyScore(query, match_text) + if (score < 0) + continue + matches.Push(Map( + "score", score, + "order", win["order"], + "hwnd", win["hwnd"], + "exe", win["exe"], + "exe_display", WindowWalker.DisplayExe(win["exe"]), + "exe_path", win["exe_path"], + "title", win["title"], + "title_preview", WindowWalker.TruncateText(win["title"], preview_len) + )) + } + + WindowWalker.SortMatches(matches) + if (max_results > 0 && matches.Length > max_results) { + while matches.Length > max_results + matches.Pop() + } + + WindowWalker.filtered := matches + WindowWalker.list_view.Delete() + + for _, item in matches { + icon_index := WindowWalker.GetIconIndex(item["exe"], item["exe_path"]) + WindowWalker.list_view.Add("Icon" icon_index, item["exe_display"], item["title_preview"]) + } + + if (WindowWalker.list_view.GetCount() > 0) + WindowWalker.list_view.Modify(1, "Select Focus Vis") + } + + static BuildMatchText(win, match_title, match_exe) + { + text := "" + if match_exe + text := win["exe"] + if match_title { + if (text != "") + text .= " " + text .= win["title"] + } + return text + } + + static IsWindowEligible(hwnd) + { + if Window.IsException("ahk_id " hwnd) + return false + + if (!Config["window_selector"]["include_minimized"] && WinGetMinMax("ahk_id " hwnd) = -1) + return false + + ex_style := WinGetExStyle("ahk_id " hwnd) + if (ex_style & 0x80) || (ex_style & 0x8000000) + return false + if !(WinGetStyle("ahk_id " hwnd) & 0x10000000) + return false + + return true + } + + static SortMatches(matches) + { + count := matches.Length + if (count < 2) + return + + loop count - 1 { + i := A_Index + loop count - i { + j := A_Index + a := matches[j] + b := matches[j + 1] + if (a["score"] < b["score"]) || (a["score"] = b["score"] && a["order"] > b["order"]) { + matches[j] := b + matches[j + 1] := a + } + } + } + } + + static FuzzyScore(query, text) + { + if (query = "") + return 0 + + q := StrLower(query) + t := StrLower(text) + q_len := StrLen(q) + t_len := StrLen(t) + if (q_len = 0 || t_len = 0) + return -1 + + qi := 1 + score := 0 + last_match := 0 + loop t_len { + if (qi > q_len) + break + ti := A_Index + if (SubStr(t, ti, 1) = SubStr(q, qi, 1)) { + score += 10 + if (ti = last_match + 1) + score += 5 + if (ti = 1 || WindowWalker.IsWordBoundary(SubStr(t, ti - 1, 1))) + score += 3 + last_match := ti + qi += 1 + } + } + + if (qi <= q_len) + return -1 + + score += Max(0, 30 - t_len) + return score + } + + static IsWordBoundary(char) + { + return InStr(" _-./\\()[]{}", char) + } + + static TruncateText(text, max_len) + { + if (max_len <= 0) + return "" + if (StrLen(text) <= max_len) + return text + if (max_len <= 3) + return SubStr(text, 1, max_len) + return SubStr(text, 1, max_len - 3) "..." + } + + static DisplayExe(exe) + { + if (exe = "") + return exe + return RegExReplace(exe, "(?i)\.exe$", "") + } + + static GetIconIndex(exe, exe_path) + { + if (exe_path != "" && WindowWalker.icon_cache.Has(exe_path)) + return WindowWalker.icon_cache[exe_path] + if (exe_path = "" && WindowWalker.icon_cache.Has(exe)) + return WindowWalker.icon_cache[exe] + + icon_index := 0 + if (exe_path != "") { + try icon_index := IL_Add(WindowWalker.image_list, exe_path, 1) + } + if (!icon_index) { + try icon_index := IL_Add(WindowWalker.image_list, "shell32.dll", 1) + } + if (!icon_index) + icon_index := 1 + + cache_key := exe_path != "" ? exe_path : exe + WindowWalker.icon_cache[cache_key] := icon_index + return icon_index + } + + static GetActiveWorkArea(&left, &top, &right, &bottom) + { + mon := "" + try hwnd := WinGetID("A") + if hwnd { + try mon_handle := DllCall("MonitorFromWindow", "Ptr", hwnd, "UInt", 2, "Ptr") + if mon_handle + mon := WindowWalker.ConvertMonitorHandleToNumber(mon_handle) + } + if !mon + mon := MonitorGetPrimary() + MonitorGetWorkArea(mon, &left, &top, &right, &bottom) + } + + static ConvertMonitorHandleToNumber(handle) + { + mon_handle_list := "" + mon_callback := CallbackCreate(__EnumMonitors, "Fast", 4) + + if DllCall("EnumDisplayMonitors", "Ptr", 0, "Ptr", 0, "Ptr", mon_callback, "UInt", 0) { + loop parse, mon_handle_list, "`n" + if (A_LoopField = handle) + return A_Index + } + return "" + + __EnumMonitors(hMonitor, hDevCon, pRect, args) { + mon_handle_list .= hMonitor "`n" + return true + } + } +}