| title | GridMove App Design |
|---|---|
| description | 记录 GridMove 的当前软件行为和实现细节,基于 `Sources/GridMove/` 代码。此文档用于对软件设计的整体理解和新功能。此文档随着软件迭代更新,反映当前设计和实现状态。 |
| status | active |
GridMove is a native macOS menu bar application for:
- applying predefined window layouts
- cycling layouts
- moving windows with pointer-triggered interactions
- relaying CLI layout actions to the running app
This document records the current software behavior and implementation details based on the code in Sources/GridMove/.
The current codebase implements these user-visible surfaces:
- menu bar app
- settings window with
General,Layouts,Appearance,Hotkeys, andAbouttabs - CLI entrypoint for layout actions
- JSON configuration at
~/.config/GridMove/config.json - layout-group files at
~/.config/GridMove/layout/*.grid.json
The settings window now uses the real configuration model:
General,Appearance, andHotkeysapply immediately and save through the shared settings action pathLayoutskeeps a draft and only saves when the user clicksSave- switching away from
Layoutskeeps that draft in memory without saving it - closing the settings window discards any unsaved
Layoutsdraft Aboutcan manually reload configuration and restore the built-in defaults
UI.md records the accepted interaction structure for this window.
The runtime is coordinated by AppDelegate.
Main responsibilities:
- load configuration on launch
- keep the menu bar state in sync with the current configuration
- start and stop the drag controller and shortcut controller based on Accessibility status
- listen for remote CLI commands
- request Accessibility permission from the system when access is missing
- post a success notification when manual config reload applies the full config without skipped files
- post a diagnostic notification when manual config reload rejects the full config
- post a warning when manual config reload skips invalid layout files but still applies the remaining layout groups
Main runtime components:
AppDelegate: application lifecycle and coordinationConfigurationStore: JSON persistence and schema conversionWindowController: window lookup, focus, movement, layout applicationLayoutEngine: trigger-slot resolution and layout cyclingDragGridController: pointer-triggered runtime interactionShortcutController: global keyboard shortcut handlingLayoutActionExecutor: shared logic for menu, shortcut, and CLI actionsDistributedCommandRelay: CLI-to-app command relay
CLI layout lookup rules:
-layout <number>resolves the 1-based layout index inside the active layout group's indexed layouts-layout "<name>"resolves a layout by name inside the active layout group- duplicate layout names are allowed inside one group; CLI name lookup fails when more than one layout matches and reports the conflicting layout indexes
- internal layout IDs are not part of the CLI interface
Persistent configuration is stored in one main JSON file plus numbered layout-group JSON files.
Important properties:
- layout IDs are internal only and are regenerated from array order
- hotkey binding IDs are internal only and are regenerated from array order
config.jsonstoresgeneral,appearance,dragTriggers,hotkeys, andmonitorslayout/*.grid.jsonstores onelayoutGroups[]object per file- managed layout-group file names must match
<positive-integer>.grid.json - persisted
applyLayoutByIndexactions point to the 1-based layout index within the active layout group's indexed layouts - stroke colors are stored as
#RRGGBBAA general.activeLayoutGroupselects the currently active layout groupgeneral.launchAtLoginstores the desired login-item state and defaults tofalsewhen missing or invalidgeneral.mouseButtonNumberselects the hold-to-drag mouse button using user-facing numbering, where3is the standard middle button- the menu bar shows
Middle mouse dragwhengeneral.mouseButtonNumber == 3, andMouse button <n> dragfor other configured button numbers monitorsstores the last known monitor name by persistent display UUID using the shape"<monitor-uuid>": "<monitor-name>"- monitor metadata refresh only happens on app startup and manual reload; normal setting saves do not rescan displays
- previously learned monitor UUIDs remain in
monitorseven when those displays are currently disconnected layoutGroups[*].includeInGroupCyclecontrols whether layout-mode Shift cycling can switch to that grouplayoutGroups[*].protectprevents removing a protected group from the settings UI and defaults tofalsewhen missing or invalid- protected groups keep their names read-only in the settings UI
layoutGroups[*].sets[*].monitorroutes layouts toall,main, one monitor UUID, or multiple monitor UUIDslayoutGroups[*].sets[*].layoutsorder drives menu order, layout-index numbering, and same-display trigger precedencelayoutGroups[*].sets[*].layouts[*].includeInMenucontrols whether a layout appears in the menu bar- empty layout groups and empty monitor sets are allowed and remain inert at runtime
- the settings outline shows
Layout №<index>when a layout name is empty
Current drag-trigger configuration fields:
enableMouseButtonDragenableModifierLeftMouseDragpreferLayoutModeapplyLayoutImmediatelyWhileDraggingmodifierGroupsactivationDelayMillisecondsactivationMoveThreshold
The built-in default configuration currently resolves to the following values.
general
isEnabled = truelaunchAtLogin = falseexcludedBundleIDs = ["com.apple.Spotlight"]excludedWindowTitles = []mouseButtonNumber = 3
appearance
triggerHighlightMode = nonetriggerFillOpacity = 0.08triggerGap = 0triggerStrokeWidth = 2triggerStrokeColor = #00FDFFFFlayoutGap = 1(integer, points)renderWindowHighlight = truehighlightFillOpacity = 0.20highlightStrokeWidth = 3highlightStrokeColor = #666666
dragTriggers
enableMouseButtonDrag = trueenableModifierLeftMouseDrag = truepreferLayoutMode = trueapplyLayoutImmediatelyWhileDragging = falsemodifierGroups = [[ctrl, cmd, shift, alt], [ctrl, shift, alt]]activationDelayMilliseconds = 300activationMoveThreshold = 10
hotkeys
ctrl + cmd + shift + alt + l-> cycle next layoutctrl + cmd + shift + alt + j-> cycle previous layoutctrl + cmd + shift + alt + \-> apply active-group layout index4ctrl + cmd + shift + alt + [-> apply active-group layout index2ctrl + cmd + shift + alt + ]-> apply active-group layout index6ctrl + cmd + shift + alt + ;-> apply active-group layout index3ctrl + cmd + shift + alt + '-> apply active-group layout index7ctrl + cmd + shift + alt + --> apply active-group layout index1ctrl + cmd + shift + alt + =-> apply active-group layout index5ctrl + cmd + shift + alt + return-> apply active-group layout index10
layoutGroups
- the default
defaultgroup contains oneallset with 11 layouts - the default
fullscreengroup contains onemainset and one fallbackallset - both default groups participate in layout-mode group cycling
- all layouts use a
12 x 6grid layout-1tolayout-9use screen trigger regionslayout-10uses a screen trigger regionlayout-11uses the full menu bar strip as its trigger regionlayout-1tolayout-10participate in layout-index shortcuts and layout cyclinglayout-11does not participate in layout-index shortcuts or layout cycling- the
fullscreengroup uses these layouts:Fullscreen main: full screen with a full-screen triggerMain left 1/2: left half with a left-quarter screen triggerMain right 1/2: right half with a right-quarter screen triggerFullscreen main (menu bar): full screen with a menu-bar trigger, hidden from the menu bar list, and excluded from layout-index shortcuts and layout cyclingFullscreen other: full screen with a full-screen triggerFullscreen other (menu bar): full screen with a menu-bar trigger, hidden from the menu bar list, and excluded from layout-index shortcuts and layout cycling
Default layout names in order:
layout-1:Left 1/3layout-2:Left 1/2layout-3:Left 2/3layout-4:Centerlayout-5:Right 2/3layout-6:Right 1/2layout-7:Right 1/3layout-8:Right 1/3 toplayout-9:Right 1/3 bottomlayout-10:Fulllayout-11:Full (menu bar)
Compatibility behavior:
config.jsonmust not contain embeddedlayoutGroups- if
config.jsondecoding fails, including invalid JSON, comments, embeddedlayoutGroups, or an invalid persisted layout index, the file is left untouched - matching layout files that fail to decode are skipped individually
- unmatched files in
layout/are ignored - after skipping invalid layout files, the merged configuration must still pass validation or the whole load fails
- when skipped layout files contribute to a manual reload failure, the notification includes both the fatal config error and the skipped-file details
- successful saves also refresh
~/.config/GridMove/config.last-known-good.jsonand~/.config/GridMove/layout.last-known-good/*.grid.json - on launch, the app loads
config.last-known-good.jsonpluslayout.last-known-good/when the primary config is invalid, and only falls back to built-in defaults when no valid recovery snapshot exists - on manual reload, full-load failures are rejected and the current in-memory configuration keeps running
- on manual reload, full success applies the config and posts a success notification
- on manual reload, partial success applies valid layout files and warns about skipped files
- missing or invalid
general.launchAtLogindefaults tofalse - missing or invalid
preferLayoutModedefaults totrue - missing or invalid
applyLayoutImmediatelyWhileDraggingdefaults tofalse - missing
includeInGroupCycledefaults totrue - missing or empty
triggerRegionsmeans the layout is menu, shortcut, and CLI only triggerRegionssupports up to 3 entries; extra entries beyond 3 are silently ignored- missing
triggerHighlightModedefaults tonone - invalid
triggerHighlightModefalls back toall - missing
triggerFillOpacitydefaults to0.08 - missing
triggerStrokeWidthdefaults to2 - missing
includeInMenudefaults totrue - missing
includeInLayoutIndexdefaults totrue - missing or invalid
general.mouseButtonNumberdefaults to3 - missing or invalid
layoutGapdefaults to1
GridMove depends on Accessibility permission for window targeting and manipulation.
The app uses AccessibilityAccessMonitor to cache the current permission state.
Polling behavior:
- no background polling while permission is granted
1spolling while permission is missing- every real action entry re-checks Accessibility access on demand before using AX-dependent behavior
When permission is available and general.isEnabled is true:
- drag interactions are enabled
- shortcut handling is enabled
When permission is missing, revoked, or the app is disabled:
- drag interactions stop
- shortcut handling stops
- if the access state changed to missing, the app directly triggers one system Accessibility permission request for that state transition
- if access stays missing, the app does not keep re-requesting until the next transition or the next launch
- while permission is missing, the menu bar menu collapses to a single
Get accessibility accessitem - clicking
Get accessibility accessinvalidates the cached permission state and triggers the same system Accessibility prompt path again - the normal menu items return only after Accessibility permission is actually available
Launch-at-login coordination:
- GridMove uses
SMAppService.mainAppas the login-item backend - the menu item
Launch at loginis bound togeneral.launchAtLogin - startup and manual reload schedule a login-item reconciliation pass after config is applied
- reconciliation only runs once Accessibility access is available; if access is still missing, the app waits for the existing polling path to observe a granted state
- normal config saves do not touch the login-item backend
- direct clicks on
Launch at logintrigger immediate register or unregister attempts - enabling from the menu first re-checks Accessibility access and prompts if needed; if access is still missing, the config stays unchanged
- if enabling fails, still requires system approval, or does not end in
enabled, GridMove writesgeneral.launchAtLogin = falseand posts a notification that points the user to System Settings > General > Login Items - if disabling fails or does not end in
disabled, GridMove keeps the existing config value and posts a failure notification
Layout cycling state is stored in memory only:
- GridMove keeps the most recent 10 window-to-layout records
- older window records are dropped automatically
- reloading a changed layout list clears the recorded cycle baseline
Layout-mode group cycling:
- while a drag interaction is active in layout-selection mode, pressing and releasing
Shiftalone cycles to the next group whoseincludeInGroupCycleistrue - while a drag interaction is active in layout-selection mode, vertical mouse-wheel scrolling cycles groups directly: upward scrolling moves to the previous group and downward scrolling moves to the next group
- one scroll gesture only triggers one group change after a small accumulated-distance threshold; the gesture must stop briefly before the next group change can trigger
- the
Shifttap is evaluated relative to the modifier baseline captured when the interaction starts, so GridMove only cycles groups whenShiftis tapped as the only extra modifier beyond that baseline - the switch updates in-memory runtime state immediately, then saves
general.activeLayoutGroupasynchronously - the trigger overlay is recomputed immediately for the new group
- after the switch, layout selection returns to the same pre-threshold state used at initial activation, so no layout is applied until the pointer crosses the movement threshold again
- the overlay shows the new group name centered inside the current highlight region for the same duration used by the move-only highlight flash, while keeping the current highlight and trigger overlay visible
WindowController has two main targeting paths:
- focused-window lookup
- window-under-cursor lookup
Focused-window lookup:
- first ask the system-wide AX focused application
- if that fails, fall back to the frontmost app from
NSWorkspace
Pointer-based lookup:
- inspect
CGWindowListCopyWindowInfo - prefer windows whose bounds contain the pointer
- resolve AX windows for the owning app
- score matches by title, bounds, and standard-window status
- fall back to AX hit-testing and parent traversal if direct matching fails
Window exclusion rules apply in both paths:
- built-in excluded bundle IDs
- configured excluded bundle IDs
- configured excluded titles
- true macOS fullscreen windows (
AXFullScreen == true) - non-operable windows
- desktop-like Finder window
The drag runtime is owned by DragGridController.
Primary trigger entry points:
- configured mouse-button hold
- configured modifier group + left mouse
Once active, the trigger runs one of two sub-modes:
layoutSelectionmoveOnly
Default sub-mode:
- controlled by
dragTriggers.preferLayoutMode truestarts in layout selectionfalsestarts in move-only
Mode switching while active:
- right click toggles sub-mode
- Option key tap toggles sub-mode
Exit conditions:
Escx: exit and close the current target window1...9,0: exit and apply layout indexes1...9,10- releasing the primary trigger button
- Accessibility loss
- event-tap shutdown paths
Behavior:
- resolve trigger slots for the active screen
- show overlay
- keep a move threshold before layout application starts
- before the threshold is crossed, highlight the current window frame
- when
applyLayoutImmediatelyWhileDraggingisfalse, after the threshold is crossed the window continues moving with the pointer using the same throttled move path asmoveOnly - when
applyLayoutImmediatelyWhileDraggingisfalse, trigger hit testing still follows the pointer position; hovered trigger slots only drive preview and mouse-up apply - when
applyLayoutImmediatelyWhileDraggingisfalse, after the threshold is crossed and no trigger slot is hovered, the window highlight overlay is hidden - after the threshold is crossed, use hovered trigger slot to apply layouts
- remember the last applied layout to avoid redundant reapplication
- if trigger regions overlap on one display, the later declared layout wins
- overlapping trigger regions are resolved when trigger slots are built, so earlier layouts do not keep temporary hit regions inside a later layout's winning area
Cross-screen behavior:
- if the pointer changes screens, trigger slots are recomputed for the new screen
Behavior:
- when entering move-only mode, the current window frame is briefly highlighted with a fade-out flash
- the flash uses the same style as the window highlight overlay (stroke color, stroke width, fill opacity)
- the flash is only shown when
appearance.renderWindowHighlightis enabled - after the flash fades out, the window highlight is dismissed
- while the interaction remains active, the overlay can still show the move cursor feedback
- only window position is updated
- window size is preserved
- movement keeps the pointer-to-window grab offset captured at mode entry
Switching from move-only back to layout selection:
- resets to the same state as an initial layout-selection entry
- does not immediately apply the layout under the current pointer
- waits for the same thresholded layout-selection conditions as a normal entry
There are three non-pointer action entry points:
- menu bar action items
- global keyboard shortcuts
- CLI relay
Menu actions:
- are built from current configuration
- collapse to a single
Get accessibility accessitem while Accessibility permission is missing - include a
Layout groupsubmenu that switchesgeneral.activeLayoutGroup - keep a separator between the drag-preference items and the
Layout groupsubmenu - include
Settings...,Launch at login, andQuitin the final settings section, in that order - only include layouts whose
includeInMenuvalue istrue - always go through
LayoutActionExecutor - layouts hidden from the menu remain available to trigger and CLI paths, and remain available to layout-index shortcuts only when
includeInLayoutIndexistrue
Keyboard shortcuts:
- are captured through a global event tap
- resolve to the first matching enabled binding
- interpret
applyLayoutByIndexas a global index within the active layout group's indexed layouts - only require the configured index to be a positive integer; missing indexes fail when invoked instead of at load time
- operate on the currently resolved target window
- use physical-key names shared by recording, JSON config, and runtime matching
- support the standard number row (
1...0) - support function keys (
f1...f20) - support navigation keys (
left,right,up,down,home,end,pageUp,pageDown,insert) - support common special keys (
return,tab,space,delete,forwardDelete,escape) - support keypad keys (
keypad0...keypad9,keypadDecimal,keypadPlus,keypadMinus,keypadMultiply,keypadDivide,keypadEnter,keypadEquals,keypadClear) - support common aliases (
enter,backspace,esc,del,ins,help,pgup,pgdn, andkp*)
CLI:
- parses arguments in the command process
- sends commands to the running app through
DistributedNotificationCenter - waits for a reply notification with a short timeout
- fails fast if the app is not running
This relay exists so CLI actions share the same runtime window-targeting behavior as the app instead of manipulating windows from a short-lived helper process.
Display set resolution:
- each physical display resolves exactly one set from the active layout group
- priority is explicit monitor UUID or UUID array, then
main, thenall - drag overlays and trigger hit testing only use the resolved set for the current display
cycleNextandcyclePreviousonly use the resolved set for the target window's current display, skip layouts whoseincludeInLayoutIndexisfalse, and never move the window across displays- menu, shortcut, and CLI direct layout application first resolve which displays map to the selected layout set inside the active group, then keep the current display only when it belongs to that set
monitor: allkeeps the current display only when that display still resolves to the selected set; if another set owns the current display, it picks the first currently connected display that resolves to the selected setmonitor: mainalways targets the current system main displaymonitor: "<monitor-uuid>"always targets that displaymonitor: ["<monitor-uuid>", ...]keeps the current display when it is included; otherwise it picks the first currently connected display in declaration order- menu actions target layouts by internal layout ID so same-name layouts in different sets still go to the intended display
Overlay drawing is handled by OverlayController.
Runtime rules:
- overlay is active during drag interactions when there is trigger, highlight, badge, or cursor feedback to draw
- before threshold crossing, it highlights the current target window
- after threshold crossing, it highlights the target frame of the hovered slot
- when
applyLayoutImmediatelyWhileDraggingisfalseand no trigger slot is hovered after threshold crossing, the highlight is hidden even though the window still follows the pointer - in
moveOnly, a brief highlight flash of the current window frame is shown at mode entry, then only the highlight fades out - the flash respects
appearance.renderWindowHighlightand uses the same highlight style - cursor feedback is rendered in the same overlay panel as the other overlay layers; it does not create a second overlay panel or an
NSViewdrawing path - cursor feedback follows the latest pointer point, falling back to the activation point and then the mouse-down point when needed
layoutSelectionuses the layout cursor shape;moveOnlyuses the move cursor shape
Appearance is controlled by configuration:
- trigger area visibility
- trigger stroke color
- trigger gap
- layout gap (integer, applied to window layout frames and overlay highlight; layouts whose target frame collapses on the current screen are skipped)
- window highlight visibility
- window fill opacity
- window stroke width and color
These behaviors are intentional and should be preserved unless replaced with a better design.
Configured mouse-button activation uses a hold delay. If the hold never becomes a drag interaction, GridMove replays a synthetic click sequence for that same other-mouse button so the original click behavior is not lost.
Relevant code:
DragGridController+Utilities.swiftSyntheticEventMarker.swift
CLI layout actions do not manipulate windows directly. They send a command to the running app and wait for a reply.
This is a compromise to preserve the same window-resolution and focus behavior across:
- CLI
- menu actions
- running-app state
Focused AX window lookup can fail in real applications. The code therefore falls back through multiple layers:
- AX focused app
- frontmost app
- CGWindow list
- AX element-at-point traversal
This stack is deliberately redundant.
Cross-screen frame changes are not always stable in one pass. The code first primes the window onto the target screen, then reapplies position and size after a short delay.
Relevant code:
primeWindowOnTargetScreenscheduleCrossScreenSettle
When the app is not running as a normal app bundle, user notifications fall back to AppleScript notifications. This keeps local development behavior usable without bundling.
Current design boundaries:
- no Space switching
- no Mission Control automation
- no cross-Space window movement
- no fullscreen-Space management
- no third-party shortcut dependency
These boundaries matter because several code paths already use platform heuristics and AX workarounds; expanding into cross-Space behavior would need a different design.