From a5dec2ad797c1b7a87e787677c9fdbbe3cd39534 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Wed, 4 Feb 2026 09:42:52 -0600 Subject: [PATCH 01/20] renames be-there to harken --- .github/workflows/dev.yml | 7 +++---- .github/workflows/release.yml | 5 ++--- .gitignore | 1 + AGENTS.md | 20 ++++++++++---------- README.md | 10 +++++----- docs/INSTALL.md | 10 +++++----- docs/RELEASE.md | 10 +++++----- be-there.ahk => harken.ahk | 4 ++-- justfile | 6 +++--- src/lib/command_toast.ahk | 6 +++--- src/lib/directional_focus.ahk | 2 +- src/lib/focus_or_run.ahk | 2 +- src/lib/window_inspector.ahk | 2 +- src/lib/window_walker.ahk | 2 +- tools/build_release.ps1 | 16 ++++++---------- 15 files changed, 49 insertions(+), 54 deletions(-) rename be-there.ahk => harken.ahk (98%) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 13700cf..7e7d1ed 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -35,8 +35,7 @@ jobs: if: steps.src_changes.outputs.has_src_changes == 'true' uses: actions/upload-artifact@v4 with: - name: be-there-dev-${{ github.sha }} + name: harken-dev-${{ github.sha }} path: | - dist\be-there-source-dev.zip - dist\be-there-dev-win64.zip - dist\be-there.exe + dist\harken-source-dev.zip + dist\harken.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3792685..8b01bda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,5 @@ jobs: tag_name: ${{ env.VERSION }} prerelease: ${{ env.PRERELEASE }} files: | - dist/be-there-source-${{ env.VERSION }}.zip - dist/be-there-${{ env.VERSION }}-win64.zip - dist/be-there.exe + dist/harken-source-${{ env.VERSION }}.zip + dist/harken.exe diff --git a/.gitignore b/.gitignore index 65551be..f878f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ PLAN.md config/config.json dist/ ahk/ +*_PLAN.md diff --git a/AGENTS.md b/AGENTS.md index 6f4344f..4679662 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ No Cursor rules found in `.cursor/rules/` or `.cursorrules`. No Copilot instructions found in `.github/copilot-instructions.md`. ## Quick Context -- Entry point: `be-there.ahk`. +- Entry point: `harken.ahk`. - Source modules: `src/` (hotkeys, window management, UI helpers, config loader). - Tools: `tools/` (build script, inspector utilities). - Config example: `config/config.example.json` (user config lives outside the repo). @@ -25,7 +25,7 @@ Dev build (CI): - GitHub Actions runs `tools/build_release.ps1 -Version dev` on `main`/`dev` when `src/` changes. Single test / focused run: -- No unit test runner exists. Use manual verification steps (launch `be-there.ahk`, validate +- No unit test runner exists. Use manual verification steps (launch `harken.ahk`, validate hotkeys, reload flow, and Command Overlay) or run targeted helper tools in `tools/`. ## Code Style Guidelines @@ -55,7 +55,7 @@ Single test / focused run: ### Imports and module structure - Keep modules focused by domain (hotkeys, window management, utilities). - Avoid circular dependencies; shared helpers should live in `src/lib/`. -- `be-there.ahk` should remain the only top-level orchestrator. +- `harken.ahk` should remain the only top-level orchestrator. ### Error handling - Use `try`/`catch` for file IO and JSON parsing; return structured errors where possible. @@ -68,7 +68,7 @@ Single test / focused run: - Do not edit or delete user config without backup. - Validate config with schema before registering hotkeys. - When adding config keys, update: - - `DefaultConfig()` in `be-there.ahk` + - `DefaultConfig()` in `harken.ahk` - Schema in `src/lib/config_loader.ahk` - `config/config.example.json` - `README.md` @@ -94,22 +94,22 @@ Single test / focused run: - Keep `README.md` and `AGENTS.md` aligned with current behavior. ## Suggested Manual Checks -- Launch `be-there.ahk` with a clean `config.json` and verify hotkeys. +- Launch `harken.ahk` with a clean `config.json` and verify hotkeys. - Validate reload flow (normal hotkey and command mode). - Confirm Command Overlay and helper tools still open and update. -- If touching config schema, ensure errors log correctly in `~/.config/be-there/config.errors.log`. +- If touching config schema, ensure errors log correctly in `~/.config/harken/config.errors.log`. ## Paths and Layout Notes -- Main script: `be-there.ahk`. +- Main script: `harken.ahk`. - Config loader: `src/lib/config_loader.ahk` (schema + validation). - JSON parsing: `src/lib/JXON.ahk`. - Window manager: `src/lib/window_manager.ahk`. - Hotkeys: `src/hotkeys/*.ahk`. ## Build Artifacts -- `dist/be-there.exe` -- `dist/be-there-source-.zip` -- `dist/be-there--win64.zip` +- `dist/harken.exe` +- `dist/harken-source-.zip` +- `dist/harken--win64.zip` ## When In Doubt - Keep behavior consistent with existing hotkeys and overlays. diff --git a/README.md b/README.md index fb23275..ad38b1e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# be-there +# Harken 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. @@ -70,7 +70,7 @@ Enter Command Mode with `super + ;`. ### Quick start - 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`. +- Press `e` to open the config file. You can also find it manually in `~/.config/Harken/config.json`. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. ### Default Config Keys @@ -107,7 +107,7 @@ Enter Command Mode with `super + ;`. - 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. +- Windows with elevated permissions may ignore Harken hotkeys unless Harken is run as Administrator. ## Third-Party - JXON (AHK v2 JSON serializer) from https://github.com/TheArkive/JXON_ahk2 @@ -115,11 +115,11 @@ Enter Command Mode with `super + ;`. ## Similar tools and inspirations -For this project I was primarily inspired by what I was able to accomplish with [Raycast](https://www.raycast.com/) on macOS. Between [Karabiner](https://karabiner-elements.pqrs.org/), Raycast, and [HammerSpoon](https://www.hammerspoon.org/) one could achieve all of `be-there` and more on macOS. I needed to move back to Windows for work, and I wanted a way to use the same flow on Windows that I had become accustomed to on macOS. +For this project I was primarily inspired by what I was able to accomplish with [Raycast](https://www.raycast.com/) on macOS. Between [Karabiner](https://karabiner-elements.pqrs.org/), Raycast, and [HammerSpoon](https://www.hammerspoon.org/) one could achieve all of Harken and more on macOS. I needed to move back to windows for work, and I wanted a way to use the same flow on Windows that I had become accustomed to on macOS. Other macOS tools that I tried for more than five minutes were [AeroSpace](https://github.com/nikitabobko/AeroSpace) and [Loop](https://github.com/MrKai77/Loop). -The foundation of be-there was built upon [this reddit post](https://old.reddit.com/r/AutoHotkey/comments/17qv594/window_management_tool/), shared by u/CrashKZ -- Thanks to [/u/plankoe](https://old.reddit.com/user/plankoe) for the pieces they shared, too. +The foundation of Harken was built upon [this reddit post](https://old.reddit.com/r/AutoHotkey/comments/17qv594/window_management_tool/), shared by u/CrashKZ -- Thanks to [/u/plankoe](https://old.reddit.com/user/plankoe) for their initial contributions, too. ### [FancyZones](https://learn.microsoft.com/en-us/windows/powertoys/fancyzones) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index a391146..4bd034b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,14 +1,14 @@ # Installation -be-there supports two distribution formats: +harken supports two distribution formats: ## Option A: Source (recommended for customization) 1. Install [AutoHotkey v2](https://github.com/AutoHotkey/AutoHotkey). 2. Download the source release (zip) or clone the repo. -3. Copy `config/config.example.json` to `~/.config/be-there/config.json`. -4. Run `be-there.ahk`. +3. Copy `config/config.example.json` to `~/.config/harken/config.json`. +4. Run `harken.ahk`. ## Option B: Compiled EXE (recommended for non-technical users) 1. Download the compiled exe. -2. Run `be-there.exe`. -3. Enter command mode (`super + ;`) then `e` to edit your config (auto created in `~/.config/be-there` if it doesn't already exist) +2. Run `harken.exe`. +3. Enter command mode (`super + ;`) then `e` to edit your config (auto created in `~/.config/harken` if it doesn't already exist) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 74a4939..ec670b4 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,7 +4,7 @@ This project ships two artifacts per release: ## 1) Source zip Include: -- `be-there.ahk` +- `harken.ahk` - `src/` - `tools/` - `config/config.example.json` @@ -12,7 +12,7 @@ Include: ## 2) Compiled zip Include: -- `be-there.exe` +- `harken.exe` - `tools/` (optional; requires AutoHotkey for `.ahk` tools) - `config/config.example.json` - `README.md`, `LICENSE`, `LICENSES/` @@ -28,9 +28,9 @@ Include: 2. Create a tag: `git tag vX.Y.Z`. 3. Push tags: `git push origin vX.Y.Z`. 4. GitHub Actions builds and publishes release assets: - - `be-there-source-vX.Y.Z.zip` - - `be-there-vX.Y.Z-win64.zip` - - `be-there-vX.Y.Z.exe` + - `harken-source-vX.Y.Z.zip` + - `harken-vX.Y.Z-win64.zip` + - `harken-vX.Y.Z.exe` ## Release Candidates - Use a prerelease tag like `vX.Y.Z-rc.1`. diff --git a/be-there.ahk b/harken.ahk similarity index 98% rename from be-there.ahk rename to harken.ahk index 37e8220..2584a8d 100644 --- a/be-there.ahk +++ b/harken.ahk @@ -184,7 +184,7 @@ LogConfigErrors(errors, log_path, config_path := "") { } ShowConfigErrorsGui(details, log_path) { - gui := Gui("+AlwaysOnTop", "be-there Config Errors") + gui := Gui("+AlwaysOnTop", "harken Config Errors") gui.SetFont("s10", "Segoe UI") edit := gui.AddEdit("w760 r18 ReadOnly", details) open_btn := gui.AddButton("xm y+10 w120", "Open Log") @@ -206,7 +206,7 @@ GetConfigDir() { user_profile := EnvGet("USERPROFILE") if !user_profile user_profile := A_ScriptDir - return user_profile "\.config\be-there" + return user_profile "\.config\harken" } StartConfigWatcher(path, interval_ms := 1000) { diff --git a/justfile b/justfile index 66ed7c7..994c0bf 100644 --- a/justfile +++ b/justfile @@ -16,10 +16,10 @@ set windows-shell := ["pwsh.exe", "-NoLogo", "-Command"] ./tools/build_release.ps1 @start: - ./be-there.ahk + ./harken.ahk @start_bin: - ./dist/be-there.exe + ./dist/harken.exe compress_gifs path: #!{{shebang}} @@ -29,4 +29,4 @@ compress_gifs path: 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/lib/command_toast.ahk b/src/lib/command_toast.ahk index 0ea7af2..bd3946e 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -95,7 +95,7 @@ ShowCommandToast() { 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 := Gui("+AlwaysOnTop -Caption +ToolWindow +Border", "harken Command Overlay") command_toast_gui.MarginX := 12 command_toast_gui.MarginY := 10 opacity := NormalizeOverlayOpacity() @@ -188,7 +188,7 @@ ToggleCommandHelper() { SaveState(AppState) status := command_helper_enabled ? "enabled" : "disabled" TrayTip("", "") - TrayTip("be-there", "Command overlay " status, 2) + TrayTip("harken", "Command overlay " status, 2) UpdateCommandToastVisibility() } @@ -256,7 +256,7 @@ BuildCommandToastModel() { } model["mode"] := "normal" - model["title"] := "be-there" + model["title"] := "harken" model["apps"] := BuildAppRows() model["rows"] := BuildCommandToastRows(key_width) model["key"] := "normal|" BuildCommandToastRowsKey(model["rows"]) "|" BuildAppsKey(model["apps"]) diff --git a/src/lib/directional_focus.ahk b/src/lib/directional_focus.ahk index 9495829..ba4f86a 100644 --- a/src/lib/directional_focus.ahk +++ b/src/lib/directional_focus.ahk @@ -209,7 +209,7 @@ UpdateDirectionalFocusDebug(mode, direction, active, candidates, stacked, select return if !directional_focus_debug_gui { - directional_focus_debug_gui := Gui("+AlwaysOnTop +ToolWindow", "be-there Directional Focus Debug") + directional_focus_debug_gui := Gui("+AlwaysOnTop +ToolWindow", "harken 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()) diff --git a/src/lib/focus_or_run.ahk b/src/lib/focus_or_run.ahk index ad71dea..102dd56 100644 --- a/src/lib/focus_or_run.ahk +++ b/src/lib/focus_or_run.ahk @@ -57,7 +57,7 @@ RunResolved(command, app_config := "") { MsgBox( "Failed to launch: " command "`n" err.Message "`n`n" . "Tip: set apps[].run to a full path or an App Paths-registered exe.", - "be-there", + "harken", "Iconx" ) } diff --git a/src/lib/window_inspector.ahk b/src/lib/window_inspector.ahk index 3ce8697..8497c2f 100644 --- a/src/lib/window_inspector.ahk +++ b/src/lib/window_inspector.ahk @@ -10,7 +10,7 @@ ShowWindowInspector() { return } - inspector_gui := Gui("+Resize", "be-there Window Inspector") + inspector_gui := Gui("+Resize", "harken Window Inspector") inspector_gui.SetFont("s10", "Segoe UI") window_list := inspector_gui.AddListView("w980 r26 Grid", ["Title", "Exe", "Class", "PID", "HWND"]) diff --git a/src/lib/window_walker.ahk b/src/lib/window_walker.ahk index 8deaa8a..bb3f092 100644 --- a/src/lib/window_walker.ahk +++ b/src/lib/window_walker.ahk @@ -47,7 +47,7 @@ class WindowWalker if WindowWalker.gui return - WindowWalker.gui := Gui("+AlwaysOnTop +ToolWindow -Caption +Border", "be-there Window Selector") + WindowWalker.gui := Gui("+AlwaysOnTop +ToolWindow -Caption +Border", "harken Window Selector") WindowWalker.gui.MarginX := 12 WindowWalker.gui.MarginY := 10 WindowWalker.gui.SetFont("s10", "Segoe UI") diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 39d4a6a..8833172 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -68,13 +68,13 @@ $base = $base_item.FullName New-Item -ItemType Directory -Force -Path $out_path | Out-Null -$exe_path = Join-Path $out_path "be-there.exe" +$exe_path = Join-Path $out_path "harken.exe" if (Test-Path $exe_path) { Remove-Item $exe_path -Force } $main_args = @( - "/in", "$repo_root/be-there.ahk", + "/in", "$repo_root/harken.ahk", "/out", "$exe_path", "/base", "$base", "/silent", "verbose" @@ -83,15 +83,14 @@ $main_args = @( $null = Start-Process -FilePath $compiler -ArgumentList $main_args -NoNewWindow -Wait -PassThru if (!(Test-Path $exe_path)) { - throw "Ahk2Exe did not produce be-there.exe. Check the compiler base file and try again." + throw "Ahk2Exe did not produce harken.exe. Check the compiler base file and try again." } Write-Host "Compiled: $exe_path" -$source_zip = "$out_path/be-there-source-$version.zip" -$compiled_zip = "$out_path/be-there-$version-win64.zip" +$source_zip = "$out_path/harken-source-$version.zip" $source_files = @( - "be-there.ahk", + "harken.ahk", "src", "tools", "config/config.example.json", @@ -104,7 +103,7 @@ $source_files = @( Compress-Archive -Path $source_files -DestinationPath $source_zip -Force $compiled_files = @( - "$out_path/be-there.exe", + "$out_path/harken.exe", "tools", "config/config.example.json", "README.md", @@ -113,9 +112,6 @@ $compiled_files = @( "docs" ) -Compress-Archive -Path $compiled_files -DestinationPath $compiled_zip -Force - -Write-Host "Built: $compiled_zip" Write-Host "Built: $source_zip" $default_config = Join-Path $out_path "config.example.json" From 74e5c38c351bf12b1a71af9faf5e4f01bea251e4 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 13:03:23 -0600 Subject: [PATCH 02/20] Use TOML config instead of JSON --- .gitignore | 2 +- AGENTS.md | 6 +- README.md | 5 +- config/config.example.json | 127 -------------- config/config.example.toml | 122 +++++++++++++ docs/INSTALL.md | 2 +- docs/RELEASE.md | 8 +- harken.ahk | 10 +- src/lib/config_loader.ahk | 4 +- src/lib/focus_border.ahk | 10 +- src/lib/toml.ahk | 344 +++++++++++++++++++++++++++++++++++++ tools/build_release.ps1 | 8 +- 12 files changed, 498 insertions(+), 150 deletions(-) delete mode 100644 config/config.example.json create mode 100644 config/config.example.toml create mode 100644 src/lib/toml.ahk diff --git a/.gitignore b/.gitignore index f878f3a..ea18efd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ PLAN.md -config/config.json +config/harken.toml dist/ ahk/ *_PLAN.md diff --git a/AGENTS.md b/AGENTS.md index 4679662..267dc8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ No Copilot instructions found in `.github/copilot-instructions.md`. - Entry point: `harken.ahk`. - Source modules: `src/` (hotkeys, window management, UI helpers, config loader). - Tools: `tools/` (build script, inspector utilities). -- Config example: `config/config.example.json` (user config lives outside the repo). +- Config example: `config/config.example.toml` (user config lives outside the repo). ## Build / Lint / Test This repo does not currently use a formal test or lint framework. @@ -70,7 +70,7 @@ Single test / focused run: - When adding config keys, update: - `DefaultConfig()` in `harken.ahk` - Schema in `src/lib/config_loader.ahk` - - `config/config.example.json` + - `config/config.example.toml` - `README.md` ### Hotkeys and window behavior @@ -94,7 +94,7 @@ Single test / focused run: - Keep `README.md` and `AGENTS.md` aligned with current behavior. ## Suggested Manual Checks -- Launch `harken.ahk` with a clean `config.json` and verify hotkeys. +- Launch `harken.ahk` with a clean `harken.toml` and verify hotkeys. - Validate reload flow (normal hotkey and command mode). - Confirm Command Overlay and helper tools still open and update. - If touching config schema, ensure errors log correctly in `~/.config/harken/config.errors.log`. diff --git a/README.md b/README.md index ad38b1e..f5714c7 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ Enter Command Mode with `super + ;`. ### Quick start - 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/Harken/config.json`. +- Press `e` to open the config file. You can also find it manually in `~/.config/harken/harken.toml`. +- The repository example lives at `config/config.example.toml`. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. ### Default Config Keys @@ -82,7 +83,7 @@ Enter Command Mode with `super + ;`. - `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. +- `focus_border`: overlay appearance and update interval (including command mode color). - `helper`: command overlay settings. - `reload`: hotkey and file watch settings for config reload. diff --git a/config/config.example.json b/config/config.example.json deleted file mode 100644 index 1e77621..0000000 --- a/config/config.example.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "config_version": 1, - "super_key": "CapsLock", - "apps": [ - { - "id": "browser", - "hotkey": "f", - "win_title": "ahk_exe floorp.exe", - "run": "floorp", - "run_paths": [] - }, - { - "id": "files", - "hotkey": "e", - "win_title": "ahk_exe explorer.exe", - "run": "explorer", - "run_paths": [] - }, - { - "id": "editor", - "hotkey": "v", - "win_title": "ahk_exe Code.exe", - "run": "code", - "run_paths": [] - }, - { - "id": "terminal", - "hotkey": "s", - "win_title": "ahk_exe WindowsTerminal.exe", - "run": "wt", - "run_paths": [] - }, - { - "id": "notepad", - "hotkey": "n", - "win_title": "ahk_exe notepad++.exe", - "run": "notepad++", - "run_paths": [] - }, - { - "id": "chat", - "hotkey": "t", - "win_title": "ahk_exe telegram.exe", - "run": "telegram", - "run_paths": [] - }, - { - "id": "discord", - "hotkey": "d", - "win_title": "ahk_exe discord.exe", - "run": "discord", - "run_paths": [] - } - ], - "global_hotkeys": [ - { - "enabled": true, - "hotkey": "Alt", - "target_exes": [], - "send_keys": "^{Tab}" - } - ], - "window": { - "resize_step": 20, - "move_step": 20, - "super_double_tap_ms": 300, - "move_mode": { - "enable": true, - "cancel_key": "Esc" - }, - "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": { - "top": 6, - "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": "#357EC7", - "move_mode_color": "#2ECC71", - "border_thickness": 4, - "corner_radius": 12, - "update_interval_ms": 20 - }, - "helper": { - "enabled": true, - "overlay_opacity": 200 - }, - "reload": { - "enabled": true, - "hotkey": "r", - "super_key_required": true, - "watch_enabled": false, - "watch_interval_ms": 1000, - "mode_enabled": true, - "mode_hotkey": ";", - "mode_timeout_ms": 20000 - } -} diff --git a/config/config.example.toml b/config/config.example.toml new file mode 100644 index 0000000..46fc026 --- /dev/null +++ b/config/config.example.toml @@ -0,0 +1,122 @@ +config_version = 1 +super_key = "CapsLock" + +[[apps]] +id = "browser" +hotkey = "f" +win_title = "ahk_exe floorp.exe" +run = "floorp" +run_paths = [] + +[[apps]] +id = "files" +hotkey = "e" +win_title = "ahk_exe explorer.exe" +run = "explorer" +run_paths = [] + +[[apps]] +id = "editor" +hotkey = "v" +win_title = "ahk_exe Code.exe" +run = "code" +run_paths = [] + +[[apps]] +id = "terminal" +hotkey = "s" +win_title = "ahk_exe WindowsTerminal.exe" +run = "wt" +run_paths = [] + +[[apps]] +id = "notepad" +hotkey = "n" +win_title = "ahk_exe notepad++.exe" +run = "notepad++" +run_paths = [] + +[[apps]] +id = "chat" +hotkey = "t" +win_title = "ahk_exe telegram.exe" +run = "telegram" +run_paths = [] + +[[apps]] +id = "discord" +hotkey = "d" +win_title = "ahk_exe discord.exe" +run = "discord" +run_paths = [] + +[[global_hotkeys]] +enabled = true +hotkey = "Alt" +target_exes = [] +send_keys = "^{Tab}" + +[window] +resize_step = 20 +move_step = 20 +super_double_tap_ms = 300 +cycle_app_windows_hotkey = "c" +center_width_cycle_hotkey = "Space" + +[window.move_mode] +enable = true +cancel_key = "Esc" + +[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 +gap_px = 0 +exceptions_regex = "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHostIslandWindow)" + +[window_manager.margins] +top = 6 +left = 4 +right = 4 + +[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 = "#357EC7" +move_mode_color = "#2ECC71" +command_mode_color = "#FFD400" +border_thickness = 4 +corner_radius = 16 +update_interval_ms = 20 + +[helper] +enabled = true +overlay_opacity = 200 + +[reload] +enabled = true +hotkey = "r" +super_key_required = true +watch_enabled = false +watch_interval_ms = 1000 +mode_enabled = true +mode_hotkey = ";" +mode_timeout_ms = 20000 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4bd034b..4174a74 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -5,7 +5,7 @@ harken supports two distribution formats: ## Option A: Source (recommended for customization) 1. Install [AutoHotkey v2](https://github.com/AutoHotkey/AutoHotkey). 2. Download the source release (zip) or clone the repo. -3. Copy `config/config.example.json` to `~/.config/harken/config.json`. +3. Copy `config/config.example.toml` to `~/.config/harken/harken.toml`. 4. Run `harken.ahk`. ## Option B: Compiled EXE (recommended for non-technical users) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ec670b4..0e5595a 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -7,20 +7,20 @@ Include: - `harken.ahk` - `src/` - `tools/` -- `config/config.example.json` +- `config/config.example.toml` - `README.md`, `LICENSE`, `LICENSES/` ## 2) Compiled zip Include: - `harken.exe` - `tools/` (optional; requires AutoHotkey for `.ahk` tools) -- `config/config.example.json` +- `config/config.example.toml` - `README.md`, `LICENSE`, `LICENSES/` ## Checklist - Update `README.md` if config/schema changed. - Update `docs/INSTALL.md` if paths or requirements changed. -- Verify `config/config.example.json` matches defaults. +- Verify `config/config.example.toml` matches defaults. - (Optional) test start/reload and Command Overlay in both variants. ## GitHub Release Flow @@ -52,4 +52,4 @@ powershell -ExecutionPolicy Bypass -File tools/build_release.ps1 -Version dev Artifacts are written to `dist/`. ## Tooling -- The build script also exports `config.example.json` into `dist/` for quick inspection. +- The build script also exports `config.example.toml` into `dist/` for quick inspection. diff --git a/harken.ahk b/harken.ahk index 2584a8d..22cc52f 100644 --- a/harken.ahk +++ b/harken.ahk @@ -2,6 +2,7 @@ #SingleInstance #Include src/lib/JXON.ahk +#Include src/lib/toml.ahk #Include src/lib/config_loader.ahk #Include src/lib/state_store.ahk #Include src/lib/focus_or_run.ahk @@ -10,7 +11,7 @@ config_dir := GetConfigDir() DirCreate(config_dir) -global config_path := config_dir "\config.json" +global config_path := config_dir "\harken.toml" EnsureConfigExists(config_path, DefaultConfig()) config_result := LoadConfig(config_path, DefaultConfig()) global Config := config_result["config"] @@ -140,8 +141,9 @@ DefaultConfig() { "enabled", true, "border_color", "#357EC7", "move_mode_color", "#2ECC71", + "command_mode_color", "#FFD400", "border_thickness", 4, - "corner_radius", 12, + "corner_radius", 16, "update_interval_ms", 20 ), "helper", Map( @@ -198,8 +200,8 @@ EnsureConfigExists(config_path, default_config) { if FileExist(config_path) return - config_text := Jxon_Dump(default_config, 2) - FileAppend(config_text, config_path) + config_text := TomlDump(default_config, ["apps", "global_hotkeys"]) + FileAppend(config_text, config_path, "UTF-8") } GetConfigDir() { diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index ae536d3..76db7ec 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -3,9 +3,8 @@ LoadConfig(config_path, default_config := Map()) { errors := [] if FileExist(config_path) { - json_text := FileRead(config_path) try { - user_config := Jxon_Load(&json_text) + user_config := TomlLoadFile(config_path) config := DeepMergeMaps(config, user_config) } catch as err { errors.Push("config.parse: " err.Message) @@ -87,6 +86,7 @@ ConfigSchema() { "enabled", "bool", "border_color", "string", "move_mode_color", "string", + "command_mode_color", "string", "border_thickness", "number", "corner_radius", "number", "update_interval_ms", "number" diff --git a/src/lib/focus_border.ahk b/src/lib/focus_border.ahk index 2aea728..bc810e8 100644 --- a/src/lib/focus_border.ahk +++ b/src/lib/focus_border.ahk @@ -13,6 +13,7 @@ if focus_border_enabled { ; ------------- User Settings ------------- border_color := ParseHexColor(focus_config["border_color"]) ; Hex color (#RRGGBB) move_mode_color := ParseHexColor(focus_config["move_mode_color"]) ; Hex color (#RRGGBB) + command_mode_color := ParseHexColor(focus_config["command_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 @@ -43,7 +44,7 @@ if focus_border_enabled { ; ------------------------------- UpdateBorder(*) { global overlay, h_overlay - global border_thickness, corner_radius, border_color, move_mode_color, current_color + global border_thickness, corner_radius, border_color, move_mode_color, command_mode_color, current_color global prev_hwnd, prev_ax, prev_ay, prev_aw, prev_ah ; Get the currently active window. @@ -57,6 +58,11 @@ if focus_border_enabled { overlay.Hide() return } + class_name := WinGetClass("ahk_id " active_hwnd) + if (class_name = "Progman" || class_name = "WorkerW" || class_name = "Shell_TrayWnd" || class_name = "Shell_SecondaryTrayWnd") { + overlay.Hide() + return + } style := WinGetStyle("ahk_id " active_hwnd) if (style & 0x20000000) { ; WS_MINIMIZE flag overlay.Hide() @@ -80,7 +86,7 @@ if focus_border_enabled { if (flash_until > A_TickCount) desired_color := flash_color else if ReloadModeActive() - desired_color := 0xFFD400 + desired_color := command_mode_color else desired_color := Window.IsMoveMode() ? move_mode_color : border_color if (desired_color != current_color) { diff --git a/src/lib/toml.ahk b/src/lib/toml.ahk new file mode 100644 index 0000000..1fd287b --- /dev/null +++ b/src/lib/toml.ahk @@ -0,0 +1,344 @@ +TomlLoadFile(file_path) { + toml_text := FileRead(file_path, "UTF-8") + return TomlParse(toml_text) +} + +TomlParse(toml_text) { + data := Map() + current_context := data + + lines := StrSplit(toml_text, "`n", "`r") + for _, raw_line in lines { + line := Trim(TomlStripComment(raw_line)) + if (line = "") + continue + + if (SubStr(line, 1, 2) = "[[" && SubStr(line, -2) = "]]" ) { + section_path := Trim(SubStr(line, 3, StrLen(line) - 4)) + current_context := TomlGetOrCreateArrayItem(data, section_path) + continue + } + + if (SubStr(line, 1, 1) = "[" && SubStr(line, -1) = "]") { + section_path := Trim(SubStr(line, 2, StrLen(line) - 2)) + current_context := TomlGetOrCreateSection(data, section_path) + continue + } + + if InStr(line, "=") { + kv := TomlSplitKeyValue(line) + if (kv.Has("key") && kv["key"] != "") + TomlSetNestedKey(current_context, kv["key"], TomlParseValue(kv["value"])) + } + } + + return data +} + +TomlStripComment(line) { + in_double := false + in_single := false + escaped := false + result := "" + + loop parse, line { + ch := A_LoopField + if (escaped) { + result .= ch + escaped := false + continue + } + + if (ch = "\\" && in_double) { + escaped := true + result .= ch + continue + } + + if (ch = '"' && !in_single) + in_double := !in_double + else if (ch = "'" && !in_double) + in_single := !in_single + + if (ch = "#" && !in_double && !in_single) + break + + result .= ch + } + + return result +} + +TomlSplitKeyValue(line) { + in_double := false + in_single := false + escaped := false + + loop parse, line { + ch := A_LoopField + if (escaped) { + escaped := false + continue + } + if (ch = "\\" && in_double) { + escaped := true + continue + } + if (ch = '"' && !in_single) + in_double := !in_double + else if (ch = "'" && !in_double) + in_single := !in_single + + if (ch = "=" && !in_double && !in_single) { + key := Trim(SubStr(line, 1, A_Index - 1)) + value := Trim(SubStr(line, A_Index + 1)) + return Map("key", key, "value", value) + } + } + + return Map() +} + +TomlGetOrCreateSection(data, section_path) { + parts := StrSplit(section_path, ".") + current := data + for _, part in parts { + if (!current.Has(part) || !(current[part] is Map)) + current[part] := Map() + current := current[part] + } + return current +} + +TomlGetOrCreateArrayItem(data, section_path) { + parts := StrSplit(section_path, ".") + if (parts.Length = 1) { + array_name := parts[1] + if (!data.Has(array_name) || !(data[array_name] is Array)) + data[array_name] := [] + data[array_name].Push(Map()) + return data[array_name][data[array_name].Length] + } + + parent := TomlGetOrCreateSection(data, TomlJoinRange(parts, ".", 1, parts.Length - 1)) + array_name := parts[parts.Length] + if (!parent.Has(array_name) || !(parent[array_name] is Array)) + parent[array_name] := [] + parent[array_name].Push(Map()) + return parent[array_name][parent[array_name].Length] +} + +TomlSetNestedKey(root, key_path, value) { + parts := StrSplit(key_path, ".") + current := root + loop parts.Length - 1 { + part := parts[A_Index] + if (!current.Has(part) || !(current[part] is Map)) + current[part] := Map() + current := current[part] + } + current[parts[parts.Length]] := value +} + +TomlParseValue(value) { + if (value = "") + return "" + + if (SubStr(value, 1, 1) = "[" && SubStr(value, -1) = "]") { + array_content := Trim(SubStr(value, 2, StrLen(value) - 2)) + return TomlParseArray(array_content) + } + + if (SubStr(value, 1, 1) = '"' && SubStr(value, -1) = '"') { + inner := SubStr(value, 2, StrLen(value) - 2) + inner := StrReplace(inner, "\\`"", "`"") + inner := StrReplace(inner, "\\\\", "\\") + return inner + } + + if (SubStr(value, 1, 1) = "'" && SubStr(value, -1) = "'") + return SubStr(value, 2, StrLen(value) - 2) + + if (value = "true") + return true + if (value = "false") + return false + + if (value ~= "^-?\d+$") + return Integer(value) + if (value ~= "^-?\d+\.\d+$") + return Float(value) + + return value +} + +TomlParseArray(content) { + result := [] + if (content = "") + return result + + elements := [] + in_double := false + in_single := false + escaped := false + depth := 0 + current := "" + + loop parse, content { + ch := A_LoopField + if (escaped) { + current .= ch + escaped := false + continue + } + + if (ch = "\\" && in_double) { + escaped := true + current .= ch + continue + } + + if (ch = '"' && !in_single) + in_double := !in_double + else if (ch = "'" && !in_double) + in_single := !in_single + + if (ch = "[" && !in_double && !in_single) + depth += 1 + else if (ch = "]" && !in_double && !in_single) + depth -= 1 + + if (ch = "," && !in_double && !in_single && depth = 0) { + elements.Push(Trim(current)) + current := "" + continue + } + + current .= ch + } + + if (Trim(current) != "") + elements.Push(Trim(current)) + + for _, element in elements + result.Push(TomlParseValue(element)) + + return result +} + +TomlDump(config, table_array_keys := []) { + lines := [] + + for key, value in config { + if (value is Map) + continue + if (TomlIsTableArrayKey(key, value, table_array_keys)) + continue + lines.Push(key " = " TomlFormatValue(value)) + } + + if (lines.Length) + lines.Push("") + + for key, value in config { + if (TomlIsTableArrayKey(key, value, table_array_keys)) { + for _, item in value { + lines.Push("[[" key "]]" ) + for item_key, item_value in item + lines.Push(item_key " = " TomlFormatValue(item_value)) + lines.Push("") + } + } + } + + for key, value in config { + if (value is Map) + TomlAppendSection(lines, key, value) + } + + while (lines.Length && lines[lines.Length] = "") + lines.Pop() + + return TomlJoin(lines, "`n") +} + +TomlAppendSection(lines, section_path, section_map) { + lines.Push("[" section_path "]") + nested_sections := [] + + for key, value in section_map { + if (value is Map) { + nested_sections.Push(Map("path", section_path "." key, "map", value)) + continue + } + lines.Push(key " = " TomlFormatValue(value)) + } + + lines.Push("") + + for _, nested in nested_sections + TomlAppendSection(lines, nested["path"], nested["map"]) +} + +TomlIsTableArrayKey(key, value, table_array_keys) { + if (value is Array) { + if (value.Length > 0 && value[1] is Map) + return true + for _, table_key in table_array_keys { + if (key = table_key) + return true + } + } + return false +} + +TomlFormatValue(value) { + if (value is Array) { + if (value.Length = 0) + return "[]" + elements := [] + for _, item in value + elements.Push(TomlFormatValue(item)) + return "[" TomlJoin(elements, ", ") "]" + } + + if (value is Integer || value is Float) + return value + + if (value = true) + return "true" + if (value = false) + return "false" + + return TomlQuoteString(value) +} + +TomlQuoteString(value) { + escaped := StrReplace(value, "\\", "\\\\") + escaped := StrReplace(escaped, "`"", "\\`"") + return "`"" escaped "`"" +} + +TomlJoin(items, sep) { + output := "" + for i, item in items { + if (i > 1) + output .= sep + output .= item + } + return output +} + +TomlJoinRange(parts, sep, start_index, length) { + output := "" + end_index := start_index + length - 1 + loop length { + idx := start_index + A_Index - 1 + if (idx > end_index) + break + if (output != "") + output .= sep + output .= parts[idx] + } + return output +} diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 8833172..115767b 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -93,7 +93,7 @@ $source_files = @( "harken.ahk", "src", "tools", - "config/config.example.json", + "config/config.example.toml", "README.md", "LICENSE", "LICENSES", @@ -105,7 +105,7 @@ Compress-Archive -Path $source_files -DestinationPath $source_zip -Force $compiled_files = @( "$out_path/harken.exe", "tools", - "config/config.example.json", + "config/config.example.toml", "README.md", "LICENSE", "LICENSES", @@ -114,6 +114,6 @@ $compiled_files = @( Write-Host "Built: $source_zip" -$default_config = Join-Path $out_path "config.example.json" -Get-Content "$repo_root/config/config.example.json" -Raw | Out-File -FilePath $default_config -Encoding UTF8 +$default_config = Join-Path $out_path "config.example.toml" +Get-Content "$repo_root/config/config.example.toml" -Raw | Out-File -FilePath $default_config -Encoding UTF8 Write-Host "Exported: $default_config" From 53d678de70716fda812e7613fbf0f178f09ed093 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 13:07:00 -0600 Subject: [PATCH 03/20] [config] support multiple hotkeys, for example `super_key=["CapsLock", "F24", "Win+Alt"] --- README.md | 2 +- config/config.example.toml | 2 +- harken.ahk | 62 ++++++++++++++++++++++++++--------- src/hotkeys/apps.ahk | 4 +-- src/hotkeys/global_hotkey.ahk | 6 ++-- src/hotkeys/unbound.ahk | 4 +-- src/hotkeys/window.ahk | 6 ++-- src/hotkeys/window_walker.ahk | 4 +-- src/lib/command_toast.ahk | 4 +-- src/lib/config_loader.ahk | 21 +++++++++++- src/lib/window_manager.ahk | 5 ++- 11 files changed, 84 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index f5714c7..6ec7a8d 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Enter Command Mode with `super + ;`. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. ### Default Config Keys -- `super_key`: key or modifier chord used as the super modifier (e.g., `CapsLock`, `Ctrl+Shift+Alt`). +- `super_key`: key or list of keys used as the super modifier (e.g., `CapsLock` or `["F24", "CapsLock"]`). - `apps`: list of app bindings with `hotkey`, `win_title`, and `run` command. - `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). diff --git a/config/config.example.toml b/config/config.example.toml index 46fc026..f992a68 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,5 +1,5 @@ config_version = 1 -super_key = "CapsLock" +super_key = ["F24", "CapsLock"] [[apps]] id = "browser" diff --git a/harken.ahk b/harken.ahk index 22cc52f..c3cb95e 100644 --- a/harken.ahk +++ b/harken.ahk @@ -23,10 +23,12 @@ if (config_errors.Length) { global AppState := LoadState() InitCommandToast() -super_key := Config["super_key"] +super_keys := Config["super_key"] +if !(super_keys is Array) + super_keys := [super_keys] -Hotkey("~" super_key, (*) => OnSuperKeyDown()) -if (super_key = "CapsLock") +RegisterSuperKeyHotkey("~", "", (*) => OnSuperKeyDown()) +if HasSuperKey("CapsLock") SetCapsLockState "AlwaysOff" reload_config := Config["reload"] @@ -38,7 +40,7 @@ if reload_config["enabled"] { global reload_mode_active := false if reload_config["super_key_required"] { - HotIf (*) => GetKeyState(super_key, "P") + HotIf IsSuperKeyPressed Hotkey(reload_hotkey, (*) => Reload()) HotIf } else { @@ -46,7 +48,7 @@ if reload_config["enabled"] { } if reload_mode_enabled { - Hotkey(super_key " & " reload_mode_hotkey, (*) => ActivateReloadMode(reload_mode_timeout)) + RegisterSuperComboHotkey(reload_mode_hotkey, (*) => ActivateReloadMode(reload_mode_timeout)) HotIf ReloadModeActive Hotkey(reload_hotkey, (*) => ExecuteCommand(() => Reload())) Hotkey("i", (*) => ExecuteCommand(OpenWindowInspector)) @@ -63,9 +65,6 @@ if reload_config["enabled"] && reload_config["watch_enabled"] SetWinDelay(-1) -; This variable is the modifier key for window navigation hotkeys. -window_nav_modifier := super_key - #Include src/lib/window_manager.ahk #Include src/lib/directional_focus.ahk #Include src/lib/focus_border.ahk @@ -80,7 +79,7 @@ window_nav_modifier := super_key DefaultConfig() { return Map( "config_version", 1, - "super_key", "CapsLock", + "super_key", ["CapsLock"], "apps", [ Map("id", "files", "hotkey", "e", "win_title", "ahk_exe explorer.exe", "run", "explorer"), Map("id", "editor", "hotkey", "v", "win_title", "ahk_exe Code.exe", "run", "code"), @@ -186,14 +185,14 @@ LogConfigErrors(errors, log_path, config_path := "") { } ShowConfigErrorsGui(details, log_path) { - gui := Gui("+AlwaysOnTop", "harken Config Errors") - gui.SetFont("s10", "Segoe UI") - edit := gui.AddEdit("w760 r18 ReadOnly", details) - open_btn := gui.AddButton("xm y+10 w120", "Open Log") + error_gui := Gui("+AlwaysOnTop", "harken Config Errors") + error_gui.SetFont("s10", "Segoe UI") + edit := error_gui.AddEdit("w760 r18 ReadOnly", details) + open_btn := error_gui.AddButton("xm y+10 w120", "Open Log") open_btn.OnEvent("Click", (*) => Run(log_path)) - close_btn := gui.AddButton("x+10 yp w120", "Close") - close_btn.OnEvent("Click", (*) => gui.Destroy()) - gui.Show() + close_btn := error_gui.AddButton("x+10 yp w120", "Close") + close_btn.OnEvent("Click", (*) => error_gui.Destroy()) + error_gui.Show() } EnsureConfigExists(config_path, default_config) { @@ -260,6 +259,37 @@ OnSuperKeyDown() { UpdateCommandToastVisibility() } +IsSuperKeyPressed(*) { + global super_keys + for _, key in super_keys { + if GetKeyState(key, "P") + return true + } + return false +} + +HasSuperKey(target_key) { + global super_keys + target_lower := StrLower(target_key) + for _, key in super_keys { + if (StrLower(key) = target_lower) + return true + } + return false +} + +RegisterSuperKeyHotkey(prefix, suffix, callback) { + global super_keys + for _, key in super_keys + Hotkey(prefix key suffix, callback) +} + +RegisterSuperComboHotkey(hotkey_name, callback) { + global super_keys + for _, key in super_keys + Hotkey(key " & " hotkey_name, callback) +} + OpenWindowInspector() { ShowWindowInspector() } diff --git a/src/hotkeys/apps.ahk b/src/hotkeys/apps.ahk index b2e8a31..897bb6f 100644 --- a/src/hotkeys/apps.ahk +++ b/src/hotkeys/apps.ahk @@ -1,6 +1,6 @@ -global Config, super_key +global Config -HotIf (*) => GetKeyState(super_key, "P") +HotIf IsSuperKeyPressed for _, app in Config["apps"] { hotkey_name := app["hotkey"] win_title := app["win_title"] diff --git a/src/hotkeys/global_hotkey.ahk b/src/hotkeys/global_hotkey.ahk index 0155de5..02f02d7 100644 --- a/src/hotkeys/global_hotkey.ahk +++ b/src/hotkeys/global_hotkey.ahk @@ -1,4 +1,4 @@ -global Config, super_key +global Config for _, hotkey_config in Config["global_hotkeys"] { if !hotkey_config["enabled"] @@ -10,10 +10,10 @@ for _, hotkey_config in Config["global_hotkeys"] { if (target_exes.Length > 0) { HotIf (*) => ScopedHotIf(target_exes) - Hotkey(super_key " & " hotkey_name, (*) => Send(send_keys)) + RegisterSuperComboHotkey(hotkey_name, (*) => Send(send_keys)) HotIf } else { - Hotkey(super_key " & " hotkey_name, (*) => Send(send_keys)) + RegisterSuperComboHotkey(hotkey_name, (*) => Send(send_keys)) } } diff --git a/src/hotkeys/unbound.ahk b/src/hotkeys/unbound.ahk index 39a212b..54e9eec 100644 --- a/src/hotkeys/unbound.ahk +++ b/src/hotkeys/unbound.ahk @@ -1,4 +1,4 @@ -global Config, super_key +global Config RegisterUnboundHotkeys() { used_keys := Map() @@ -29,7 +29,7 @@ RegisterUnboundHotkeys() { } candidates := BuildUnboundCandidateKeys() - HotIf (*) => GetKeyState(super_key, "P") + HotIf IsSuperKeyPressed for _, key in candidates { if !used_keys.Has(StrLower(key)) Hotkey(key, (*) => FlashUnboundHotkey()) diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index 477b065..9887c81 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -1,4 +1,4 @@ -global Config, super_key +global Config resize_step := Config["window"]["resize_step"] move_step := Config["window"]["move_step"] @@ -176,7 +176,7 @@ OnSuperKeyUp() { } if (move_mode_enabled || Config["reload"]["mode_enabled"] || Config["helper"]["enabled"]) { - Hotkey(super_key " up", (*) => OnSuperKeyUp()) + RegisterSuperKeyHotkey("", " up", (*) => OnSuperKeyUp()) } CenterWidthCycle(*) { @@ -296,7 +296,7 @@ CycleAppWindows(*) { WinActivate "ahk_id " win_list[next_index] } -HotIf (*) => GetKeyState(super_key, "P") +HotIf IsSuperKeyPressed Hotkey(center_cycle_hotkey, CenterWidthCycle) Hotkey("Left", (*) => ResizeActiveWindow(-resize_step, 0)) Hotkey("Right", (*) => ResizeActiveWindow(resize_step, 0)) diff --git a/src/hotkeys/window_walker.ahk b/src/hotkeys/window_walker.ahk index af6ed0e..5be5283 100644 --- a/src/hotkeys/window_walker.ahk +++ b/src/hotkeys/window_walker.ahk @@ -1,6 +1,6 @@ -global Config, super_key +global Config if Config.Has("window_selector") && Config["window_selector"]["enabled"] { hotkey_name := Config["window_selector"]["hotkey"] - Hotkey(super_key " & " hotkey_name, (*) => WindowWalker.Show()) + RegisterSuperComboHotkey(hotkey_name, (*) => WindowWalker.Show()) } diff --git a/src/lib/command_toast.ahk b/src/lib/command_toast.ahk index bd3946e..d129f70 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -20,13 +20,13 @@ InitCommandToast() { } UpdateCommandToastVisibility() { - global command_helper_enabled, super_key + global command_helper_enabled if !command_helper_enabled { HideCommandToast() return } - if GetKeyState(super_key, "P") || ReloadModeActive() || Window.IsMoveMode() { + if IsSuperKeyPressed() || ReloadModeActive() || Window.IsMoveMode() { ShowCommandToast() } else { HideCommandToast() diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 76db7ec..18e3700 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -15,7 +15,9 @@ LoadConfig(config_path, default_config := Map()) { } } + NormalizeSuperKeyConfig(config) errors := ValidateConfig(config, ConfigSchema()) + ValidateSuperKeys(config, errors) return Map( "config", config, @@ -26,7 +28,7 @@ LoadConfig(config_path, default_config := Map()) { ConfigSchema() { return Map( "config_version", "number", - "super_key", "string", + "super_key", ["string"], "apps", [Map( "id", "string", "hotkey", "string", @@ -114,6 +116,23 @@ ValidateConfig(config, schema) { return errors } +NormalizeSuperKeyConfig(config) { + if !config.Has("super_key") + return + + super_value := config["super_key"] + if (super_value is String) + config["super_key"] := [super_value] +} + +ValidateSuperKeys(config, errors) { + if !config.Has("super_key") + return + super_keys := config["super_key"] + if (super_keys is Array && super_keys.Length = 0) + errors.Push("config.super_key should contain at least one key") +} + ValidateNode(value, spec, path, errors) { if (spec is Map) { if spec.Has("__optional__") { diff --git a/src/lib/window_manager.ahk b/src/lib/window_manager.ahk index e669557..46706e4 100644 --- a/src/lib/window_manager.ahk +++ b/src/lib/window_manager.ahk @@ -45,8 +45,7 @@ class Window static __New() { - global window_nav_modifier - HotIf (*) => GetKeyState(window_nav_modifier, 'P') + HotIf (*) => IsSuperKeyPressed() && !GetKeyState('Shift', 'P') && !GetKeyState('Ctrl', 'P') && !this.IsMoveMode() @@ -61,7 +60,7 @@ class Window HotIf ; releasing modifier key destroys gui guides - Hotkey('*' window_nav_modifier ' up', ObjBindMethod(Gui_Guides, 'Destroy_Guis')) + RegisterSuperKeyHotkey("*", " up", ObjBindMethod(Gui_Guides, 'Destroy_Guis')) } static SetMoveMode(state) => Window.move_mode := state From 6edd2828063026ade3f6e4f539960cd7791b622f Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 13:36:42 -0600 Subject: [PATCH 04/20] [config] enable app configs without hotkeys for custom border params --- README.md | 7 +++-- config/config.example.toml | 5 +++ harken.ahk | 61 ++++++++++++++++++++++++++++++++---- src/hotkeys/apps.ahk | 6 ++-- src/hotkeys/unbound.ahk | 3 +- src/lib/command_toast.ahk | 4 ++- src/lib/config_loader.ahk | 61 +++++++++++++++++++++++++++++++++--- src/lib/focus_border.ahk | 53 ++++++++++++++++++++++++++----- src/lib/focus_or_run.ahk | 20 +++++++++++- src/lib/toml.ahk | 64 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 259 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6ec7a8d..b9097ae 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,10 @@ Enter Command Mode with `super + ;`. ### Default Config Keys - `super_key`: key or list of keys used as the super modifier (e.g., `CapsLock` or `["F24", "CapsLock"]`). -- `apps`: list of app bindings with `hotkey`, `win_title`, and `run` command. +- `apps`: list of app bindings with `hotkey`, `win_title`/`match`, and `run` command. Omit `hotkey` for matcher-only entries (they won't appear in the Command Overlay). - `apps[].run_paths`: optional list of directories to search for the executable. +- `apps[].match`: optional match map with `exe`, `class`, `title`, and `*_regex` flags (exact match is case-insensitive by default; regex is also case-insensitive). +- `apps[].focus_border`: optional per-app focus border overrides (colors, thickness, and corner radius). - `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). - `window`: resize/move steps and hotkeys (including move mode). - `window_selector`: Window Selector settings (hotkey, match fields, display limits). @@ -92,7 +94,8 @@ Enter Command Mode with `super + ;`. - Common forms: plain title text, `ahk_exe `, `ahk_class `, `ahk_pid `. - Use `ahk_exe` for stable matching when window titles change (e.g., tabs/documents). - Plain title text supports AutoHotkey's standard title matching and wildcards (e.g., `* - Notepad`). -- `ahk_exe`, `ahk_class`, and `ahk_pid` are exact matches; wildcards/regex are not supported today but could be added later. +- `ahk_exe`, `ahk_class`, and `ahk_pid` are exact matches; `win_title` does not support regex (use `apps[].match` for regex). +- `apps[].match` provides explicit matching by `exe`, `class`, and/or `title` with optional `*_regex` flags (non-regex is exact, case-insensitive). ### Path Expansion - `apps[].run_paths` supports environment variables like `%APPDATA%` and `%LOCALAPPDATA%`. diff --git a/config/config.example.toml b/config/config.example.toml index f992a68..68158cc 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -50,6 +50,11 @@ win_title = "ahk_exe discord.exe" run = "discord" run_paths = [] +[[apps]] +id = "vscode-border" +match = { exe = "Code.exe", title = " - Visual Studio Code$", title_regex = true } +focus_border = { border_color = "#4D96FF", border_thickness = 3 } + [[global_hotkeys]] enabled = true hotkey = "Alt" diff --git a/harken.ahk b/harken.ahk index c3cb95e..1de84a4 100644 --- a/harken.ahk +++ b/harken.ahk @@ -18,7 +18,7 @@ global Config := config_result["config"] config_errors := config_result["errors"] if (config_errors.Length) { LogConfigErrors(config_errors, config_dir "\config.errors.log", config_path) - ExitApp + return } global AppState := LoadState() InitCommandToast() @@ -290,6 +290,57 @@ RegisterSuperComboHotkey(hotkey_name, callback) { Hotkey(key " & " hotkey_name, callback) } +MatchAppWindow(app, hwnd := 0) { + if !(app is Map) + return false + if (hwnd = 0) + hwnd := WinGetID("A") + if !hwnd + return false + + if app.Has("match") && (app["match"] is Map) + return MatchWindowFields(app["match"], hwnd) + + if app.Has("win_title") && app["win_title"] != "" { + if (hwnd = WinGetID("A")) + return WinActive(app["win_title"]) + } + + return false +} + +MatchWindowFields(match, hwnd) { + exe_name := WinGetProcessName("ahk_id " hwnd) + class_name := WinGetClass("ahk_id " hwnd) + title := WinGetTitle("ahk_id " hwnd) + + if !MatchField(match, "exe", exe_name) + return false + if !MatchField(match, "class", class_name) + return false + if !MatchField(match, "title", title) + return false + + return true +} + +MatchField(match, field_key, value) { + if !match.Has(field_key) || match[field_key] = "" + return true + + pattern := match[field_key] + regex_key := field_key "_regex" + use_regex := match.Has(regex_key) && match[regex_key] + + if use_regex { + if !RegExMatch(pattern, "^\(\?i\)") + pattern := "(?i)" pattern + return RegExMatch(value, pattern) != 0 + } + + return StrLower(value) = StrLower(pattern) +} + OpenWindowInspector() { ShowWindowInspector() } @@ -311,7 +362,7 @@ OpenNewWindowForActiveApp() { if !exe return - app_config := FindAppConfigByExe(exe) + app_config := FindAppConfigByWindow(hwnd) if (app_config is Map) { RunResolved(app_config["run"], app_config) return @@ -320,11 +371,9 @@ OpenNewWindowForActiveApp() { RunResolved(exe) } -FindAppConfigByExe(exe_name) { - exe_lower := StrLower(exe_name) +FindAppConfigByWindow(hwnd) { for _, app in Config["apps"] { - win_title := StrLower(app["win_title"]) - if InStr(win_title, "ahk_exe " exe_lower) + if MatchAppWindow(app, hwnd) return app } return "" diff --git a/src/hotkeys/apps.ahk b/src/hotkeys/apps.ahk index 897bb6f..2beea09 100644 --- a/src/hotkeys/apps.ahk +++ b/src/hotkeys/apps.ahk @@ -2,9 +2,11 @@ global Config HotIf IsSuperKeyPressed for _, app in Config["apps"] { + if !app.Has("hotkey") || app["hotkey"] = "" + continue hotkey_name := app["hotkey"] - win_title := app["win_title"] - run_cmd := app["run"] + win_title := app.Has("win_title") ? app["win_title"] : "" + run_cmd := app.Has("run") ? app["run"] : "" hotkey_id := app["id"] Hotkey(hotkey_name, FocusOrRun.Bind(win_title, run_cmd, hotkey_id, app)) } diff --git a/src/hotkeys/unbound.ahk b/src/hotkeys/unbound.ahk index 54e9eec..6221a43 100644 --- a/src/hotkeys/unbound.ahk +++ b/src/hotkeys/unbound.ahk @@ -20,7 +20,8 @@ RegisterUnboundHotkeys() { AddUsedKey(used_keys, reload_config["hotkey"]) for _, app in Config["apps"] { - AddUsedKey(used_keys, app["hotkey"]) + if app.Has("hotkey") && app["hotkey"] != "" + AddUsedKey(used_keys, app["hotkey"]) } for _, hotkey_config in Config["global_hotkeys"] { diff --git a/src/lib/command_toast.ahk b/src/lib/command_toast.ahk index d129f70..46d844a 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -306,6 +306,8 @@ BuildAppRows() { global Config rows := [] for _, app in Config["apps"] { + if !app.Has("hotkey") || app["hotkey"] = "" + continue icon_path := ResolveAppIconPath(app) rows.Push(Map( "hotkey", app["hotkey"], @@ -327,7 +329,7 @@ BuildAppsKey(apps) { ResolveAppIconPath(app) { if !(app is Map) return "" - if app.Has("win_title") { + if app.Has("win_title") && app["win_title"] != "" { path := FindAppWindowPath(app["win_title"]) if (path != "") return path diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 18e3700..1280b71 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -18,6 +18,7 @@ LoadConfig(config_path, default_config := Map()) { NormalizeSuperKeyConfig(config) errors := ValidateConfig(config, ConfigSchema()) ValidateSuperKeys(config, errors) + ValidateApps(config, errors) return Map( "config", config, @@ -31,10 +32,26 @@ ConfigSchema() { "super_key", ["string"], "apps", [Map( "id", "string", - "hotkey", "string", - "win_title", "string", - "run", "string", - "run_paths", OptionalSpec(["string"]) + "hotkey", OptionalSpec("string"), + "win_title", OptionalSpec("string"), + "match", OptionalSpec(Map( + "exe", OptionalSpec("string"), + "exe_regex", OptionalSpec("bool"), + "class", OptionalSpec("string"), + "class_regex", OptionalSpec("bool"), + "title", OptionalSpec("string"), + "title_regex", OptionalSpec("bool") + )), + "run", OptionalSpec("string"), + "run_paths", OptionalSpec(["string"]), + "focus_border", OptionalSpec(Map( + "border_color", OptionalSpec("string"), + "move_mode_color", OptionalSpec("string"), + "command_mode_color", OptionalSpec("string"), + "border_thickness", OptionalSpec("number"), + "corner_radius", OptionalSpec("number"), + "update_interval_ms", OptionalSpec("number") + )) )], "global_hotkeys", [Map( "enabled", "bool", @@ -133,6 +150,42 @@ ValidateSuperKeys(config, errors) { errors.Push("config.super_key should contain at least one key") } +ValidateApps(config, errors) { + if !config.Has("apps") || !(config["apps"] is Array) + return + + for index, app in config["apps"] { + if !(app is Map) + continue + + has_hotkey := app.Has("hotkey") && (app["hotkey"] != "") + has_win_title := app.Has("win_title") && (app["win_title"] != "") + has_match := app.Has("match") && (app["match"] is Map) + + if !has_win_title && !has_match + errors.Push("config.apps[" index "] must define win_title or match") + + if has_hotkey && (!app.Has("run") || app["run"] = "") + errors.Push("config.apps[" index "].run is required when hotkey is set") + + if has_match { + match := app["match"] + has_exe := match.Has("exe") && (match["exe"] != "") + has_class := match.Has("class") && (match["class"] != "") + has_title := match.Has("title") && (match["title"] != "") + if !has_exe && !has_class && !has_title + errors.Push("config.apps[" index "].match must define exe, class, or title") + + if (match.Has("exe_regex") && !has_exe) + errors.Push("config.apps[" index "].match.exe_regex requires exe") + if (match.Has("class_regex") && !has_class) + errors.Push("config.apps[" index "].match.class_regex requires class") + if (match.Has("title_regex") && !has_title) + errors.Push("config.apps[" index "].match.title_regex requires title") + } + } +} + ValidateNode(value, spec, path, errors) { if (spec is Map) { if spec.Has("__optional__") { diff --git a/src/lib/focus_border.ahk b/src/lib/focus_border.ahk index bc810e8..36c4038 100644 --- a/src/lib/focus_border.ahk +++ b/src/lib/focus_border.ahk @@ -11,11 +11,11 @@ focus_config := Config["focus_border"] global focus_border_enabled := focus_config["enabled"] if focus_border_enabled { ; ------------- User Settings ------------- - border_color := ParseHexColor(focus_config["border_color"]) ; Hex color (#RRGGBB) - move_mode_color := ParseHexColor(focus_config["move_mode_color"]) ; Hex color (#RRGGBB) - command_mode_color := ParseHexColor(focus_config["command_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 + base_border_color := ParseHexColor(focus_config["border_color"]) ; Hex color (#RRGGBB) + base_move_mode_color := ParseHexColor(focus_config["move_mode_color"]) ; Hex color (#RRGGBB) + base_command_mode_color := ParseHexColor(focus_config["command_mode_color"]) ; Hex color (#RRGGBB) + base_border_thickness := Integer(focus_config["border_thickness"]) ; Border thickness in pixels + base_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 ; ------------- End Settings ------------- @@ -23,7 +23,7 @@ if focus_border_enabled { overlay := Gui("+AlwaysOnTop +ToolWindow -Caption +E0x20", "ActiveWindowBorder") ; Set the background color of the overlay (it will only be visible in its "region") ; Convert the numeric color (0xRRGGBB) to a 6-digit hex string (without "0x") - bg_color := Format("{:06X}", border_color & 0xFFFFFF) + bg_color := Format("{:06X}", base_border_color & 0xFFFFFF) overlay.BackColor := bg_color overlay.Show("NoActivate") h_overlay := overlay.Hwnd @@ -32,7 +32,7 @@ if focus_border_enabled { ; Global variables to track the last active window's position & size. global prev_hwnd := 0, prev_ax := 0, prev_ay := 0, prev_aw := 0, prev_ah := 0 - global current_color := border_color + global current_color := base_border_color global flash_until := 0 global flash_color := 0xB0B0B0 @@ -44,7 +44,8 @@ if focus_border_enabled { ; ------------------------------- UpdateBorder(*) { global overlay, h_overlay - global border_thickness, corner_radius, border_color, move_mode_color, command_mode_color, current_color + global base_border_thickness, base_corner_radius + global base_border_color, base_move_mode_color, base_command_mode_color, current_color global prev_hwnd, prev_ax, prev_ay, prev_aw, prev_ah ; Get the currently active window. @@ -83,6 +84,25 @@ if focus_border_enabled { WinGetPos(&ax, &ay, &aw, &ah, "ahk_id " active_hwnd) } + border_color := base_border_color + move_mode_color := base_move_mode_color + command_mode_color := base_command_mode_color + border_thickness := base_border_thickness + corner_radius := base_corner_radius + override := FindFocusBorderOverride(active_hwnd) + if (override is Map) { + if (override.Has("border_color") && override["border_color"] != "") + border_color := ParseHexColor(override["border_color"]) + if (override.Has("move_mode_color") && override["move_mode_color"] != "") + move_mode_color := ParseHexColor(override["move_mode_color"]) + if (override.Has("command_mode_color") && override["command_mode_color"] != "") + command_mode_color := ParseHexColor(override["command_mode_color"]) + if (override.Has("border_thickness")) + border_thickness := Integer(override["border_thickness"]) + if (override.Has("corner_radius")) + corner_radius := Integer(override["corner_radius"]) + } + if (flash_until > A_TickCount) desired_color := flash_color else if ReloadModeActive() @@ -131,6 +151,23 @@ if focus_border_enabled { } } +FindFocusBorderOverride(active_hwnd) { + global Config + if !Config.Has("apps") + return "" + + for _, app in Config["apps"] { + if !(app is Map) + continue + if !app.Has("focus_border") || !(app["focus_border"] is Map) + continue + if MatchAppWindow(app, active_hwnd) + return app["focus_border"] + } + + return "" +} + FlashFocusBorder(color := 0xB0B0B0, duration_ms := 130) { global focus_border_enabled, flash_until, flash_color, overlay, current_color if !focus_border_enabled diff --git a/src/lib/focus_or_run.ahk b/src/lib/focus_or_run.ahk index 102dd56..3c7df29 100644 --- a/src/lib/focus_or_run.ahk +++ b/src/lib/focus_or_run.ahk @@ -4,7 +4,7 @@ FocusOrRun(winTitle, exePath, hotkey_id, app_config := "", *) { static last_window := Map() target_hwnd := 0 - hwnds := WinGetList(winTitle) + hwnds := GetAppWindowList(winTitle, app_config) current_hwnd := WinGetID("A") if (hwnds.Length) { @@ -49,6 +49,24 @@ FocusOrRun(winTitle, exePath, hotkey_id, app_config := "", *) { } } +GetAppWindowList(win_title, app_config := "") { + if (app_config is Map && app_config.Has("match") && app_config["match"] is Map) { + return GetWindowsByMatch(app_config["match"]) + } + if !win_title + return [] + return WinGetList(win_title) +} + +GetWindowsByMatch(match) { + matches := [] + for _, hwnd in WinGetList() { + if MatchWindowFields(match, hwnd) + matches.Push(hwnd) + } + return matches +} + RunResolved(command, app_config := "") { resolved := ResolveRunPath(command, app_config) try { diff --git a/src/lib/toml.ahk b/src/lib/toml.ahk index 1fd287b..167ce79 100644 --- a/src/lib/toml.ahk +++ b/src/lib/toml.ahk @@ -144,6 +144,11 @@ TomlParseValue(value) { if (value = "") return "" + if (SubStr(value, 1, 1) = "{" && SubStr(value, -1) = "}") { + table_content := Trim(SubStr(value, 2, StrLen(value) - 2)) + return TomlParseInlineTable(table_content) + } + if (SubStr(value, 1, 1) = "[" && SubStr(value, -1) = "]") { array_content := Trim(SubStr(value, 2, StrLen(value) - 2)) return TomlParseArray(array_content) @@ -172,6 +177,65 @@ TomlParseValue(value) { return value } +TomlParseInlineTable(content) { + table := Map() + if (content = "") + return table + + elements := [] + in_double := false + in_single := false + escaped := false + depth := 0 + current := "" + + loop parse, content { + ch := A_LoopField + if (escaped) { + current .= ch + escaped := false + continue + } + + if (ch = "\\" && in_double) { + escaped := true + current .= ch + continue + } + + if (ch = '"' && !in_single) + in_double := !in_double + else if (ch = "'" && !in_double) + in_single := !in_single + + if (!in_double && !in_single) { + if (ch = "[" || ch = "{") + depth += 1 + else if (ch = "]" || ch = "}") + depth -= 1 + } + + if (ch = "," && !in_double && !in_single && depth = 0) { + elements.Push(Trim(current)) + current := "" + continue + } + + current .= ch + } + + if (Trim(current) != "") + elements.Push(Trim(current)) + + for _, element in elements { + kv := TomlSplitKeyValue(element) + if (kv.Has("key") && kv["key"] != "") + TomlSetNestedKey(table, kv["key"], TomlParseValue(kv["value"])) + } + + return table +} + TomlParseArray(content) { result := [] if (content = "") From fc59c8656eb9e6320b0a54ee013cbf08489cbd5a Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 13:42:30 -0600 Subject: [PATCH 05/20] [config] use winAPI to watch config file for changes --- harken.ahk | 77 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/harken.ahk b/harken.ahk index 1de84a4..df5a134 100644 --- a/harken.ahk +++ b/harken.ahk @@ -212,24 +212,89 @@ GetConfigDir() { StartConfigWatcher(path, interval_ms := 1000) { global config_watch_mtime := "" + global config_watch_handle := 0 + global config_watch_dir := "" + global config_watch_path := path + global config_watch_use_fallback := false + global config_watch_fallback_notified := false + if FileExist(path) config_watch_mtime := FileGetTime(path, "M") - SetTimer((*) => CheckConfigWatcher(path), interval_ms) + try { + SplitPath(path, &config_watch_filename, &config_watch_dir) + flags := 0x00000010 | 0x00000001 + config_watch_handle := DllCall("FindFirstChangeNotificationW", "str", config_watch_dir, "int", false, "uint", flags, "ptr") + if (!config_watch_handle || config_watch_handle = -1) + throw Error("FindFirstChangeNotificationW failed") + } catch { + config_watch_use_fallback := true + NotifyConfigWatcherFallback() + } + + SetTimer((*) => CheckConfigWatcher(), interval_ms) } -CheckConfigWatcher(path) { - global config_watch_mtime - if !FileExist(path) +CheckConfigWatcher() { + global config_watch_mtime, config_watch_handle, config_watch_path, config_watch_use_fallback + if config_watch_use_fallback { + CheckConfigWatcherPoll() + return + } + + if (!config_watch_handle || config_watch_handle = -1) { + config_watch_use_fallback := true + NotifyConfigWatcherFallback() + CheckConfigWatcherPoll() return + } - current := FileGetTime(path, "M") + result := DllCall("WaitForSingleObject", "ptr", config_watch_handle, "uint", 0) + if (result != 0) + return + + if !FileExist(config_watch_path) { + DllCall("FindNextChangeNotification", "ptr", config_watch_handle) + return + } + + current := FileGetTime(config_watch_path, "M") if (config_watch_mtime = "") { config_watch_mtime := current + } else if (current != config_watch_mtime) { + config_watch_mtime := current + Reload() return } - if (current != config_watch_mtime) + + if !DllCall("FindNextChangeNotification", "ptr", config_watch_handle) { + config_watch_use_fallback := true + NotifyConfigWatcherFallback() + } +} + +CheckConfigWatcherPoll() { + global config_watch_mtime, config_watch_path + if !FileExist(config_watch_path) + return + + current := FileGetTime(config_watch_path, "M") + if (config_watch_mtime = "") { + config_watch_mtime := current + return + } + if (current != config_watch_mtime) { + config_watch_mtime := current Reload() + } +} + +NotifyConfigWatcherFallback() { + global config_watch_fallback_notified + if config_watch_fallback_notified + return + config_watch_fallback_notified := true + TrayTip("harken", "Config watcher fell back to polling.", 5) } ActivateReloadMode(timeout_ms := 1500) { From 1ed802bef951d1ed6558230f722d7289b1bb90c9 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 14:09:16 -0600 Subject: [PATCH 06/20] support shortcuts in start menu (ex: discord and their crazy update hook) --- README.md | 2 ++ config/config.example.toml | 1 + src/lib/config_loader.ahk | 1 + src/lib/focus_or_run.ahk | 53 ++++++++++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b9097ae..565a8fd 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Other - `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 +- Custom border params per exe/title/class. For example, color certain vscode project windows with different colors/thickness. Enter Command Mode with `super + ;`. - `r` to reload program/config @@ -78,6 +79,7 @@ Enter Command Mode with `super + ;`. - `super_key`: key or list of keys used as the super modifier (e.g., `CapsLock` or `["F24", "CapsLock"]`). - `apps`: list of app bindings with `hotkey`, `win_title`/`match`, and `run` command. Omit `hotkey` for matcher-only entries (they won't appear in the Command Overlay). - `apps[].run_paths`: optional list of directories to search for the executable. +- `apps[].run_start_in`: optional working directory for launching the app. - `apps[].match`: optional match map with `exe`, `class`, `title`, and `*_regex` flags (exact match is case-insensitive by default; regex is also case-insensitive). - `apps[].focus_border`: optional per-app focus border overrides (colors, thickness, and corner radius). - `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). diff --git a/config/config.example.toml b/config/config.example.toml index 68158cc..f802d8a 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -20,6 +20,7 @@ id = "editor" hotkey = "v" win_title = "ahk_exe Code.exe" run = "code" +# run_start_in = "C:\\projects" run_paths = [] [[apps]] diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 1280b71..78aed85 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -43,6 +43,7 @@ ConfigSchema() { "title_regex", OptionalSpec("bool") )), "run", OptionalSpec("string"), + "run_start_in", OptionalSpec("string"), "run_paths", OptionalSpec(["string"]), "focus_border", OptionalSpec(Map( "border_color", OptionalSpec("string"), diff --git a/src/lib/focus_or_run.ahk b/src/lib/focus_or_run.ahk index 3c7df29..7b9585d 100644 --- a/src/lib/focus_or_run.ahk +++ b/src/lib/focus_or_run.ahk @@ -69,8 +69,25 @@ GetWindowsByMatch(match) { RunResolved(command, app_config := "") { resolved := ResolveRunPath(command, app_config) + run_start_in := "" + if (app_config is Map && app_config.Has("run_start_in")) + run_start_in := app_config["run_start_in"] try { - Run resolved + if (run_start_in = "") { + link_info := ResolveShortcutStartIn(resolved) + if (link_info is Map) { + resolved := link_info["target"] + run_start_in := link_info["start_in"] + } + } else if (StrLower(SubStr(resolved, -4)) = ".lnk") { + shortcut := ResolveShortcutTarget(resolved) + if shortcut + resolved := shortcut + } + if (run_start_in != "") + Run resolved, run_start_in + else + Run resolved } catch as err { MsgBox( "Failed to launch: " command "`n" err.Message "`n`n" . @@ -85,6 +102,9 @@ ResolveRunPath(command, app_config := "") { command := Trim(command, " `t") if (SubStr(command, 1, 1) = '"' && SubStr(command, -1) = '"') command := SubStr(command, 2, -1) + if (StrLower(SubStr(command, -4)) = ".lnk") { + return command + } if (InStr(command, "\\") || InStr(command, "/") || InStr(command, ":")) { if FileExist(command) return command @@ -217,28 +237,45 @@ FindStartMenuShortcut(command) { if !target continue if InStr(StrLower(target), name) - return target + return A_LoopFilePath continue } target := ResolveShortcutTarget(A_LoopFilePath) if target - return target + return A_LoopFilePath } } return "" } ResolveShortcutTarget(link_path) { - if !FileExist(link_path) + if !link_path || !FileExist(link_path) return "" try { shell := ComObject("WScript.Shell") shortcut := shell.CreateShortcut(link_path) - target := shortcut.TargetPath - if target && FileExist(target) - return target + target := Trim(shortcut.TargetPath " " shortcut.Arguments) + return target + } catch { + return "" + } +} + +ResolveShortcutStartIn(link_path) { + if !link_path || !FileExist(link_path) + return "" + if (StrLower(SubStr(link_path, -4)) != ".lnk") + return "" + try { + shell := ComObject("WScript.Shell") + shortcut := shell.CreateShortcut(link_path) + return Map( + "target", Trim(shortcut.TargetPath " " shortcut.Arguments), + "start_in", shortcut.WorkingDirectory + ) + } catch { + return "" } - return "" } FindWindowsAppsAlias(exe_name) { From 3355f63e4f646181a2e05c3c43ab27ef997aa798 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 15:12:25 -0600 Subject: [PATCH 07/20] [hotkeys] adds minimize_others_hotkey --- README.md | 1 + harken.ahk | 3 ++- src/hotkeys/window.ahk | 47 +++++++++++++++++++++++++++++++++++++++ src/lib/config_loader.ahk | 3 ++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 565a8fd..62401c4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Enter Command Mode with `super + ;`. - `apps[].focus_border`: optional per-app focus border overrides (colors, thickness, and corner radius). - `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). - `window`: resize/move steps and hotkeys (including move mode). +- `window.minimize_others_hotkey`: optional hotkey to minimize all other windows on the current monitor. - `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). diff --git a/harken.ahk b/harken.ahk index df5a134..8f53eca 100644 --- a/harken.ahk +++ b/harken.ahk @@ -103,7 +103,8 @@ DefaultConfig() { "cancel_key", "Esc" ), "cycle_app_windows_hotkey", "c", - "center_width_cycle_hotkey", "Space" + "center_width_cycle_hotkey", "Space", + "minimize_others_hotkey", "" ), "window_selector", Map( "enabled", true, diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index 9887c81..a98610b 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -7,6 +7,9 @@ move_mode_enabled := Config["window"]["move_mode"]["enable"] move_mode_cancel_key := Config["window"]["move_mode"]["cancel_key"] center_cycle_hotkey := Config["window"]["center_width_cycle_hotkey"] cycle_app_windows_hotkey := Config["window"]["cycle_app_windows_hotkey"] +minimize_others_hotkey := "" +if Config["window"].Has("minimize_others_hotkey") + minimize_others_hotkey := Config["window"]["minimize_others_hotkey"] last_super_tap := 0 max_restore := Map() @@ -179,6 +182,8 @@ if (move_mode_enabled || Config["reload"]["mode_enabled"] || Config["helper"]["e RegisterSuperKeyHotkey("", " up", (*) => OnSuperKeyUp()) } +Hotkey("~LButton", (*) => BeginSuperDrag()) + CenterWidthCycle(*) { static state := 0 state := Mod(state + 1, 3) @@ -313,6 +318,8 @@ Hotkey("^k", (*) => MoveActiveWindow(0, -move_step)) Hotkey("m", ToggleMaximize) Hotkey("q", CloseWindow) Hotkey(cycle_app_windows_hotkey, CycleAppWindows) +if (minimize_others_hotkey != "") + Hotkey(minimize_others_hotkey, MinimizeOtherWindows) HotIf Hotkey("!-", MinimizeWindow) @@ -332,6 +339,46 @@ ExitMoveMode() { UpdateCommandToastVisibility() } +BeginSuperDrag(*) { + if !IsSuperKeyPressed() + return + if Window.IsMoveMode() + return + MouseGetPos(, , &hwnd) + if !hwnd + return + if Window.IsException("ahk_id " hwnd) + return + if (WinGetMinMax("ahk_id " hwnd) = -1) + return + WinActivate "ahk_id " hwnd + DllCall("ReleaseCapture") + SendMessage(0xA1, 2, 0, , "ahk_id " hwnd) +} + +MinimizeOtherWindows(*) { + active_hwnd := WinGetID("A") + if !active_hwnd + return + active_monitor := Screen.FromWindow("ahk_id " active_hwnd) + for _, hwnd in WinGetList() { + if (hwnd = active_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 + if (Screen.FromWindow("ahk_id " hwnd) != active_monitor) + continue + WinMinimize "ahk_id " hwnd + } +} + ActivateMostRecentWindow(exclude_hwnd := 0) { z_list := WinGetList() for _, hwnd in z_list { diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 78aed85..51606c1 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -69,7 +69,8 @@ ConfigSchema() { "cancel_key", "string" ), "cycle_app_windows_hotkey", "string", - "center_width_cycle_hotkey", "string" + "center_width_cycle_hotkey", "string", + "minimize_others_hotkey", OptionalSpec("string") ), "window_selector", Map( "enabled", "bool", From a650c6ef59e66e579e1f08ad5a6973b0f11a6f26 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 15:58:50 -0600 Subject: [PATCH 08/20] [config] adds missing `bottom` to window_manager.margins --- config/config.example.toml | 1 + harken.ahk | 3 ++- src/hotkeys/window.ahk | 3 ++- src/lib/config_loader.ahk | 3 ++- src/lib/window_manager.ahk | 3 +++ 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index f802d8a..7f0543d 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -92,6 +92,7 @@ exceptions_regex = "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHo top = 6 left = 4 right = 4 +bottom = 5 [directional_focus] enabled = true diff --git a/harken.ahk b/harken.ahk index 8f53eca..d053ef4 100644 --- a/harken.ahk +++ b/harken.ahk @@ -121,7 +121,8 @@ DefaultConfig() { "margins", Map( "top", 6, "left", 4, - "right", 4 + "right", 4, + "bottom", 5 ), "gap_px", 0, "exceptions_regex", "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHostIslandWindow)" diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index a98610b..876ec77 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -204,11 +204,12 @@ CenterWidthCycle(*) { left_margin := Screen.left_margin right_margin := Screen.right_margin top_margin := Screen.top_margin + bottom_margin := Screen.bottom_margin gap_px := Config["window_manager"]["gap_px"] mx1 += left_margin mw := mw - left_margin - right_margin - mh := mh - top_margin + mh := mh - top_margin - bottom_margin if (gap_px > 0) { mx1 += gap_px diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 51606c1..f1f9e81 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -87,7 +87,8 @@ ConfigSchema() { "margins", Map( "top", "number", "left", "number", - "right", "number" + "right", "number", + "bottom", "number" ), "gap_px", "number", "exceptions_regex", "string" diff --git a/src/lib/window_manager.ahk b/src/lib/window_manager.ahk index 46706e4..b7b2de1 100644 --- a/src/lib/window_manager.ahk +++ b/src/lib/window_manager.ahk @@ -585,6 +585,7 @@ class Screen static top_margin := Config["window_manager"]["margins"]["top"] static left_margin := Config["window_manager"]["margins"]["left"] static right_margin := Config["window_manager"]["margins"]["right"] + static bottom_margin := Config["window_manager"]["margins"]["bottom"] static activeWindowIsOn => Screen.FromWindow() static top => this.GetScreenCoordinates(this.activeWindowIsOn, 'top') static bottom => this.GetScreenCoordinates(this.activeWindowIsOn, 'bottom') @@ -609,6 +610,8 @@ class Screen left += this.left_margin if this.right_margin right -= this.right_margin + if this.bottom_margin + bottom -= this.bottom_margin width := Abs(right - left) ; calculate width of screen height := Abs(bottom - top) ; calculate height of screen From a103b81ad07940c359de03b03a34e1dc6d7a4e71 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 16:57:19 -0600 Subject: [PATCH 09/20] initial support for virtual desktops --- LICENSES/VD.ahk-LICENSE.md | 21 +++++++ README.md | 6 ++ config/config.example.toml | 9 +++ harken.ahk | 9 +++ src/hotkeys/unbound.ahk | 1 + src/hotkeys/window.ahk | 83 +++++++++++++++++++++++-- src/lib/command_toast.ahk | 2 + src/lib/config_loader.ahk | 9 +++ src/lib/directional_focus.ahk | 3 + src/lib/focus_or_run.ahk | 114 ++++++++++++++++++++++++++-------- src/lib/virtual_desktop.ahk | 103 ++++++++++++++++++++++++++++++ src/lib/window_walker.ahk | 58 ++++++++++++++--- 12 files changed, 377 insertions(+), 41 deletions(-) create mode 100644 LICENSES/VD.ahk-LICENSE.md create mode 100644 src/lib/virtual_desktop.ahk diff --git a/LICENSES/VD.ahk-LICENSE.md b/LICENSES/VD.ahk-LICENSE.md new file mode 100644 index 0000000..35e86cf --- /dev/null +++ b/LICENSES/VD.ahk-LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 [Fu Pei Jiang](https://github.com/FuPeiJiang) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 62401c4..dad6be3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Use the "window switcher" (like powertoys window walker) with `super + w`. Other - `super + alt` sends `ctrl + tab` (configurable via `global_hotkeys`) - `super + c` cycle through windows of the same app +- `super + shift + c` cycle through windows of the same app on the current desktop - `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) @@ -82,11 +83,15 @@ Enter Command Mode with `super + ;`. - `apps[].run_start_in`: optional working directory for launching the app. - `apps[].match`: optional match map with `exe`, `class`, `title`, and `*_regex` flags (exact match is case-insensitive by default; regex is also case-insensitive). - `apps[].focus_border`: optional per-app focus border overrides (colors, thickness, and corner radius). +- `apps[].desktop`: optional virtual desktop number to move new windows to. +- `apps[].follow_on_spawn`: optional override for whether to switch to the app's desktop after launching (default true). - `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). - `window`: resize/move steps and hotkeys (including move mode). - `window.minimize_others_hotkey`: optional hotkey to minimize all other windows on the current monitor. +- `window.cycle_app_windows_current_hotkey`: hotkey to cycle app windows on the current desktop only. - `window_selector`: Window Selector settings (hotkey, match fields, display limits). - `window_manager`: grid size, margins, gaps, and ignored window classes. +- `virtual_desktop`: cross-desktop focus/cycle settings and desktop auto-creation. - `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 (including command mode color). - `helper`: command overlay settings. @@ -112,6 +117,7 @@ Enter Command Mode with `super + ;`. ## Known Limitations - This has not been tested with multi-monitor setups or much outside of ultra-wide monitors. +- Virtual desktop integration requires AutoHotkey v2.1 alpha or later. - 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 Harken hotkeys unless Harken is run as Administrator. diff --git a/config/config.example.toml b/config/config.example.toml index 7f0543d..789bbd6 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -6,6 +6,8 @@ id = "browser" hotkey = "f" win_title = "ahk_exe floorp.exe" run = "floorp" +# desktop = 2 +# follow_on_spawn = true run_paths = [] [[apps]] @@ -67,6 +69,7 @@ resize_step = 20 move_step = 20 super_double_tap_ms = 300 cycle_app_windows_hotkey = "c" +cycle_app_windows_current_hotkey = "+c" center_width_cycle_hotkey = "Space" [window.move_mode] @@ -94,6 +97,12 @@ left = 4 right = 4 bottom = 5 +[virtual_desktop] +enabled = true +switch_on_focus = true +ensure_count = 0 +cycle_prefer_current = true + [directional_focus] enabled = true stacked_overlap_threshold = 0.5 diff --git a/harken.ahk b/harken.ahk index d053ef4..d393510 100644 --- a/harken.ahk +++ b/harken.ahk @@ -5,6 +5,7 @@ #Include src/lib/toml.ahk #Include src/lib/config_loader.ahk #Include src/lib/state_store.ahk +#Include src/lib/virtual_desktop.ahk #Include src/lib/focus_or_run.ahk #Include src/lib/command_toast.ahk #Include src/lib/window_inspector.ahk @@ -20,6 +21,7 @@ if (config_errors.Length) { LogConfigErrors(config_errors, config_dir "\config.errors.log", config_path) return } +InitVirtualDesktop() global AppState := LoadState() InitCommandToast() @@ -103,6 +105,7 @@ DefaultConfig() { "cancel_key", "Esc" ), "cycle_app_windows_hotkey", "c", + "cycle_app_windows_current_hotkey", "+c", "center_width_cycle_hotkey", "Space", "minimize_others_hotkey", "" ), @@ -127,6 +130,12 @@ DefaultConfig() { "gap_px", 0, "exceptions_regex", "(Shell_TrayWnd|Shell_SecondaryTrayWnd|WorkerW|XamlExplorerHostIslandWindow)" ), + "virtual_desktop", Map( + "enabled", true, + "switch_on_focus", true, + "ensure_count", 0, + "cycle_prefer_current", true + ), "directional_focus", Map( "enabled", true, "stacked_overlap_threshold", 0.5, diff --git a/src/hotkeys/unbound.ahk b/src/hotkeys/unbound.ahk index 6221a43..12743cb 100644 --- a/src/hotkeys/unbound.ahk +++ b/src/hotkeys/unbound.ahk @@ -5,6 +5,7 @@ RegisterUnboundHotkeys() { AddUsedKey(used_keys, Config["window"]["center_width_cycle_hotkey"]) AddUsedKey(used_keys, Config["window"]["cycle_app_windows_hotkey"]) + AddUsedKey(used_keys, Config["window"]["cycle_app_windows_current_hotkey"]) AddUsedKey(used_keys, "m") AddUsedKey(used_keys, "q") AddUsedKey(used_keys, "h") diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index 876ec77..d367514 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -7,6 +7,7 @@ move_mode_enabled := Config["window"]["move_mode"]["enable"] move_mode_cancel_key := Config["window"]["move_mode"]["cancel_key"] center_cycle_hotkey := Config["window"]["center_width_cycle_hotkey"] cycle_app_windows_hotkey := Config["window"]["cycle_app_windows_hotkey"] +cycle_app_windows_current_hotkey := Config["window"]["cycle_app_windows_current_hotkey"] minimize_others_hotkey := "" if Config["window"].Has("minimize_others_hotkey") minimize_others_hotkey := Config["window"]["minimize_others_hotkey"] @@ -129,21 +130,57 @@ SortWindowList(list) { FilterWindowList(exe, list) { filtered := [] for _, id in list { - class_name := WinGetClass("ahk_id " id) + if !WinExist("ahk_id " id) + continue + try class_name := WinGetClass("ahk_id " id) + catch + continue if (exe = "explorer.exe") { if (class_name = "Progman" || class_name = "WorkerW" || class_name = "Shell_TrayWnd") continue } - ex_style := WinGetExStyle("ahk_id " id) + try ex_style := WinGetExStyle("ahk_id " id) + catch + continue if (ex_style & 0x80) || (ex_style & 0x8000000) continue - if (!(WinGetStyle("ahk_id " id) & 0x10000000)) + try style := WinGetStyle("ahk_id " id) + catch + continue + if (!(style & 0x10000000)) continue filtered.Push(id) } return filtered } +ApplyDesktopCyclePreference(list, current_only := false) { + if !VirtualDesktopEnabled() + return list + + current := [] + other := [] + for _, id in list { + if IsWindowOnCurrentDesktop(id) + current.Push(id) + else if !current_only + other.Push(id) + } + + if current_only + return current + + if !Config["virtual_desktop"]["cycle_prefer_current"] + return list + + ordered := [] + for _, id in current + ordered.Push(id) + for _, id in other + ordered.Push(id) + return ordered +} + HandleSuperTap() { global last_super_tap, super_double_tap_ms @@ -283,12 +320,46 @@ CycleAppWindows(*) { if !exe return - win_list := WinGetList("ahk_exe " exe) + win_list := GetWindowsAcrossDesktops("ahk_exe " exe) win_list := FilterWindowList(exe, win_list) if (win_list.Length < 2) return win_list := SortWindowList(win_list) + win_list := ApplyDesktopCyclePreference(win_list, false) + if (win_list.Length < 2) + return + + current_index := 0 + for i, id in win_list { + if (id = hwnd) { + current_index := i + break + } + } + + next_index := (current_index >= win_list.Length || current_index = 0) ? 1 : current_index + 1 + ActivateWindowAcrossDesktops(win_list[next_index]) +} + +CycleAppWindowsCurrent(*) { + hwnd := WinExist("A") + if !hwnd + return + + exe := WinGetProcessName("ahk_id " hwnd) + if !exe + return + + win_list := GetWindowsAcrossDesktops("ahk_exe " exe) + win_list := FilterWindowList(exe, win_list) + if (win_list.Length < 2) + return + + win_list := SortWindowList(win_list) + win_list := ApplyDesktopCyclePreference(win_list, true) + if (win_list.Length < 2) + return current_index := 0 for i, id in win_list { @@ -299,7 +370,7 @@ CycleAppWindows(*) { } next_index := (current_index >= win_list.Length || current_index = 0) ? 1 : current_index + 1 - WinActivate "ahk_id " win_list[next_index] + ActivateWindowAcrossDesktops(win_list[next_index]) } HotIf IsSuperKeyPressed @@ -319,6 +390,8 @@ Hotkey("^k", (*) => MoveActiveWindow(0, -move_step)) Hotkey("m", ToggleMaximize) Hotkey("q", CloseWindow) Hotkey(cycle_app_windows_hotkey, CycleAppWindows) +if (cycle_app_windows_current_hotkey != "") + Hotkey(cycle_app_windows_current_hotkey, CycleAppWindowsCurrent) if (minimize_others_hotkey != "") Hotkey(minimize_others_hotkey, MinimizeOtherWindows) HotIf diff --git a/src/lib/command_toast.ahk b/src/lib/command_toast.ahk index 46d844a..b3404c6 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -274,6 +274,8 @@ BuildCommandToastRows(key_width := 16) { 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["window"]["cycle_app_windows_current_hotkey"] != "") + rows.Push(Map("key", "super+" Config["window"]["cycle_app_windows_current_hotkey"], "desc", "cycle app windows (current desktop)")) if Config.Has("window_selector") && Config["window_selector"]["enabled"] { rows.Push(Map("key", "super+" Config["window_selector"]["hotkey"], "desc", "window selector")) } diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index f1f9e81..ba4c20c 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -45,6 +45,8 @@ ConfigSchema() { "run", OptionalSpec("string"), "run_start_in", OptionalSpec("string"), "run_paths", OptionalSpec(["string"]), + "desktop", OptionalSpec("number"), + "follow_on_spawn", OptionalSpec("bool"), "focus_border", OptionalSpec(Map( "border_color", OptionalSpec("string"), "move_mode_color", OptionalSpec("string"), @@ -69,6 +71,7 @@ ConfigSchema() { "cancel_key", "string" ), "cycle_app_windows_hotkey", "string", + "cycle_app_windows_current_hotkey", "string", "center_width_cycle_hotkey", "string", "minimize_others_hotkey", OptionalSpec("string") ), @@ -93,6 +96,12 @@ ConfigSchema() { "gap_px", "number", "exceptions_regex", "string" ), + "virtual_desktop", Map( + "enabled", "bool", + "switch_on_focus", "bool", + "ensure_count", "number", + "cycle_prefer_current", "bool" + ), "directional_focus", Map( "enabled", "bool", "stacked_overlap_threshold", "number", diff --git a/src/lib/directional_focus.ahk b/src/lib/directional_focus.ahk index ba4f86a..c56182f 100644 --- a/src/lib/directional_focus.ahk +++ b/src/lib/directional_focus.ahk @@ -610,6 +610,9 @@ IsCandidateInCenterBand(win, center_band) { ActivateWindow(hwnd) { if !hwnd return 0 + if VirtualDesktopSwitchOnFocus() + return ActivateWindowAcrossDesktops(hwnd) + WinActivate("ahk_id " hwnd) try WinWaitActive("ahk_id " hwnd,, 0.2) active_hwnd := WinGetID("A") diff --git a/src/lib/focus_or_run.ahk b/src/lib/focus_or_run.ahk index 7b9585d..349c254 100644 --- a/src/lib/focus_or_run.ahk +++ b/src/lib/focus_or_run.ahk @@ -8,44 +8,25 @@ FocusOrRun(winTitle, exePath, hotkey_id, app_config := "", *) { current_hwnd := WinGetID("A") if (hwnds.Length) { - for hwnd in hwnds { - if (InStr(winTitle, "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 - - state := WinGetMinMax("ahk_id " hwnd) - if (state = -1) { - if (!target_hwnd) - target_hwnd := hwnd - continue - } - target_hwnd := hwnd - break - } + target_hwnd := PickFocusableAppWindow(hwnds, winTitle) if (!target_hwnd) target_hwnd := hwnds[1] } if target_hwnd { if (current_hwnd = target_hwnd) { - if last_window.Has(hotkey_id) && WinExist("ahk_id " last_window[hotkey_id]) { - WinActivate "ahk_id " last_window[hotkey_id] + if last_window.Has(hotkey_id) && WindowExistsAcrossDesktops(last_window[hotkey_id]) { + ActivateWindowAcrossDesktops(last_window[hotkey_id]) } return } if current_hwnd && (current_hwnd != target_hwnd) { last_window[hotkey_id] := current_hwnd } - WinActivate "ahk_id " target_hwnd + ActivateWindowAcrossDesktops(target_hwnd) } else { RunResolved(exePath, app_config) + ScheduleMoveAppWindowToDesktop(winTitle, app_config) } } @@ -55,18 +36,99 @@ GetAppWindowList(win_title, app_config := "") { } if !win_title return [] - return WinGetList(win_title) + return GetWindowsAcrossDesktops(win_title) } GetWindowsByMatch(match) { matches := [] - for _, hwnd in WinGetList() { + for _, hwnd in GetWindowsAcrossDesktops() { if MatchWindowFields(match, hwnd) matches.Push(hwnd) } return matches } +PickFocusableAppWindow(hwnds, win_title) { + for hwnd in hwnds { + if !WinExist("ahk_id " hwnd) + continue + if (InStr(win_title, "explorer.exe")) { + class_name := WinGetClass("ahk_id " hwnd) + if (class_name = "Progman" || class_name = "WorkerW" || class_name = "Shell_TrayWnd") + continue + } + try ex_style := WinGetExStyle("ahk_id " hwnd) + catch + continue + if (ex_style & 0x80) || (ex_style & 0x8000000) + continue + try style := WinGetStyle("ahk_id " hwnd) + catch + continue + if (!(style & 0x10000000)) + continue + + state := WinGetMinMax("ahk_id " hwnd) + if (state = -1) + continue + return hwnd + } + + for hwnd in hwnds { + if !WinExist("ahk_id " hwnd) + continue + state := WinGetMinMax("ahk_id " hwnd) + if (state = -1) + return hwnd + } + return 0 +} + +ScheduleMoveAppWindowToDesktop(win_title, app_config := "") { + if !VirtualDesktopEnabled() + return + target_desktop := GetAppTargetDesktop(app_config) + if (target_desktop <= 0) + return + + follow_on_spawn := GetAppFollowOnSpawn(app_config) + attempts := 0 + callback := 0 + callback := () => TryMoveAppWindowToDesktop(win_title, app_config, target_desktop, follow_on_spawn, &attempts, callback) + SetTimer(callback, 200) +} + +GetAppTargetDesktop(app_config := "") { + if !(app_config is Map) + return 0 + if !app_config.Has("desktop") + return 0 + return app_config["desktop"] +} + +GetAppFollowOnSpawn(app_config := "") { + if (app_config is Map && app_config.Has("follow_on_spawn")) + return app_config["follow_on_spawn"] + return true +} + +TryMoveAppWindowToDesktop(win_title, app_config, target_desktop, follow_on_spawn, &attempts, callback) { + attempts += 1 + hwnds := GetAppWindowList(win_title, app_config) + hwnd := PickFocusableAppWindow(hwnds, win_title) + if (!hwnd && hwnds.Length) + hwnd := hwnds[1] + + if (hwnd) { + VD.MoveWindowToDesktopNum("ahk_id " hwnd, target_desktop, follow_on_spawn) + SetTimer(callback, 0) + return + } + + if (attempts >= 30) + SetTimer(callback, 0) +} + RunResolved(command, app_config := "") { resolved := ResolveRunPath(command, app_config) run_start_in := "" diff --git a/src/lib/virtual_desktop.ahk b/src/lib/virtual_desktop.ahk new file mode 100644 index 0000000..efbfb8a --- /dev/null +++ b/src/lib/virtual_desktop.ahk @@ -0,0 +1,103 @@ +#Include %A_LineFile%\..\VD.ahk + +VirtualDesktopEnabled() { + global Config + if !IsSet(Config) + return false + if !Config.Has("virtual_desktop") + return false + return Config["virtual_desktop"]["enabled"] +} + +VirtualDesktopSwitchOnFocus() { + global Config + if !VirtualDesktopEnabled() + return false + return Config["virtual_desktop"]["switch_on_focus"] +} + +InitVirtualDesktop() { + if !VirtualDesktopEnabled() + return + ensure_count := Config["virtual_desktop"]["ensure_count"] + if (ensure_count > 0) + VD.createUntil(ensure_count) +} + +GetWindowDesktopNum(hwnd) { + if !VirtualDesktopEnabled() + return 0 + try return VD.getDesktopNumOfHWND(hwnd) + catch as err + return 0 +} + +IsWindowOnCurrentDesktop(hwnd) { + if !VirtualDesktopEnabled() + return true + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num <= 0) + return true + return desktop_num = VD.getCurrentDesktopNum() +} + +GetWindowsAcrossDesktops(win_title := "") { + if !VirtualDesktopEnabled() + return WinGetList(win_title) + bak_detect_hidden_windows := A_DetectHiddenWindows + A_DetectHiddenWindows := true + windows := WinGetList(win_title) + A_DetectHiddenWindows := bak_detect_hidden_windows + return windows +} + +WindowExistsAcrossDesktops(hwnd) { + bak_detect_hidden_windows := A_DetectHiddenWindows + A_DetectHiddenWindows := true + exists := WinExist("ahk_id " hwnd) + A_DetectHiddenWindows := bak_detect_hidden_windows + return exists +} + +ActivateWindowAcrossDesktops(hwnd) { + if !hwnd + return 0 + if !WindowExistsAcrossDesktops(hwnd) + return 0 + + if VirtualDesktopSwitchOnFocus() { + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num > 0 && desktop_num != VD.getCurrentDesktopNum()) { + try { + VD.goToDesktopOfWindow("ahk_id " hwnd, true) + } catch as err { + try { + WinActivate "ahk_id " hwnd + } catch + return 0 + } + try return WinGetID("A") + catch + return 0 + } + + if (desktop_num <= 0) { + try { + VD.goToDesktopOfWindow("ahk_id " hwnd, true) + try return WinGetID("A") + catch + return 0 + } catch as err { + ; fall through to direct activation + } + } + } + + try { + WinActivate "ahk_id " hwnd + } catch + return 0 + try return WinGetID("A") + catch + return 0 +} diff --git a/src/lib/window_walker.ahk b/src/lib/window_walker.ahk index bb3f092..b7d084c 100644 --- a/src/lib/window_walker.ahk +++ b/src/lib/window_walker.ahk @@ -12,6 +12,7 @@ class WindowWalker static visible := false static image_list := "" static icon_cache := Map() + static corner_radius := 8 static Show(*) { @@ -47,19 +48,20 @@ class WindowWalker if WindowWalker.gui return - WindowWalker.gui := Gui("+AlwaysOnTop +ToolWindow -Caption +Border", "harken Window Selector") + WindowWalker.gui := Gui("+AlwaysOnTop +ToolWindow -Caption", "harken 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 := WindowWalker.gui.AddEdit("w600", "") WindowWalker.search_edit.OnEvent("Change", (*) => WindowWalker.ApplyFilter()) - WindowWalker.list_view := WindowWalker.gui.AddListView("w560 r10 -Multi", ["App", "Title"]) + WindowWalker.list_view := WindowWalker.gui.AddListView("w600 r10 -Multi", ["App", "Title", "Desk"]) 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.ModifyCol(1, 150) + WindowWalker.list_view.ModifyCol(2, 360) + WindowWalker.list_view.ModifyCol(3, 60) WindowWalker.list_view.OnEvent("DoubleClick", (*) => WindowWalker.ActivateSelected()) WindowWalker.gui.OnEvent("Close", (*) => WindowWalker.Hide()) @@ -74,6 +76,20 @@ class WindowWalker pos_x := left + (right - left - w) / 2 pos_y := top + (bottom - top - h) / 2 WindowWalker.gui.Show("x" pos_x " y" pos_y) + WindowWalker.ApplyRoundedCorners(w, h) + } + + static ApplyRoundedCorners(width := 0, height := 0) + { + if !WindowWalker.gui + return + if (!width || !height) { + WindowWalker.gui.GetPos(,, &width, &height) + } + if (width <= 0 || height <= 0) + return + radius := WindowWalker.corner_radius + try WinSetRegion("0-0 w" width " h" height " r" radius "-" radius, WindowWalker.gui) } static StartFocusWatch() @@ -163,7 +179,7 @@ class WindowWalker if WinExist("ahk_id " hwnd) { if (WinGetMinMax("ahk_id " hwnd) = -1) WinRestore "ahk_id " hwnd - WinActivate "ahk_id " hwnd + ActivateWindowAcrossDesktops(hwnd) } } @@ -188,12 +204,14 @@ class WindowWalker exe := "unknown" exe_path := "" try exe_path := WinGetProcessPath("ahk_id " hwnd) + desktop_label := WindowWalker.DesktopLabel(hwnd) WindowWalker.windows.Push(Map( "hwnd", hwnd, "title", title, "exe", exe, "exe_path", exe_path, + "desktop", desktop_label, "order", A_Index )) } @@ -224,7 +242,8 @@ class WindowWalker "exe_display", WindowWalker.DisplayExe(win["exe"]), "exe_path", win["exe_path"], "title", win["title"], - "title_preview", WindowWalker.TruncateText(win["title"], preview_len) + "title_preview", WindowWalker.TruncateText(win["title"], preview_len), + "desktop", win["desktop"] )) } @@ -239,7 +258,7 @@ class WindowWalker 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"]) + WindowWalker.list_view.Add("Icon" icon_index, item["exe_display"], item["title_preview"], item["desktop"]) } if (WindowWalker.list_view.GetCount() > 0) @@ -261,21 +280,40 @@ class WindowWalker static IsWindowEligible(hwnd) { + if !WinExist("ahk_id " hwnd) + return false 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) + try ex_style := WinGetExStyle("ahk_id " hwnd) + catch + return false if (ex_style & 0x80) || (ex_style & 0x8000000) return false - if !(WinGetStyle("ahk_id " hwnd) & 0x10000000) + try style := WinGetStyle("ahk_id " hwnd) + catch + return false + if !(style & 0x10000000) return false return true } + static DesktopLabel(hwnd) + { + if !VirtualDesktopEnabled() + return "" + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num = -1 || desktop_num = -2) + return "all" + if (desktop_num <= 0) + return "" + return desktop_num + } + static SortMatches(matches) { count := matches.Length From a306519764b95d33ce8600e108410d48b3d02849 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 22:41:58 -0600 Subject: [PATCH 10/20] - hotkeys for moving between desktops and moving tiles between desktops - better apps configs with desktop param to set desired desktop location --- README.md | 9 +- config/config.example.toml | 13 + harken.ahk | 38 +- src/hotkeys/directional_focus.ahk | 2 + src/hotkeys/window.ahk | 419 +++++++++- src/hotkeys/window_walker.ahk | 2 +- src/lib/VD.ahk | 1231 +++++++++++++++++++++++++++++ src/lib/command_toast.ahk | 125 ++- src/lib/config_loader.ahk | 61 +- src/lib/focus_or_run.ahk | 24 +- src/lib/virtual_desktop.ahk | 22 + src/lib/window_inspector.ahk | 43 +- src/lib/window_manager.ahk | 2 + src/lib/window_walker.ahk | 54 +- 14 files changed, 1991 insertions(+), 54 deletions(-) create mode 100644 src/lib/VD.ahk diff --git a/README.md b/README.md index dad6be3..39af029 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Other - `super + alt` sends `ctrl + tab` (configurable via `global_hotkeys`) - `super + c` cycle through windows of the same app - `super + shift + c` cycle through windows of the same app on the current desktop +- `super + alt + h/l` switch to previous/next virtual desktop +- `super + alt + shift + h/l` move the active window to previous/next desktop (follow) - `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) @@ -91,7 +93,7 @@ Enter Command Mode with `super + ;`. - `window.cycle_app_windows_current_hotkey`: hotkey to cycle app windows on the current desktop only. - `window_selector`: Window Selector settings (hotkey, match fields, display limits). - `window_manager`: grid size, margins, gaps, and ignored window classes. -- `virtual_desktop`: cross-desktop focus/cycle settings and desktop auto-creation. +- `virtual_desktop`: cross-desktop focus/cycle settings, desktop auto-creation, and virtual desktop hotkeys. - `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 (including command mode color). - `helper`: command overlay settings. @@ -105,6 +107,11 @@ Enter Command Mode with `super + ;`. - `ahk_exe`, `ahk_class`, and `ahk_pid` are exact matches; `win_title` does not support regex (use `apps[].match` for regex). - `apps[].match` provides explicit matching by `exe`, `class`, and/or `title` with optional `*_regex` flags (non-regex is exact, case-insensitive). +### Virtual Desktop Hotkeys +- `virtual_desktop.prev_hotkey` / `virtual_desktop.next_hotkey`: switch to previous/next desktop while holding super+alt (defaults to `h` / `l`). +- `virtual_desktop.move_prev_hotkey` / `virtual_desktop.move_next_hotkey`: move the active window to previous/next desktop (follow) while holding super+alt+shift (defaults to `h` / `l`). +- `[[virtual_desktop.]]`: map a desktop number to a hotkey. `super + alt + ` switches to desktop `N`, `super + alt + shift + ` moves the active window to desktop `N` (follow). + ### Path Expansion - `apps[].run_paths` supports environment variables like `%APPDATA%` and `%LOCALAPPDATA%`. - `~\` expands to your user profile (e.g., `~\AppData\Roaming`). diff --git a/config/config.example.toml b/config/config.example.toml index 789bbd6..f3fb016 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -102,6 +102,19 @@ enabled = true switch_on_focus = true ensure_count = 0 cycle_prefer_current = true +prev_hotkey = "h" +next_hotkey = "l" +move_prev_hotkey = "h" +move_next_hotkey = "l" +# debug_cycle = false +# hotkey fields use AHK syntax without super (super is implied) +# modifiers are applied by Harken (alt or alt+shift) for desktop actions + +[[virtual_desktop.1]] +hotkey = "u" + +[[virtual_desktop.2]] +hotkey = "i" [directional_focus] enabled = true diff --git a/harken.ahk b/harken.ahk index d393510..333055d 100644 --- a/harken.ahk +++ b/harken.ahk @@ -18,7 +18,8 @@ config_result := LoadConfig(config_path, DefaultConfig()) global Config := config_result["config"] config_errors := config_result["errors"] if (config_errors.Length) { - LogConfigErrors(config_errors, config_dir "\config.errors.log", config_path) + appdata_dir := GetAppDataDir() + LogConfigErrors(config_errors, appdata_dir "\config.errors.log", config_path) return } InitVirtualDesktop() @@ -41,14 +42,6 @@ if reload_config["enabled"] { reload_mode_timeout := reload_config["mode_timeout_ms"] global reload_mode_active := false - if reload_config["super_key_required"] { - HotIf IsSuperKeyPressed - Hotkey(reload_hotkey, (*) => Reload()) - HotIf - } else { - Hotkey(reload_hotkey, (*) => Reload()) - } - if reload_mode_enabled { RegisterSuperComboHotkey(reload_mode_hotkey, (*) => ActivateReloadMode(reload_mode_timeout)) HotIf ReloadModeActive @@ -134,7 +127,15 @@ DefaultConfig() { "enabled", true, "switch_on_focus", true, "ensure_count", 0, - "cycle_prefer_current", true + "cycle_prefer_current", true, + "prev_hotkey", "h", + "next_hotkey", "l", + "move_prev_hotkey", "h", + "move_next_hotkey", "l", + "desktop_hotkeys", [], + "goto_hotkeys", [], + "move_hotkeys", [], + "debug_cycle", false ), "directional_focus", Map( "enabled", true, @@ -174,7 +175,7 @@ DefaultConfig() { } LogConfigErrors(errors, log_path, config_path := "") { - DirCreate(GetConfigDir()) + DirCreate(GetAppDataDir()) header := "[" A_Now "] Config errors:" "`n" FileAppend(header, log_path) for _, err in errors { @@ -221,6 +222,13 @@ GetConfigDir() { return user_profile "\.config\harken" } +GetAppDataDir() { + appdata := EnvGet("APPDATA") + if appdata + return appdata "\harken" + return GetConfigDir() +} + StartConfigWatcher(path, interval_ms := 1000) { global config_watch_mtime := "" global config_watch_handle := 0 @@ -332,6 +340,14 @@ ExecuteCommand(callback) { } OnSuperKeyDown() { + if WindowWalker.IsActive() { + WindowWalker.Hide() + return + } + if command_toast_temp_visible { + HideCommandToast() + return + } UpdateCommandToastVisibility() } diff --git a/src/hotkeys/directional_focus.ahk b/src/hotkeys/directional_focus.ahk index b04d12f..45d90ba 100644 --- a/src/hotkeys/directional_focus.ahk +++ b/src/hotkeys/directional_focus.ahk @@ -1,6 +1,7 @@ global Config if Config.Has("directional_focus") && Config["directional_focus"]["enabled"] { + HotIf (*) => !IsSuperKeyPressed() Hotkey("!h", (*) => DirectionalFocus("left")) Hotkey("!l", (*) => DirectionalFocus("right")) Hotkey("!j", (*) => DirectionalFocus("down")) @@ -9,4 +10,5 @@ if Config.Has("directional_focus") && Config["directional_focus"]["enabled"] { Hotkey("!]", (*) => DirectionalFocusStacked("next")) Hotkey("!+d", (*) => ToggleDirectionalFocusDebug()) Hotkey("!+s", (*) => SetLastStackedFromActive()) + HotIf } diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index d367514..fcd4c27 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -8,18 +8,36 @@ move_mode_cancel_key := Config["window"]["move_mode"]["cancel_key"] center_cycle_hotkey := Config["window"]["center_width_cycle_hotkey"] cycle_app_windows_hotkey := Config["window"]["cycle_app_windows_hotkey"] cycle_app_windows_current_hotkey := Config["window"]["cycle_app_windows_current_hotkey"] +vd_config := Config.Has("virtual_desktop") ? Config["virtual_desktop"] : Map() +vd_prev_hotkey := vd_config.Has("prev_hotkey") ? vd_config["prev_hotkey"] : "" +vd_next_hotkey := vd_config.Has("next_hotkey") ? vd_config["next_hotkey"] : "" +vd_move_prev_hotkey := vd_config.Has("move_prev_hotkey") ? vd_config["move_prev_hotkey"] : "" +vd_move_next_hotkey := vd_config.Has("move_next_hotkey") ? vd_config["move_next_hotkey"] : "" +vd_desktop_hotkeys := vd_config.Has("desktop_hotkeys") ? vd_config["desktop_hotkeys"] : [] +vd_goto_hotkeys := vd_config.Has("goto_hotkeys") ? vd_config["goto_hotkeys"] : [] +vd_move_hotkeys := vd_config.Has("move_hotkeys") ? vd_config["move_hotkeys"] : [] minimize_others_hotkey := "" if Config["window"].Has("minimize_others_hotkey") minimize_others_hotkey := Config["window"]["minimize_others_hotkey"] +vd_prev_hotkey := NormalizeAltHotkey(vd_prev_hotkey) +vd_next_hotkey := NormalizeAltHotkey(vd_next_hotkey) +vd_move_prev_hotkey := NormalizeAltHotkey(vd_move_prev_hotkey, true) +vd_move_next_hotkey := NormalizeAltHotkey(vd_move_next_hotkey, true) + last_super_tap := 0 max_restore := Map() +app_cycle_cache := Map() +cycle_hwnd_desktop := Map() ResizeActiveWindow(delta_w, delta_h) { hwnd := WinExist("A") if !hwnd || Window.IsException("ahk_id " hwnd) return + if IsAltPressed() || AltPressedSoon() + return + if (WinGetMinMax("ahk_id " hwnd) = 1) WinRestore "ahk_id " hwnd @@ -49,6 +67,9 @@ ResizeActiveWindowCentered(delta_w, delta_h) { if !hwnd || Window.IsException("ahk_id " hwnd) return + if IsAltPressed() || AltPressedSoon() + return + if (WinGetMinMax("ahk_id " hwnd) = 1) WinRestore "ahk_id " hwnd @@ -127,10 +148,35 @@ SortWindowList(list) { return sorted } +IsAltPressed() { + return GetKeyState("LAlt", "P") || GetKeyState("RAlt", "P") +} + +AltPressedSoon() { + if IsAltPressed() + return true + KeyWait "Alt", "D T0.05" + return IsAltPressed() +} + +NormalizeAltHotkey(hotkey_name, require_shift := false) { + if !hotkey_name + return "" + normalized := hotkey_name + if (SubStr(normalized, 1, 1) != "*") + normalized := "*" normalized + if !InStr(normalized, "!") + normalized := "!" normalized + if (require_shift && !InStr(normalized, "+")) + normalized := "+" normalized + return normalized +} + + FilterWindowList(exe, list) { filtered := [] for _, id in list { - if !WinExist("ahk_id " id) + if !WindowExistsAcrossDesktops(id) continue try class_name := WinGetClass("ahk_id " id) catch @@ -147,7 +193,15 @@ FilterWindowList(exe, list) { try style := WinGetStyle("ahk_id " id) catch continue - if (!(style & 0x10000000)) + allow_invisible := false + if VirtualDesktopEnabled() { + desktop_num := GetWindowDesktopNum(id) + if (desktop_num <= 0) + allow_invisible := true + else if (desktop_num != VD.getCurrentDesktopNum()) + allow_invisible := true + } + if (!allow_invisible && !(style & 0x10000000)) continue filtered.Push(id) } @@ -161,7 +215,9 @@ ApplyDesktopCyclePreference(list, current_only := false) { current := [] other := [] for _, id in list { - if IsWindowOnCurrentDesktop(id) + desktop_num := GetWindowDesktopForCycle(id) + is_current := (desktop_num > 0 && desktop_num = VD.getCurrentDesktopNum()) + if is_current current.Push(id) else if !current_only other.Push(id) @@ -181,6 +237,169 @@ ApplyDesktopCyclePreference(list, current_only := false) { return ordered } +ApplyCurrentDesktopOrder(exe, win_list, active_hwnd, current_only := false) { + if !VirtualDesktopEnabled() + return win_list + current_order := GetCurrentDesktopOrderedList(exe) + current_set := Map() + for _, id in current_order + current_set[id] := true + + if (current_order.Length = 0) { + if (current_only && active_hwnd && WindowExistsAcrossDesktops(active_hwnd)) + return [active_hwnd] + return win_list + } + + if current_only + return current_order + + if !Config["virtual_desktop"]["cycle_prefer_current"] + return win_list + + ordered := [] + for _, id in current_order + ordered.Push(id) + + seen := Map() + for _, id in current_order + seen[id] := true + + for _, id in win_list { + if seen.Has(id) + continue + ordered.Push(id) + seen[id] := true + } + return ordered +} + +GetCurrentDesktopWindowSet(exe) { + bak_detect_hidden_windows := A_DetectHiddenWindows + A_DetectHiddenWindows := false + list := WinGetList("ahk_exe " exe) + A_DetectHiddenWindows := bak_detect_hidden_windows + + list := FilterCurrentDesktopWindowList(exe, list) + set := Map() + for _, id in list + set[id] := true + return set +} + +GetCurrentDesktopOrderedList(exe) { + bak_detect_hidden_windows := A_DetectHiddenWindows + A_DetectHiddenWindows := false + list := WinGetList("ahk_exe " exe) + A_DetectHiddenWindows := bak_detect_hidden_windows + + return FilterCurrentDesktopWindowList(exe, list) +} + +FilterCurrentDesktopWindowList(exe, list) { + filtered := [] + for _, id in list { + if !WinExist("ahk_id " id) + continue + try class_name := WinGetClass("ahk_id " id) + catch + continue + if (exe = "explorer.exe") { + if (class_name = "Progman" || class_name = "WorkerW" || class_name = "Shell_TrayWnd") + continue + } + try ex_style := WinGetExStyle("ahk_id " id) + catch + continue + if (ex_style & 0x80) || (ex_style & 0x8000000) + continue + filtered.Push(id) + } + return filtered +} + +GetWindowDesktopForCycle(hwnd) { + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num > 0) + return desktop_num + if cycle_hwnd_desktop.Has(hwnd) + return cycle_hwnd_desktop[hwnd] + return 0 +} + +CycleDebugEnabled() { + if !Config.Has("virtual_desktop") + return false + return Config["virtual_desktop"].Has("debug_cycle") && Config["virtual_desktop"]["debug_cycle"] +} + +LogCycleDebug(lines) { + if !CycleDebugEnabled() + return + log_dir := GetCycleDebugDir() + DirCreate(log_dir) + log_path := log_dir "\\cycle.debug.log" + header := "[" A_Now "] " + if !(lines is Array) + lines := [lines] + for _, line in lines { + FileAppend(header line "`n", log_path) + } +} + +GetCycleDebugDir() { + appdata := EnvGet("APPDATA") + if appdata + return appdata "\\harken" + return GetConfigDir() +} + +UpdateAppCycleCache(exe, win_list) { + if !app_cycle_cache.Has(exe) + app_cycle_cache[exe] := Map() + + cache := app_cycle_cache[exe] + for _, hwnd in win_list { + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num > 0) + cache[hwnd] := desktop_num + else if !cache.Has(hwnd) + cache[hwnd] := 0 + if (desktop_num > 0) + cycle_hwnd_desktop[hwnd] := desktop_num + } + + for hwnd, _ in cache { + if !WindowExistsAcrossDesktops(hwnd) + cache.Delete(hwnd) + } + + return cache +} + +BuildCycleWindowList(exe, win_list) { + if !VirtualDesktopEnabled() + return win_list + + cache := UpdateAppCycleCache(exe, win_list) + if (cache.Count = 0) + return win_list + + dedup := Map() + merged := [] + for _, hwnd in win_list { + dedup[hwnd] := true + merged.Push(hwnd) + } + for hwnd, _ in cache { + if !dedup.Has(hwnd) { + merged.Push(hwnd) + dedup[hwnd] := true + } + } + return merged +} + HandleSuperTap() { global last_super_tap, super_double_tap_ms @@ -322,11 +541,34 @@ CycleAppWindows(*) { win_list := GetWindowsAcrossDesktops("ahk_exe " exe) win_list := FilterWindowList(exe, win_list) + win_list := BuildCycleWindowList(exe, win_list) if (win_list.Length < 2) return + if CycleDebugEnabled() { + lines := [] + lines.Push("cycle_all exe=" exe " active=" Format("0x{:X}", hwnd) " current_desktop=" GetCurrentDesktopNumFresh() " cache_count=" (app_cycle_cache.Has(exe) ? app_cycle_cache[exe].Count : 0)) + for _, id in win_list { + desktop_num := GetWindowDesktopNum(id) + exists := WindowExistsAcrossDesktops(id) ? "1" : "0" + lines.Push(" hwnd=" Format("0x{:X}", id) " desktop=" desktop_num " exists=" exists) + } + LogCycleDebug(lines) + } + + found_current := false + for _, id in win_list { + if (id = hwnd) { + found_current := true + break + } + } + if (!found_current && WindowExistsAcrossDesktops(hwnd)) + win_list.Push(hwnd) + win_list := SortWindowList(win_list) win_list := ApplyDesktopCyclePreference(win_list, false) + win_list := ApplyCurrentDesktopOrder(exe, win_list, hwnd, false) if (win_list.Length < 2) return @@ -339,7 +581,7 @@ CycleAppWindows(*) { } next_index := (current_index >= win_list.Length || current_index = 0) ? 1 : current_index + 1 - ActivateWindowAcrossDesktops(win_list[next_index]) + ActivateNextAvailableWindow(win_list, next_index) } CycleAppWindowsCurrent(*) { @@ -353,11 +595,34 @@ CycleAppWindowsCurrent(*) { win_list := GetWindowsAcrossDesktops("ahk_exe " exe) win_list := FilterWindowList(exe, win_list) + win_list := BuildCycleWindowList(exe, win_list) if (win_list.Length < 2) return + if CycleDebugEnabled() { + lines := [] + lines.Push("cycle_current exe=" exe " active=" Format("0x{:X}", hwnd) " current_desktop=" GetCurrentDesktopNumFresh() " cache_count=" (app_cycle_cache.Has(exe) ? app_cycle_cache[exe].Count : 0)) + for _, id in win_list { + desktop_num := GetWindowDesktopNum(id) + exists := WindowExistsAcrossDesktops(id) ? "1" : "0" + lines.Push(" hwnd=" Format("0x{:X}", id) " desktop=" desktop_num " exists=" exists) + } + LogCycleDebug(lines) + } + + found_current := false + for _, id in win_list { + if (id = hwnd) { + found_current := true + break + } + } + if (!found_current && WindowExistsAcrossDesktops(hwnd)) + win_list.Push(hwnd) + win_list := SortWindowList(win_list) win_list := ApplyDesktopCyclePreference(win_list, true) + win_list := ApplyCurrentDesktopOrder(exe, win_list, hwnd, true) if (win_list.Length < 2) return @@ -370,10 +635,87 @@ CycleAppWindowsCurrent(*) { } next_index := (current_index >= win_list.Length || current_index = 0) ? 1 : current_index + 1 - ActivateWindowAcrossDesktops(win_list[next_index]) + ActivateNextAvailableWindow(win_list, next_index) } -HotIf IsSuperKeyPressed +ActivateNextAvailableWindow(win_list, start_index) { + count := win_list.Length + if (count = 0) + return + + index := start_index + loop count { + if (index > count) + index := 1 + hwnd := win_list[index] + if WindowExistsAcrossDesktops(hwnd) { + activated := ActivateWindowAcrossDesktops(hwnd) + if CycleDebugEnabled() + LogCycleDebug(" try hwnd=" Format("0x{:X}", hwnd) " activated=" activated) + if activated + return + } + index += 1 + } +} + +GoToRelativeDesktop(delta) { + if !VirtualDesktopEnabled() + return + RefreshVirtualDesktopState() + current := GetCurrentDesktopNumFresh() + if (current <= 0) + current := VD.getCurrentDesktopNum() + if (current <= 0) + return + target := VD.modulusResolveDesktopNum(current + delta) + RefreshVirtualDesktopState() + VD.goToDesktopNum(target) + VD.WaitDesktopSwitched(target) + RefreshVirtualDesktopState() +} + +GoToDesktopNumber(desktop_num) { + if !VirtualDesktopEnabled() + return + if (desktop_num <= 0) + return + RefreshVirtualDesktopState() + GetCurrentDesktopNumFresh() + VD.goToDesktopNum(desktop_num) + VD.WaitDesktopSwitched(desktop_num) + RefreshVirtualDesktopState() +} + +MoveWindowToRelativeDesktop(delta) { + if !VirtualDesktopEnabled() + return + RefreshVirtualDesktopState() + current := GetCurrentDesktopNumFresh() + if (current <= 0) + current := VD.getCurrentDesktopNum() + if (current <= 0) + return + target := VD.modulusResolveDesktopNum(current + delta) + RefreshVirtualDesktopState() + VD.MoveWindowToDesktopNum("A", target, true) + VD.WaitDesktopSwitched(target) + RefreshVirtualDesktopState() +} + +MoveWindowToDesktopNumber(desktop_num) { + if !VirtualDesktopEnabled() + return + if (desktop_num <= 0) + return + RefreshVirtualDesktopState() + GetCurrentDesktopNumFresh() + VD.MoveWindowToDesktopNum("A", desktop_num, true) + VD.WaitDesktopSwitched(desktop_num) + RefreshVirtualDesktopState() +} + +HotIf (*) => IsSuperKeyPressed() && !IsAltPressed() Hotkey(center_cycle_hotkey, CenterWidthCycle) Hotkey("Left", (*) => ResizeActiveWindow(-resize_step, 0)) Hotkey("Right", (*) => ResizeActiveWindow(resize_step, 0)) @@ -387,15 +729,80 @@ Hotkey("^h", (*) => MoveActiveWindow(-move_step, 0)) Hotkey("^l", (*) => MoveActiveWindow(move_step, 0)) Hotkey("^j", (*) => MoveActiveWindow(0, move_step)) Hotkey("^k", (*) => MoveActiveWindow(0, -move_step)) +HotIf IsSuperKeyPressed Hotkey("m", ToggleMaximize) Hotkey("q", CloseWindow) Hotkey(cycle_app_windows_hotkey, CycleAppWindows) if (cycle_app_windows_current_hotkey != "") Hotkey(cycle_app_windows_current_hotkey, CycleAppWindowsCurrent) +RegisterSuperComboHotkey("/", (*) => ShowCommandToastTemporary()) if (minimize_others_hotkey != "") Hotkey(minimize_others_hotkey, MinimizeOtherWindows) HotIf +HotIf IsSuperKeyPressed +if (vd_prev_hotkey != "") + Hotkey(vd_prev_hotkey, (*) => GoToRelativeDesktop(-1)) +if (vd_next_hotkey != "") + Hotkey(vd_next_hotkey, (*) => GoToRelativeDesktop(1)) +for _, entry in vd_goto_hotkeys { + if !(entry is Map) + continue + if !entry.Has("hotkey") || !entry.Has("desktop") + continue + hotkey_name := NormalizeAltHotkey(entry["hotkey"]) + desktop_num := entry["desktop"] + key_copy := hotkey_name + num_copy := desktop_num + if (key_copy != "") + Hotkey(key_copy, (*) => GoToDesktopNumber(num_copy)) +} +if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { + for _, entry in vd_desktop_hotkeys { + if !(entry is Map) + continue + if !entry.Has("hotkey") || !entry.Has("desktop") + continue + hotkey_name := NormalizeAltHotkey(entry["hotkey"]) + desktop_num := entry["desktop"] + key_copy := hotkey_name + num_copy := desktop_num + if (key_copy != "") + Hotkey(key_copy, (*) => GoToDesktopNumber(num_copy)) + } +} +if (vd_move_prev_hotkey != "") + Hotkey(vd_move_prev_hotkey, (*) => MoveWindowToRelativeDesktop(-1)) +if (vd_move_next_hotkey != "") + Hotkey(vd_move_next_hotkey, (*) => MoveWindowToRelativeDesktop(1)) +for _, entry in vd_move_hotkeys { + if !(entry is Map) + continue + if !entry.Has("hotkey") || !entry.Has("desktop") + continue + hotkey_name := NormalizeAltHotkey(entry["hotkey"], true) + desktop_num := entry["desktop"] + key_copy := hotkey_name + num_copy := desktop_num + if (key_copy != "") + Hotkey(key_copy, (*) => MoveWindowToDesktopNumber(num_copy)) +} +if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { + for _, entry in vd_desktop_hotkeys { + if !(entry is Map) + continue + if !entry.Has("hotkey") || !entry.Has("desktop") + continue + hotkey_name := NormalizeAltHotkey(entry["hotkey"], true) + desktop_num := entry["desktop"] + key_copy := hotkey_name + num_copy := desktop_num + if (key_copy != "") + Hotkey(key_copy, (*) => MoveWindowToDesktopNumber(num_copy)) + } +} +HotIf + Hotkey("!-", MinimizeWindow) if move_mode_enabled { diff --git a/src/hotkeys/window_walker.ahk b/src/hotkeys/window_walker.ahk index 5be5283..e406cc7 100644 --- a/src/hotkeys/window_walker.ahk +++ b/src/hotkeys/window_walker.ahk @@ -2,5 +2,5 @@ global Config if Config.Has("window_selector") && Config["window_selector"]["enabled"] { hotkey_name := Config["window_selector"]["hotkey"] - RegisterSuperComboHotkey(hotkey_name, (*) => WindowWalker.Show()) + RegisterSuperComboHotkey(hotkey_name, (*) => WindowWalker.Toggle()) } diff --git a/src/lib/VD.ahk b/src/lib/VD.ahk new file mode 100644 index 0000000..01c5ac4 --- /dev/null +++ b/src/lib/VD.ahk @@ -0,0 +1,1231 @@ +#requires AutoHotkey v2.1-alpha.5 + +class VD { + class Array { + static find(arr, callback) { + for v in arr { + if (callback(v)) { + return v + } + } + return VD.Null + } + static flatMap(iterator, callback) { + arr := [] + if (callback.IsVariadic || callback.MaxParams > 1) { + idx := 1 + for v in iterator { + arr.Push(callback(v, idx)*) + ++idx + } + } else if (callback.MaxParams == 1) { + for v in iterator { + arr.Push(callback(v)*) + } + } else { + throw "Invalid Callback With MaxParams == 0" + } + return arr + } + } + + static Null := {} + + static versions := [ + { + buildNumber: 20348, + revisionNumber: 0, + IID_IVirtualDesktopManagerInternal_str: "{f31574d6-b682-4cdc-bd56-1827860abec6}", + IID_IVirtualDesktop_str: "{ff72ffdd-be7e-43fc-9c03-ad81681e88e4}", + IID_IVirtualDesktopNotification_str: "{c179334c-4295-40d3-bea1-c654d965605a}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_Normal, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:10, + idx_RemoveDesktop:11, + idx_FindDesktop:12, + ; get/set + idx_GetName:-1, + idx_SetDesktopName:-1, + idx_GetWallpaper:-1, + idx_SetDesktopWallpaper:-1, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_Normal, + IVirtualDesktopNotification_methods_count: 9, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:8, + }, + { + buildNumber: 22000, + revisionNumber: 0, + IID_IVirtualDesktopManagerInternal_str: "{094afe11-44f2-4ba0-976f-29a97e263ee0}", + IID_IVirtualDesktop_str: "{62fdf88b-11ca-4afb-8bd8-2296dfae49e2}", + IID_IVirtualDesktopNotification_str: "{f3163e11-6b04-433c-a64b-6f82c9094257}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_HMONITOR, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:10, + idx_RemoveDesktop:11, + idx_FindDesktop:12, + ; get/set + idx_GetName:6, + idx_SetDesktopName:14, + idx_GetWallpaper:-1, + idx_SetDesktopWallpaper:-1, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_Normal, + IVirtualDesktopNotification_methods_count: 11, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:10, + }, + { + buildNumber: 22483, + revisionNumber: 0, + IID_IVirtualDesktopManagerInternal_str: "{b2f925b9-5a0f-4d2e-9f4d-2b1507593c10}", + IID_IVirtualDesktop_str: "{536d3495-b208-4cc9-ae26-de8111275bf8}", + IID_IVirtualDesktopNotification_str: "{cd403e52-deed-4c13-b437-b98380f2b1e8}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_HMONITOR, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:10, + idx_RemoveDesktop:12, + idx_FindDesktop:13, + ; get/set + idx_GetName:6, + idx_SetDesktopName:15, + idx_GetWallpaper:7, + idx_SetDesktopWallpaper:16, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_IObjectArray, + IVirtualDesktopNotification_methods_count: 13, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:11, + }, + { + buildNumber: 22621, + revisionNumber: 2215, + IID_IVirtualDesktopManagerInternal_str: "{b2f925b9-5a0f-4d2e-9f4d-2b1507593c10}", + IID_IVirtualDesktop_str: "{536d3495-b208-4cc9-ae26-de8111275bf8}", + IID_IVirtualDesktopNotification_str: "{cd403e52-deed-4c13-b437-b98380f2b1e8}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_HMONITOR, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:8, + idx_SwitchDesktop:10, + idx_CreateDesktop:11, + idx_RemoveDesktop:13, + idx_FindDesktop:14, + ; get/set + idx_GetName:6, + idx_SetDesktopName:16, + idx_GetWallpaper:7, + idx_SetDesktopWallpaper:17, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_IObjectArray, + IVirtualDesktopNotification_methods_count: 13, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:11, + }, + { + buildNumber: 22631, + revisionNumber: 3085, + IID_IVirtualDesktopManagerInternal_str: "{a3175f2d-239c-4bd2-8aa0-eeba8b0b138e}", + IID_IVirtualDesktop_str: "{3f07f4be-b107-441a-af0f-39d82529072c}", + IID_IVirtualDesktopNotification_str: "{b287fa1c-7771-471a-a2df-9b6b21f0d675}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_Normal, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:10, + idx_RemoveDesktop:12, + idx_FindDesktop:13, + ; get/set + idx_GetName:5, + idx_SetDesktopName:15, + idx_GetWallpaper:6, + idx_SetDesktopWallpaper:16, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_Normal, + IVirtualDesktopNotification_methods_count: 14, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:10, + }, + { + buildNumber: 26100, + revisionNumber: 0, + IID_IVirtualDesktopManagerInternal_str: "{53f5ca0b-158f-4124-900c-057158060b27}", + IID_IVirtualDesktop_str: "{3f07f4be-b107-441a-af0f-39d82529072c}", + IID_IVirtualDesktopNotification_str: "{b9e5e94d-233e-49ab-af5c-2b4541c3aade}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_Normal, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:10, + idx_RemoveDesktop:12, + idx_FindDesktop:13, + ; get/set + idx_GetName:5, + idx_SetDesktopName:15, + idx_GetWallpaper:6, + idx_SetDesktopWallpaper:16, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_Normal, + IVirtualDesktopNotification_methods_count: 14, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:10, + }, + { + buildNumber: 99999, + revisionNumber: 0, + IID_IVirtualDesktopManagerInternal_str: "{53f5ca0b-158f-4124-900c-057158060b27}", + IID_IVirtualDesktop_str: "{3f07f4be-b107-441a-af0f-39d82529072c}", + IID_IVirtualDesktopNotification_str: "{b9e5e94d-233e-49ab-af5c-2b4541c3aade}", + ; vtbl + IVirtualDesktopManagerInternal: VD.IVirtualDesktopManagerInternal_Normal, + idx_MoveViewToDesktop:4, + idx_GetCurrentDesktop:6, + idx_GetDesktops:7, + idx_SwitchDesktop:9, + idx_CreateDesktop:11, + idx_RemoveDesktop:13, + idx_FindDesktop:14, + ; get/set + idx_GetName:5, + idx_SetDesktopName:16, + idx_GetWallpaper:6, + idx_SetDesktopWallpaper:17, + ;vtbl Notification + IVirtualDesktopNotification: VD.IVirtualDesktopNotification_Normal, + IVirtualDesktopNotification_methods_count: 14, + idx_VirtualDesktopCreated:3, + idx_VirtualDesktopDestroyed:6, + idx_CurrentVirtualDesktopChanged:10, + }, + ] ; default: version: 99999 ; (just a big number like Infinity) + + static __New() { + OS_Version := FileGetVersion(A_WinDir "\System32\twinui.pcshell.dll") + splitByDot:=StrSplit(OS_Version, ".") + buildNumber:=Integer(splitByDot[3]) + revisionNumber:=Integer(splitByDot[4]) + VD.version := VD.Array.find(VD.versions, + version => (buildNumber < version.buildNumber || (buildNumber == version.buildNumber && revisionNumber < version.revisionNumber)) + ) + DllCall("ole32\CLSIDFromString", "WStr", VD.version.IID_IVirtualDesktop_str, "Ptr", VD.version.IID_IVirtualDesktop_ptr := Buffer(0x10)) + + VD.ret := Buffer(1, 0xc3) + DllCall("VirtualProtect", "Ptr", VD.ret, "Ptr", 1, "Uint", 0x40, "Uint*", &lpflOldProtect:=0) ;0x40=PAGE_EXECUTE_READWRITE ;WRITE is needed because it changes more than 1 byte + + VD.ListenersCurrentVirtualDesktopChanged := Map() + VD.WinActivate_callback := 0 + + VD.DesktopIdMap := Map() + + VD.IApplicationViewCollection := VD.IApplicationViewCollection_Class() + VD.IVirtualPinnedAppsHandler := VD.IVirtualPinnedAppsHandler_Class() + VD.IVirtualDesktopNotificationService := VD.IVirtualDesktopNotificationService_Class() + VD.IVirtualDesktopNotification := VD.version.IVirtualDesktopNotification() + VD.IVirtualDesktopManagerInternal := VD.version.IVirtualDesktopManagerInternal() + + (VD.LocalizedWord_TaskView) ; "Task View" + + OnMessage(DllCall("RegisterWindowMessageW","WStr","TaskbarCreated","Uint"), (*) => (VD.reinit(), "")) + VD.reinit() + if (VD.waiting) { ; block / poll because it may be immediately used, ex: VD.createUntil(3) + loop 50 { ; 10 seconds + Sleep 200 + if (!VD.waiting) { + break + } + } + } + } + + static reinit() { + try { + CImmersiveShell_IServiceProvider := ComObject("{c2f03a33-21f5-47fa-b4bb-156362a2f239}", "{6d5140c1-7436-11ce-8034-00aa006009fa}") + VD.IApplicationViewCollection.reinit(CImmersiveShell_IServiceProvider) + VD.IVirtualPinnedAppsHandler.reinit(CImmersiveShell_IServiceProvider) + VD.IVirtualDesktopNotificationService.reinit(CImmersiveShell_IServiceProvider) + VD.IVirtualDesktopNotificationService.Register(VD.IVirtualDesktopNotification) + VD.IVirtualDesktopManagerInternal.reinit(CImmersiveShell_IServiceProvider) + + VD.IVirtualDesktopListChanged() + VD.currentDesktopNum := VD.IVirtualDesktopMap[VD.IVirtualDesktopManagerInternal.GetCurrentDesktop()] + VD.waiting := false + return + } + VD.waiting := true + } + + static IVirtualDesktopListChanged() { + VD.IVirtualDesktopList := [VD.IObjectArray(VD.IVirtualDesktopManagerInternal.GetDesktops(), VD.version.IID_IVirtualDesktop_ptr)*] + VD.IVirtualDesktopMap := Map(VD.Array.flatMap(VD.IVirtualDesktopList, (IVirtualDesktop, i) => [IVirtualDesktop, i])*) + } + + class IApplicationViewCollection_Class { + reinit(CImmersiveShell_IServiceProvider) { + this.IApplicationViewCollection := ComObjQuery(CImmersiveShell_IServiceProvider, "{1841c6d7-4f9d-42c0-af41-8747538f10e5}", "{1841c6d7-4f9d-42c0-af41-8747538f10e5}") + } + GetViewForHwnd(HWND) { + hr := ComCall(6, this.IApplicationViewCollection, "Ptr", HWND, "Ptr*", &IApplicationView := 0, "Uint") + return IApplicationView + } + } + + static WinActivatePriority := { + NewWindow: "new window", + OldWindowExcludeDesktop: "old window - Exclude Desktop", + OldWindowIncludeDesktop: "old window - Include Desktop", + } + + static ModulusResolveDesktopNum(desktopNum) { + desktopNum := Mod(desktopNum, VD.IVirtualDesktopList.Length) + if (desktopNum <= 0) { + desktopNum := desktopNum + VD.IVirtualDesktopList.Length + } + return desktopNum + } + + static goToRelativeDesktopNum(relative_count) { + absolute_desktopNum := VD.modulusResolveDesktopNum(VD.currentDesktopNum + relative_count) + return VD.goToDesktopNum(absolute_desktopNum) + } + + static MoveWindowToRelativeDesktopNum(wintitle, relative_count, follow := false, WinActivatePriority := VD.WinActivatePriority.NewWindow) { + absolute_desktopNum := VD.modulusResolveDesktopNum(VD.currentDesktopNum + relative_count) + return VD.MoveWindowToDesktopNum(wintitle, absolute_desktopNum, follow, WinActivatePriority) + } + + static MoveWindowToCurrentDesktop(wintitle, activateYourWindow := true) { + return VD.MoveWindowToDesktopNum(wintitle, VD.currentDesktopNum, activateYourWindow) + } + + static TryWinGetID(wintitle) { + loop 3 { + hwnd := VD.FindFirstWindowInAllDesktops(wintitle) + if (hwnd) { + break + } + Sleep 10 + } + return hwnd + } + + static MoveWindowToDesktopNum(wintitle, desktopNum, follow := false, WinActivatePriority := VD.WinActivatePriority.NewWindow) { + loop 1 { + hwnd := VD.TryWinGetID(wintitle) + if (!hwnd) { + desktopNum := -1 + break + } + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + if (!IApplicationView) { + desktopNum := -1 + break + } + window_desktopNum := VD.getDesktopNumOfHWND(hwnd) + activeWindow := WinGetID("A") + if (window_desktopNum !== desktopNum) { + VD.IVirtualDesktopManagerInternal.MoveViewToDesktop(IApplicationView, VD.IVirtualDesktopList[desktopNum]) + if (!follow && activeWindow == hwnd) { + VD.WinActivateFirstWindowInCurrentDesktop(50) + Sleep 100 ; don't want to switch the changing foreground window + } + } + if (follow) { + if (desktopNum == VD.currentDesktopNum) { + VD.SetForegroundWindow(hwnd) + } else { + VD.RegisterWinActivateUponSwitch(hwnd) + if (activeWindow !== hwnd) { + VD.AllowSetForegroundWindowAny() + } + VD.IVirtualDesktopManagerInternal.SwitchDesktop(VD.IVirtualDesktopList[desktopNum]) + } + } + } + return desktopNum + } + + static getDesktopNumOfHWND(hwnd) { + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + if (!IApplicationView) { + return 0 + } + DesktopId := VD.IApplicationView_Class(IApplicationView).GetVirtualDesktopId() + DesktopId_GUID_str := VD._StringFromGUID(DesktopId) + switch DesktopId_GUID_str { + ; https://github.com/MScholtes/VirtualDesktop/blob/a725cbd3cdb9e977678eeaf034a7cc96d2e74bc6/VirtualDesktop11.cs#L329 + ; https://github.com/MScholtes/VirtualDesktop/commit/614c7176a384116afcacac4650d445d7a31f645d + ; CVirtualDesktopVisibilityPolicy::PinViewToAllDesktops(struct IApplicationView * __ptr64,bool) __ptr64 + ; bool:true -> "{BB64D5B7-4DE3-4AB2-A87C-DB7601AEA7DC}" ; AppOnAllDesktops + ; bool:false -> "{C2DDEA68-66F2-4CF9-8264-1BFD00FBBBAC}" ; WindowOnAllDesktops + case "{BB64D5B7-4DE3-4AB2-A87C-DB7601AEA7DC}": return -1 ; PinAppID + case "{C2DDEA68-66F2-4CF9-8264-1BFD00FBBBAC}": return -2 ; PinView (unless IsAppIdPinned is set) + default: + if (VD.DesktopIdMap.Has(DesktopId_GUID_str)) { ;cache + IVirtualDesktop_found := VD.DesktopIdMap[DesktopId_GUID_str] + } else { + IVirtualDesktop_found := VD.IVirtualDesktopManagerInternal.FindDesktop(DesktopId) + if (!IVirtualDesktop_found) { + throw Error("Desktop not found: " DesktopId_GUID_str) + } + VD.DesktopIdMap[DesktopId_GUID_str] := IVirtualDesktop_found + } + return VD.IVirtualDesktopMap[IVirtualDesktop_found] + } + } + + static getDesktopNumOfWindow(wintitle) { + hwnd := WinGetID(wintitle) + return VD.getDesktopNumOfHWND(hwnd) + } + + static FindFirstWindowInAllDesktops(wintitle) { + bak_A_DetectHiddenWindows := A_DetectHiddenWindows + A_DetectHiddenWindows := true + hwnd_list := WinGetList(wintitle) + A_DetectHiddenWindows := bak_A_DetectHiddenWindows + found := VD._FindValidWindow(hwnd_list) + return found + } + + static goToDesktopOfWindow(wintitle, activateYourWindow := true) { + hwnd := VD.FindFirstWindowInAllDesktops(wintitle) + if (!hwnd) { + VD._UnblockVDFunctionRunning() + throw Error("Window not found: " wintitle) + } + desktopNum := VD.getDesktopNumOfHWND(hwnd) + if (desktopNum == VD.currentDesktopNum) { + if (activateYourWindow) { + VD.SetForegroundWindow(hwnd) + } + } else { + if (activateYourWindow) { + VD.RegisterWinActivateUponSwitch(hwnd) + } + VD.AllowSetForegroundWindowAny() + VD.IVirtualDesktopManagerInternal.SwitchDesktop(VD.IVirtualDesktopList[desktopNum]) + } + return desktopNum + } + + static SetForegroundWindow(hWnd, waitCompletionDelay := 0) { + if (DllCall("AllowSetForegroundWindow", "Uint", DllCall("GetCurrentProcessId"))) { + DllCall("SetForegroundWindow", "Ptr", hwnd) + } else { + LCtrlDown := GetKeyState("LCtrl") + RCtrlDown := GetKeyState("RCtrl") + LShiftDown := GetKeyState("LShift") + RShiftDown := GetKeyState("RShift") + LWinDown := GetKeyState("LWin") + RWinDown := GetKeyState("RWin") + LAltDown := GetKeyState("LAlt") + RAltDown := GetKeyState("RAlt") + if ((LCtrlDown || RCtrlDown) && (LWinDown || RWinDown)) { + toRelease := "" + if (LShiftDown) { + toRelease .= "{LShift Up}" + } + if (RShiftDown) { + toRelease .= "{RShift Up}" + } + if (toRelease) { + Send "{Blind}" toRelease + } + } + BlockInput "On" + Send "{LAlt Down}{LAlt Down}" + DllCall("SetForegroundWindow", "Ptr", hwnd) + toAppend := "" + if (!LAltDown) { + toAppend .= "{LAlt Up}" + } + if (RAltDown) { + toAppend .= "{RAlt Down}" + } + if (LCtrlDown) { + toAppend .= "{LCtrl Down}" + } + if (RCtrlDown) { + toAppend .= "{RCtrl Down}" + } + if (LShiftDown) { + toAppend .= "{LShift Down}" + } + if (RShiftDown) { + toAppend .= "{RShift Down}" + } + if (LWinDown) { + toAppend .= "{LWin Down}" + } + if (RWinDown) { + toAppend .= "{RWin Down}" + } + if (toAppend) { + Send "{Blind}" toAppend + } + BlockInput "Off" + } + if (waitCompletionDelay) { + end := A_TickCount + waitCompletionDelay + while (A_TickCount < end) { + if (DllCall("GetForegroundWindow", "Ptr") == hWnd) { + break + } + } + } + } + + static AllowSetForegroundWindowAny() { + VD_animation_gui := Gui("-Border -SysMenu +Owner -Caption") + this.SetForegroundWindow(VD_animation_gui.Hwnd) + DllCall("AllowSetForegroundWindow", "Uint", 0xFFFFFFFF) ;ASFW_ANY + } + + static FindFirstWindowInCurrentDesktop() { + bak_A_DetectHiddenWindows := A_DetectHiddenWindows + A_DetectHiddenWindows := false + hwnd_list := WinGetList() + A_DetectHiddenWindows := bak_A_DetectHiddenWindows + found := VD._FindValidWindow(hwnd_list, true) + return found + } + + static WinActivateFirstWindowInCurrentDesktop(waitCompletionDelay := 0) { + hwnd := VD.FindFirstWindowInCurrentDesktop() + if (!hwnd) { + VD.SetForegroundWindow(WinGetID("ahk_class Progman ahk_exe explorer.exe")) ; Desktop + } else { + VD.SetForegroundWindow(hwnd, waitCompletionDelay) + } + } + + static ShouldActivateUponArrival() { + if (WinActive(VD.LocalizedWord_TaskView " ahk_exe explorer.exe")) { + return false + } + return true + } + + static RegisterWinActivateUponSwitch(hwnd) { + VD.WinActivate_callback := callback := () { + VD.WinActivate_callback := 0 + if (hwnd == 0) { + VD.WinActivateFirstWindowInCurrentDesktop() + } else { + if (VD._isMinimizedWindow(hwnd)) { + DllCall("ShowWindow", "Ptr", hwnd, "Uint", 9) ;SW_RESTORE + } + VD.SetForegroundWindow(hwnd) + } + } + SetTimer () { + if (VD.WinActivate_callback == callback) { + VD.WinActivate_callback := 0 + } + }, -1000 + } + + static _FindValidWindow(hwnd_list, isNotMinized := false) { + already_hwnd := Map() + _innerFindValidWindow(hwnd) { + loop 1 { + if (already_hwnd.Has(hwnd)) { + found := already_hwnd[hwnd] + break + } + owner := DllCall("GetWindow", "Ptr", hwnd, "Uint", 4) + if (owner) { + found := _innerFindValidWindow(owner) + break + } + dwStyle := DllCall("GetWindowLongPtrW", "Ptr", hWnd, "Int", -16, "Ptr") + if (!(dwStyle & 0x10000000)) { ;WS_VISIBLE + found := false + break + } + if (isNotMinized && (dwStyle & 0x20000000)) { ; WS_MINIMIZE + found := false + break + } + dwExStyle := DllCall("GetWindowLongPtrW", "Ptr", hWnd, "Int", -20, "Ptr") + if (dwExStyle & 0x00040000) { ;WS_EX_APPWINDOW + found := hwnd + break + } + if (dwExStyle & 0x08000080) { ; WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW + found := false + break + } + found := hwnd + } + already_hwnd[hwnd] := found + return found + } + found := false + for (hwnd in hwnd_list) { + if ((found := _innerFindValidWindow(hwnd))) { + break + } + } + return found + } + + static _isMinimizedWindow(hWnd) { + dwStyle := DllCall("GetWindowLongPtrW", "Ptr", hWnd, "Int", -16, "Ptr") + if (dwStyle & 0x20000000) { ; WS_MINIMIZE + return true + } + return false + } + + static WaitDesktopSwitched(desktopNum, waitMiliseconds := 1000, additionalWaitMiliseconds := 100) { + if (desktopNum <= 0) { + return + } + loop 1 { + end := A_TickCount + waitMiliseconds + while (A_TickCount < end) { + Critical + if (VD.currentDesktopNum == desktopNum) { + Critical "Off" + Sleep additionalWaitMiliseconds ; additional sleep + return true + } + Critical "Off" + Sleep -1 + } + Sleep additionalWaitMiliseconds ; additional sleep, just in case + return false + } + } + + static goToDesktopNum(desktopNum) { + if (desktopNum == VD.currentDesktopNum) { + if (VD.ShouldActivateUponArrival()) { + VD.WinActivateFirstWindowInCurrentDesktop() + } + } else { + if (VD.ShouldActivateUponArrival()) { + VD.RegisterWinActivateUponSwitch(0) + VD.AllowSetForegroundWindowAny() + } + VD.IVirtualDesktopManagerInternal.SwitchDesktop(VD.IVirtualDesktopList[desktopNum]) + } + return desktopNum + } + + static getCurrentDesktopNum() { + return VD.currentDesktopNum + } + static getCount() { + return VD.IVirtualDesktopList.Length + } + + static createDesktop(goThere := false) { + IVirtualDesktop_ofNewDesktop := VD.IVirtualDesktopManagerInternal.CreateDesktop() + if (goThere) { + VD.IVirtualDesktopManagerInternal.SwitchDesktop(IVirtualDesktop_ofNewDesktop) + } + } + static createUntil(howMany, goToLastlyCreated := false) { + howManyThereAlreadyAre := VD.IVirtualDesktopList.Length + if (howManyThereAlreadyAre >= howMany) { ; ensure at least 1 was created for goToLastlyCreated:true + return + } + loop (howMany - howManyThereAlreadyAre) { + IVirtualDesktop_ofNewDesktop := VD.IVirtualDesktopManagerInternal.CreateDesktop() + } + if (goToLastlyCreated) { + VD.IVirtualDesktopManagerInternal.SwitchDesktop(IVirtualDesktop_ofNewDesktop) + } + } + static removeDesktop(desktopNum := VD.currentDesktopNum, fallback_desktopNum := -1) { + if (VD.IVirtualDesktopList.Length == 1) { + return ;can't delete last + } + if (desktopNum == VD.currentDesktopNum) { + if (fallback_desktopNum == -1) { ; provide default, do not verify fallback_desktopNum if provided + fallback_desktopNum := VD.modulusResolveDesktopNum(VD.currentDesktopNum - 1) + } + } else { ; there's really no need for a fallback if it's not the current + fallback_desktopNum := VD.currentDesktopNum + } + VD.IVirtualDesktopManagerInternal.RemoveDesktop(VD.IVirtualDesktopList[desktopNum], VD.IVirtualDesktopList[fallback_desktopNum]) + } + + static IsWindowPinned(wintitle) { + hwnd := WinGetID(wintitle) + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + viewIsPinned := VD.IVirtualPinnedAppsHandler.IsViewPinned(IApplicationView) + return viewIsPinned + } + static PinWindow(wintitle) { + hwnd := WinGetID(wintitle) + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + VD.IVirtualPinnedAppsHandler.PinView(IApplicationView) + } + static UnPinWindow(wintitle) { + hwnd := WinGetID(wintitle) + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + VD.IVirtualPinnedAppsHandler.UnpinView(IApplicationView) + } + static TogglePinWindow(wintitle) { + hwnd := WinGetID(wintitle) + IApplicationView := VD.IApplicationViewCollection.GetViewForHwnd(hwnd) + viewIsPinned := VD.IVirtualPinnedAppsHandler.IsViewPinned(IApplicationView) + if (viewIsPinned) { + VD.IVirtualPinnedAppsHandler.UnpinView(IApplicationView) + } else { + VD.IVirtualPinnedAppsHandler.PinView(IApplicationView) + } + } + + static IsExePinned(exe_path) { + appIsPinned := VD.IVirtualPinnedAppsHandler.IsAppIdPinned(exe_path) + return appIsPinned + } + static PinExe(exe_path) { + VD.IVirtualPinnedAppsHandler.PinAppID(exe_path) + } + static UnPinExe(exe_path) { + VD.IVirtualPinnedAppsHandler.UnpinAppID(exe_path) + } + static TogglePinExe(exe_path) { + appIsPinned := VD.IVirtualPinnedAppsHandler.IsAppIdPinned(exe_path) + if (appIsPinned) { + VD.IVirtualPinnedAppsHandler.UnpinAppID(exe_path) + } else { + VD.IVirtualPinnedAppsHandler.PinAppID(exe_path) + } + } + + static IsAppPinned(wintitle) { + return VD.IsExePinned(WinGetProcessPath(wintitle)) + } + static PinApp(wintitle) { + VD.PinExe(WinGetProcessPath(wintitle)) + } + static UnPinApp(wintitle) { + VD.UnPinExe(WinGetProcessPath(wintitle)) + } + static TogglePinApp(wintitle) { + VD.TogglePinExe(WinGetProcessPath(wintitle)) + } + + class IVirtualPinnedAppsHandler_Class { + reinit(CImmersiveShell_IServiceProvider) { + this.IVirtualDesktopPinnedApps := ComObjQuery(CImmersiveShell_IServiceProvider, "{b5a399e7-1c87-46b8-88e9-fc5747b171bd}", "{4ce81583-1e4c-4632-a621-07a53543148f}") + } + IsAppIdPinned(exe_path) { + ComCall(3, this.IVirtualDesktopPinnedApps, "WStr", exe_path, "Int*", &appIsPinned := 0) + return appIsPinned + } + PinAppID(exe_path) { + ComCall(4, this.IVirtualDesktopPinnedApps, "WStr", exe_path) + } + UnpinAppID(exe_path) { + ComCall(5, this.IVirtualDesktopPinnedApps, "WStr", exe_path) + } + IsViewPinned(IApplicationView) { + ComCall(6, this.IVirtualDesktopPinnedApps, "Ptr", IApplicationView, "Int*", &viewIsPinned := 0) + return viewIsPinned + } + PinView(IApplicationView) { + ComCall(7, this.IVirtualDesktopPinnedApps, "Ptr", IApplicationView) + } + UnpinView(IApplicationView) { + ComCall(8, this.IVirtualDesktopPinnedApps, "Ptr", IApplicationView) + } + } + + static LocalizedWord_TaskView => VD._LocalizedWord_TaskView ??= VD._get_LocalizedWord_TaskView() + static _LocalizedWord_TaskView := unset + static _get_LocalizedWord_TaskView() { + hModule := DllCall("LoadLibraryExW", "WStr", "twinui.pcshell.dll", "Ptr", 0, "Uint", 0x00000022, "Ptr") ;LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE + chars := 128 + lpBuffer := Buffer(chars << 1) + length := DllCall("LoadStringW", "Uint", hModule, "Uint", 1512, "Ptr", lpBuffer, "Int", chars) + _LocalizedWord_TaskView := StrGet(lpBuffer, length, "UTF-16") + DllCall("FreeLibrary", "Ptr", hModule) + return _LocalizedWord_TaskView + } + + static LocalizedWord_Desktop => VD._LocalizedWord_Desktop ??= VD._get_LocalizedWord_Desktop() + static _LocalizedWord_Desktop := unset + static _get_LocalizedWord_Desktop() { + hModule := DllCall("LoadLibraryExW", "WStr", "shell32.dll", "Ptr", 0, "Uint", 0x00000022, "Ptr") ;LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE + chars := 128 + lpBuffer := Buffer(chars << 1) + length := DllCall("LoadStringW", "Uint", hModule, "Uint", 21769, "Ptr", lpBuffer, "Int", chars) + _LocalizedWord_Desktop := StrGet(lpBuffer, length, "UTF-16") + DllCall("FreeLibrary", "Ptr", hModule) + return _LocalizedWord_Desktop + } + + static getNameFromDesktopNum(desktopNum) { + desktopName := "" + if (VD.version.idx_GetName > -1) { + IVirtualDesktop := VD.IVirtualDesktopList[desktopNum] + desktopName := VD.IVirtualDesktop_Class(IVirtualDesktop, VD.version).GetName() + } + if (!desktopName) { + desktopName := VD.LocalizedWord_Desktop " " desktopNum + } + return desktopName + } + + static getWallpaperFromDesktopNum(desktopNum) { + wallpaperPath := "" + if (VD.version.idx_GetWallpaper > -1) { + IVirtualDesktop := VD.IVirtualDesktopList[desktopNum] + wallpaperPath := VD.IVirtualDesktop_Class(IVirtualDesktop, VD.version).GetWallpaper() + } + return wallpaperPath + } + + static setNameToDesktopNum(desktopNum, desktopName) { + if (VD.version.idx_SetDesktopName > -1) { + IVirtualDesktop := VD.IVirtualDesktopList[desktopNum] + VD.IVirtualDesktopManagerInternal.SetDesktopName(IVirtualDesktop, desktopName) + } + } + + static setWallpaperToDesktopNum(desktopNum, wallpaperPath) { + if (VD.version.idx_SetDesktopWallpaper > -1) { + IVirtualDesktop := VD.IVirtualDesktopList[desktopNum] + VD.IVirtualDesktopManagerInternal.SetDesktopWallpaper(IVirtualDesktop, wallpaperPath) + } + } + + class IVirtualDesktop_Class { + __New(IVirtualDesktop, version) { + this.version := version + this.IVirtualDesktop := IVirtualDesktop + } + GetName() { + ComCall(this.version.idx_GetName, this.IVirtualDesktop, "Ptr*", &HSTRING := 0) + desktopName := StrGet(DllCall("combase\WindowsGetStringRawBuffer", "Ptr", HSTRING, "Uint*", &length := 0, "Ptr"), "UTF-16") + DllCall("combase\WindowsDeleteString", "Ptr", HSTRING) + return desktopName + } + GetWallpaper() { + ComCall(this.version.idx_GetWallpaper, this.IVirtualDesktop, "Ptr*", &HSTRING := 0) + wallpaperPath := StrGet(DllCall("combase\WindowsGetStringRawBuffer", "Ptr", HSTRING, "Uint*", &length := 0, "Ptr"), "UTF-16") + DllCall("combase\WindowsDeleteString", "Ptr", HSTRING) + return wallpaperPath + } + } + + class IApplicationView_Class { + __New(IApplicationView) { + this.IApplicationView := IApplicationView + } + GetVirtualDesktopId() { + ComCall(25, this.IApplicationView, "Ptr", DesktopId := Buffer(16)) ; GUID + return DesktopId + } + } + + class IVirtualDesktopManagerInternal_Normal { + __New(version) { + this.version := version + } + reinit(CImmersiveShell_IServiceProvider) { + this.IVirtualDesktopManagerInternal := ComObjQuery(CImmersiveShell_IServiceProvider, "{c5e0cdca-7b6e-41b2-9fc4-d93975cc467b}", this.version.IID_IVirtualDesktopManagerInternal_str) + } + ; interface + MoveViewToDesktop(IApplicationView, IVirtualDesktop) { + ComCall(this.version.idx_MoveViewToDesktop, this.IVirtualDesktopManagerInternal, "Ptr", IApplicationView, "Ptr", IVirtualDesktop) + } + GetCurrentDesktop() { + ComCall(this.version.idx_GetCurrentDesktop, this.IVirtualDesktopManagerInternal, "Ptr*", &IVirtualDesktop_current := 0) + return IVirtualDesktop_current + } + GetDesktops() { + ComCall(this.version.idx_GetDesktops, this.IVirtualDesktopManagerInternal, "Ptr*", &IObjectArray := 0) + return IObjectArray + } + SwitchDesktop(IVirtualDesktop) { + ComCall(this.version.idx_SwitchDesktop, this.IVirtualDesktopManagerInternal, "Ptr", IVirtualDesktop) + } + CreateDesktop() { + ComCall(this.version.idx_CreateDesktop, this.IVirtualDesktopManagerInternal, "Ptr*", &IVirtualDesktop_created := 0) + return IVirtualDesktop_created + } + RemoveDesktop(IVirtualDesktop, IVirtualDesktop_fallback) { + ComCall(this.version.idx_RemoveDesktop, this.IVirtualDesktopManagerInternal, "Ptr", IVirtualDesktop, "Ptr", IVirtualDesktop_fallback) + } + FindDesktop(DesktopId) { + ComCall(this.version.idx_FindDesktop, this.IVirtualDesktopManagerInternal, "Ptr", DesktopId, "Ptr*", &IVirtualDesktop_found := 0) + return IVirtualDesktop_found + } + SetDesktopName(IVirtualDesktop, desktopName) { + DllCall("combase\WindowsCreateString", "WStr", desktopName, "Uint", StrLen(desktopName), "Ptr*", &HSTRING := 0) + ComCall(this.version.idx_SetDesktopName, this.IVirtualDesktopManagerInternal, "Ptr", IVirtualDesktop, "Ptr", HSTRING) + DllCall("combase\WindowsDeleteString", "Ptr", HSTRING) + } + SetDesktopWallpaper(IVirtualDesktop, wallpaperPath) { + DllCall("combase\WindowsCreateString", "WStr", wallpaperPath, "Uint", StrLen(wallpaperPath), "Ptr*", &HSTRING := 0) + ComCall(this.version.idx_SetDesktopWallpaper, this.IVirtualDesktopManagerInternal, "Ptr", IVirtualDesktop, "Ptr", HSTRING) + DllCall("combase\WindowsDeleteString", "Ptr", HSTRING) + } + } + + class IVirtualDesktopManagerInternal_HMONITOR extends VD.IVirtualDesktopManagerInternal_Normal { + ; just set HMONITOR to 0 ? + GetCurrentDesktop() { + ComCall(this.version.idx_GetCurrentDesktop, this.IVirtualDesktopManagerInternal, "Ptr", 0, "Ptr*", &IVirtualDesktop_current := 0) + return IVirtualDesktop_current + } + GetDesktops() { + ComCall(this.version.idx_GetDesktops, this.IVirtualDesktopManagerInternal, "Ptr", 0, "Ptr*", &IObjectArray := 0) + return IObjectArray + } + SwitchDesktop(IVirtualDesktop) { + ComCall(this.version.idx_SwitchDesktop, this.IVirtualDesktopManagerInternal, "Ptr", 0, "Ptr", IVirtualDesktop) + } + CreateDesktop() { + ComCall(this.version.idx_CreateDesktop, this.IVirtualDesktopManagerInternal, "Ptr", 0, "Ptr*", &IVirtualDesktop_created := 0) + return IVirtualDesktop_created + } + } + + class IObjectArray { + __New(IObjectArray, IID_Interface_ptr) { + this.IObjectArray := IObjectArray + this.IID_Interface_ptr := IID_Interface_ptr + } + Length => _length ??= this.GetCount() + _length := unset + GetCount() { + ComCall(3, this.IObjectArray, "Uint*", &Count := 0) + return Count + } + GetAt(oneBasedIndex) { + ComCall(4, this.IObjectArray, "UInt", oneBasedIndex - 1, "Ptr", this.IID_Interface_ptr, "Ptr*", &IInterface:=0) + return IInterface + } + __Item[idx] => this.GetAt(idx) + __Enum(NumberOfVars) { + switch NumberOfVars { + case 1: return VD.Enumerator1(this) + case 2: return VD.Enumerator2(this) + default: throw "Invalid number of variables" + } + } + } + + class Enumerator1 { + __New(arr) { + this.arr := arr + this.idx := 1 + } + Call(&OutputVar1) { + if (this.idx <= this.arr.Length) { + OutputVar1 := this.arr[this.idx] + ++this.idx + return 1 + } + return 0 + } + } + + class Enumerator2 { + __New(arr) { + this.arr := arr + this.idx := 1 + } + Call(&OutputVar1, &OutputVar2) { + if (this.idx <= this.arr.Length) { + OutputVar1 := this.idx + OutputVar2 := this.arr[this.idx] + ++this.idx + return 1 + } + return 0 + } + } + + class IVirtualDesktopNotificationService_Class { + reinit(CImmersiveShell_IServiceProvider) { + this.IVirtualDesktopNotificationService := ComObjQuery(CImmersiveShell_IServiceProvider, "{a501fdec-4a09-464c-ae4e-1b9c21b84918}", "{0cd45e71-d927-4f15-8b0a-8fef525337bf}") + } + Register(IVirtualDesktopNotification) { + ComCall(3,this.IVirtualDesktopNotificationService,"Ptr",IVirtualDesktopNotification,"Uint*",pdwCookie:=0) ;3=Register + } + } + + class IVirtualDesktopNotification_Normal { + __New(version) { + this.version := version + ; obj and vtbl in the same buffer, obj only contain vtbl_pointer + this.obj_and_vtbl := Buffer(A_PtrSize*(version.IVirtualDesktopNotification_methods_count+1)) + this.vtbl := this.obj_and_vtbl.Ptr + A_PtrSize + NumPut("Ptr", this.vtbl, this.obj_and_vtbl) + offset := 0 + loop version.IVirtualDesktopNotification_methods_count { + NumPut("Ptr", VD.ret.Ptr, this.vtbl, offset) + offset += A_PtrSize + } + + ; QueryInterface only called during IVirtualDesktopNotificationService::Register, thread-safe (hopefully) + NumPut("Ptr", VD.BoundCallbackCreate(CallbackCreate, this.QueryInterface, this), this.vtbl) + + ; PostMessage + CallbackCreate_PostMessage := VD.CallbackCreate_PostMessage.Bind(VD) + NumPut("Ptr", VD.BoundCallbackCreate(CallbackCreate_PostMessage, this.VirtualDesktopCreated, this), this.vtbl, version.idx_VirtualDesktopCreated*A_PtrSize) + NumPut("Ptr", VD.BoundCallbackCreate(CallbackCreate_PostMessage, this.VirtualDesktopDestroyed, this), this.vtbl, version.idx_VirtualDesktopDestroyed*A_PtrSize) + NumPut("Ptr", VD.BoundCallbackCreate(CallbackCreate_PostMessage, this.CurrentVirtualDesktopChanged, this), this.vtbl, version.idx_CurrentVirtualDesktopChanged*A_PtrSize) + } + + Ptr => this.obj_and_vtbl.Ptr + + QueryInterface(that, riid, ppvObject) { + if (!ppvObject) { + return 0x80070057 ;E_INVALIDARG + } + + switch VD._StringFromGUID(riid), 0 { ;not case-sensitive + case "{00000000-0000-0000-c000-000000000046}", this.version.IID_IVirtualDesktopNotification_str: + NumPut("Ptr", that, ppvObject) ; *ppvObject = this; + return 0 ;S_OK + default: + NumPut("Ptr", 0, ppvObject) ; *ppvObject = NULL; + return 0x80004002 ;E_NOINTERFACE + } + } + + VirtualDesktopCreated() { + VD.IVirtualDesktopListChanged() + } + VirtualDesktopDestroyed() { + VD.IVirtualDesktopListChanged() ; called after CurrentVirtualDesktopChanged + } + _common_CurrentVirtualDesktopChanged(IVirtualDesktop_old, IVirtualDesktop_new) { + desktopNum_old := VD.IVirtualDesktopMap[IVirtualDesktop_old] + VD.currentDesktopNum := VD.IVirtualDesktopMap[IVirtualDesktop_new] + if (VD.WinActivate_callback) { + VD.WinActivate_callback.Call() + } + for k, _ in VD.ListenersCurrentVirtualDesktopChanged { + k(desktopNum_old, VD.currentDesktopNum) + } + } + CurrentVirtualDesktopChanged(that, IVirtualDesktop_old, IVirtualDesktop_new) { + this._common_CurrentVirtualDesktopChanged(IVirtualDesktop_old, IVirtualDesktop_new) + } + } + class IVirtualDesktopNotification_IObjectArray extends VD.IVirtualDesktopNotification_Normal { + CurrentVirtualDesktopChanged(that, IObjectArray, IVirtualDesktop_old, IVirtualDesktop_new) { + this._common_CurrentVirtualDesktopChanged(IVirtualDesktop_old, IVirtualDesktop_new) + } + } + static BoundCallbackCreate(func_CallbackCreate, callback, that) { + return func_CallbackCreate(callback.Bind(that),, callback.MinParams - 1) + } + + static _StringFromGUID(guid) { + DllCall("ole32\StringFromGUID2", "Ptr", guid, "ptr", buf := Buffer(78), "Int", 39) + return StrGet(buf, "UTF-16") + } + + static CallbackCreate_PostMessage(callback, Options := "", ParamCount := callback.MinParams) { + ; CallbackCreate for Multithreading (by) using the message queue + ;idea from: + ;Lexikos : RegisterSyncCallback (for multi-threaded APIs) : https://www.autohotkey.com/boards/viewtopic.php?t=21223 + ;https://github.com/thqby/ahk2_lib/blob/master/OVERLAPPED.ahk + static PostMessageW := DllCall('GetProcAddress', 'ptr', DllCall('GetModuleHandle', 'str', 'user32', 'ptr'), 'astr', 'PostMessageW', 'ptr') + static GlobalAlloc := DllCall('GetProcAddress', 'ptr', DllCall('GetModuleHandle', 'str', 'kernel32', 'ptr'), 'astr', 'GlobalAlloc', 'ptr') + static AHK_CallbackCreate_PostMessage := DllCall('RegisterWindowMessageW', 'WStr', 'AHK_CallbackCreate_PostMessage', 'uint') + static asm := init() + static init() { + OnMessage(AHK_CallbackCreate_PostMessage, A_PtrSize == 8 ? VD.CallbackCreate_PostMessage_event_x86_64.Bind(VD) : VD.CallbackCreate_PostMessage_event_x86.Bind(VD)) + if (A_PtrSize == 8) { + asm := Buffer(0x81) + ; params := [] + ; params.Push( + NumPut( + ; 55 | push rbp + ; 48 89 e5 | mov rbp, rsp + 'uint', 0xe5894855, + + ; save to shadow space ; https://github.com/simon-whitehead/assembly-fun/blob/master/windows-x64/README.md#shadow-space + ; 48 89 4c 24 10 | mov [rsp + 0x10], rcx + 'uchar', 0x48, 'uint', 0x10244c89, + ; 48 89 54 24 18 | mov [rsp + 0x18], rdx + 'uchar', 0x48, 'uint', 0x18245489, + ; 4c 89 44 24 20 | mov [rsp + 0x20], r8 + 'uchar', 0x4c, 'uint', 0x20244489, + ; 4c 89 4c 24 28 | mov [rsp + 0x28], r9 + 'uchar', 0x4c, 'uint', 0x28244c89, + + ; 48 83 ec 40 | sub rsp, 0x40 + 'uint', 0x40ec8348, + + ; The x64 ABI considers registers RBX, RBP, RDI, RSI, RSP, R12, R13, R14, R15, and XMM6-XMM15 nonvolatile. + ; https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170 + ; save RSI, RDI, R12, R13 + ; 48 89 74 24 20 | mov [rsp + 0x20], rsi + 'uchar', 0x48, 'uint', 0x20247489, + ; 48 89 7c 24 28 | mov [rsp + 0x28], rdi + 'uchar', 0x48, 'uint', 0x28247c89, + ; 4c 89 64 24 30 | mov [rsp + 0x30], r12 + 'uchar', 0x4c, 'uint', 0x30246489, + ; 4c 89 6c 24 38 | mov [rsp + 0x38], r13 + 'uchar', 0x4c, 'uint', 0x38246c89, + + ; The x64 ABI considers the registers RAX, RCX, RDX, R8, R9, R10, R11, and XMM0-XMM5 volatile. + ; R10: ParamCount + ; R11: obj + ; 4d 89 d4 | mov r12, r10 + 'uchar', 0x4d, 'ushort', 0xd489, + ; 4d 89 dd | mov r13, r11 + 'uchar', 0x4d, 'ushort', 0xdd89, + + ; 31 c9 | xor ecx, ecx + 'ushort', 0xc931, + ; 4c 89 e2 | mov rdx, r12 + 'uchar', 0x4c, 'ushort', 0xe289, + ; 48 c1 e2 03 | shl rdx, 3 + 'uint', 0x03e2c148, + ; 48 b8 XX XX XX XX XX XX XX XX | mov rax, GlobalAlloc + 'ushort', 0xb848, "ptr", GlobalAlloc, + ; ff d0 | call rax + ; GlobalAlloc(GMEM_FIXED, A_PtrSize * ParamCount) + 'ushort', 0xd0ff, + + ; 48 8d 74 24 50 | lea rsi, [rsp + 0x50] + 'uchar', 0x48, 'uint', 0x5024748d, + ; 48 89 c7 | mov rdi, rax + ; no error handling, assumes GlobalAlloc succeeded + 'uchar', 0x48, 'ushort', 0xc789, + ; 4c 89 e1 | mov rcx, r12 + 'uchar', 0x4c, 'ushort', 0xe189, + ; could be memcpy + ; f3 48 a5 | rep movsq + 'uchar', 0xf3, 'ushort', 0xa548, + + ; 49 89 c1 | mov r9, rax + 'uchar', 0x49, 'ushort', 0xc189, + ; 4d 89 e8 | mov r8, r13 + 'uchar', 0x4d, 'ushort', 0xe889, + ; ba XX XX XX XX | mov edx, AHK_CallbackCreate_PostMessage + 'uchar', 0xba, 'uint', AHK_CallbackCreate_PostMessage, + ; b9 XX XX XX XX | mov ecx, A_ScriptHwnd ; assume handles are 32-bit + 'uchar', 0xb9, 'uint', A_ScriptHwnd, + ; 48 b8 XX XX XX XX XX XX XX XX | mov rax, PostMessageW + 'ushort', 0xb848, "ptr", PostMessageW, + ; ff d0 | call rax + ; PostMessageW(A_ScriptHwnd, AHK_CallbackCreate_PostMessage, obj, saved_params) + 'ushort', 0xd0ff, + + ; 48 83 c4 20 | add rsp, 0x20 + 'uint', 0x20c48348, + ; 5e | pop rsi + 'uchar', 0x5e, + ; 5f | pop rdi + 'uchar', 0x5f, + ; 41 5c | pop r12 + 'ushort', 0x5c41, + ; 41 5d | pop r13 + 'ushort', 0x5d41, + ; 5d | pop rbp + 'uchar', 0x5d, + ; c3 | ret + 'uchar', 0xc3, + asm + ) + + ; i:=1,sum:=0,end:=params.Length & ~1 + ; sizeMap:=Map() + ; sizeMap.CaseSense:=false + ; sizeMap.Set('uchar',1,"ushort",2,"uint",4,"ptr",A_PtrSize) + ; while (i <= end) { + ; sum+=sizeMap[params[i]] + ; i+=2 + ; } + } else { + throw Error("not implemented") + } + DllCall("VirtualProtect", "Ptr", asm, "Ptr", asm.Size, "Uint", 0x40, "Uint*", &lpflOldProtect:=0) ;0x40=PAGE_EXECUTE_READWRITE ;WRITE is needed because it changes more than 1 byte + return asm + } + + params:=[] + obj := {callback: callback, ParamCount: ParamCount} + + if (A_PtrSize == 8) { + stub := Buffer(0x15) + ; R10: ParamCount + ; R11: obj + NumPut( + ; 41 ba XX XX XX XX | mov r10d, ParamCount + "ushort", 0xba41, "uint", ParamCount, + ; 49 bb XX XX XX XX XX XX XX XX | mov r11, obj + "ushort", 0xbb49, "ptr", ObjPtr(obj), + ; e9 XX XX XX XX | jmp asm ; assumes no further than 0x7FFFFFFF + "uchar", 0xe9, "int", asm.Ptr - (stub.Ptr + stub.Size), + stub + ) + } else { + throw Error("not implemented") + } + DllCall("VirtualProtect", "Ptr", stub, "Ptr", stub.Size, "Uint", 0x40, "Uint*", &lpflOldProtect:=0) ;0x40=PAGE_EXECUTE_READWRITE ;WRITE is needed because it changes more than 1 byte + + if (!VD.CallbackFree_PostMessage.HasOwnProp("CallBacks")) { + VD.CallbackFree_PostMessage.CallBacks := Map() + } + VD.CallbackFree_PostMessage.CallBacks[stub.Ptr] := [stub, obj] + + return stub.Ptr + } + + static CallbackCreate_PostMessage_event_x86_64(wParam, lParam, msg, hwnd) { + Critical + + obj := ObjFromPtrAddRef(wParam) + + params:=[],i:=lParam,end:=i + (obj.ParamCount * A_PtrSize) + while (i < end) { + params.Push(NumGet(i,"Ptr")),i+=A_PtrSize + } + DllCall("GlobalFree","Ptr",lParam) + obj.callback.Call(params*) + } + static CallbackCreate_PostMessage_event_x86(wParam, lParam, msg, hwnd) { + Critical + + throw Error("not implemented") + } + + static CallbackFree_PostMessage(Address) { + if (VD.CallbackFree_PostMessage.HasOwnProp("CallBacks")) { + VD.CallbackFree_PostMessage.CallBacks.Delete(Address) + } + } + +} \ No newline at end of file diff --git a/src/lib/command_toast.ahk b/src/lib/command_toast.ahk index b3404c6..e8db702 100644 --- a/src/lib/command_toast.ahk +++ b/src/lib/command_toast.ahk @@ -11,6 +11,12 @@ 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 +global command_toast_visibility_timer := 0 +global command_toast_temp_visible := false +global command_toast_input_hook := 0 +global command_toast_input_timer := 0 +global command_toast_keydown_enabled := false +global command_toast_keydown_handler := 0 InitCommandToast() { global Config, AppState, command_helper_enabled @@ -20,22 +26,42 @@ InitCommandToast() { } UpdateCommandToastVisibility() { - global command_helper_enabled - if !command_helper_enabled { + global command_helper_enabled, command_toast_temp_visible + if !command_helper_enabled && !command_toast_temp_visible { + StopCommandToastVisibilityTimer() HideCommandToast() return } - if IsSuperKeyPressed() || ReloadModeActive() || Window.IsMoveMode() { - ShowCommandToast() + if ReloadModeActive() || Window.IsMoveMode() || command_toast_temp_visible { + ShowCommandToast(command_toast_temp_visible) + StartCommandToastVisibilityTimer() } else { + StopCommandToastVisibilityTimer() HideCommandToast() } } -ShowCommandToast() { +StartCommandToastVisibilityTimer() { + global command_toast_visibility_timer + if !command_toast_visibility_timer + command_toast_visibility_timer := CommandToastVisibilityTick + SetTimer(command_toast_visibility_timer, 200) +} + +StopCommandToastVisibilityTimer() { + global command_toast_visibility_timer + if command_toast_visibility_timer + SetTimer(command_toast_visibility_timer, 0) +} + +CommandToastVisibilityTick(*) { + UpdateCommandToastVisibility() +} + +ShowCommandToast(force_show := false) { 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 + if !command_helper_enabled && !force_show return model := BuildCommandToastModel() @@ -76,14 +102,8 @@ ShowCommandToast() { margin := 16 if (margin > 64) margin := 64 - pos_x := right - w - margin - pos_y := bottom - h - margin - min_x := left + margin - max_x := right - w - margin - min_y := top + margin - max_y := bottom - h - margin - pos_x := Max(min_x, Min(pos_x, max_x)) - pos_y := Max(min_y, Min(pos_y, max_y)) + pos_x := left + (right - left - w) / 2 + pos_y := top + (bottom - top - h) / 2 command_toast_gui.Show("NoActivate x" pos_x " y" pos_y) if (model["mode"] = "normal" && command_toast_normal_refresh_pending) { RefreshCommandToastIcons(model) @@ -172,11 +192,14 @@ RefreshCommandToastIcons(model) { } HideCommandToast() { - global command_toast_gui, command_toast_visible + global command_toast_gui, command_toast_visible, command_toast_temp_visible if command_toast_gui { command_toast_gui.Hide() command_toast_visible := false } + command_toast_temp_visible := false + StopCommandToastInputHook() + UnregisterCommandToastKeydown() } ToggleCommandHelper() { @@ -192,6 +215,78 @@ ToggleCommandHelper() { UpdateCommandToastVisibility() } +ShowCommandToastTemporary() { + global command_helper_enabled, command_toast_temp_visible + command_toast_temp_visible := true + ShowCommandToast(true) + StartCommandToastInputHook() + RegisterCommandToastKeydown() +} + +StartCommandToastInputHook() { + global command_toast_input_timer + if ReloadModeActive() || Window.IsMoveMode() + return + StopCommandToastInputHook() + command_toast_input_timer := CommandToastStartInputHook + SetTimer(command_toast_input_timer, -100) +} + +CommandToastStartInputHook(*) { + global command_toast_input_hook + if ReloadModeActive() || Window.IsMoveMode() + return + command_toast_input_hook := InputHook("V") + command_toast_input_hook.KeyOpt("{All}", "E") + command_toast_input_hook.OnKeyDown := CommandToastOnKeyDown + command_toast_input_hook.Start() +} + +StopCommandToastInputHook() { + global command_toast_input_timer, command_toast_input_hook + if command_toast_input_timer + SetTimer(command_toast_input_timer, 0) + command_toast_input_timer := 0 + if command_toast_input_hook { + try command_toast_input_hook.Stop() + command_toast_input_hook := 0 + } +} + +RegisterCommandToastKeydown() { + global command_toast_keydown_enabled, command_toast_keydown_handler + if command_toast_keydown_enabled + return + if !command_toast_keydown_handler + command_toast_keydown_handler := CommandToastKeydownHandler + OnMessage(0x100, command_toast_keydown_handler) + OnMessage(0x104, command_toast_keydown_handler) + command_toast_keydown_enabled := true +} + +UnregisterCommandToastKeydown() { + global command_toast_keydown_enabled, command_toast_keydown_handler + if !command_toast_keydown_enabled + return + if command_toast_keydown_handler { + OnMessage(0x100, command_toast_keydown_handler, 0) + OnMessage(0x104, command_toast_keydown_handler, 0) + } + command_toast_keydown_enabled := false +} + +CommandToastKeydownHandler(*) { + if ReloadModeActive() || Window.IsMoveMode() + return + HideCommandToast() +} + +CommandToastOnKeyDown(*) { + if ReloadModeActive() || Window.IsMoveMode() + return + HideCommandToast() +} + GetCommandToastWorkArea(&left, &top, &right, &bottom) { mon := "" try hwnd := WinGetID("A") diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index ba4c20c..7c6fb7f 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -16,6 +16,7 @@ LoadConfig(config_path, default_config := Map()) { } NormalizeSuperKeyConfig(config) + NormalizeVirtualDesktopConfig(config) errors := ValidateConfig(config, ConfigSchema()) ValidateSuperKeys(config, errors) ValidateApps(config, errors) @@ -100,7 +101,24 @@ ConfigSchema() { "enabled", "bool", "switch_on_focus", "bool", "ensure_count", "number", - "cycle_prefer_current", "bool" + "cycle_prefer_current", "bool", + "prev_hotkey", "string", + "next_hotkey", "string", + "move_prev_hotkey", "string", + "move_next_hotkey", "string", + "desktop_hotkeys", OptionalSpec([Map( + "desktop", "number", + "hotkey", "string" + )]), + "goto_hotkeys", [Map( + "hotkey", "string", + "desktop", "number" + )], + "move_hotkeys", [Map( + "hotkey", "string", + "desktop", "number" + )], + "debug_cycle", "bool" ), "directional_focus", Map( "enabled", "bool", @@ -154,6 +172,47 @@ NormalizeSuperKeyConfig(config) { config["super_key"] := [super_value] } +NormalizeVirtualDesktopConfig(config) { + if !config.Has("virtual_desktop") || !(config["virtual_desktop"] is Map) + return + + vd_config := config["virtual_desktop"] + desktop_hotkeys := [] + keys_to_remove := [] + + for key, val in vd_config { + if !IsInteger(key) + continue + keys_to_remove.Push(key) + desktop_num := Integer(key) + if (val is Array) { + for _, entry in val { + if !(entry is Map) + continue + if entry.Has("hotkey") && entry["hotkey"] != "" { + desktop_hotkeys.Push(Map( + "desktop", desktop_num, + "hotkey", entry["hotkey"] + )) + } + } + } else if (val is Map) { + if val.Has("hotkey") && val["hotkey"] != "" { + desktop_hotkeys.Push(Map( + "desktop", desktop_num, + "hotkey", val["hotkey"] + )) + } + } + } + + if (desktop_hotkeys.Length > 0) + vd_config["desktop_hotkeys"] := desktop_hotkeys + + for _, key in keys_to_remove + vd_config.Delete(key) +} + ValidateSuperKeys(config, errors) { if !config.Has("super_key") return diff --git a/src/lib/focus_or_run.ahk b/src/lib/focus_or_run.ahk index 349c254..932e078 100644 --- a/src/lib/focus_or_run.ahk +++ b/src/lib/focus_or_run.ahk @@ -5,7 +5,10 @@ FocusOrRun(winTitle, exePath, hotkey_id, app_config := "", *) { static last_window := Map() target_hwnd := 0 hwnds := GetAppWindowList(winTitle, app_config) - current_hwnd := WinGetID("A") + current_hwnd := 0 + try current_hwnd := WinGetID("A") + catch + current_hwnd := 0 if (hwnds.Length) { target_hwnd := PickFocusableAppWindow(hwnds, winTitle) @@ -65,7 +68,15 @@ PickFocusableAppWindow(hwnds, win_title) { try style := WinGetStyle("ahk_id " hwnd) catch continue - if (!(style & 0x10000000)) + allow_invisible := false + if VirtualDesktopEnabled() { + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num <= 0) + allow_invisible := true + else if (desktop_num != VD.getCurrentDesktopNum()) + allow_invisible := true + } + if (!allow_invisible && !(style & 0x10000000)) continue state := WinGetMinMax("ahk_id " hwnd) @@ -121,6 +132,15 @@ TryMoveAppWindowToDesktop(win_title, app_config, target_desktop, follow_on_spawn if (hwnd) { VD.MoveWindowToDesktopNum("ahk_id " hwnd, target_desktop, follow_on_spawn) + if follow_on_spawn { + VD.goToDesktopNum(target_desktop) + VD.WaitDesktopSwitched(target_desktop) + } + try { + assigned_desktop := GetWindowDesktopNum(hwnd) + if (assigned_desktop > 0 && assigned_desktop != target_desktop) + VD.MoveWindowToDesktopNum("ahk_id " hwnd, target_desktop, follow_on_spawn) + } SetTimer(callback, 0) return } diff --git a/src/lib/virtual_desktop.ahk b/src/lib/virtual_desktop.ahk index efbfb8a..3398442 100644 --- a/src/lib/virtual_desktop.ahk +++ b/src/lib/virtual_desktop.ahk @@ -24,6 +24,28 @@ InitVirtualDesktop() { VD.createUntil(ensure_count) } +RefreshVirtualDesktopState() { + if !VirtualDesktopEnabled() + return + try { + VD.IVirtualDesktopListChanged() + VD.currentDesktopNum := VD.IVirtualDesktopMap[VD.IVirtualDesktopManagerInternal.GetCurrentDesktop()] + } +} + +GetCurrentDesktopNumFresh() { + if !VirtualDesktopEnabled() + return 0 + try { + VD.IVirtualDesktopListChanged() + current := VD.IVirtualDesktopMap[VD.IVirtualDesktopManagerInternal.GetCurrentDesktop()] + if (current > 0) + VD.currentDesktopNum := current + return current + } + return 0 +} + GetWindowDesktopNum(hwnd) { if !VirtualDesktopEnabled() return 0 diff --git a/src/lib/window_inspector.ahk b/src/lib/window_inspector.ahk index 8497c2f..984e2b2 100644 --- a/src/lib/window_inspector.ahk +++ b/src/lib/window_inspector.ahk @@ -3,22 +3,25 @@ ShowWindowInspector() { static inspector_gui := "" static window_list := "" + static refresh_timer := 0 if inspector_gui { inspector_gui.Show() RefreshList(window_list) + StartAutoRefresh(window_list, &refresh_timer) return } inspector_gui := Gui("+Resize", "harken Window Inspector") inspector_gui.SetFont("s10", "Segoe UI") - window_list := inspector_gui.AddListView("w980 r26 Grid", ["Title", "Exe", "Class", "PID", "HWND"]) - window_list.ModifyCol(1, 400) - window_list.ModifyCol(2, 140) - window_list.ModifyCol(3, 200) - window_list.ModifyCol(4, 80) - window_list.ModifyCol(5, 120) + window_list := inspector_gui.AddListView("w1020 r26 Grid", ["Active", "Title", "Exe", "Class", "PID", "HWND"]) + window_list.ModifyCol(1, 60) + window_list.ModifyCol(2, 380) + window_list.ModifyCol(3, 140) + window_list.ModifyCol(4, 200) + window_list.ModifyCol(5, 80) + window_list.ModifyCol(6, 120) refresh_btn := inspector_gui.AddButton("xm y+10 w110", "Refresh") refresh_btn.OnEvent("Click", (*) => RefreshList(window_list)) @@ -32,24 +35,41 @@ ShowWindowInspector() { export_btn := inspector_gui.AddButton("x+10 yp w140", "Export File") export_btn.OnEvent("Click", (*) => ExportAll(window_list)) - inspector_gui.OnEvent("Close", (*) => inspector_gui.Hide()) + inspector_gui.OnEvent("Close", (*) => HideInspector(inspector_gui, &refresh_timer)) inspector_gui.Show() RefreshList(window_list) + StartAutoRefresh(window_list, &refresh_timer) } RefreshList(window_list) { window_list.Delete() + active_hwnd := 0 + try active_hwnd := WinGetID("A") for _, hwnd in WinGetList() { title := WinGetTitle("ahk_id " hwnd) exe := WinGetProcessName("ahk_id " hwnd) class_name := WinGetClass("ahk_id " hwnd) pid := WinGetPID("ahk_id " hwnd) - window_list.Add("", title, exe, class_name, pid, Format("0x{:X}", hwnd)) + active_label := (hwnd = active_hwnd) ? "active" : "" + window_list.Add("", active_label, title, exe, class_name, pid, Format("0x{:X}", hwnd)) } } +StartAutoRefresh(window_list, &refresh_timer) { + if refresh_timer + SetTimer(refresh_timer, 0) + refresh_timer := (*) => RefreshList(window_list) + SetTimer(refresh_timer, 2000) +} + +HideInspector(inspector_gui, &refresh_timer) { + if refresh_timer + SetTimer(refresh_timer, 0) + inspector_gui.Hide() +} + CopySelected(window_list) { rows := [] row := 0 @@ -94,14 +114,15 @@ GetRowValues(window_list, row) { window_list.GetText(row, 2), window_list.GetText(row, 3), window_list.GetText(row, 4), - window_list.GetText(row, 5) + window_list.GetText(row, 5), + window_list.GetText(row, 6) ] } BuildOutput(rows) { - output := "Title\tExe\tClass\tPID\tHWND`n" + output := "Active\tTitle\tExe\tClass\tPID\tHWND`n" for _, row in rows { - output .= row[1] "\t" row[2] "\t" row[3] "\t" row[4] "\t" row[5] "`n" + output .= row[1] "\t" row[2] "\t" row[3] "\t" row[4] "\t" row[5] "\t" row[6] "`n" } return output } diff --git a/src/lib/window_manager.ahk b/src/lib/window_manager.ahk index b7b2de1..39ca5f1 100644 --- a/src/lib/window_manager.ahk +++ b/src/lib/window_manager.ahk @@ -48,6 +48,8 @@ class Window HotIf (*) => IsSuperKeyPressed() && !GetKeyState('Shift', 'P') && !GetKeyState('Ctrl', 'P') + && !GetKeyState('LAlt', 'P') + && !GetKeyState('RAlt', 'P') && !this.IsMoveMode() Hotkey('*k', ObjBindMethod(this, 'HotkeyCallback', ObjBindMethod(this, 'MoveUp'))) Hotkey('*h', ObjBindMethod(this, 'HotkeyCallback', ObjBindMethod(this, 'MoveLeft'))) diff --git a/src/lib/window_walker.ahk b/src/lib/window_walker.ahk index b7d084c..6d65b07 100644 --- a/src/lib/window_walker.ahk +++ b/src/lib/window_walker.ahk @@ -13,6 +13,18 @@ class WindowWalker static image_list := "" static icon_cache := Map() static corner_radius := 8 + static excluded_exes := Map( + "applicationframehost", true, + "lockapp", true, + "searchexperiencehost", true, + "searchhost", true, + "searchapp", true, + "searchui", true, + "shellexperiencehost", true, + "startmenuexperiencehost", true, + "systemsettings", true, + "textinputhost", true + ) static Show(*) { @@ -29,6 +41,15 @@ class WindowWalker WindowWalker.StartFocusWatch() } + static Toggle(*) + { + if WindowWalker.IsActive() { + WindowWalker.Hide() + return + } + WindowWalker.Show() + } + static Hide(*) { if WindowWalker.gui { @@ -176,9 +197,11 @@ class WindowWalker return WindowWalker.Hide() - if WinExist("ahk_id " hwnd) { - if (WinGetMinMax("ahk_id " hwnd) = -1) - WinRestore "ahk_id " hwnd + if WindowExistsAcrossDesktops(hwnd) { + try { + if (WinGetMinMax("ahk_id " hwnd) = -1) + WinRestore "ahk_id " hwnd + } ActivateWindowAcrossDesktops(hwnd) } } @@ -186,7 +209,9 @@ class WindowWalker static RefreshWindows() { WindowWalker.windows := [] - win_list := WinGetList() + win_list := GetWindowsAcrossDesktops() + bak_detect_hidden_windows := A_DetectHiddenWindows + A_DetectHiddenWindows := true for _, hwnd in win_list { if WindowWalker.gui && (hwnd = WindowWalker.gui.Hwnd) @@ -215,6 +240,8 @@ class WindowWalker "order", A_Index )) } + + A_DetectHiddenWindows := bak_detect_hidden_windows } static ApplyFilter() @@ -280,11 +307,20 @@ class WindowWalker static IsWindowEligible(hwnd) { - if !WinExist("ahk_id " hwnd) + if !WindowExistsAcrossDesktops(hwnd) return false if Window.IsException("ahk_id " hwnd) return false + try exe_name := WinGetProcessName("ahk_id " hwnd) + catch + return false + exe_name := StrLower(exe_name) + if (SubStr(exe_name, -4) = ".exe") + exe_name := SubStr(exe_name, 1, -4) + if WindowWalker.excluded_exes.Has(exe_name) + return false + if (!Config["window_selector"]["include_minimized"] && WinGetMinMax("ahk_id " hwnd) = -1) return false @@ -296,7 +332,13 @@ class WindowWalker try style := WinGetStyle("ahk_id " hwnd) catch return false - if !(style & 0x10000000) + allow_invisible := false + if VirtualDesktopEnabled() { + desktop_num := GetWindowDesktopNum(hwnd) + if (desktop_num > 0 && desktop_num != VD.getCurrentDesktopNum()) + allow_invisible := true + } + if (!allow_invisible && !(style & 0x10000000)) return false return true From 12bb4bcb028dacf15f43d89783f6ea6c64b228ac Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 23:00:48 -0600 Subject: [PATCH 11/20] fix: virtual_desktop index hotkeys --- config/config.example.toml | 1 + harken.ahk | 35 +++++++++- src/hotkeys/window.ahk | 134 +++++++++++++++++++++++++++++++++---- src/lib/config_loader.ahk | 59 +++++++++++++--- 4 files changed, 204 insertions(+), 25 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index f3fb016..2584fb1 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -107,6 +107,7 @@ next_hotkey = "l" move_prev_hotkey = "h" move_next_hotkey = "l" # debug_cycle = false +# debug_hotkeys = false # hotkey fields use AHK syntax without super (super is implied) # modifiers are applied by Harken (alt or alt+shift) for desktop actions diff --git a/harken.ahk b/harken.ahk index 333055d..5a40e3b 100644 --- a/harken.ahk +++ b/harken.ahk @@ -135,7 +135,8 @@ DefaultConfig() { "desktop_hotkeys", [], "goto_hotkeys", [], "move_hotkeys", [], - "debug_cycle", false + "debug_cycle", false, + "debug_hotkeys", false ), "directional_focus", Map( "enabled", true, @@ -229,6 +230,33 @@ GetAppDataDir() { return GetConfigDir() } +ResetDebugLogs() { + if !Config.Has("virtual_desktop") || !(Config["virtual_desktop"] is Map) + return + vd_config := Config["virtual_desktop"] + debug_cycle := vd_config.Has("debug_cycle") && vd_config["debug_cycle"] + debug_hotkeys := vd_config.Has("debug_hotkeys") && vd_config["debug_hotkeys"] + if !(debug_cycle || debug_hotkeys) + return + + log_dir := GetAppDataDir() + DirCreate(log_dir) + if debug_cycle { + TryDeleteFile(log_dir "\cycle.debug.log") + } + if debug_hotkeys { + TryDeleteFile(log_dir "\vd.hotkeys.log") + TryDeleteFile(log_dir "\vd.actions.log") + } +} + +TryDeleteFile(path) { + try { + if FileExist(path) + FileDelete(path) + } +} + StartConfigWatcher(path, interval_ms := 1000) { global config_watch_mtime := "" global config_watch_handle := 0 @@ -386,7 +414,7 @@ MatchAppWindow(app, hwnd := 0) { if !(app is Map) return false if (hwnd = 0) - hwnd := WinGetID("A") + try hwnd := WinGetID("A") if !hwnd return false @@ -394,7 +422,8 @@ MatchAppWindow(app, hwnd := 0) { return MatchWindowFields(app["match"], hwnd) if app.Has("win_title") && app["win_title"] != "" { - if (hwnd = WinGetID("A")) + try active_hwnd := WinGetID("A") + if (active_hwnd && hwnd = active_hwnd) return WinActive(app["win_title"]) } diff --git a/src/hotkeys/window.ahk b/src/hotkeys/window.ahk index fcd4c27..28f6269 100644 --- a/src/hotkeys/window.ahk +++ b/src/hotkeys/window.ahk @@ -29,6 +29,7 @@ last_super_tap := 0 max_restore := Map() app_cycle_cache := Map() cycle_hwnd_desktop := Map() +debug_logs_initialized := false ResizeActiveWindow(delta_w, delta_h) { hwnd := WinExist("A") @@ -333,9 +334,42 @@ CycleDebugEnabled() { return Config["virtual_desktop"].Has("debug_cycle") && Config["virtual_desktop"]["debug_cycle"] } +HotkeyDebugEnabled() { + if !Config.Has("virtual_desktop") + return false + return Config["virtual_desktop"].Has("debug_hotkeys") && Config["virtual_desktop"]["debug_hotkeys"] +} + +EnsureDebugLogInit() { + global debug_logs_initialized + if debug_logs_initialized + return + if !(CycleDebugEnabled() || HotkeyDebugEnabled()) + return + log_dir := GetCycleDebugDir() + DirCreate(log_dir) + if CycleDebugEnabled() { + TryResetLogFile(log_dir "\\cycle.debug.log") + } + if HotkeyDebugEnabled() { + TryResetLogFile(log_dir "\\vd.hotkeys.log") + TryResetLogFile(log_dir "\\vd.actions.log") + } + debug_logs_initialized := true +} + +TryResetLogFile(path) { + try { + if FileExist(path) + FileDelete(path) + FileAppend("", path) + } +} + LogCycleDebug(lines) { if !CycleDebugEnabled() return + EnsureDebugLogInit() log_dir := GetCycleDebugDir() DirCreate(log_dir) log_path := log_dir "\\cycle.debug.log" @@ -347,6 +381,45 @@ LogCycleDebug(lines) { } } +LogVirtualDesktopHotkeys(lines) { + if !HotkeyDebugEnabled() + return + EnsureDebugLogInit() + log_dir := GetCycleDebugDir() + DirCreate(log_dir) + log_path := log_dir "\\vd.hotkeys.log" + header := "[" A_Now "] " + if !(lines is Array) + lines := [lines] + for _, line in lines { + FileAppend(header line "`n", log_path) + } +} + +LogVirtualDesktopAction(lines) { + if !HotkeyDebugEnabled() + return + EnsureDebugLogInit() + log_dir := GetCycleDebugDir() + DirCreate(log_dir) + log_path := log_dir "\\vd.actions.log" + header := "[" A_Now "] " + if !(lines is Array) + lines := [lines] + for _, line in lines { + FileAppend(header line "`n", log_path) + } +} + +RegisterDesktopHotkey(kind, hotkey_name, desktop_num, callback) { + if (hotkey_name = "") + return + Hotkey(hotkey_name, (*) => ( + LogVirtualDesktopAction(kind " hotkey=" hotkey_name " desktop=" desktop_num " current=" GetCurrentDesktopNumFresh()), + callback() + )) +} + GetCycleDebugDir() { appdata := EnvGet("APPDATA") if appdata @@ -669,6 +742,7 @@ GoToRelativeDesktop(delta) { if (current <= 0) return target := VD.modulusResolveDesktopNum(current + delta) + LogVirtualDesktopAction("goto_relative current=" current " delta=" delta " target=" target) RefreshVirtualDesktopState() VD.goToDesktopNum(target) VD.WaitDesktopSwitched(target) @@ -680,6 +754,7 @@ GoToDesktopNumber(desktop_num) { return if (desktop_num <= 0) return + LogVirtualDesktopAction("goto_absolute target=" desktop_num " current=" GetCurrentDesktopNumFresh()) RefreshVirtualDesktopState() GetCurrentDesktopNumFresh() VD.goToDesktopNum(desktop_num) @@ -697,6 +772,7 @@ MoveWindowToRelativeDesktop(delta) { if (current <= 0) return target := VD.modulusResolveDesktopNum(current + delta) + LogVirtualDesktopAction("move_relative current=" current " delta=" delta " target=" target) RefreshVirtualDesktopState() VD.MoveWindowToDesktopNum("A", target, true) VD.WaitDesktopSwitched(target) @@ -708,6 +784,7 @@ MoveWindowToDesktopNumber(desktop_num) { return if (desktop_num <= 0) return + LogVirtualDesktopAction("move_absolute target=" desktop_num " current=" GetCurrentDesktopNumFresh()) RefreshVirtualDesktopState() GetCurrentDesktopNumFresh() VD.MoveWindowToDesktopNum("A", desktop_num, true) @@ -740,11 +817,18 @@ if (minimize_others_hotkey != "") Hotkey(minimize_others_hotkey, MinimizeOtherWindows) HotIf -HotIf IsSuperKeyPressed +HotIf (*) => IsSuperKeyPressed() && IsAltPressed() && !GetKeyState("Shift", "P") if (vd_prev_hotkey != "") - Hotkey(vd_prev_hotkey, (*) => GoToRelativeDesktop(-1)) + Hotkey(vd_prev_hotkey, (*) => ( + LogVirtualDesktopAction("goto_relative hotkey=" vd_prev_hotkey " delta=-1 current=" GetCurrentDesktopNumFresh()), + GoToRelativeDesktop(-1) + )) if (vd_next_hotkey != "") - Hotkey(vd_next_hotkey, (*) => GoToRelativeDesktop(1)) + Hotkey(vd_next_hotkey, (*) => ( + LogVirtualDesktopAction("goto_relative hotkey=" vd_next_hotkey " delta=1 current=" GetCurrentDesktopNumFresh()), + GoToRelativeDesktop(1) + )) +LogVirtualDesktopHotkeys("prev_hotkey=" vd_prev_hotkey " next_hotkey=" vd_next_hotkey) for _, entry in vd_goto_hotkeys { if !(entry is Map) continue @@ -754,10 +838,14 @@ for _, entry in vd_goto_hotkeys { desktop_num := entry["desktop"] key_copy := hotkey_name num_copy := desktop_num - if (key_copy != "") - Hotkey(key_copy, (*) => GoToDesktopNumber(num_copy)) + if (key_copy != "") { + callback := GoToDesktopNumber.Bind(num_copy) + RegisterDesktopHotkey("goto_absolute", key_copy, num_copy, callback) + } + LogVirtualDesktopHotkeys("map goto hotkey=" key_copy " desktop=" num_copy) } if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { + LogVirtualDesktopHotkeys("desktop_hotkeys_count=" vd_desktop_hotkeys.Length) for _, entry in vd_desktop_hotkeys { if !(entry is Map) continue @@ -767,14 +855,27 @@ if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { desktop_num := entry["desktop"] key_copy := hotkey_name num_copy := desktop_num - if (key_copy != "") - Hotkey(key_copy, (*) => GoToDesktopNumber(num_copy)) + if (key_copy != "") { + callback := GoToDesktopNumber.Bind(num_copy) + RegisterDesktopHotkey("goto_absolute", key_copy, num_copy, callback) + } + LogVirtualDesktopHotkeys("map goto hotkey=" key_copy " desktop=" num_copy) } } +HotIf + +HotIf (*) => IsSuperKeyPressed() && IsAltPressed() && GetKeyState("Shift", "P") if (vd_move_prev_hotkey != "") - Hotkey(vd_move_prev_hotkey, (*) => MoveWindowToRelativeDesktop(-1)) + Hotkey(vd_move_prev_hotkey, (*) => ( + LogVirtualDesktopAction("move_relative hotkey=" vd_move_prev_hotkey " delta=-1 current=" GetCurrentDesktopNumFresh()), + MoveWindowToRelativeDesktop(-1) + )) if (vd_move_next_hotkey != "") - Hotkey(vd_move_next_hotkey, (*) => MoveWindowToRelativeDesktop(1)) + Hotkey(vd_move_next_hotkey, (*) => ( + LogVirtualDesktopAction("move_relative hotkey=" vd_move_next_hotkey " delta=1 current=" GetCurrentDesktopNumFresh()), + MoveWindowToRelativeDesktop(1) + )) +LogVirtualDesktopHotkeys("move_prev_hotkey=" vd_move_prev_hotkey " move_next_hotkey=" vd_move_next_hotkey) for _, entry in vd_move_hotkeys { if !(entry is Map) continue @@ -784,10 +885,14 @@ for _, entry in vd_move_hotkeys { desktop_num := entry["desktop"] key_copy := hotkey_name num_copy := desktop_num - if (key_copy != "") - Hotkey(key_copy, (*) => MoveWindowToDesktopNumber(num_copy)) + if (key_copy != "") { + callback := MoveWindowToDesktopNumber.Bind(num_copy) + RegisterDesktopHotkey("move_absolute", key_copy, num_copy, callback) + } + LogVirtualDesktopHotkeys("map move hotkey=" key_copy " desktop=" num_copy) } if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { + LogVirtualDesktopHotkeys("desktop_hotkeys_count=" vd_desktop_hotkeys.Length) for _, entry in vd_desktop_hotkeys { if !(entry is Map) continue @@ -797,8 +902,11 @@ if (vd_desktop_hotkeys is Array && vd_desktop_hotkeys.Length > 0) { desktop_num := entry["desktop"] key_copy := hotkey_name num_copy := desktop_num - if (key_copy != "") - Hotkey(key_copy, (*) => MoveWindowToDesktopNumber(num_copy)) + if (key_copy != "") { + callback := MoveWindowToDesktopNumber.Bind(num_copy) + RegisterDesktopHotkey("move_absolute", key_copy, num_copy, callback) + } + LogVirtualDesktopHotkeys("map move hotkey=" key_copy " desktop=" num_copy) } } HotIf diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 7c6fb7f..979766f 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -20,6 +20,7 @@ LoadConfig(config_path, default_config := Map()) { errors := ValidateConfig(config, ConfigSchema()) ValidateSuperKeys(config, errors) ValidateApps(config, errors) + ValidateVirtualDesktopHotkeys(config, errors) return Map( "config", config, @@ -118,7 +119,13 @@ ConfigSchema() { "hotkey", "string", "desktop", "number" )], - "debug_cycle", "bool" + "debug_cycle", "bool", + "debug_hotkeys", "bool", + "desktop_hotkeys_duplicates", OptionalSpec([Map( + "hotkey", "string", + "desktop", "number", + "existing", "number" + )]) ), "directional_focus", Map( "enabled", "bool", @@ -178,6 +185,8 @@ NormalizeVirtualDesktopConfig(config) { vd_config := config["virtual_desktop"] desktop_hotkeys := [] + duplicates := [] + seen_hotkeys := Map() keys_to_remove := [] for key, val in vd_config { @@ -190,18 +199,30 @@ NormalizeVirtualDesktopConfig(config) { if !(entry is Map) continue if entry.Has("hotkey") && entry["hotkey"] != "" { - desktop_hotkeys.Push(Map( - "desktop", desktop_num, - "hotkey", entry["hotkey"] - )) + hotkey := entry["hotkey"] + if seen_hotkeys.Has(hotkey) { + duplicates.Push(Map("hotkey", hotkey, "desktop", desktop_num, "existing", seen_hotkeys[hotkey])) + } else { + seen_hotkeys[hotkey] := desktop_num + desktop_hotkeys.Push(Map( + "desktop", desktop_num, + "hotkey", hotkey + )) + } } } } else if (val is Map) { if val.Has("hotkey") && val["hotkey"] != "" { - desktop_hotkeys.Push(Map( - "desktop", desktop_num, - "hotkey", val["hotkey"] - )) + hotkey := val["hotkey"] + if seen_hotkeys.Has(hotkey) { + duplicates.Push(Map("hotkey", hotkey, "desktop", desktop_num, "existing", seen_hotkeys[hotkey])) + } else { + seen_hotkeys[hotkey] := desktop_num + desktop_hotkeys.Push(Map( + "desktop", desktop_num, + "hotkey", hotkey + )) + } } } } @@ -209,6 +230,9 @@ NormalizeVirtualDesktopConfig(config) { if (desktop_hotkeys.Length > 0) vd_config["desktop_hotkeys"] := desktop_hotkeys + if (duplicates.Length > 0) + vd_config["desktop_hotkeys_duplicates"] := duplicates + for _, key in keys_to_remove vd_config.Delete(key) } @@ -257,6 +281,23 @@ ValidateApps(config, errors) { } } +ValidateVirtualDesktopHotkeys(config, errors) { + if !config.Has("virtual_desktop") || !(config["virtual_desktop"] is Map) + return + vd_config := config["virtual_desktop"] + if vd_config.Has("desktop_hotkeys_duplicates") && (vd_config["desktop_hotkeys_duplicates"] is Array) { + for _, entry in vd_config["desktop_hotkeys_duplicates"] { + if !(entry is Map) + continue + hotkey := entry.Has("hotkey") ? entry["hotkey"] : "" + desktop := entry.Has("desktop") ? entry["desktop"] : "" + existing := entry.Has("existing") ? entry["existing"] : "" + if (hotkey != "") + errors.Push("config.virtual_desktop.hotkey '" hotkey "' maps to desktop " desktop " but is already mapped to " existing) + } + } +} + ValidateNode(value, spec, path, errors) { if (spec is Map) { if spec.Has("__optional__") { From 5012321e9038fbf04f6220f64e0390aa614434ef Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 23:25:57 -0600 Subject: [PATCH 12/20] script tray icon shows current desktop number ``` [virtual_desktop] tray_indicator = true tray_format = "{current}/{total}" ``` --- README.md | 63 +++++++----------- config/config.example.toml | 2 + harken.ahk | 4 +- src/lib/config_loader.ahk | 2 + src/lib/virtual_desktop.ahk | 129 ++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 39af029..d1b9737 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Harken 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. +The aim is a keyboard-centered workflow on Windows: 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 - [Overview](#overview) @@ -24,6 +24,20 @@ The aim is a low-friction workflow: a single super modifier, mnemonic app keys, ### Features +- Launch-or-focus your most common programs with the hotkeys you assign in your config file. +- Focused tile includes border highlight that can be customized, including per app, window class, or window title. +- Snap tiles to grid and cycle through various positions with subsequent key presses. +- Freely move tiles with your keyboard. +- Resize tiles and their edges with your keyboard. +- Directional focus changes with vim-like motions. +- Cycle stacked tiles with `super + [` and `super + ]`. +- Cycle focus between tiles of the same program with `super + c`. +- Show a hotkey menu with `super + /`. +- Virtual desktops (native) + - Navigate between desktops with better hotkeys + - Assign custom hotkeys for the index-based virtual desktops. + + 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) @@ -59,7 +73,8 @@ Other - `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 -- Custom border params per exe/title/class. For example, color certain vscode project windows with different colors/thickness. +- `super + alt + h/l` to move between desktops +- `super + shift + alt + h/l` send current tile to adjacent desktop Enter Command Mode with `super + ;`. - `r` to reload program/config @@ -68,6 +83,13 @@ Enter Command Mode with `super + ;`. - `n` toggles the command overlay on or off - `i` opens the [Helper Utility](#helper-utility) +## TODO + +- Clarify and work on areas of state + - saving layouts + - per app configs determining where things go (virtual desktop destination is supported now) +- Possibly rework the config schema. It's hectic. + ## Configuration @@ -78,43 +100,6 @@ Enter Command Mode with `super + ;`. - The repository example lives at `config/config.example.toml`. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. -### Default Config Keys -- `super_key`: key or list of keys used as the super modifier (e.g., `CapsLock` or `["F24", "CapsLock"]`). -- `apps`: list of app bindings with `hotkey`, `win_title`/`match`, and `run` command. Omit `hotkey` for matcher-only entries (they won't appear in the Command Overlay). -- `apps[].run_paths`: optional list of directories to search for the executable. -- `apps[].run_start_in`: optional working directory for launching the app. -- `apps[].match`: optional match map with `exe`, `class`, `title`, and `*_regex` flags (exact match is case-insensitive by default; regex is also case-insensitive). -- `apps[].focus_border`: optional per-app focus border overrides (colors, thickness, and corner radius). -- `apps[].desktop`: optional virtual desktop number to move new windows to. -- `apps[].follow_on_spawn`: optional override for whether to switch to the app's desktop after launching (default true). -- `global_hotkeys`: array of scoped hotkey bindings (set `target_exes` empty for global use). -- `window`: resize/move steps and hotkeys (including move mode). -- `window.minimize_others_hotkey`: optional hotkey to minimize all other windows on the current monitor. -- `window.cycle_app_windows_current_hotkey`: hotkey to cycle app windows on the current desktop only. -- `window_selector`: Window Selector settings (hotkey, match fields, display limits). -- `window_manager`: grid size, margins, gaps, and ignored window classes. -- `virtual_desktop`: cross-desktop focus/cycle settings, desktop auto-creation, and virtual desktop hotkeys. -- `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 (including command mode color). -- `helper`: command overlay settings. -- `reload`: hotkey and file watch settings for config reload. - -### Window Matching -- `apps[].win_title` accepts standard AutoHotkey window selectors. -- Common forms: plain title text, `ahk_exe `, `ahk_class `, `ahk_pid `. -- Use `ahk_exe` for stable matching when window titles change (e.g., tabs/documents). -- Plain title text supports AutoHotkey's standard title matching and wildcards (e.g., `* - Notepad`). -- `ahk_exe`, `ahk_class`, and `ahk_pid` are exact matches; `win_title` does not support regex (use `apps[].match` for regex). -- `apps[].match` provides explicit matching by `exe`, `class`, and/or `title` with optional `*_regex` flags (non-regex is exact, case-insensitive). - -### Virtual Desktop Hotkeys -- `virtual_desktop.prev_hotkey` / `virtual_desktop.next_hotkey`: switch to previous/next desktop while holding super+alt (defaults to `h` / `l`). -- `virtual_desktop.move_prev_hotkey` / `virtual_desktop.move_next_hotkey`: move the active window to previous/next desktop (follow) while holding super+alt+shift (defaults to `h` / `l`). -- `[[virtual_desktop.]]`: map a desktop number to a hotkey. `super + alt + ` switches to desktop `N`, `super + alt + shift + ` moves the active window to desktop `N` (follow). - -### Path Expansion -- `apps[].run_paths` supports environment variables like `%APPDATA%` and `%LOCALAPPDATA%`. -- `~\` expands to your user profile (e.g., `~\AppData\Roaming`). ### Helper Utility - `tools/window_inspector.ahk` lists active window titles, exe names, classes, and PIDs. diff --git a/config/config.example.toml b/config/config.example.toml index 2584fb1..7469e14 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -108,6 +108,8 @@ move_prev_hotkey = "h" move_next_hotkey = "l" # debug_cycle = false # debug_hotkeys = false +# tray_indicator = false +# tray_format = "{current}/{total}" # hotkey fields use AHK syntax without super (super is implied) # modifiers are applied by Harken (alt or alt+shift) for desktop actions diff --git a/harken.ahk b/harken.ahk index 5a40e3b..85a557b 100644 --- a/harken.ahk +++ b/harken.ahk @@ -136,7 +136,9 @@ DefaultConfig() { "goto_hotkeys", [], "move_hotkeys", [], "debug_cycle", false, - "debug_hotkeys", false + "debug_hotkeys", false, + "tray_indicator", false, + "tray_format", "{current}/{total}" ), "directional_focus", Map( "enabled", true, diff --git a/src/lib/config_loader.ahk b/src/lib/config_loader.ahk index 979766f..d1e3e0c 100644 --- a/src/lib/config_loader.ahk +++ b/src/lib/config_loader.ahk @@ -121,6 +121,8 @@ ConfigSchema() { )], "debug_cycle", "bool", "debug_hotkeys", "bool", + "tray_indicator", "bool", + "tray_format", "string", "desktop_hotkeys_duplicates", OptionalSpec([Map( "hotkey", "string", "desktop", "number", diff --git a/src/lib/virtual_desktop.ahk b/src/lib/virtual_desktop.ahk index 3398442..636e04f 100644 --- a/src/lib/virtual_desktop.ahk +++ b/src/lib/virtual_desktop.ahk @@ -9,6 +9,13 @@ VirtualDesktopEnabled() { return Config["virtual_desktop"]["enabled"] } +VirtualDesktopTrayEnabled() { + global Config + if !VirtualDesktopEnabled() + return false + return Config["virtual_desktop"].Has("tray_indicator") && Config["virtual_desktop"]["tray_indicator"] +} + VirtualDesktopSwitchOnFocus() { global Config if !VirtualDesktopEnabled() @@ -22,6 +29,128 @@ InitVirtualDesktop() { ensure_count := Config["virtual_desktop"]["ensure_count"] if (ensure_count > 0) VD.createUntil(ensure_count) + InitVirtualDesktopTrayIndicator() +} + +InitVirtualDesktopTrayIndicator() { + if !VirtualDesktopTrayEnabled() + return + UpdateVirtualDesktopTrayIndicator() + VD.ListenersCurrentVirtualDesktopChanged[UpdateVirtualDesktopTrayIndicator] := true +} + +UpdateVirtualDesktopTrayIndicator(*) { + if !VirtualDesktopTrayEnabled() + return + current := GetCurrentDesktopNumFresh() + if (current <= 0) + current := VD.getCurrentDesktopNum() + total := VD.getCount() + if (total <= 0) + return + text := FormatVirtualDesktopTrayText(current, total) + try A_TrayMenu.SetTip(text) + SetTrayIconText(text) +} + +FormatVirtualDesktopTrayText(current, total) { + global Config + format := "{current}/{total}" + if Config.Has("virtual_desktop") && Config["virtual_desktop"].Has("tray_format") + format := Config["virtual_desktop"]["tray_format"] + format := StrReplace(format, "{current}", current) + format := StrReplace(format, "{total}", total) + return format +} + +SetTrayIconText(text) { + if (text = "") + return + hicon := CreateTextTrayIcon(text) + if !hicon + return + TraySetIcon("HICON:" hicon) + global tray_indicator_hicon + if (IsSet(tray_indicator_hicon) && tray_indicator_hicon) + DllCall("user32\DestroyIcon", "Ptr", tray_indicator_hicon) + tray_indicator_hicon := hicon +} + +CreateTextTrayIcon(text) { + icon_size := 32 + hdc := DllCall("gdi32\CreateCompatibleDC", "Ptr", 0, "Ptr") + if !hdc + return 0 + + bi := Buffer(40, 0) + NumPut("UInt", 40, bi, 0) + NumPut("Int", icon_size, bi, 4) + NumPut("Int", -icon_size, bi, 8) + NumPut("UShort", 1, bi, 12) + NumPut("UShort", 32, bi, 14) + NumPut("UInt", 0, bi, 16) + ppv_bits := 0 + hbm_color := DllCall("gdi32\CreateDIBSection", "Ptr", hdc, "Ptr", bi, "UInt", 0, "Ptr*", &ppv_bits, "Ptr", 0, "UInt", 0, "Ptr") + if !hbm_color { + DllCall("gdi32\\DeleteDC", "Ptr", hdc) + return 0 + } + + hbm_mask := DllCall("gdi32\CreateBitmap", "Int", icon_size, "Int", icon_size, "UInt", 1, "UInt", 1, "Ptr", 0, "Ptr") + old_bmp := DllCall("gdi32\SelectObject", "Ptr", hdc, "Ptr", hbm_color, "Ptr") + + DllCall("gdi32\SetBkMode", "Ptr", hdc, "Int", 1) + + font_height := -Round(icon_size * 0.55) + hfont := DllCall("gdi32\CreateFontW", "Int", font_height, "Int", 0, "Int", 0, "Int", 0, "Int", 600, "UInt", 0, "UInt", 0, "UInt", 0, "UInt", 0, "UInt", 0, "UInt", 0, "UInt", 0, "UInt", 0, "WStr", "Segoe UI", "Ptr") + old_font := 0 + if hfont + old_font := DllCall("gdi32\SelectObject", "Ptr", hdc, "Ptr", hfont, "Ptr") + + rect := Buffer(16, 0) + NumPut("Int", 0, rect, 0) + NumPut("Int", 0, rect, 4) + NumPut("Int", icon_size, rect, 8) + NumPut("Int", icon_size, rect, 12) + format := 0x00000001 | 0x00000004 | 0x00000020 + + DllCall("gdi32\SetTextColor", "Ptr", hdc, "UInt", 0x000000) + rect_shadow := Buffer(16, 0) + NumPut("Int", 1, rect_shadow, 0) + NumPut("Int", 1, rect_shadow, 4) + NumPut("Int", icon_size + 1, rect_shadow, 8) + NumPut("Int", icon_size + 1, rect_shadow, 12) + DllCall("user32\DrawTextW", "Ptr", hdc, "WStr", text, "Int", -1, "Ptr", rect_shadow, "UInt", format) + + DllCall("gdi32\SetTextColor", "Ptr", hdc, "UInt", 0xFFFFFF) + DllCall("user32\DrawTextW", "Ptr", hdc, "WStr", text, "Int", -1, "Ptr", rect, "UInt", format) + + if hfont { + if old_font + DllCall("gdi32\SelectObject", "Ptr", hdc, "Ptr", old_font) + DllCall("gdi32\DeleteObject", "Ptr", hfont) + } + if old_bmp + DllCall("gdi32\SelectObject", "Ptr", hdc, "Ptr", old_bmp) + DllCall("gdi32\DeleteDC", "Ptr", hdc) + + iconinfo := Buffer(A_PtrSize == 8 ? 32 : 20, 0) + NumPut("UInt", 1, iconinfo, 0) + NumPut("UInt", 0, iconinfo, 4) + NumPut("UInt", 0, iconinfo, 8) + if (A_PtrSize == 8) { + NumPut("Ptr", hbm_mask, iconinfo, 16) + NumPut("Ptr", hbm_color, iconinfo, 24) + } else { + NumPut("Ptr", hbm_mask, iconinfo, 12) + NumPut("Ptr", hbm_color, iconinfo, 16) + } + hicon := DllCall("user32\CreateIconIndirect", "Ptr", iconinfo, "Ptr") + + DllCall("gdi32\DeleteObject", "Ptr", hbm_color) + DllCall("gdi32\DeleteObject", "Ptr", hbm_mask) + + return hicon } RefreshVirtualDesktopState() { From 90590c33d9b0cbccaaf5abf25ba05a0587f07c2b Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Fri, 6 Feb 2026 23:52:22 -0600 Subject: [PATCH 13/20] adds keybindings table to readme and minor commentary to config.example.toml --- README.md | 80 +++++++++++++++++++++++++++++++++++--- config/config.example.toml | 32 +++++++++------ 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d1b9737..896ddf4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Harken A window manager written in AutoHotkey v2. -The aim is a keyboard-centered workflow on Windows: a single super modifier, mnemonic app keys, and fast window actions. Alt+Tab and Win+Tab still work, but you will hardly use them. +Harken allows a keyboard-centered workflow on Windows: 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 - [Overview](#overview) @@ -95,11 +95,77 @@ Enter Command Mode with `super + ;`. ### Quick start -- 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/harken/harken.toml`. -- The repository example lives at `config/config.example.toml`. +- If not using the binary, make sure to install AutoHotKey 2.1-alpha18 or newer. +- 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 `harken.ahk` directly as an alternative. +- The program might fail on first run? Probably something to do with the config. For now you can create the config first to _maybe_ avoid the initial-crash scenario. +- Press `e` to open the config file. You can also find it manually in `~/.config/harken/harken.toml` as it will be created on first run. - After making changes to your config you can reload the config (the entire program, actually) with `r` while in command mode. +### All default keybindings + +#### Window management (Super) + +| Shortcut | Action | +| --- | --- | +| `super + /` | Show command overlay (temporary) | +| `super + w` | Window selector (window walker) | +| `super + c` | Cycle app windows on current desktop | +| `super + shift + c` | Cycle app windows across desktops (not working) | +| `super + space` | Center width cycle | +| `super + m` | Maximize/un-maximize | +| `super + q` | Close window | +| `super + Left/Right/Up/Down` | Resize window and snap to grids | +| `super + shift + h/j/k/l` | Resize centered | +| `super + ctrl + h/j/k/l` | Move window | +| `super` (double tap) | Toggle move mode | + +#### Window management (Move mode) + +| Shortcut | Action | +| --- | --- | +| `h/j/k/l` | Move window | +| `Esc` or `super` | Exit move mode | + +#### Focus navigation (Alt) + +| Shortcut | Action | +| --- | --- | +| `alt + h/l` | Focus left/right | +| `alt + j/k` | Focus down/up | +| `alt + [` / `alt + ]` | Cycle stacked (prev/next) | + +#### Virtual desktops + +| Shortcut | Action | +| --- | --- | +| `super + alt + h/l` | Previous/next desktop | +| `super + alt + shift + h/l` | Move window to previous/next desktop (follow) | +| `super + alt + ` | Go to mapped desktop (`[[virtual_desktop.]]`) | +| `super + alt + shift + ` | Move window to mapped desktop (follow) | + +#### Apps (defaults) + +These are examples for the launch-or-focus keybindings. + +| Shortcut | Action | +| --- | --- | +| `super + e` | Files (`explorer.exe`) | +| `super + v` | Editor (`Code.exe`) | +| `super + s` | Terminal (`WindowsTerminal.exe`) | +| `super + n` | Notes (`notepad++.exe`) | + +#### Command mode + +| Shortcut | Action | +| --- | --- | +| `super + ;` | Enter command mode | +| `r` | Reload program/config | +| `e` | Open config file | +| `w` | Open a new window for the active app | +| `n` | Toggle command overlay | +| `i` | Open window inspector | +| `Esc` | Exit command mode | + ### Helper Utility - `tools/window_inspector.ahk` lists active window titles, exe names, classes, and PIDs. @@ -107,9 +173,9 @@ Enter Command Mode with `super + ;`. - In Command Mode, press `i` to launch the window inspector. - Use Refresh to update the list; Copy Selected/All or Export to save results. -## Known Limitations +## Limitations - This has not been tested with multi-monitor setups or much outside of ultra-wide monitors. -- Virtual desktop integration requires AutoHotkey v2.1 alpha or later. +- Virtual desktop integration requires AutoHotkey v2.1-alpha-18 or later. - 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 Harken hotkeys unless Harken is run as Administrator. @@ -117,6 +183,8 @@ Enter Command Mode with `super + ;`. ## Third-Party - JXON (AHK v2 JSON serializer) from https://github.com/TheArkive/JXON_ahk2 - License: `LICENSES/JXON_ahk2-LICENSE.md` +- VD.ahk from https://github.com/FuPeiJiang/VD.ahk + - License: `LICENSES/VD.ahk-LICENSE.md` ## Similar tools and inspirations diff --git a/config/config.example.toml b/config/config.example.toml index 7469e14..295caeb 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,4 +1,5 @@ config_version = 1 +# Use one or more keys as super (CapsLock is the default). super_key = ["F24", "CapsLock"] [[apps]] @@ -45,6 +46,7 @@ hotkey = "t" win_title = "ahk_exe telegram.exe" run = "telegram" run_paths = [] +# desktop = 2 [[apps]] id = "discord" @@ -52,23 +54,29 @@ hotkey = "d" win_title = "ahk_exe discord.exe" run = "discord" run_paths = [] +# desktop = 2 [[apps]] id = "vscode-border" match = { exe = "Code.exe", title = " - Visual Studio Code$", title_regex = true } -focus_border = { border_color = "#4D96FF", border_thickness = 3 } +# Optional per-app focus border tweaks. +focus_border = { border_color = "#4D96FF", border_thickness = 3, corner_radius = 12lll } [[global_hotkeys]] enabled = true hotkey = "Alt" target_exes = [] +# Send Ctrl+Tab when holding super+alt. +# Will require virtual desktop hotkeys to be used without pressing super first in the combo. send_keys = "^{Tab}" [window] resize_step = 20 move_step = 20 super_double_tap_ms = 300 +# Cycle app windows across all desktops. cycle_app_windows_hotkey = "c" +# Cycle app windows on the current desktop only. cycle_app_windows_current_hotkey = "+c" center_width_cycle_hotkey = "Space" @@ -102,24 +110,26 @@ enabled = true switch_on_focus = true ensure_count = 0 cycle_prefer_current = true +# Hotkey fields here use AHK syntax without super (super is implied). +# Modifiers are applied by Harken (alt or alt+shift) for desktop actions. prev_hotkey = "h" next_hotkey = "l" move_prev_hotkey = "h" move_next_hotkey = "l" -# debug_cycle = false -# debug_hotkeys = false -# tray_indicator = false -# tray_format = "{current}/{total}" -# hotkey fields use AHK syntax without super (super is implied) -# modifiers are applied by Harken (alt or alt+shift) for desktop actions +# Debug logs write to %APPDATA%\harken when enabled. +debug_cycle = false +debug_hotkeys = false +tray_indicator = true +tray_format = "{current}/{total}" [[virtual_desktop.1]] -hotkey = "u" +hotkey = "1" [[virtual_desktop.2]] -hotkey = "i" +hotkey = "2" [directional_focus] +# Directional focus was tricky to make good enough. enabled = true stacked_overlap_threshold = 0.5 stack_tolerance_px = 25 @@ -132,7 +142,7 @@ debug_enabled = false [focus_border] enabled = true -border_color = "#357EC7" +border_color = "#222feb" move_mode_color = "#2ECC71" command_mode_color = "#FFD400" border_thickness = 4 @@ -148,7 +158,7 @@ enabled = true hotkey = "r" super_key_required = true watch_enabled = false -watch_interval_ms = 1000 +watch_interval_ms = 2500 mode_enabled = true mode_hotkey = ";" mode_timeout_ms = 20000 From 0d54c1258093de39dbf2eadef8bb78c76d151fcd Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 00:05:34 -0600 Subject: [PATCH 14/20] fix build --- tools/build_release.ps1 | 60 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 115767b..60705bb 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -20,6 +20,22 @@ function Get-AssetUrl($repo, $pattern) { return $asset.browser_download_url } +function Get-AlphaAssetUrl($repo, $pattern) { + if ($repo -eq "AutoHotkey/AutoHotkey") { + $version = Invoke-RestMethod -Uri "https://www.autohotkey.com/download/2.1/version.txt" + $version = $version.Trim() + if (!$version) { throw "Alpha version not found at autohotkey.com" } + return "https://www.autohotkey.com/download/2.1/AutoHotkey_$version.zip" + } + + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" + $alpha = $releases | Where-Object { $_.tag_name -match "alpha" } | Select-Object -First 1 + if (!$alpha) { throw "Alpha release not found for $repo" } + $asset = $alpha.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 + if (!$asset) { throw "Alpha release asset not found for $repo" } + return $asset.browser_download_url +} + function Download-And-Extract($url, $dest_dir) { $zip_path = Join-Path $dest_dir "download.zip" Invoke-WebRequest -Uri $url -OutFile $zip_path @@ -27,9 +43,24 @@ function Download-And-Extract($url, $dest_dir) { Remove-Item $zip_path -Force } +function Test-AhkAlpha($path) { + if (!(Test-Path $path)) { return $false } + try { + $ver = (Get-Item $path).VersionInfo.FileVersion + $parsed = [version]$ver + return ($parsed.Major -ge 2 -and $parsed.Minor -ge 1) + } catch { + return $false + } +} + if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter Ahk2Exe.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahk2Exe (latest release)..." - $url = Get-AssetUrl "AutoHotkey/Ahk2Exe" "^Ahk2Exe.*\.zip$" + Write-Host "Downloading Ahk2Exe (v2.1 alpha if available)..." + try { + $url = Get-AlphaAssetUrl "AutoHotkey/Ahk2Exe" "^Ahk2Exe.*\.zip$" + } catch { + $url = Get-AssetUrl "AutoHotkey/Ahk2Exe" "^Ahk2Exe.*\.zip$" + } Download-And-Extract $url $ahk_path } @@ -48,9 +79,15 @@ if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkeySC.bin -ErrorActi } } -if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkeySC.bin -ErrorAction SilentlyContinue) -and !(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue) -and !(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey32.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading AutoHotkey base bin (latest release)..." - $url = Get-AssetUrl "AutoHotkey/AutoHotkey" "^AutoHotkey_.*\.zip$" +$existing_ahk64 = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +$existing_ahk32 = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey32.exe -ErrorAction SilentlyContinue | Select-Object -First 1 +$needs_alpha = $true +if ($existing_ahk64 -and (Test-AhkAlpha $existing_ahk64.FullName)) { $needs_alpha = $false } +elseif ($existing_ahk32 -and (Test-AhkAlpha $existing_ahk32.FullName)) { $needs_alpha = $false } + +if ($needs_alpha) { + Write-Host "Downloading AutoHotkey v2.1 alpha base bin..." + $url = Get-AlphaAssetUrl "AutoHotkey/AutoHotkey" "^AutoHotkey_2\.1-alpha.*\.zip$" Download-And-Extract $url $ahk_path } @@ -81,9 +118,18 @@ $main_args = @( ) -$null = Start-Process -FilePath $compiler -ArgumentList $main_args -NoNewWindow -Wait -PassThru +$stdout_path = Join-Path $out_path "ahk2exe.stdout.log" +$stderr_path = Join-Path $out_path "ahk2exe.stderr.log" +if (Test-Path $stdout_path) { Remove-Item $stdout_path -Force } +if (Test-Path $stderr_path) { Remove-Item $stderr_path -Force } + +$proc = Start-Process -FilePath $compiler -ArgumentList $main_args -NoNewWindow -Wait -PassThru -RedirectStandardOutput $stdout_path -RedirectStandardError $stderr_path if (!(Test-Path $exe_path)) { - throw "Ahk2Exe did not produce harken.exe. Check the compiler base file and try again." + $stdout = "" + $stderr = "" + if (Test-Path $stdout_path) { $stdout = Get-Content $stdout_path -Raw } + if (Test-Path $stderr_path) { $stderr = Get-Content $stderr_path -Raw } + throw "Ahk2Exe did not produce harken.exe (exit $($proc.ExitCode)).`nSTDOUT:`n$stdout`nSTDERR:`n$stderr" } Write-Host "Compiled: $exe_path" From fdada0387cf3cc0645a801ff90e00e8de00e091b Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 00:05:51 -0600 Subject: [PATCH 15/20] auto update AGENTS.md; needs tweaks --- AGENTS.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 267dc8a..8e50ef7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,16 @@ Dev build (CI): Single test / focused run: - No unit test runner exists. Use manual verification steps (launch `harken.ahk`, validate hotkeys, reload flow, and Command Overlay) or run targeted helper tools in `tools/`. +- Focused validation (pick relevant items): + - Launch `harken.ahk` with a clean config and verify: super hotkeys, window cycling, overlay. + - If virtual desktops touched: test `super+alt+h/l`, mapped desktop hotkeys, and tray indicator. ## Code Style Guidelines ### AutoHotkey version and file headers - Use AutoHotkey v2 syntax and conventions. -- Keep `#Requires AutoHotkey v2.0` in entry points and new top-level scripts. +- Virtual desktop integration requires AutoHotkey v2.1 alpha (VD.ahk dependency). +- Keep `#Requires AutoHotkey v2.0` in entry points and new top-level scripts unless a file + specifically requires v2.1 alpha features. - Keep `#Include` statements at the top and ordered by dependency. ### Formatting @@ -62,6 +67,7 @@ Single test / focused run: - Use clear, user-facing error messages for missing dependencies or invalid config. - For config validation, return error arrays and handle them in the entry point. - Avoid throwing for normal control flow. +- Guard `WinGetID("A")` calls when no active window is possible (use `try`/`catch`). ### Config handling - Keep user configuration separate from core behavior. @@ -72,15 +78,22 @@ Single test / focused run: - Schema in `src/lib/config_loader.ahk` - `config/config.example.toml` - `README.md` +- If adding virtual desktop hotkeys: + - Normalize new config formats in `NormalizeVirtualDesktopConfig`. + - Update debug logs and config validation for duplicate hotkeys. ### Hotkeys and window behavior - Keep hotkey registration centralized under `src/hotkeys/`. - Avoid direct global state unless required; prefer explicit `global` declarations when needed. - For window manipulation, consider edge cases with elevated windows and multiple monitors. +- Be careful with modifier ordering: use `HotIf` guards and wildcard hotkeys when needed. ### UI helpers - GUI helpers (overlays, inspectors) should remain non-blocking and lightweight. - Prefer explicit refresh actions instead of continuous loops when possible. +- Command overlay behavior: + - Normal mode: `super + /` shows temporary overlay; any key hides it. + - Command/move modes: overlay stays visible and is centered on screen. ### Third-party code - Keep third-party code in `src/lib/` and document licensing in `LICENSES/`. @@ -97,7 +110,9 @@ Single test / focused run: - Launch `harken.ahk` with a clean `harken.toml` and verify hotkeys. - Validate reload flow (normal hotkey and command mode). - Confirm Command Overlay and helper tools still open and update. -- If touching config schema, ensure errors log correctly in `~/.config/harken/config.errors.log`. +- If touching config schema, ensure errors log correctly in `%APPDATA%\harken\config.errors.log`. +- If touching tray indicator: confirm tray icon updates on desktop change. +- If touching cycling: verify `super+c` (all desktops) and `super+shift+c` (current desktop). ## Paths and Layout Notes - Main script: `harken.ahk`. @@ -105,6 +120,7 @@ Single test / focused run: - JSON parsing: `src/lib/JXON.ahk`. - Window manager: `src/lib/window_manager.ahk`. - Hotkeys: `src/hotkeys/*.ahk`. +- Virtual desktop helpers: `src/lib/virtual_desktop.ahk` + `src/lib/VD.ahk`. ## Build Artifacts - `dist/harken.exe` @@ -115,3 +131,13 @@ Single test / focused run: - Keep behavior consistent with existing hotkeys and overlays. - Document any new public functions or configuration keys. - Favor explicitness over cleverness. + +## Recent Project-Specific Notes +- Debug logs: + - `virtual_desktop.debug_cycle` writes `%APPDATA%\harken\cycle.debug.log`. + - `virtual_desktop.debug_hotkeys` writes `%APPDATA%\harken\vd.hotkeys.log` and `vd.actions.log`. + - Logs reset on startup when debug flags are enabled. +- Tray indicator: + - `virtual_desktop.tray_indicator` draws `{current}/{total}` on the existing tray icon. + - Tooltip uses `virtual_desktop.tray_format`. +- Window cycling across desktops uses a cache to include off-desktop windows. From 2c0056d613926d07385525ff398be275a7f0252a Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 00:07:55 -0600 Subject: [PATCH 16/20] fix build; pin to 2.1-alpha18 --- tools/build_release.ps1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 60705bb..7608e85 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -22,9 +22,7 @@ function Get-AssetUrl($repo, $pattern) { function Get-AlphaAssetUrl($repo, $pattern) { if ($repo -eq "AutoHotkey/AutoHotkey") { - $version = Invoke-RestMethod -Uri "https://www.autohotkey.com/download/2.1/version.txt" - $version = $version.Trim() - if (!$version) { throw "Alpha version not found at autohotkey.com" } + $version = "2.1-alpha.18" return "https://www.autohotkey.com/download/2.1/AutoHotkey_$version.zip" } From 83c186f39e57dcd86b89721050f1ea8091ec2a37 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 12:01:41 -0600 Subject: [PATCH 17/20] simply build_release.ps1 and pin versions --- tools/build_release.ps1 | 113 +++++++++++----------------------------- 1 file changed, 29 insertions(+), 84 deletions(-) diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 7608e85..95edc5a 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -6,6 +6,8 @@ param( $ErrorActionPreference = "Stop" +$ahk_link = "https://www.autohotkey.com/download/2.1/AutoHotkey_2.1-alpha.18.zip" +$ahk_2exe_link = "https://github.com/AutoHotkey/Ahk2Exe/releases/download/Ahk2Exe1.1.37.02a0a/Ahk2Exe1.1.37.02a0.zip" $script_dir = Split-Path -Parent $MyInvocation.MyCommand.Path $repo_root = Join-Path $script_dir ".." | Resolve-Path $ahk_path = Join-Path $repo_root $ahk_dir @@ -13,91 +15,44 @@ $out_path = Join-Path $repo_root $out_dir New-Item -ItemType Directory -Force -Path $ahk_path | Out-Null -function Get-AssetUrl($repo, $pattern) { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" - $asset = $release.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 - if (!$asset) { throw "Release asset not found for $repo" } - return $asset.browser_download_url -} - -function Get-AlphaAssetUrl($repo, $pattern) { - if ($repo -eq "AutoHotkey/AutoHotkey") { - $version = "2.1-alpha.18" - return "https://www.autohotkey.com/download/2.1/AutoHotkey_$version.zip" - } - - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" - $alpha = $releases | Where-Object { $_.tag_name -match "alpha" } | Select-Object -First 1 - if (!$alpha) { throw "Alpha release not found for $repo" } - $asset = $alpha.assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 - if (!$asset) { throw "Alpha release asset not found for $repo" } - return $asset.browser_download_url -} - -function Download-And-Extract($url, $dest_dir) { - $zip_path = Join-Path $dest_dir "download.zip" - Invoke-WebRequest -Uri $url -OutFile $zip_path - Expand-Archive -Path $zip_path -DestinationPath $dest_dir -Force - Remove-Item $zip_path -Force -} - -function Test-AhkAlpha($path) { - if (!(Test-Path $path)) { return $false } - try { - $ver = (Get-Item $path).VersionInfo.FileVersion - $parsed = [version]$ver - return ($parsed.Major -ge 2 -and $parsed.Minor -ge 1) - } catch { - return $false - } +## +# Download AHK Prereqs +## + +# First, AHK +if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue)) { + Write-Host "Downloading Ahk2Exe (latest release)..." + $ahk_zip_path = Join-Path $ahk_path "ahk.zip" + Invoke-WebRequest -Uri $ahk_link -OutFile $ahk_zip_path + Expand-Archive -Path $ahk_zip_path -DestinationPath $ahk_path -Force + Remove-Item $ahk_zip_path -Force +} else { + Write-Host "./dist/Ahk2Exe.exe already exists, skipping download" } +# Second, Ahk2Exe if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter Ahk2Exe.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahk2Exe (v2.1 alpha if available)..." - try { - $url = Get-AlphaAssetUrl "AutoHotkey/Ahk2Exe" "^Ahk2Exe.*\.zip$" - } catch { - $url = Get-AssetUrl "AutoHotkey/Ahk2Exe" "^Ahk2Exe.*\.zip$" - } - Download-And-Extract $url $ahk_path -} - -if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkeySC.bin -ErrorAction SilentlyContinue) -and !(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue) -and !(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey32.exe -ErrorAction SilentlyContinue)) { - Write-Host "Searching for AutoHotkey base bin in installed locations..." - $install_paths = @() - if ($env:ProgramFiles) { $install_paths += Join-Path $env:ProgramFiles "AutoHotkey\\Compiler" } - if (${env:ProgramFiles(x86)}) { $install_paths += Join-Path ${env:ProgramFiles(x86)} "AutoHotkey\\Compiler" } - - foreach ($path in $install_paths) { - $bin = Join-Path $path "AutoHotkeySC.bin" - if (Test-Path $bin) { - Copy-Item -Path $bin -Destination (Join-Path $ahk_path "AutoHotkeySC.bin") -Force - break - } - } -} - -$existing_ahk64 = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -$existing_ahk32 = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey32.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -$needs_alpha = $true -if ($existing_ahk64 -and (Test-AhkAlpha $existing_ahk64.FullName)) { $needs_alpha = $false } -elseif ($existing_ahk32 -and (Test-AhkAlpha $existing_ahk32.FullName)) { $needs_alpha = $false } - -if ($needs_alpha) { - Write-Host "Downloading AutoHotkey v2.1 alpha base bin..." - $url = Get-AlphaAssetUrl "AutoHotkey/AutoHotkey" "^AutoHotkey_2\.1-alpha.*\.zip$" - Download-And-Extract $url $ahk_path + Write-Host "Downloading Ahk2Exe (latest release)..." + $ahk_2xe_zip_path = Join-Path $ahk_path "ahk2exe.zip" + Invoke-WebRequest -Uri $ahk_2exe_link -OutFile $ahk_2xe_zip_path + Expand-Archive -Path $ahk_2xe_zip_path -DestinationPath $ahk_path -Force + Remove-Item $ahk_2xe_zip_path -Force +} else { + Write-Host "./dist/Ahk2Exe.exe already exists, skipping download" } $compiler_item = Get-ChildItem -Path $ahk_path -Recurse -Filter Ahk2Exe.exe | Select-Object -First 1 $base_item = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkeySC.bin | Select-Object -First 1 if (!$base_item) { $base_item = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe | Select-Object -First 1 } -if (!$base_item) { $base_item = Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey32.exe | Select-Object -First 1 } if (!$compiler_item -or !$base_item) { throw "Ahk2Exe.exe or AutoHotkeySC.bin/AutoHotkey64.exe not found under $ahk_path. Install AutoHotkey or copy the base file into $ahk_path." } +## +# Build +## + $compiler = $compiler_item.FullName $base = $base_item.FullName @@ -115,19 +70,9 @@ $main_args = @( "/silent", "verbose" ) - -$stdout_path = Join-Path $out_path "ahk2exe.stdout.log" -$stderr_path = Join-Path $out_path "ahk2exe.stderr.log" -if (Test-Path $stdout_path) { Remove-Item $stdout_path -Force } -if (Test-Path $stderr_path) { Remove-Item $stderr_path -Force } - -$proc = Start-Process -FilePath $compiler -ArgumentList $main_args -NoNewWindow -Wait -PassThru -RedirectStandardOutput $stdout_path -RedirectStandardError $stderr_path +$null = Start-Process -FilePath $compiler -ArgumentList $main_args -NoNewWindow -Wait -PassThru if (!(Test-Path $exe_path)) { - $stdout = "" - $stderr = "" - if (Test-Path $stdout_path) { $stdout = Get-Content $stdout_path -Raw } - if (Test-Path $stderr_path) { $stderr = Get-Content $stderr_path -Raw } - throw "Ahk2Exe did not produce harken.exe (exit $($proc.ExitCode)).`nSTDOUT:`n$stdout`nSTDERR:`n$stderr" + throw "Ahk2Exe did not produce harken.exe. Check the logs and try again." } Write-Host "Compiled: $exe_path" From 6fcb4c71eadedf969cae505cc05c4e1c74d6ba8d Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 12:08:14 -0600 Subject: [PATCH 18/20] build_release.ps1 logging --- tools/build_release.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 95edc5a..9cfb937 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -10,6 +10,7 @@ $ahk_link = "https://www.autohotkey.com/download/2.1/AutoHotkey_2.1-alpha.18.zip $ahk_2exe_link = "https://github.com/AutoHotkey/Ahk2Exe/releases/download/Ahk2Exe1.1.37.02a0a/Ahk2Exe1.1.37.02a0.zip" $script_dir = Split-Path -Parent $MyInvocation.MyCommand.Path $repo_root = Join-Path $script_dir ".." | Resolve-Path +$ahk_dir = "./dist" $ahk_path = Join-Path $repo_root $ahk_dir $out_path = Join-Path $repo_root $out_dir @@ -21,7 +22,7 @@ New-Item -ItemType Directory -Force -Path $ahk_path | Out-Null # First, AHK if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahk2Exe (latest release)..." + Write-Host "Downloading Ahkv2 from $ahk_link..." $ahk_zip_path = Join-Path $ahk_path "ahk.zip" Invoke-WebRequest -Uri $ahk_link -OutFile $ahk_zip_path Expand-Archive -Path $ahk_zip_path -DestinationPath $ahk_path -Force @@ -32,7 +33,7 @@ if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorActi # Second, Ahk2Exe if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter Ahk2Exe.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahk2Exe (latest release)..." + Write-Host "Downloading Ahk2Exe from $ahk_2exe_link..." $ahk_2xe_zip_path = Join-Path $ahk_path "ahk2exe.zip" Invoke-WebRequest -Uri $ahk_2exe_link -OutFile $ahk_2xe_zip_path Expand-Archive -Path $ahk_2xe_zip_path -DestinationPath $ahk_path -Force From eaf3495021dff01cb72660ce24b6468b4a9c315a Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 12:32:46 -0600 Subject: [PATCH 19/20] fix dep on alpha release by hosting in a deps release --- tools/build_release.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 9cfb937..03034cc 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -6,7 +6,10 @@ param( $ErrorActionPreference = "Stop" -$ahk_link = "https://www.autohotkey.com/download/2.1/AutoHotkey_2.1-alpha.18.zip" +# Use the mirror to avoid 403 and not need to mess with useragent +# The alpha releases don't have a reliable mirror for automations currently +# so we created a deps release for 2.1-alpha.18 +$ahk_link = "https://github.com/grantith/harken/releases/download/deps/AutoHotkey_2.1-alpha.18.zip" $ahk_2exe_link = "https://github.com/AutoHotkey/Ahk2Exe/releases/download/Ahk2Exe1.1.37.02a0a/Ahk2Exe1.1.37.02a0.zip" $script_dir = Split-Path -Parent $MyInvocation.MyCommand.Path $repo_root = Join-Path $script_dir ".." | Resolve-Path @@ -22,7 +25,7 @@ New-Item -ItemType Directory -Force -Path $ahk_path | Out-Null # First, AHK if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahkv2 from $ahk_link..." + Write-Host "Downloading Ahkv2 from $ahk_link" $ahk_zip_path = Join-Path $ahk_path "ahk.zip" Invoke-WebRequest -Uri $ahk_link -OutFile $ahk_zip_path Expand-Archive -Path $ahk_zip_path -DestinationPath $ahk_path -Force @@ -33,7 +36,7 @@ if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter AutoHotkey64.exe -ErrorActi # Second, Ahk2Exe if (!(Get-ChildItem -Path $ahk_path -Recurse -Filter Ahk2Exe.exe -ErrorAction SilentlyContinue)) { - Write-Host "Downloading Ahk2Exe from $ahk_2exe_link..." + Write-Host "Downloading Ahk2Exe from $ahk_2exe_link" $ahk_2xe_zip_path = Join-Path $ahk_path "ahk2exe.zip" Invoke-WebRequest -Uri $ahk_2exe_link -OutFile $ahk_2xe_zip_path Expand-Archive -Path $ahk_2xe_zip_path -DestinationPath $ahk_path -Force From 383352a35271ec65287f58aa6e391f1dec422502 Mon Sep 17 00:00:00 2001 From: Grant Kilber Date: Sat, 7 Feb 2026 12:32:58 -0600 Subject: [PATCH 20/20] fix: occasional bug with alt+tab --- harken.ahk | 1 + 1 file changed, 1 insertion(+) diff --git a/harken.ahk b/harken.ahk index 85a557b..44f5642 100644 --- a/harken.ahk +++ b/harken.ahk @@ -424,6 +424,7 @@ MatchAppWindow(app, hwnd := 0) { return MatchWindowFields(app["match"], hwnd) if app.Has("win_title") && app["win_title"] != "" { + active_hwnd := 0 try active_hwnd := WinGetID("A") if (active_hwnd && hwnd = active_hwnd) return WinActive(app["win_title"])