Skip to content

feat(app): anchor the mode flash at the caret and add a screen-border style - #68

Merged
pilyang merged 9 commits into
mainfrom
feat/mode-indicator-caret-and-border
Sep 15, 2026
Merged

pilyang merged 9 commits into
mainfrom
feat/mode-indicator-caret-and-border

Conversation

@pilyang

@pilyang pilyang commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Third and last feature PR of the on-screen mode indicator plan (PRD §7.7, roadmap Stage 4). Two things, both settled in the 2026-09-06 decisions:

  • The transient flash now anchors below the caret. The geometry reader adds a caret rect only when the request carries a flash: AXSelectedTextRange → AXBoundsForRange (loc,1) → (loc-1,1) at document end → AXSelectedTextMarkerRange + AXBoundsForTextMarkerRange (opaque marker passed straight through). Validity lives in the pure layer (ModeIndicatorLayout.isUsableCaret): height > 0 (width may be 0), not equal to the element rect (marker degeneration at content end), and inside the element rect when that rect is usable. The ladder is now per pill: flash = caret → element → window, persistent badge = element → window (the badge never follows the caret). Chromium browsers yield no caret and fall back to the element rung; no screen-reader mode is forced.
  • A screen-border presentation style, selectable in Settings under the indicator toggle (now on the Indicator tab, see below). It replaces the persistent badge with an accent-colored frame around the display that holds the focused window plus a mode-label pill in the top-right corner (text always present). The screen is chosen from the anchor the badge ladder already produced, so no extra AX read; the frame is drawn on visibleFrame so the top and bottom edges are not hidden behind the menu bar and Dock. The flash keeps its caret/element ladder in both styles.

Setting: onScreenModeIndicatorStyle (badge default / screenBorder), owned by ModeIndicatorController like the toggle (runtime SSOT is the property, didSet persists and reconciles; unknown or missing value folds to the default). All three panels share one non-activating panel factory; every hide rule for the badge applies to the border through a single helper.

Extra AX cost: up to 5 calls per flash on top of the existing element/window reads; anchor and ladder events stay at the previous cost.

Extension (2026-09-15) — from dogfooding feedback

Three more commits on this branch:

  • fix: invalidate in-flight reads when VimAction itself is frontmost (7b92d9d, Copilot's review finding). The self-pid branch of reconcile now bumps token, clears pending and expires a pending flash, so a read already in flight for the previous app can no longer land after Settings (or a menu-bar click) hid the old badge or border and redraw it. inFlight is left to finish so pump() keeps its one-read-at-a-time guard. token is private(set) so the regression is pinned without a panel or an AX read.
  • feat: flash-only option (87e7529). A second Bool setting, onScreenModeIndicatorPersistentEnabled (default on), shown under the indicator toggle as a "Show" picker: Only when the mode changes / Also while in Normal or Visual mode. Off folds showsBadge to false in presentation, so everything downstream behaves like the Insert path: nothing stays on screen and anchor events cost zero AX reads. The style picker is disabled whenever there is no persistent layer to style. Defaults are unchanged.
  • feat: focused-window border style (2d2b738). ModeIndicatorPresentationStyle.windowBorder frames the focused window's own rect (no ladder, no fallback: no window, nothing drawn) with the label inside its top-right corner. The reader takes includesWindow (mirroring includesCaret) and reads the window rect once, only when a persistent window border is actually going to be drawn. The corner label is clamped first into the anchor screen's visibleFrame, then into the window, so a window narrower than the label plus insets no longer clips it. Both border styles share the one ModeIndicatorBorderPanel; the controller remembers which style it is showing (borderPanelStyle) so a border-to-border switch hides the old shape immediately, the same contract badge-to-border already had. borderLayout is renamed screenBorderLayout.

Decision recorded: 20260914_mode-indicator-optin-flash-only-and-window-border.md (both options are opt-in; the 2026-09-06 policy is not superseded).

Extension verification

  • xcodebuild test -only-testing:VimActionTests: pass. New tests cover the flash-only presentation rows and read coalescing, persistence of the new key (with the object(forKey:) != nil guard), the windowBorder raw value and persistence, usesBorderPanel, six windowBorderLayout cases (window over element, nil/degenerate window, label placement, secondary display, menu-bar band, small window) and the self-pid token invalidation. xcodebuild build … CODE_SIGNING_ALLOWED=NO: 0 warnings.
  • On-device, scripted (Developer ID Release build, two displays): defaults unchanged (flash + badge on Esc, nothing on i); the window border's panel bounds equal the TextEdit window bounds exactly and follow move and resize; it moves to Slack's window on app switch and back; hidden on minimize and on i, back on un-minimize; correct on the secondary display (negative x). Flash-only in both styles: the flash appears and nothing persists, including after switching apps.
  • Remaining device checks are being done by hand: Settings picker transitions with the window open, live mouse-drag lag, full-screen, partially off-screen and very small windows, windows straddling displays, sheets/popovers, Chrome/Arc.

Settings: Indicator tab (2026-09-15)

Two more commits move the indicator settings out of General:

  • feat: Indicator tab (3224093). The settings window gains a fourth tab, Indicator (General / Indicator / Apps / About, SF Symbol text.cursor), holding the on-screen indicator toggle, the "Show" picker and the "Indicator style" picker with their footnotes. It is a move only: keys, wording, defaults, the .disabled rules, the fixed 460×560 window, the default Apps tab and the Dock-icon hook on the TabView root are unchanged. General > Behavior keeps only Launch at login and Exit Normal mode on ⌘/⌥ shortcuts. ModeIndicatorController, Preferences.swift and the tests are untouched, since the view only binds to the controller.
  • docs (4e54732). Decision 20260915_settings-window-indicator-tab.md (four tabs; partially supersedes the 2026-08-09 three-tab decision, tab count only), app-shell.md and mode-indicator-overlay.md updated, plan updated (the colour/opacity follow-up now adds its options to this tab).

Verification: xcodebuild build … CODE_SIGNING_ALLOWED=NO with 0 warnings and xcodebuild test -only-testing:VimActionTests passing, re-run independently by the supervising session. A Developer ID Release build is installed for the remaining hands-on check of the tab.

Behaviour notes

  • In apps with a caret, a transition shows the flash below the caret and the badge at the element corner at the same time for about a second. This follows directly from the decision that the flash starts at the caret and the badge at the element.
  • Changing the style while Settings is open hides the previous style immediately; the new style is drawn on the next anchor event (clicking back into the target app), the same convergence as turning the toggle on.
  • Over a full-screen app the border's top edge sits below the hidden menu-bar band, since visibleFrame still excludes it.
  • For a non-empty selection the caret is the selection start (loc).

Verification

  • swift test (engine): 101 tests pass. xcodebuild test -only-testing:VimActionTests: pass (new layout tests for the per-pill ladder, caret validity, caret placement, border layout; controller tests for style in the presentation, read coalescing on style switch, style persistence with the object(forKey:) != nil guard). xcodebuild build … CODE_SIGNING_ALLOWED=NO: 0 warnings.
  • On-device (Developer ID Release build installed to /Applications, two displays):
    • Flash below the caret, matching the probe-measured caret rect to the pixel (x within 1 px): TextEdit and Notes via (loc,1); Safari's address bar via (loc-1,1) (the end-of-text caret sat just outside the field rect, so the first variant was rejected as designed); Slack's composer and a Notion block via the text-marker path. Chrome and Arc yield no caret: flash and badge at the element corner.
    • Badge stays at the element corner in every app; Insert shows nothing; Arc re-anchored the badge when page focus moved.
    • Screen border: frame around the display holding the focused window on both displays, label top-right, flash still under the caret; hidden on i, back on v; follows the window across displays through the window-moved notification (warm app — a freshly launched app hits the pre-existing AXObserver registration failure already recorded in the plan).
    • The frontmost app never changed during any run.

Docs

Architecture references updated to the current state: mode-indicator-overlay.md (per-pill ladder, caret read order and validity rule, border panel and style, hide rules), profiles-and-config.md (new UserDefaults key), app-shell.md (style picker). Plan updated.
Extension docs: mode-indicator-overlay.md (three settings, includesWindow, shared border panel, self-pid invariant, new known limits), profiles-and-config.md (third key), app-shell.md ("Show" picker), plus the new decision entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q

pilyang and others added 3 commits September 7, 2026 00:04
… style

The transient mode label now starts at the caret (AXBoundsForRange
(loc,1) → (loc-1,1) → text-marker bounds), read only for requests that
carry a flash; caret validity lives in the pure layout layer and the
persistent badge keeps the element rung. A second presentation style
draws an accent frame around the display that holds the focused window
with the mode label in its corner, selectable in Settings > General and
stored in UserDefaults (onScreenModeIndicatorStyle, default badge).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An in-flight geometry read can redraw the previous persistent style after Settings hides it.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds caret-anchored mode flashes and a configurable persistent screen-border indicator.

Changes:

  • Reads and validates caret geometry with element/window fallbacks.
  • Adds badge/screen-border styles with persisted Settings selection.
  • Expands layout/controller tests and architecture documentation.
File summaries
File Description
VimAction/ModeIndicatorController.swift Coordinates styles, geometry reads, and panels.
VimAction/ModeIndicatorGeometryReader.swift Reads caret bounds through AX fallbacks.
VimAction/ModeIndicatorLayout.swift Adds per-overlay ladders and border layout.
VimAction/ModeIndicatorPanel.swift Shares panel and pill implementations.
VimAction/ModeIndicatorBorderPanel.swift Draws the screen border and label.
VimAction/SettingsView.swift Adds the indicator style picker.
VimAction/Preferences.swift Defines the persisted style preference.
VimActionTests/ModeIndicatorLayoutTests.swift Tests caret and border geometry.
VimActionTests/ModeIndicatorControllerTests.swift Tests style state and persistence.
.claude/skills/plans/SKILL.md Updates active-plan status.
.claude/skills/plans/references/20260906_onscreen-mode-indicator.md Records PR progress and follow-ups.
.claude/skills/architecture/references/profiles-and-config.md Documents the new preference.
.claude/skills/architecture/references/mode-indicator-overlay.md Documents the expanded overlay architecture.
.claude/skills/architecture/references/app-shell.md Documents the Settings picker.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread VimAction/ModeIndicatorController.swift
pilyang and others added 6 commits September 15, 2026 09:39
…is frontmost

the self-pid branch of `reconcile` hid stale persistent panels but left
`token` untouched, so a geometry read already in flight for the previous
app could land afterwards and redraw the old badge or border right after
settings hid it (copilot review on #68). bump the token, drop the pending
request and expire any pending flash there — a flash that missed its
moment is dropped, same contract `finish` applies when no anchor exists.
`inFlight` stays untouched so `pump()` keeps its one-read-at-a-time guard.

`token` is now `private(set)` so the regression can be pinned without a
panel or an ax read: the self-pid path is the one trigger that neither
reads nor draws.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
dogfooding feedback: some users want the transient flash on every mode
change but find a badge that stays on screen during normal/visual too
noisy. add a second bool setting next to the on/off toggle — when the
persistent layer is off, `presentation` folds `showsBadge` to false for
every mode, so everything downstream already behaves like the insert
path: nothing stays on screen and anchor events cost zero ax reads.

settings gets a "show" picker under the indicator toggle (only when the
mode changes / also while in normal or visual mode); the style picker
is disabled whenever there is no persistent layer to style. defaults are
unchanged — flash plus persistent badge stays the product default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
dogfooding feedback: between "badge near the field" and "whole screen
border" there was no way to frame just the focused app window. add
`windowBorder` as a third persistent style.

- the geometry reader takes `includesWindow` (mirroring `includesCaret`)
  and reads the focused window rect once whenever the window border
  asks for it or the element rect is unusable; the request only asks
  when a persistent window border is actually going to be drawn, so
  insert and flash-only requests stay at zero extra ax calls.
- the pure layer gains `windowBorderLayout`: it frames the window rect
  itself (no ladder, no fallback) and clamps the corner label first into
  the anchor screen's visible frame and then into the window, so a
  window narrower than the label plus insets no longer clips it. the
  existing screen layout is renamed `screenBorderLayout`.
- both border styles share the one `ModeIndicatorBorderPanel`; the
  controller now remembers which style the panel is showing so a
  border-to-border switch hides the old shape immediately, the same
  contract badge-to-border already had. `usesBorderPanel` on the style
  enum keeps the hide rule exhaustive.
- settings lists the new style between badge and screen border.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
…itecture

- decisions: new entry for the flash-only option and the focused-window
  border style (opt-in, defaults unchanged; the 2026-09-06 policy is not
  superseded) plus the self-pid token invalidation that both rely on.
- architecture: mode-indicator-overlay reflects the three settings,
  `includesWindow`, the shared border panel with `borderPanelStyle`,
  `screenBorderLayout`/`windowBorderLayout`, the self-pid invariant and
  the new known limits; profiles-and-config lists the third key;
  app-shell lists the "show" picker.
- plans: pr 3 extension recorded, the token follow-up removed, the
  remaining device dogfooding handed to the user, tools re-created.
- border panel: note why both border styles share one corner radius.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q
the indicator grew to a toggle, two pickers and three footnotes, which
crowded the behavior section in general and mixed system behavior
(launch at login, normal-mode exit) with on-screen display preferences.
the color and opacity follow-up will add more options, so give them a
home now.

this only moves the existing controls into a new "indicator" tab right
after general, in the same order with the same disabled conditions.
settings keys, wording, defaults (apps as the default tab, general for
onboarding), the 460x560 window size and the dock icon hook on the tab
view root are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…plan docs

- decisions: new entry for the four-tab settings window (general /
  indicator / apps / about); the 2026-08-09 three-tab decision is
  superseded only for the tab count, its other rules stay valid.
- architecture: app-shell describes the four tabs and the indicator tab
  contents; mode-indicator-overlay points at the indicator tab.
- plans: the tab split recorded as done in pr #68, the "consider a
  visual tab" note dropped from the color/opacity follow-up, and the
  indicator tab added to the user's dogfooding checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pilyang
pilyang merged commit b8765c8 into main Sep 15, 2026
3 checks passed
@pilyang
pilyang deleted the feat/mode-indicator-caret-and-border branch September 15, 2026 11:39
pilyang added a commit that referenced this pull request Sep 16, 2026
the plan reached its goal: four prs merged (#66 flash, #67 persistent
badge, #68 caret anchor and border styles, #70 per-mode colors) and the
roadmap item checked. its two open follow-ups moved to github issues so
nothing is lost with the plan file:

- #71 (cold-launched apps miss observer registration)
- #72 (one unreproduced flash placement after a cross-display move)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants