From 1f91657abfa15c3ade7b7be4cdf9207136f25df2 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 7 Sep 2026 00:04:47 +0900 Subject: [PATCH 1/9] feat(app): anchor the mode flash at the caret and add a screen-border style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721 --- .../architecture/references/app-shell.md | 4 +- .../references/mode-indicator-overlay.md | 61 +++--- .../references/profiles-and-config.md | 4 +- .../20260906_onscreen-mode-indicator.md | 11 +- VimAction/ModeIndicatorBorderPanel.swift | 60 ++++++ VimAction/ModeIndicatorController.swift | 164 ++++++++++---- VimAction/ModeIndicatorGeometryReader.swift | 121 +++++++++-- VimAction/ModeIndicatorLayout.swift | 110 +++++++++- VimAction/ModeIndicatorPanel.swift | 39 +++- VimAction/Preferences.swift | 8 +- VimAction/SettingsView.swift | 12 +- .../ModeIndicatorControllerTests.swift | 93 +++++++- VimActionTests/ModeIndicatorLayoutTests.swift | 204 ++++++++++++++++-- 13 files changed, 757 insertions(+), 134 deletions(-) create mode 100644 VimAction/ModeIndicatorBorderPanel.swift diff --git a/.claude/skills/architecture/references/app-shell.md b/.claude/skills/architecture/references/app-shell.md index 4b47df5..7793b80 100644 --- a/.claude/skills/architecture/references/app-shell.md +++ b/.claude/skills/architecture/references/app-shell.md @@ -1,6 +1,6 @@ # 앱 셸 -- **Last updated**: 2026-09-04 (릴리스 노트 노출 배선 반영) +- **Last updated**: 2026-09-06 (General 탭 인디케이터 스타일 Picker) ## 현재 구조 @@ -20,7 +20,7 @@ ### 설정 창 — `TabView` 3탭, 460×560 고정 -**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출·온스크린 모드 인디케이터] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Apps**(설정 파일 상태 — [profiles-and-config.md](profiles-and-config.md)) / **About**(버전 + 업데이트 확인 + 링크). 탭 순서는 General이 먼저지만 **기본 선택은 Apps**이고, 권한 섹션은 미허용이면 General 최상단·허용되면 아래쪽(Behavior·Updates 뒤)에 한 줄로 자리를 옮긴다. 높이를 명시하지 않으면 greedy한 grouped `Form` 때문에 창이 임의 높이로 굳는다 ([20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)). +**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출·온스크린 모드 인디케이터 토글과 그 바로 아래 "Indicator style" Picker(배지 / 화면 테두리 — 토글이 꺼지면 `.disabled`, [mode-indicator-overlay.md](mode-indicator-overlay.md))] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Apps**(설정 파일 상태 — [profiles-and-config.md](profiles-and-config.md)) / **About**(버전 + 업데이트 확인 + 링크). 탭 순서는 General이 먼저지만 **기본 선택은 Apps**이고, 권한 섹션은 미허용이면 General 최상단·허용되면 아래쪽(Behavior·Updates 뒤)에 한 줄로 자리를 옮긴다. 높이를 명시하지 않으면 greedy한 grouped `Form` 때문에 창이 임의 높이로 굳는다 ([20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)). ### 로그인 시 자동 시작 — `SMAppService.mainApp` diff --git a/.claude/skills/architecture/references/mode-indicator-overlay.md b/.claude/skills/architecture/references/mode-indicator-overlay.md index cc3bf0b..7601d25 100644 --- a/.claude/skills/architecture/references/mode-indicator-overlay.md +++ b/.claude/skills/architecture/references/mode-indicator-overlay.md @@ -4,73 +4,82 @@ ## 현재 구조 -화면의 라벨 알약은 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 포커스 요소 근처에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 한 단계 작은 **상시 배지**가 같은 자리에 계속 붙어 있는다 (Insert는 배지 없음). 둘 다 Settings General 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. +화면의 표시는 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 **캐럿 아래**(캐럿이 없으면 포커스 요소 근처)에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 **상시 표시**가 계속 남는다 (Insert는 상시 표시 없음). 상시 표시의 형태는 Settings에서 고른다 — 기본은 요소 모서리의 한 단계 작은 **배지**, 대체 스타일은 포커스 창이 있는 디스플레이를 두르는 **화면 테두리**(강조색 프레임 + 오른쪽 위 모서리 라벨). 어느 스타일이든 flash는 캐럿/요소 사다리를 탄다. 전부 Settings General 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. ```mermaid graph LR T1["① EventTapController.updateMode
(탭 콜백 동기 구간)"] --> R T2["② FocusedElementResolver
포커스·앱 활성화·창 이동/리사이즈"] --> R T3["③ AppState.trackIndicatorLadder
(withObservationTracking 재무장)"] --> R - T4["④ isEnabled.didSet (Settings 토글)"] --> R + T4["④ isEnabled·style.didSet (Settings)"] --> R T5["⑤ didChangeScreenParameters"] --> R R["ModeIndicatorController.reconcile
presentation(순수) + token·pending 코얼레싱"] - R -->|전용 큐 .utility| G["ModeIndicatorGeometryReader.read(pid)
요소 rect → (없으면) 창 rect"] - G -->|rect만 메인 홉| L["ModeIndicatorLayout.panelFrame
사다리·배치·flip·클램프 (순수)"] - L --> P["flash 패널 / 배지 패널
비활성화 NSPanel 2개"] + R -->|전용 큐 .utility| G["ModeIndicatorGeometryReader.read(pid, includesCaret)
요소 rect → (없으면) 창 rect, flash면 캐럿 rect"] + G -->|rect만 메인 홉| L["ModeIndicatorLayout (순수)
panelFrame(ladder) · borderLayout"] + L --> P["flash 패널 / 배지 패널 / 테두리 패널
비활성화 NSPanel 3개"] ``` | 구성 요소 | 격리 | 역할 | |---|---|---| -| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 토글(`isEnabled`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자 | -| `ModeIndicatorGeometryReader` | `nonisolated` | pid만 받아 `AXRead.focusedElement` → `AXPosition`+`AXSize`, 요소 rect가 쓸 만하지 않을 때만 `AXFocusedWindow`. 50ms 타임아웃은 `AXRead` 상속 | -| `ModeIndicatorLayout` | `nonisolated` 순수 | 앵커 사다리(요소 → 창 → 없음, 면적 있는 rect만) → 배치(요소 단은 요소 바깥 오른쪽 위 4pt, 창 단은 창 안쪽 오른쪽 위 12/6pt) → AX→AppKit flip → 클램프. 화면은 호출자가 `Screen`(frame + visibleFrame) 배열로 넘긴다 | -| `ModeIndicatorPanel` × 2 | `@MainActor` | `NSPanel(.borderless, .nonactivatingPanel)`, `ignoresMouseEvents`, `hidesOnDeactivate = false`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]`. `ModeIndicatorStyle`이 폰트·여백·radius·창 층을 가른다: flash는 bold 12pt/`.statusBar`, 배지는 semibold 10pt/`.statusBar - 1`. `flash`는 0.15/0.7/0.3s 페이드, `show`는 애니메이션도 타이머도 없다. 각각 첫 표시에서 lazy 생성 | +| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 두 개(`isEnabled`·`style: ModeIndicatorPresentationStyle`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리다 | +| `ModeIndicatorGeometryReader` | `nonisolated` | pid만 받아 `AXRead.focusedElement` → `AXPosition`+`AXSize`, 요소 rect가 쓸 만하지 않을 때만 `AXFocusedWindow`. **`includesCaret`(= 요청에 flash가 있음)일 때만** 캐럿 rect를 추가로 읽는다 — 순서는 `AXSelectedTextRange`의 `loc`으로 `AXBoundsForRange (loc,1)` → 안 되고 `loc>0`이면 `(loc-1,1)` → `AXSelectedTextMarkerRange`(불투명 CFType, 변환 없음)+`AXBoundsForTextMarkerRange`, 첫 쓸 만한 것에서 멈춘다. 길이 0 범위는 쓰지 않고 `AXNumberOfCharacters`도 미리 묻지 않는다. 50ms 타임아웃은 `AXRead` 상속 | +| `ModeIndicatorLayout` | `nonisolated` 순수 | 앵커 사다리는 **묻는 알약에 따라 첫 단이 다르다**(`Ladder`): flash는 `caretFirst`(캐럿 → 요소 → 창 → 없음), 상시 표시는 `elementFirst`(요소 → 창 → 없음). 면적 있는 rect만 한 단이고, 캐럿의 유효성(`isUsableCaret`)도 여기 소유 — 높이 > 0(폭 0 허용), 요소 rect와 다름, 요소 rect가 쓸 만하면 그 안에 걸침. 배치는 캐럿 단 캐럿 아래 왼쪽 정렬 4pt / 요소 단 요소 바깥 오른쪽 위 4pt / 창 단 창 안쪽 오른쪽 위 12/6pt → AX→AppKit flip → 클램프. `borderLayout`은 상시 사다리의 앵커가 속한 화면의 `visibleFrame`과 그 안 오른쪽 위 라벨 프레임(인셋 = 선 굵기 4 + 8)을 낸다. 화면은 호출자가 `Screen`(frame + visibleFrame) 배열로 넘긴다 | +| `ModeIndicatorPanel` × 2 | `@MainActor` | flash·배지 알약. `ModeIndicatorStyle`이 폰트·여백·radius·창 층을 가른다: flash는 bold 12pt/`.statusBar`, 배지는 semibold 10pt/`.statusBar - 1`. `flash`는 0.15/0.7/0.3s 페이드, `show`는 애니메이션도 타이머도 없다. 각각 첫 표시에서 lazy 생성 | +| `ModeIndicatorBorderPanel` × 1 | `@MainActor` | 화면 테두리 — 디스플레이의 `visibleFrame`을 덮는 패널 **하나**에 강조색 테두리(`ModeIndicatorBorderView`)와 모서리 라벨(`ModeIndicatorPillView(style: .badge)` 서브뷰)을 함께 그린다. 층은 배지와 같다. `show`/`hide`는 배지와 같은 계약(멱등, 무애니메이션). 그 스타일을 고른 뒤 첫 표시에서 lazy 생성 | +| `ModeIndicatorPanel.makeOverlayPanel(level:)` | `@MainActor` | 비활성화 패널 설정의 **유일한** 생성 지점 — `NSPanel(.borderless, .nonactivatingPanel)`, `ignoresMouseEvents`, `hidesOnDeactivate = false`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]`. 알약 둘과 테두리가 전부 여기서 받는다 | -라벨 문자열은 `Mode.overlayLabel`, 배지 표시 여부는 `Mode.showsPersistentBadge`(Insert만 false)로 둘 다 `AppState.swift`에 있다. +라벨 문자열은 `Mode.overlayLabel`, 상시 표시 여부는 `Mode.showsPersistentBadge`(Insert만 false)로 둘 다 `AppState.swift`에 있다. 스타일 enum `ModeIndicatorPresentationStyle`(`badge`/`screenBorder`, raw 문자열이 UserDefaults 값)은 `ModeIndicatorController.swift`에 있다 — 알약 외양 enum `ModeIndicatorStyle`(flash/badge)과는 다른 축이다. ### 트리거 다섯 | # | 계기 | 경로 | 하는 일 | |---|---|---|---| -| ① | 모드 전환 | `updateMode` → `onModeChange` → `modeDidChange` | flash + 배지, 항상 읽는다 | -| ② | 포커스 요소 변경·앱 활성화·창 이동·리사이즈 | `FocusedElementResolver.onFocusGeometryChanged` → (`EventTapController` 통로) → `anchorDidChange` | 배지가 보여야 할 때만 읽는다 | +| ① | 모드 전환 | `updateMode` → `onModeChange` → `modeDidChange` | flash + 상시 표시, 항상 읽는다 (캐럿 포함) | +| ② | 포커스 요소 변경·앱 활성화·창 이동·리사이즈 | `FocusedElementResolver.onFocusGeometryChanged` → (`EventTapController` 통로) → `anchorDidChange` | 상시 표시가 보여야 할 때만 읽는다 (캐럿 없이) | | ③ | 사다리 변화 (탭 고장·마스터 off·킬스위치·앱별 disabled·Secure Input·설정 리로드) | `AppState.trackIndicatorLadder()`의 `withObservationTracking` 재무장 루프 → `stateDidChange` | 표시할 것이 실제로 달라졌을 때만 읽는다 | -| ④ | Settings 토글 | `isEnabled.didSet` | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀) | +| ④ | Settings 토글·스타일 | `isEnabled.didSet`·`style.didSet` | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | | ⑤ | 디스플레이 재구성 | `NSApplication.didChangeScreenParametersNotification` → `anchorDidChange` | ②와 같다 | ## 불변식·계약 - **훅은 탭 콜백의 동기 구간에서 불린다.** 거기서 하는 일은 판정(순수)·`pending` 대입·`queue.async` 1회까지다. AX 호출과 `NSScreen` 조회는 전부 큐/메인 홉 뒤다 — 어기면 콜백 경량 불변식이 깨진다. -- **AX는 전용 큐에서만, pid만 건너간다.** `AXUIElement`는 큐 경계를 넘지 않고 돌아오는 것은 rect뿐이다. **리졸버 `readQueue`를 재사용하지 않는다** — 콜드 앱에서 이 읽기는 AX 호출 6회×50ms까지 걸릴 수 있고, 리졸버 큐는 키 디스패치가 의존하는 포커스 계열 캐시를 먹인다. -- **표시 조건은 메뉴바 사다리와 동일하다.** `MenuBarIndicator.resolve`가 `.mode`가 아니면 즉시 두 패널 모두 `hide()`. 읽는 도중 사다리를 벗어나면 토큰 불일치로 그 읽기를 버린다 — "가로채지 않는데 NORMAL"을 띄우면 안 된다. -- **사다리 관찰 루프는 레벨 트리거다.** `onChange`는 willSet에서(킬스위치 경로면 메인 밖에서도) 불리므로 거기서는 홉만 걸고, 재무장이 **현재 값**을 다시 읽어 수렴한다. 그래서 `reconcile`은 직전 이벤트가 아니라 **자기 현재 표시(`current`)와만** 비교한다. 관찰 블록은 값만 만들고 부수효과가 없어야 한다 — 컨트롤러 메서드를 그 안에서 부르면 컨트롤러 내부 상태까지 추적돼 읽기가 끝날 때마다 루프가 스스로를 깨운다 (그래서 `isEnabled` 외 저장 프로퍼티는 전부 `@ObservationIgnored`). -- **flash 요청은 `pending`과 따로 산다.** 같이 담으면 읽는 사이에 온 앵커·사다리 이벤트가 `pending`을 덮어써 정당한 flash가 사라진다(Cmd-Tab 직후 Esc). 다만 붙일 앵커가 없어 그리지 못한 경우에는 **버린다** — 들고 있으면 한참 뒤 앵커 이벤트에 얹혀 살아나 엉뚱한 때 번쩍인다. +- **AX는 전용 큐에서만, pid만 건너간다.** `AXUIElement`는 큐 경계를 넘지 않고 돌아오는 것은 rect뿐이다. **리졸버 `readQueue`를 재사용하지 않는다** — 콜드 앱에서 이 읽기는 flash면 캐럿 변형까지 AX 호출 11회×50ms까지 걸릴 수 있고, 리졸버 큐는 키 디스패치가 의존하는 포커스 계열 캐시를 먹인다. +- **flash는 캐럿부터, 상시 표시는 요소부터 시작하고 상시 표시는 캐럿을 따라다니지 않는다.** 캐럿은 flash가 있는 요청(`pendingFlash`)에서만 읽으며 그 필요는 `pump()`가 `includesCaret`으로 리더에 명시적으로 넘긴다 — 앵커·사다리 이벤트의 재배치는 캐럿 왕복 없이 기존 비용에 머문다. flash를 세우는 유일한 경로(`modeDidChange`)가 항상 토큰을 올려 새 읽기를 띄우므로 캐럿 없이 띄운 읽기로 flash가 그려지는 일은 없다. 예산: flash 한 번에 최대 AX 호출 5회가 더 든다. +- **캐럿 유효성은 순수 계층(`isUsableCaret`) 한 벌이다.** 리더가 다음 변형을 시도할지도 같은 술어로 판정한다 — 두 벌이면 리더가 넘긴 것을 사다리가 버린다. `(loc-1,1)`이 줄 첫머리에서 개행 문자라 rect가 이전 줄 끝에 오는 quirk는 수용하고 특별 취급하지 않는다. Chromium 브라우저(Chrome·Arc)는 캐럿 변형이 전부 빈 값이라 조용히 요소 단으로 내려간다 — `AXEnhancedUserInterface`를 세우지 않는다. +- **표시 조건은 메뉴바 사다리와 동일하다.** `MenuBarIndicator.resolve`가 `.mode`가 아니면 즉시 세 패널 모두 `hide()`. 읽는 도중 사다리를 벗어나면 토큰 불일치로 그 읽기를 버린다 — "가로채지 않는데 NORMAL"을 띄우면 안 된다. +- **상시 표시의 숨김 규칙은 스타일 불문 한 자리다** — `hidePersistentPanels(keeping:)`가 유일한 숨김 지점이고, 배지에 적용되는 모든 사유(Insert·사다리 이탈·토글 off·pid 없음·자기 pid·앵커 없음·스타일 변경)가 테두리에도 같이 적용된다. 스타일이 바뀌면 이전 형태의 패널은 `reconcile`에서 즉시 감춰지고 새 형태는 읽기가 착지한 뒤 그려진다. +- **자기 pid 경로가 곧 설정 창의 경로다.** 설정 창이 열려 있으면 VimAction이 최전면이라 리졸버가 우리 pid에 붙어 있고, `reconcile`은 읽지도 `current`를 건드리지도 않는다 — 다만 거짓말은 지운다(Insert면 상시 표시 숨김, 스타일이 바뀌면 이전 형태 숨김). 그래서 Picker의 새 형태는 사용자가 앱으로 돌아가는 앵커 이벤트에서 그려진다 (토글을 켤 때와 같은 수렴). +- **사다리 관찰 루프는 레벨 트리거다.** `onChange`는 willSet에서(킬스위치 경로면 메인 밖에서도) 불리므로 거기서는 홉만 걸고, 재무장이 **현재 값**을 다시 읽어 수렴한다. 그래서 `reconcile`은 직전 이벤트가 아니라 **자기 현재 표시(`current`)와만** 비교한다. 관찰 블록은 값만 만들고 부수효과가 없어야 한다 — 컨트롤러 메서드를 그 안에서 부르면 컨트롤러 내부 상태까지 추적돼 읽기가 끝날 때마다 루프가 스스로를 깨운다 (그래서 `isEnabled`·`style` 외 저장 프로퍼티는 전부 `@ObservationIgnored`). +- **flash 요청은 `pending`과 따로 산다.** 같이 담으면 읽는 사이에 온 앵커·사다리 이벤트가 `pending`을 덮어써 정당한 flash가 사라진다(Cmd-Tab 직후 Esc). 다만 붙일 앵커가 없어 그리지 못한 경우에는 **버린다** — 들고 있으면 한참 뒤 앵커 이벤트에 얹혀 살아나 엉뚱한 때 번쩍인다. flash 사다리가 상시 사다리를 포함하므로 "flash 프레임 없음 = 앵커 없음"이고, 그 반대(캐럿만 있고 요소·창이 없음)는 flash만 뜨고 상시 표시는 감춘다. - **아무것도 안 바뀐 reconcile은 토큰을 올리지 않고, 같은 상태를 같은 토큰으로 이미 읽는 중이면 새 읽기를 띄우지 않는다**(`needsGeometryRead`가 그 판정의 순수 소유자다). 둘 다 같은 사고를 막는다: 토큰이 올라가면 진행 중인 읽기가 폐기된다. 후자가 없으면 **모드 전환마다 읽기가 두 번 난다** — 사다리 관찰 루프의 재무장이 `mode`의 willSet에서 예약돼 전환 읽기가 착지하기 전에 뒤따라 오는데, 그때 밀린 flash 때문에 판정이 참이 되기 때문이다. -- **패널은 절대 앱을 활성화하지 않는다.** `orderFrontRegardless()`만 쓰고 `makeKey…`·`NSApp.activate` 계열은 없다. 활성화되면 `FrontmostAppGate`의 최전면 캐시가 자기 자신으로 덮인다. +- **패널은 절대 앱을 활성화하지 않는다.** `orderFrontRegardless()`만 쓰고 `makeKey…`·`NSApp.activate` 계열은 없다. 활성화되면 `FrontmostAppGate`의 최전면 캐시가 자기 자신으로 덮인다. 화면을 덮는 테두리 패널도 `ignoresMouseEvents`라 클릭을 먹지 않는다. - **자기 자신(VimAction) pid는 앵커로 삼지 않는다.** 메뉴바 클릭·설정 창이 우리를 최전면으로 만들면 리졸버가 우리 pid에 붙는데, 사다리는 비자신 캐시 축이라 여전히 `.mode`다 — 그대로 두면 배지가 우리 설정 창에 붙는다. 감추지는 않는다(메뉴를 열 때마다 깜빡이면 더 나쁘다). - **pid 출처는 리졸버**(`EventTapController.observedProcessID`)다 — 디스패치 경로와 같은 앱을 겨눈다. `FrontmostAppGate`가 아니다. - **좌표 변환은 전역 flip 하나**: `y' = NSScreen.screens[0].frame.maxY - (y + h)`. 보조 디스플레이(AX x 음수)에서도 그대로다. -- **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. +- **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. 화면 테두리도 같은 축이다 — 앵커 rect(요소 또는 창)와 `frame`이 겹치는 화면을 고르고(AX를 더 읽지 않는다 — 요소는 창 안에 있어 같은 디스플레이다, 없으면 첫 화면), 그 화면의 **`visibleFrame`**을 두른다(`frame`을 두르면 위 변이 메뉴바 뒤, 아래 변이 Dock 뒤에 숨는다). - **앵커가 없으면 재시도하지 않는다** — 그것이 답이고, 다음 앵커 이벤트가 다시 부른다. -- **글씨 색은 강조색에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 강조색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다. -- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(사다리·퇴화 rect·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil), `ModeIndicatorControllerTests`(표시 판정 표·읽기 코얼레싱 판정 표·토글 영속·대비). +- **글씨 색은 강조색에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 강조색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다 — 화면 테두리도 모서리 라벨이 같은 알약 뷰라 규칙이 하나다. +- **flash가 배지 위에 그려지는 것은 창 층의 차이로 보장된다**(flash `.statusBar`, 상시 표시 `.statusBar - 1`). 캐럿이 없는 앱에서는 flash가 배지를 완전히 덮고, 캐럿이 있는 앱에서는 flash가 캐럿 아래·배지가 요소 모서리라 **전환 직후 1초간 같은 라벨 둘이 함께 보인다** — flash는 캐럿부터라는 결정의 직접 결과다. +- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종), `ModeIndicatorControllerTests`(표시 판정 표·읽기 코얼레싱 판정 표(스타일 변경 행 포함)·토글 영속·스타일 영속·대비). - 테스트에서는 아무 일도 하지 않는다 — 배선이 `bootstrap()`의 XCTest 가드 뒤이고, 입력이 밀린 적 없으면 판정이 통째로 막히며, 패널은 첫 표시에서야 만들어진다. ### 알려진 한계 -- **킬스위치 발동 중에는 배지가 남는다.** 발동 상황은 메인이 굳은 상황이라 `orderOut`이 불릴 수 없다 — 그동안 배지는 "NORMAL"을 계속 주장한다. 1초짜리 flash에는 없던 노출이다(스스로 사라졌다). -- **Secure Input 진입은 워치독 폴링으로만 관측**되므로 배지가 한 틱만큼 늦게 사라진다 (메뉴바와 같은 기존 지연, 화면에서 더 잘 보일 뿐). +- **킬스위치 발동 중에는 상시 표시가 남는다.** 발동 상황은 메인이 굳은 상황이라 `orderOut`이 불릴 수 없다 — 그동안 배지(또는 테두리)는 "NORMAL"을 계속 주장한다. 1초짜리 flash에는 없던 노출이다(스스로 사라졌다). +- **Secure Input 진입은 워치독 폴링으로만 관측**되므로 상시 표시가 한 틱만큼 늦게 사라진다 (메뉴바와 같은 기존 지연, 화면에서 더 잘 보일 뿐). - **창 드래그 추종은 알림이 오는 만큼이다.** 실측에서 창 기하 변경은 알림이 한 번이 아니라 여러 건 몰려 오고, 1-in-flight 코얼레싱이 그 버스트를 접는다. +- **Visual 선택의 캐럿은 선택 시작(`loc`)이다** — `AXSelectedTextRange`의 위치만 쓰므로 선택을 뒤로 늘린 상태의 flash는 선택 시작 아래에 뜬다. +- **전체화면 앱 위의 테두리는 위 변이 메뉴바 띠만큼 내려온다** — `visibleFrame`은 메뉴바가 자동 숨김이어도 그 띠를 뺀다. ## 근거 요약 -메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 배지), 앵커 사다리와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권은 각각 결정 문서에 있다. +메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 화면 테두리는 대체 스타일), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권은 각각 결정 문서에 있다. - 관련 결정: [20260906_mode-indicator-hybrid-display-policy.md](../../decisions/references/20260906_mode-indicator-hybrid-display-policy.md), [20260906_mode-indicator-anchor-ladder-event-driven.md](../../decisions/references/20260906_mode-indicator-anchor-ladder-event-driven.md), [20260906_no-forced-chromium-screen-reader-mode.md](../../decisions/references/20260906_no-forced-chromium-screen-reader-mode.md), [20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md), [20260725_callback-light-invariant.md](../../decisions/references/20260725_callback-light-invariant.md), [20260725_tap-main-runloop-retention.md](../../decisions/references/20260725_tap-main-runloop-retention.md) ## 관련 -- 코드: `VimAction/ModeIndicatorController.swift`, `VimAction/ModeIndicatorGeometryReader.swift`, `VimAction/ModeIndicatorLayout.swift`, `VimAction/ModeIndicatorPanel.swift`, `VimAction/FocusedElementResolver.swift`(`onFocusGeometryChanged`·창 알림), `VimAction/EventTapController.swift`(`updateMode`·`onModeChange`·`onFocusGeometryChanged`·`observedProcessID`), `VimAction/AppState.swift`(배선·`indicatorInputs`·`trackIndicatorLadder`·`Mode.overlayLabel`·`Mode.showsPersistentBadge`), `VimAction/SettingsView.swift`(토글) +- 코드: `VimAction/ModeIndicatorController.swift`(`ModeIndicatorPresentationStyle` 포함), `VimAction/ModeIndicatorGeometryReader.swift`, `VimAction/ModeIndicatorLayout.swift`, `VimAction/ModeIndicatorPanel.swift`(`makeOverlayPanel`·`ModeIndicatorPillView`), `VimAction/ModeIndicatorBorderPanel.swift`, `VimAction/FocusedElementResolver.swift`(`onFocusGeometryChanged`·창 알림), `VimAction/EventTapController.swift`(`updateMode`·`onModeChange`·`onFocusGeometryChanged`·`observedProcessID`), `VimAction/AppState.swift`(배선·`indicatorInputs`·`trackIndicatorLadder`·`Mode.overlayLabel`·`Mode.showsPersistentBadge`), `VimAction/SettingsView.swift`(토글·스타일 Picker), `VimAction/Preferences.swift`(키 둘) - 표시 사다리: [reentrancy-and-safety.md](reentrancy-and-safety.md)(메뉴바 글리프 우선순위) — 오버레이는 그 사다리의 두 번째 소비자다 - pid 출처·읽기 큐 규율·리졸버 알림: [focus-and-dispatch-reads.md](focus-and-dispatch-reads.md) -- 앱 셸·설정 토글: [app-shell.md](app-shell.md), [profiles-and-config.md](profiles-and-config.md) +- 앱 셸·설정 토글·Picker: [app-shell.md](app-shell.md), [profiles-and-config.md](profiles-and-config.md) diff --git a/.claude/skills/architecture/references/profiles-and-config.md b/.claude/skills/architecture/references/profiles-and-config.md index 33f34be..97175c1 100644 --- a/.claude/skills/architecture/references/profiles-and-config.md +++ b/.claude/skills/architecture/references/profiles-and-config.md @@ -1,6 +1,6 @@ # 프로파일과 설정 -- **Last updated**: 2026-08-14 (M5 D2 — 번들 기본 전략 `keyboard` → `auto` 전환 완료, 도그푸딩 게이트 통과 뒤 별도 커밋) +- **Last updated**: 2026-09-06 (온스크린 인디케이터 스타일 키 추가) ## 현재 구조 @@ -96,7 +96,7 @@ actions: # 명령 계열: 그 액션 자신의 키 교 **"사용자가 파일로 관리하고 싶은 설정"은 YAML, "앱이 스스로 쓰는 상태"는 UserDefaults.** -- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on — 표시 취향이지 앱별 동작 정의가 아니고, YAML로 두면 Settings UI가 YAML을 써야 한다. 소유자는 `ModeIndicatorController` — [20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). +- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본) — 둘 다 표시 취향이지 앱별 동작 정의가 아니고, YAML로 두면 Settings UI가 YAML을 써야 한다. 소유자는 `ModeIndicatorController` ([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). - YAML: 앱별 on/off, 프로파일 전부. - **어느 쪽도 아닌 셋째 칸이 있다 — 시스템이 소유하는 상태.** 로그인 시 자동 시작은 `SMAppService.mainApp.status`가 SSOT이고 앱은 값을 저장하지 않는다(사용자가 시스템 설정에서 통지 없이 바꾸므로 미러는 반드시 어긋난다 — [app-shell.md](app-shell.md)). Sparkle 자동 확인도 같은 성격으로, 값의 영속은 Sparkle이 소유한다. diff --git a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md index 467a376..6fec4b1 100644 --- a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md +++ b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md @@ -1,7 +1,7 @@ # 온스크린 모드 인디케이터 (HUD) - **생성일**: 2026-09-06 -- **갱신일**: 2026-09-06 (PR 2 구현·도그푸딩 완료, PR #67 오픈, 외양 커스텀 후속 항목 추가) +- **갱신일**: 2026-09-07 (PR 3 구현·도그푸딩 완료, PR 오픈 — 번호는 아래) ## 목표 @@ -12,17 +12,18 @@ - [x] 세 가지 표시안(캐럿 근처 / 포커스 요소 근처 / 화면 테두리)과 표시 정책(항시 vs 전환 시) 조사·보고 — 세 안은 배타적이 아니라 **앵커 정밀도 사다리**(캐럿 → 요소 → 창·화면 → 메뉴바)로 묶는 설계를 권장. 권장 조합: 모든 모드 전환 시 캐럿 근처 순간 표시(~1초 페이드) + Normal·Visual 동안 요소 모서리 상시 배지 + Insert는 무표시. 화면 테두리는 선택 스타일. - [x] 구현 가능성 스파이크 (2026-09-06, 실기기·2디스플레이): AX 기하 가용성 실측 + 비활성화 NSPanel 오버레이 프로토타입 검증. 결과는 아래 "진행 중 컨텍스트". - [x] **PR 1 머지 완료** ([PR #66](https://github.com/pilyang/vim-action/pull/66) → main `48cbd28`, 2026-09-06; Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비활성화 패널·순수 레이아웃(테스트 10건)·전용 큐 기하 리더·모드 전환 훅. 실기기 도그푸딩(Developer ID Release 설치): TextEdit Esc/i/v, Chrome Esc, Slack Esc 전부 라벨 표시·최전면 불변·보조 디스플레이 정렬 확인. architecture reference [mode-indicator-overlay.md](../../architecture/references/mode-indicator-overlay.md) 추가. -- [x] **PR 2 구현 완료** ([PR #67](https://github.com/pilyang/vim-action/pull/67), 브랜치 `feat/mode-indicator-persistent-badge`, Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비-Insert 상시 배지(flash보다 한 단 작고 한 층 아래)·재앵커 트리거 5종(모드 전환 / 포커스·앱 활성화·창 이동·리사이즈 / 디스플레이 재구성 / 사다리 관찰 루프 / 토글)이 단일 reconcile로 합류, 순수 판정 2종(`presentation`·`needsGeometryRead`) 표 테스트, `visibleFrame` 클램프(화면 선택은 `frame`), 강조색 luminance 기반 글씨색, General > Behavior 토글(`onScreenModeIndicatorEnabled`, 기본 on). 실기기 도그푸딩: 창 이동(−900,+262)·리사이즈 추종, Chrome 전환 재앵커, disabled 앱(Terminal)에서 숨김, 복귀 시 재표시, Insert 무배지, VISUAL 배지 — 전부 확인. architecture 4종 갱신(profiles-and-config의 키 반영 포함). +- [x] **PR 2 머지 완료** ([PR #67](https://github.com/pilyang/vim-action/pull/67) → main `4040fe4`, 2026-09-06, 브랜치 `feat/mode-indicator-persistent-badge`, Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비-Insert 상시 배지(flash보다 한 단 작고 한 층 아래)·재앵커 트리거 5종(모드 전환 / 포커스·앱 활성화·창 이동·리사이즈 / 디스플레이 재구성 / 사다리 관찰 루프 / 토글)이 단일 reconcile로 합류, 순수 판정 2종(`presentation`·`needsGeometryRead`) 표 테스트, `visibleFrame` 클램프(화면 선택은 `frame`), 강조색 luminance 기반 글씨색, General > Behavior 토글(`onScreenModeIndicatorEnabled`, 기본 on). 실기기 도그푸딩: 창 이동(−900,+262)·리사이즈 추종, Chrome 전환 재앵커, disabled 앱(Terminal)에서 숨김, 복귀 시 재표시, Insert 무배지, VISUAL 배지 — 전부 확인. architecture 4종 갱신(profiles-and-config의 키 반영 포함). +- [x] **PR 3 구현·도그푸딩 완료** (브랜치 `feat/mode-indicator-caret-and-border`, Fable 워커 위임 + 감독 리뷰 + 독립 검증, PR 번호는 인덱스 참조): flash 앵커가 캐럿부터(읽기 순서 `(loc,1)` → `(loc-1,1)` → 텍스트 마커, 유효성 규칙은 순수 계층 `isUsableCaret` 한 벌, flash 요청 때만 캐럿 읽기), 상시 표시 스타일 `onScreenModeIndicatorStyle`(배지 / 화면 테두리 — 앵커가 속한 디스플레이의 `visibleFrame` + 오른쪽 위 라벨, General 토글 아래 Picker). 실기기 도그푸딩(Developer ID Release, 2디스플레이): TextEdit·Notes는 `(loc,1)`, Safari 주소창은 `(loc-1,1)`(끝 캐럿이 필드 밖이라 첫 변형 기각), Slack·Notion은 마커 — 전부 flash가 캐럿 바로 아래(±1px); Chrome·Arc는 요소 모서리 폴백; 테두리 스타일은 창이 있는 디스플레이만 두르고 Insert에서 사라짐. architecture 3종 갱신. - [x] 방향 확정(2026-09-06, 권장안 채택) + decisions 4건 기록: 표시 정책 / 앵커 사다리·이벤트 기반 갱신(실측표 포함) / Chromium 스크린리더 모드 강제 안 함 / 설정은 UserDefaults·기본 on. ## 남은 것 -- [ ] [PR #67](https://github.com/pilyang/vim-action/pull/67) 머지 확인 (CI 3잡 required). -- [ ] **PR 3 — 캐럿 정밀도 + 화면 테두리 스타일.** 순간 표시 앵커를 캐럿부터 시작: `AXBoundsForRange (loc,1)` → 문서 끝 `(loc-1,1)` → `AXSelectedTextMarkerRange`+`AXBoundsForTextMarkerRange`, 마커 결과가 요소 rect와 같으면 무효. 스타일 선택(배지 / 화면 테두리 — 포커스 창이 있는 `NSScreen` frame 패널 + 모서리 라벨). 검증: 실측표 앱들(TextEdit·Notes·Safari·Slack·Notion은 캐럿 아래, Chrome·Arc는 요소 모서리 폴백). +- [ ] PR 3 머지 확인 (CI 3잡 required) — 머지되면 `/Applications`의 도그푸딩 빌드(1.0 (1))를 정식 릴리스로 되돌릴지 결정. - [ ] **후속 — 인디케이터 외양 커스텀(색상·투명도).** 모드 라벨 알약(flash·배지)의 **색상**과 **투명도**를 사용자가 고를 수 있게 한다. 시점은 PR 3(캐럿 위치 지정) **이후**, 또는 작업 크기가 작으면 PR 3에 **함께**. 이러면서 설정 항목이 늘어나면(on/off·스타일·색상·투명도) 설정 창에 별도 **Visual 탭**을 두는 것도 함께 검토 — 탭 구성은 [20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)의 결정이라 바꾸면 decisions로 기록하고 app-shell reference를 갱신한다. 색상 커스텀에서도 라벨 텍스트는 항상 동반(색만으로 구분하지 않음 — PRD NFR)하고, 글씨색은 PR 2의 luminance 파생을 사용자 색에 그대로 적용한다. 설정 소유권은 UserDefaults([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). - [ ] 마무리: 로드맵 Stage 4 항목 체크, 플랜 완료 처리. +- [ ] 후속 검토(별도 PR 후보, 작음): **자기 pid 경로의 토큰.** `reconcile`의 자기 pid 가드는 토큰을 올리지 않아, 다른 앱을 향해 이미 떠 있던 읽기가 그 경로가 배지를 감춘 뒤 착지해 다시 그릴 수 있다(PR 2부터, PR 3 리뷰에서 발견, ~50ms 창). 한 줄 `token &+= 1`로 닫히지만 밀린 flash가 복귀 시점으로 미뤄지는 부작용을 같이 판단해야 한다. - [ ] 후속 검토(별도 PR 후보): **막 실행된 앱의 AXObserver 등록 실패.** 앱을 실행하며 동시에 활성화하면 리졸버의 `AXObserverCreate`/`AddNotification`이 `cannotComplete`(−25204)로 실패하고, 리졸버는 다음 pid 전환까지 재시도하지 않는다(PR 2 도그푸딩에서 TextEdit·Chrome·Terminal 콜드 실행 3건 전부 재현, 다른 앱에 갔다 오면 정상). PR 2 이전부터 있던 동작이지만 배지가 생기며 체감이 커졌다 — 그 앱에서는 앱 전환 전까지 포커스·창 이벤트 재앵커가 없고 모드 전환만 배지를 옮긴다. 후보: 등록 실패 시 짧은 지연 뒤 1회 재시도. ## 진행 중 컨텍스트 @@ -62,7 +63,7 @@ - 텍스트 뷰가 창 전체인 앱(TextEdit)은 "요소 위쪽 바깥" 규칙으로 배지가 타이틀바 자리에 뜬다 — 글자를 안 가려 수용. Chrome은 페이지 로드 직후 앵커가 웹 영역/창으로 잡혀 창 오른쪽 위에 뜸(폴백 정상). - 라벨은 강조색 배경 — 글씨색은 PR 2부터 강조색 luminance로 파생(흰 글씨 대비 3:1 미만이면 검정: 노랑·주황·초록·다크 그래파이트). 실측 대비비는 `ModeIndicatorPanel.textColor(on:)` 주석에. - CLAUDE.md의 Swift 6 프로브(`SWIFT_VERSION=6.0` 오버라이드)가 이제 Yams SPM 체크아웃까지 적용돼 앱 타깃 전에 실패한다(깨끗한 HEAD도 동일). 별도 정리 필요. -- 도그푸딩 도구(scratchpad): `postkey [shift]`(HID 탭에 키 게시, 마커 없음), `vawin`(VimAction 소유 온스크린 창 나열 → 오버레이 표시 여부 판정), `dogfood.sh`(설치→앱 순환→키 게시→캡처). PR 2용 `dogfood3.sh`: 앱을 먼저 띄워 웜 상태로 만들고(콜드 실행은 AX 옵저버 등록 실패) AeroSpace 창을 `aerospace layout floating`으로 풀어 창 이동을 실제로 일으킨다 — 타일링 상태에서는 `set position`이 무시된다. +- 도그푸딩 도구(scratchpad): `postkey [shift]`(HID 탭에 키 게시, 마커 없음), `vawin`(VimAction 소유 온스크린 창 나열 → 오버레이 표시 여부 판정), `dogfood.sh`(설치→앱 순환→키 게시→캡처). PR 2용 `dogfood3.sh`: 앱을 먼저 띄워 웜 상태로 만들고(콜드 실행은 AX 옵저버 등록 실패) AeroSpace 창을 `aerospace layout floating`으로 풀어 창 이동을 실제로 일으킨다 — 타일링 상태에서는 `set position`이 무시된다. PR 3용 `dogfood4.sh `: 앱마다 `axprobe fields`로 캐럿을 놓고 Esc 뒤 `vawin`(flash 창 bounds)과 `axprobe sample `(캐럿 rect)을 대조해 "flash.x == caret.minX, flash.y == caret.maxY+4"를 수치로 판정, 이어서 `onScreenModeIndicatorStyle`을 `defaults write`로 바꿔 재실행해 테두리 스타일 검증(스타일은 init에서 읽으므로 실행 중 `defaults write`는 무시). `screens`는 디스플레이 frame/visibleFrame을 CG 좌표로 출력. AeroSpace에서는 floating 창도 자기 워크스페이스 모니터에 묶여 스크립트로 다른 디스플레이에 못 옮긴다 — `aerospace move-node-to-monitor --focus-follows-window `를 쓴다. ### 구조 제약 (구현 시 지킬 것) diff --git a/VimAction/ModeIndicatorBorderPanel.swift b/VimAction/ModeIndicatorBorderPanel.swift new file mode 100644 index 0000000..aad515b --- /dev/null +++ b/VimAction/ModeIndicatorBorderPanel.swift @@ -0,0 +1,60 @@ +// +// ModeIndicatorBorderPanel.swift +// VimAction +// + +import AppKit + +/// 화면 테두리 스타일의 상시 표시 — 포커스 창이 있는 디스플레이의 `visibleFrame`을 덮는 패널 +/// **하나**에 강조색 테두리와 모서리 라벨 알약을 함께 그린다. +/// +/// 라벨이 같은 패널의 서브뷰인 것이 요점이다: `hide()` 한 번에 테두리와 라벨이 같이 사라져, +/// 스타일을 배지로 되돌린 직후 라벨만 화면 구석에 남는 일이 없다. 색만으로 구분하지 않는다는 +/// PRD 접근성 NFR 때문에 라벨은 항상 동반된다 — 테두리만으로는 어느 모드인지 말하지 못한다. +/// +/// 비활성화 패널 설정은 `ModeIndicatorPanel.makeOverlayPanel`에서 받는다 — 그 설정이 섬세한 +/// 부분이라 두 벌로 만들지 않는다. 층은 배지와 같다(`statusBar - 1`): flash가 그 위에 그려진다. +/// 화면을 덮는 패널이라 `ignoresMouseEvents`가 특히 중요하다 — 클릭을 한 번이라도 먹으면 +/// 사용자가 앱 대신 우리를 누른 것이 된다. +@MainActor +final class ModeIndicatorBorderPanel { + private let panel: NSPanel + private let pill: ModeIndicatorPillView + + init() { + let border = ModeIndicatorBorderView(frame: .zero) + pill = ModeIndicatorPillView(style: .badge) + border.addSubview(pill) + panel = ModeIndicatorPanel.makeOverlayPanel(level: ModeIndicatorStyle.badge.windowLevel) + panel.contentView = border + } + + /// 테두리를 `frame`에, 라벨을 `labelFrame`에 띄우고 **그대로 둔다** — 배지의 `show`와 같은 + /// 계약이다(애니메이션도 타이머도 없고 멱등). 둘 다 화면 좌표이고, 라벨은 패널 안 좌표로 + /// 옮겨 앉힌다. + func show(_ label: String, at frame: NSRect, labelFrame: NSRect) { + pill.label = label + pill.frame = labelFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) + panel.setFrame(frame, display: true) + panel.orderFrontRegardless() + } + + /// 즉시 감춘다 — 배지의 `hide`와 같은 사유·같은 계약이다. + func hide() { + panel.orderOut(nil) + } +} + +/// 강조색 테두리. 색은 알약과 같은 이유로 그리는 시점의 외양에서 해석한다. +private final class ModeIndicatorBorderView: NSView { + override func draw(_ dirtyRect: NSRect) { + // 선은 bounds 안쪽에 온전히 들어와야 한다 — 절반이 패널 밖으로 잘리면 굵기가 반이 된다. + let width = ModeIndicatorLayout.borderStrokeWidth + let path = NSBezierPath( + roundedRect: bounds.insetBy(dx: width / 2, dy: width / 2), + xRadius: width * 2, yRadius: width * 2) + path.lineWidth = width + NSColor.controlAccentColor.setStroke() + path.stroke() + } +} diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index 175588d..42e76a0 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -8,23 +8,40 @@ import Foundation import Observation import VimEngine +/// 상시 표시의 형태 — Settings의 "Indicator style". `ModeIndicatorStyle`(알약 하나의 외양: +/// flash/badge)과는 **다른 축**이다: 그쪽은 알약의 크기·층이고, 이쪽은 비-Insert 동안 무엇을 +/// 띄우는가다. flash는 어느 스타일에서든 캐럿/요소 사다리를 탄다 — 전환 피드백은 사용자의 +/// 시선 근처에 있어야 한다. +/// +/// raw 값이 UserDefaults에 그대로 저장된다 — 없는 값·모르는 값은 기본(배지)이다. +nonisolated enum ModeIndicatorPresentationStyle: String, Sendable { + /// 포커스 요소 모서리의 작은 알약 (기본). + case badge + /// 포커스 창이 있는 디스플레이를 두르는 강조색 테두리 + 모서리 라벨 — 설정에서 고르는 + /// 대체 스타일이다. 계속 켜진 색 프레임은 피로가 커서 기본이 아니다 + /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5). + case screenBorder +} + /// 모드·사다리·기하를 잇는 조율자 — 화면에 떠 있는 것의 단일 소유자. /// -/// 겹은 둘이다. **순간 표시(flash)** 는 모드가 바뀔 때마다 ~1초 떴다 사라지고, **상시 배지**는 +/// 겹은 둘이다. **순간 표시(flash)** 는 모드가 바뀔 때마다 ~1초 떴다 사라지고, **상시 표시**는 /// 비-Insert 모드 동안 계속 붙어 있는다 (`20260906_mode-indicator-hybrid-display-policy.md`). -/// 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다 -/// (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 3). 트리거는 다섯이고 전부 -/// 아래 `reconcile` 하나로 모인다: 모드 전환 / 앵커 이벤트(포커스·앱 활성화·창 이동·리사이즈· -/// 디스플레이 재구성) / 사다리 변화 / 설정 토글. +/// 상시 표시의 형태는 둘 중 하나다 — 요소 모서리의 배지(기본) 또는 화면 테두리 +/// (`ModeIndicatorPresentationStyle`). 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 +/// 재배치도 없다 (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 3). 트리거는 +/// 다섯이고 전부 아래 `reconcile` 하나로 모인다: 모드 전환 / 앵커 이벤트(포커스·앱 활성화·창 +/// 이동·리사이즈·디스플레이 재구성) / 사다리 변화 / 설정(토글·스타일). @MainActor @Observable final class ModeIndicatorController { /// 기하 읽기 전용 직렬 큐. **리졸버의 `readQueue`를 재사용하지 않는 것이 계약이다** — - /// 콜드 앱에서 이 읽기는 요소·position·size·창까지 AX 호출 여섯 번(최악 50ms×6)이 될 수 - /// 있는데, 리졸버 큐는 **키 디스패치가 의존하는 포커스 계열 캐시**를 먹인다. 표시용 읽기가 - /// 그 캐시를 지연시키면 앱 전환 직후 `.unresolved` 창이 넓어져 첫 편집이 조용히 떨어진다. - /// QoS를 `.utility`로 낮춘 것도 같은 이유다(리졸버는 `.userInitiated`). ~600ms 프로브 - /// 때문에 큐를 따로 두는 `AXTrustProber`와 같은 근거·같은 형태다. + /// 콜드 앱에서 이 읽기는 요소·position·size·창에 flash면 캐럿 변형까지 AX 호출 열한 번 + /// (최악 50ms×11)이 될 수 있는데, 리졸버 큐는 **키 디스패치가 의존하는 포커스 계열 캐시**를 + /// 먹인다. 표시용 읽기가 그 캐시를 지연시키면 앱 전환 직후 `.unresolved` 창이 넓어져 첫 + /// 편집이 조용히 떨어진다. QoS를 `.utility`로 낮춘 것도 같은 이유다(리졸버는 + /// `.userInitiated`). ~600ms 프로브 때문에 큐를 따로 두는 `AXTrustProber`와 같은 근거·같은 + /// 형태다. private nonisolated static let geometryQueue = DispatchQueue( label: "dev.pilyang.VimAction.mode-indicator-geometry", qos: .utility) @@ -47,10 +64,13 @@ final class ModeIndicatorController { /// 않는다. nonisolated struct Presentation: Equatable { var label: String - /// Insert만 `false`다 — flash는 뜨되 배지는 남지 않는다. 상시 배지가 "지금 위험한 + /// Insert만 `false`다 — flash는 뜨되 상시 표시는 남지 않는다. 상시 표시가 "지금 위험한 /// 모드다"를 뜻하려면 기본 상태(Insert)에는 없어야 한다. var showsBadge: Bool var processID: pid_t + /// 상시 표시의 형태. 여기 실리는 이유는 `desired != current`가 스타일 변경에도 참이 + /// 되어 다시 그리게 하기 위해서다. + var style: ModeIndicatorPresentationStyle } /// 표시 판정 — **이 기능의 순수 계층이다.** AX도 AppKit도 부르지 않아 표로 검증된다. @@ -59,14 +79,17 @@ final class ModeIndicatorController { /// 앱별 disabled·Secure Input) 아무것도 띄우지 않는다. 가로채지 않는 상태에서 모드 라벨을 /// 띄우면 메뉴바에서 없앤 "가로채지 않는데 Normal이라고 말하는" 거짓말을 화면 한가운데서 /// 되풀이하게 된다 (`20260906_mode-indicator-hybrid-display-policy.md` 결정 3). - nonisolated static func presentation(isEnabled: Bool, inputs: Inputs) -> Presentation? { + nonisolated static func presentation( + isEnabled: Bool, style: ModeIndicatorPresentationStyle, inputs: Inputs + ) -> Presentation? { guard isEnabled, case .mode = inputs.indicator, let processID = inputs.processID else { return nil } return Presentation( label: inputs.mode.overlayLabel, showsBadge: inputs.mode.showsPersistentBadge, - processID: processID) + processID: processID, + style: style) } /// 새 기하 읽기를 띄워야 하는가 — **순수 함수라 표로 검증한다.** 판정이 틀리면 증상이 @@ -84,15 +107,16 @@ final class ModeIndicatorController { if !rereadGeometry, let inFlight, inFlight.request == desired, inFlight.token == token { return false } - // 밀린 flash는 그릴 프레임이 있어야 하고, 배지는 기하가 상했거나 상태가 달라졌을 때만 - // 다시 읽는다 — Insert에서 앵커 이벤트가 AX 왕복 0건인 것이 이 두 번째 항이다. + // 밀린 flash는 그릴 프레임이 있어야 하고, 상시 표시는 기하가 상했거나 상태(라벨· + // 스타일)가 달라졌을 때만 다시 읽는다 — Insert에서 앵커 이벤트가 AX 왕복 0건인 것이 + // 이 두 번째 항이다. return pendingFlash || (desired.showsBadge && (rereadGeometry || desired != current)) } /// 온스크린 인디케이터 on/off. **런타임 SSOT는 이 프로퍼티**이고 didSet이 저장을 /// 책임진다 — 실행 중 외부 `defaults write`는 재시작까지 무시된다 (탈출 옵션과 같은 - /// 소유 모델). 유일하게 `@ObservationIgnored`가 아닌 저장 프로퍼티다: Settings 토글이 - /// 여기 바인딩된다. + /// 소유 모델). `style`과 함께 `@ObservationIgnored`가 아닌 단 둘의 저장 프로퍼티다: + /// Settings 토글이 여기 바인딩된다. var isEnabled: Bool { didSet { guard oldValue != isEnabled else { return } @@ -104,6 +128,18 @@ final class ModeIndicatorController { } } + /// 상시 표시의 형태 — `isEnabled`와 같은 소유 모델(런타임 SSOT는 이 프로퍼티, didSet이 + /// 저장). Settings의 Picker가 여기 바인딩된다. + var style: ModeIndicatorPresentationStyle { + didSet { + guard oldValue != style else { return } + defaults.set(style.rawValue, forKey: PreferenceKeys.onScreenModeIndicatorStyle) + // `Presentation`에 스타일이 실려 있어 `desired != current`가 참이 된다 — 상시 표시가 + // 떠 있어야 하면 다시 읽어 새 형태로 그리고, 이전 형태의 패널은 즉시 감춘다. + reconcile(flashes: false, rereadGeometry: false) + } + } + @ObservationIgnored private nonisolated(unsafe) let defaults: UserDefaults /// 아직 화면에 반영되지 않은 최신 요청. 읽는 도중 새 트리거가 오면 여기만 덮어써 @@ -122,7 +158,7 @@ final class ModeIndicatorController { var token: Int } - /// 마지막으로 트리거가 밀어 넣은 상태. 토글 didSet이 여기서 다시 판정한다. + /// 마지막으로 트리거가 밀어 넣은 상태. 토글·스타일 didSet이 여기서 다시 판정한다. @ObservationIgnored private var lastInputs: Inputs? /// 지금 그리기로 되어 있는 것. **직전 이벤트가 아니라 이 값과만 비교한다** — 사다리 /// 관찰 루프는 레벨 트리거(중간 전이가 유실되고 현재 값으로 수렴)라 엣지 비교가 성립하지 @@ -136,9 +172,11 @@ final class ModeIndicatorController { @ObservationIgnored private var token = 0 /// 패널은 첫 표시에서야 만든다 — 런치 시 `NSPanel`을 만들지 않고, 표시할 일이 없는 - /// 실행(권한 미허용, 계속 Insert, 토글 off)에서는 끝까지 만들어지지 않는다. + /// 실행(권한 미허용, 계속 Insert, 토글 off)에서는 끝까지 만들어지지 않는다. 테두리 패널은 + /// 그 스타일을 고른 적이 있어야 생긴다. @ObservationIgnored private var flashPanel: ModeIndicatorPanel? @ObservationIgnored private var badgePanel: ModeIndicatorPanel? + @ObservationIgnored private var borderPanel: ModeIndicatorBorderPanel? /// `defaults` 주입은 테스트용이다 — `.standard`를 쓰면 TEST_HOST가 앱 프로세스라 /// 실기기에서 영속된 값이 새어 들어온다 (`EventTapController`와 같은 이유). @@ -147,6 +185,12 @@ final class ModeIndicatorController { self.isEnabled = defaults.bool( forKey: PreferenceKeys.onScreenModeIndicatorEnabled, default: PreferenceKeys.onScreenModeIndicatorEnabledDefault) + // 없는 키도 모르는 raw 값도 기본으로 접힌다 — 스타일이 늘거나 이름이 바뀌어도 저장된 + // 값 때문에 아무것도 안 뜨는 일은 없다. + self.style = + defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorStyle) + .flatMap(ModeIndicatorPresentationStyle.init(rawValue:)) + ?? PreferenceKeys.onScreenModeIndicatorStyleDefault } // MARK: - 트리거 @@ -160,7 +204,7 @@ final class ModeIndicatorController { } /// 앵커가 움직였을 수 있다 — 포커스 요소 변경·앱 활성화·창 이동·리사이즈·디스플레이 - /// 재구성. 모드는 그대로이므로 flash는 없고, 배지가 떠 있어야 할 때만 다시 읽는다 + /// 재구성. 모드는 그대로이므로 flash는 없고, 상시 표시가 떠 있어야 할 때만 다시 읽는다 /// (Insert에서는 앵커 이벤트가 AX 왕복 0건이다). func anchorDidChange(_ inputs: Inputs) { lastInputs = inputs @@ -180,7 +224,7 @@ final class ModeIndicatorController { /// 늘지 않는다. private func reconcile(flashes: Bool, rereadGeometry: Bool) { guard let inputs = lastInputs, - let desired = Self.presentation(isEnabled: isEnabled, inputs: inputs) + let desired = Self.presentation(isEnabled: isEnabled, style: style, inputs: inputs) else { // 표시가 통째로 막힌 모든 사유(사다리 이탈·토글 off·pid 없음·아직 아무 입력도 // 받지 않음)가 이 한 자리로 모인다. 페이드 없이 즉시 감춘다. @@ -189,7 +233,7 @@ final class ModeIndicatorController { pendingFlash = false current = nil flashPanel?.hide() - badgePanel?.hide() + hidePersistentPanels(keeping: nil) return } // 우리 자신이 최전면이 되면(메뉴바 아이콘 클릭·설정 창) 리졸버가 우리 pid에 붙는데, @@ -198,8 +242,11 @@ final class ModeIndicatorController { guard desired.processID != Self.ownProcessID else { // 읽지도 `current`를 건드리지도 않지만 **거짓말은 지운다**: 우리가 최전면인 동안 // Insert로 바뀌면(우리 창에서 Esc·i) 이전 앱 요소에 남은 "NORMAL" 배지가 다음 - // 앵커 이벤트까지 사실이 아닌 채로 붙어 있다. - if !desired.showsBadge { badgePanel?.hide() } + // 앵커 이벤트까지 사실이 아닌 채로 붙어 있다. 스타일 변경도 바로 이 경로다 — + // 설정 창이 열려 있으면 우리가 최전면이다 — 그래서 이전 스타일의 패널은 여기서 + // 즉시 감추고, 새 스타일은 사용자가 앱으로 돌아가는 앵커 이벤트가 그린다 + // (토글을 켤 때와 같은 수렴). + hidePersistentPanels(keeping: desired.showsBadge ? desired.style : nil) return } if flashes { pendingFlash = true } @@ -209,7 +256,7 @@ final class ModeIndicatorController { // **판정보다 뒤여야 한다**: 앞으로 옮기면 `desired != current`가 영원히 거짓이라 // 상태가 달라져도 다시 읽지 않는다. current = desired - if !desired.showsBadge { badgePanel?.hide() } + hidePersistentPanels(keeping: desired.showsBadge ? desired.style : nil) guard needsRead else { // 아무것도 달라지지 않았다 — **토큰을 올리지 않는다.** 올리면 진행 중인 읽기가 // 폐기돼 방금 요청한 flash가 화면에 닿지 못한다. @@ -228,8 +275,14 @@ final class ModeIndicatorController { pending = nil let token = self.token inFlight = InFlight(request: request, token: token) + // 캐럿은 flash만 쓴다 — 밀린 flash가 있을 때만 읽어 앵커·사다리 이벤트의 재배치는 + // 오늘의 비용에 머문다. **여기서** 잡아야 한다: `finish`의 낡은 토큰 경로가 더 새로운 + // `pendingFlash`로 다시 들어온다. flash를 세우는 유일한 경로(`modeDidChange`)가 항상 + // 토큰을 올려 새 읽기를 띄우므로, 캐럿 없이 띄운 읽기로 flash가 그려지는 일은 없다. + let includesCaret = pendingFlash Self.geometryQueue.async { [weak self] in - let anchors = ModeIndicatorGeometryReader.read(processID: request.processID) + let anchors = ModeIndicatorGeometryReader.read( + processID: request.processID, includesCaret: includesCaret) DispatchQueue.main.async { MainActor.assumeIsolated { self?.finish(request, token: token, anchors: anchors) @@ -255,23 +308,23 @@ final class ModeIndicatorController { let layoutScreens = screens.map { ModeIndicatorLayout.Screen(frame: $0.frame, visibleFrame: $0.visibleFrame) } - // 읽기 한 번의 앵커로 두 프레임을 만든다 — 크기만 다르고 사다리·배치·클램프는 같다. - func frame(for style: ModeIndicatorStyle) -> NSRect? { + // 읽기 한 번의 앵커로 flash와 상시 표시의 프레임을 만든다 — 사다리의 첫 단만 다르다. + func frame(ladder: ModeIndicatorLayout.Ladder, style: ModeIndicatorStyle) -> NSRect? { ModeIndicatorLayout.panelFrame( - anchors: anchors, + anchors: anchors, ladder: ladder, size: ModeIndicatorPanel.size(for: request.label, style: style), screens: layoutScreens, primaryScreenMaxY: primary.frame.maxY) } - guard let badgeFrame = frame(for: .badge), let flashFrame = frame(for: .flash) else { - // 사다리의 마지막 단 — 붙일 곳이 없으면 표시하지 않는다. **재시도는 없다**: - // 앵커가 없다는 것이 답이고, 다음 앵커 이벤트가 이 경로를 다시 부른다. - // + // flash 사다리(캐럿 → 요소 → 창)가 배지 사다리를 포함하므로, flash 프레임이 없으면 + // 앵커가 아예 없다 — 사다리의 마지막 단이고, 붙일 곳이 없으면 표시하지 않는다. + // **재시도는 없다**: 앵커가 없다는 것이 답이고, 다음 앵커 이벤트가 이 경로를 다시 부른다. + guard let flashFrame = frame(ladder: .caretFirst, style: .flash) else { // 여기서 flash 요청을 **버리는 것**이 계약이다. 들고 있으면 그 요청이 다음 앵커 // 이벤트(창 이동 등)에 얹혀 살아나, 한참 전에 끝난 전환의 라벨이 엉뚱한 때 번쩍인다. // 순간 표시는 그 순간에 매인 알림이라 놓쳤으면 만료되는 것이 맞다. pendingFlash = false flashPanel?.hide() - badgePanel?.hide() + hidePersistentPanels(keeping: nil) return } if pendingFlash { @@ -280,15 +333,46 @@ final class ModeIndicatorController { flashPanel = panel panel.flash(request.label, at: flashFrame) } - if request.showsBadge { - let panel = badgePanel ?? ModeIndicatorPanel(style: .badge) - badgePanel = panel - panel.show(request.label, at: badgeFrame) - } else { - badgePanel?.hide() + guard request.showsBadge else { + hidePersistentPanels(keeping: nil) + return + } + // 상시 표시는 요소부터 시작하는 사다리다 — 캐럿만 있고 요소·창이 없는 경우(이론상) + // flash는 뜨되 상시 표시는 붙일 곳이 없어 감춘다. + switch request.style { + case .badge: + if let badgeFrame = frame(ladder: .elementFirst, style: .badge) { + let panel = badgePanel ?? ModeIndicatorPanel(style: .badge) + badgePanel = panel + panel.show(request.label, at: badgeFrame) + hidePersistentPanels(keeping: .badge) + } else { + hidePersistentPanels(keeping: nil) + } + case .screenBorder: + if let layout = ModeIndicatorLayout.borderLayout( + anchors: anchors, + labelSize: ModeIndicatorPanel.size(for: request.label, style: .badge), + screens: layoutScreens, primaryScreenMaxY: primary.frame.maxY) + { + let panel = borderPanel ?? ModeIndicatorBorderPanel() + borderPanel = panel + panel.show(request.label, at: layout.frame, labelFrame: layout.labelFrame) + hidePersistentPanels(keeping: .screenBorder) + } else { + hidePersistentPanels(keeping: nil) + } } } + /// 상시 표시 패널 중 `style`이 아닌 것을 감춘다 (`nil`이면 전부). **숨김 규칙의 단일 + /// 지점이다** — 배지에 적용되는 모든 숨김 사유(Insert·사다리 이탈·토글 off·pid 없음· + /// 자기 pid·앵커 없음·스타일 변경)가 테두리에도 같은 자리에서 같이 적용된다. + private func hidePersistentPanels(keeping style: ModeIndicatorPresentationStyle?) { + if style != .badge { badgePanel?.hide() } + if style != .screenBorder { borderPanel?.hide() } + } + /// VimAction 자신의 pid — 앵커로 삼지 않기 위한 비교값이다. private nonisolated static let ownProcessID = ProcessInfo.processInfo.processIdentifier } diff --git a/VimAction/ModeIndicatorGeometryReader.swift b/VimAction/ModeIndicatorGeometryReader.swift index ded5d41..af3886b 100644 --- a/VimAction/ModeIndicatorGeometryReader.swift +++ b/VimAction/ModeIndicatorGeometryReader.swift @@ -15,22 +15,35 @@ import CoreGraphics /// /// 타임아웃은 새로 정하지 않는다 — 요소·앱 요소 양쪽을 `AXRead`에서 받으므로 50ms 단일 /// 상수가 그대로 상속된다. +/// +/// **비용**: 요소 rect(포커스 요소 조회·position·size)에, 요소가 쓸 만하지 않을 때만 창(창 +/// 조회·position·size)이 더해진다. 캐럿은 `includesCaret`일 때만 읽고 최대 다섯 번(선택 범위· +/// bounds 두 변형·마커 범위·마커 bounds)이라, 콜드 앱의 flash 한 번이 AX 왕복 최대 열한 번이다. nonisolated enum ModeIndicatorGeometryReader { - /// 포커스 요소 rect와 포커스 창 rect를 한 번에 읽는다 (AX 좌표계). + /// 포커스 요소 rect·(필요하면) 포커스 창 rect·(flash면) 캐럿 rect를 한 번에 읽는다 (AX 좌표계). /// /// 어느 단이 실패하든 그 단만 `nil`이 되고 나머지는 그대로 살아 있다 — 사다리가 아래 /// 단으로 내려가는 것은 순수 계층(`ModeIndicatorLayout.anchor`)의 몫이라, 여기서는 /// "읽힌 것만 담는다". - static func read(processID: pid_t) -> ModeIndicatorLayout.Anchors { - let element = AXRead.focusedElement(ofProcess: processID).flatMap(rect(of:)) + /// + /// `includesCaret`은 **요청에 flash가 있을 때만** 참이다 — 캐럿은 flash만 쓰고(상시 배지는 + /// 캐럿을 따라다니지 않는다), 앵커·사다리 이벤트의 배지 재배치는 캐럿 왕복 없이 오늘의 + /// 비용에 머문다. 컨트롤러가 명시적으로 넘긴다 — 전역 상태로 두면 어느 읽기가 캐럿을 + /// 물었는지 코드에서 보이지 않는다. + static func read(processID: pid_t, includesCaret: Bool) -> ModeIndicatorLayout.Anchors { + let focused = AXRead.focusedElement(ofProcess: processID) + let element = focused.flatMap(rect(of:)) + // 요소 rect 다음이어야 한다 — 캐럿 유효성 판정이 요소 rect를 본다. 그리고 아래 조기 + // 반환 **앞**이어야 한다: 요소가 쓸 만한 것이 실측표의 모든 앱에서 정상 경로다. + let caret = includesCaret ? focused.flatMap { caretRect(of: $0, within: element) } : nil // 요소가 답했으면 창은 읽지 않는다 — 사다리가 어차피 요소를 고르므로 결과는 같고, // 콜드 앱에서 AX 왕복 셋(창 조회·position·size)이 통째로 빠진다. 그래서 `window`가 // `nil`인 것은 "읽기 실패"가 아니라 "필요 없었다"일 수도 있다. if let element, ModeIndicatorLayout.isUsable(element) { - return ModeIndicatorLayout.Anchors(element: element) + return ModeIndicatorLayout.Anchors(element: element, caret: caret) } return ModeIndicatorLayout.Anchors( - element: element, window: focusedWindowRect(processID: processID)) + element: element, window: focusedWindowRect(processID: processID), caret: caret) } private static func focusedWindowRect(processID: pid_t) -> CGRect? { @@ -43,6 +56,74 @@ nonisolated enum ModeIndicatorGeometryReader { return rect(of: value as! AXUIElement) } + /// 캐럿 rect — 결정 5의 순서대로 시도하고 **첫 쓸 만한 것**에서 멈춘다 + /// (`20260906_mode-indicator-anchor-ladder-event-driven.md`). 쓸 만한지는 순수 계층의 + /// `isUsableCaret`이 정한다 — 사다리와 같은 규칙이라야 리더가 넘긴 것을 사다리가 버리는 + /// 어긋남이 없다. + /// + /// 1. `AXSelectedTextRange`의 위치 `loc`에 `AXBoundsForRange (loc, 1)`. + /// 2. 안 되면 `(loc-1, 1)` — 문서 끝이다. **길이 0 범위는 쓰지 않는다**: AppKit이 한 줄 위 + /// rect를 돌려준다. + /// 3. 텍스트 마커 경로 — Electron contenteditable(Slack 컴포저)의 유일한 답이라 마지막이다. + /// + /// 문서 끝인지 미리 묻지 않는다(`AXNumberOfCharacters`) — "해 보고 물러나는" 편이 왕복 + /// 하나가 적다. Chromium 브라우저(Chrome·Arc)는 셋 다 빈 값이라 조용히 `nil`이고, 사다리가 + /// 요소 단으로 내려간다 (`20260906_no-forced-chromium-screen-reader-mode.md`). + private static func caretRect(of element: AXUIElement, within elementRect: CGRect?) -> CGRect? { + func usable(_ rect: CGRect?) -> CGRect? { + guard let rect, ModeIndicatorLayout.isUsableCaret(rect, element: elementRect) else { + return nil + } + return rect + } + if let selection = copyRange(element, kAXSelectedTextRangeAttribute as String) { + if let rect = usable(boundsForRange(element, location: selection.location, length: 1)) { + return rect + } + if selection.location > 0, + let rect = usable( + boundsForRange(element, location: selection.location - 1, length: 1)) + { + return rect + } + } + return usable(boundsForTextMarkerRange(element)) + } + + /// `AXBoundsForRange`는 **파라미터화 속성**이다 (`FocusedTextReader`의 `AXStringForRange`와 + /// 같은 형태). + private static func boundsForRange(_ element: AXUIElement, location: Int, length: Int) + -> CGRect? + { + var range = CFRange(location: location, length: length) + guard let parameter = AXValueCreate(.cfRange, &range) else { return nil } + return copyRect( + parameterizedValue( + element, kAXBoundsForRangeParameterizedAttribute as String, parameter)) + } + + /// 텍스트 마커 경로. 마커 범위는 **불투명 CFType**이라 형 검사도 변환도 없이 그대로 + /// 파라미터로 넘긴다 — 우리가 아는 것은 "이 앱이 준 값을 이 앱에 되돌려 준다"뿐이다. + private static func boundsForTextMarkerRange(_ element: AXUIElement) -> CGRect? { + guard let marker = AXRead.copyValue(element, "AXSelectedTextMarkerRange") else { + return nil + } + return copyRect(parameterizedValue(element, "AXBoundsForTextMarkerRange", marker)) + } + + private static func parameterizedValue( + _ element: AXUIElement, _ attribute: String, _ parameter: CFTypeRef + ) -> AXValue? { + var value: CFTypeRef? + guard + AXUIElementCopyParameterizedAttributeValue( + element, attribute as CFString, parameter, &value) == .success + else { + return nil + } + return axValue(value) + } + /// `AXPosition` + `AXSize` → rect. 둘 중 하나라도 없으면 rect가 없다. private static func rect(of element: AXUIElement) -> CGRect? { guard let origin = copyPoint(element, kAXPositionAttribute as String), @@ -57,27 +138,39 @@ nonisolated enum ModeIndicatorGeometryReader { /// `FocusedTextReader`·`ViewportReader`의 `.cfRange`)과 같은 형태다. 실패는 전부 `nil` /// 하나로 접는다: 소비자의 폴백이 "이 단을 건너뛴다" 하나뿐이라 에러코드로 갈라 봐야 쓸 /// 데가 없다 (`AXRead.copyValue`와 같은 규칙). 제네릭으로 묶지 않은 것은 `AXValueGetValue`가 - /// 임의 `T`의 포인터를 받으면 경고가 나서다 — 두 벌이지만 각각 세 줄이다. + /// 임의 `T`의 포인터를 받으면 경고가 나서다 — 네 벌이지만 각각 세 줄이다. private static func copyPoint(_ element: AXUIElement, _ attribute: String) -> CGPoint? { - guard let value = axValue(element, attribute) else { return nil } + guard let value = axValue(AXRead.copyValue(element, attribute)) else { return nil } var point = CGPoint.zero guard AXValueGetValue(value, .cgPoint, &point) else { return nil } return point } private static func copySize(_ element: AXUIElement, _ attribute: String) -> CGSize? { - guard let value = axValue(element, attribute) else { return nil } + guard let value = axValue(AXRead.copyValue(element, attribute)) else { return nil } var size = CGSize.zero guard AXValueGetValue(value, .cgSize, &size) else { return nil } return size } - private static func axValue(_ element: AXUIElement, _ attribute: String) -> AXValue? { - guard let value = AXRead.copyValue(element, attribute), - CFGetTypeID(value) == AXValueGetTypeID() - else { - return nil - } + private static func copyRange(_ element: AXUIElement, _ attribute: String) -> CFRange? { + guard let value = axValue(AXRead.copyValue(element, attribute)) else { return nil } + var range = CFRange() + guard AXValueGetValue(value, .cfRange, &range) else { return nil } + return range + } + + private static func copyRect(_ value: AXValue?) -> CGRect? { + guard let value else { return nil } + var rect = CGRect.zero + guard AXValueGetValue(value, .cgRect, &rect) else { return nil } + return rect + } + + /// 속성 값이든 파라미터화 속성 값이든 `AXValue`인지 확인하고 캐스팅한다 — 형 검사 없이 + /// 강제 캐스팅하면 엉뚱한 타입을 돌려주는 앱에서 크래시다. + private static func axValue(_ value: CFTypeRef?) -> AXValue? { + guard let value, CFGetTypeID(value) == AXValueGetTypeID() else { return nil } return (value as! AXValue) } } diff --git a/VimAction/ModeIndicatorLayout.swift b/VimAction/ModeIndicatorLayout.swift index 3829593..684ebfe 100644 --- a/VimAction/ModeIndicatorLayout.swift +++ b/VimAction/ModeIndicatorLayout.swift @@ -13,10 +13,17 @@ import CoreGraphics /// 그것을 돌린 머신의 디스플레이 배치에 묶인다 (`MenuBarIndicator.resolve`· /// `AXTrustVerdict.extentIsVisible`와 같은 부류의 순수 함수). nonisolated enum ModeIndicatorLayout { + /// 캐럿 단 알약이 캐럿 아래에 뜰 때의 간격 (스파이크 값). + private static let caretGap: CGFloat = 4 /// 요소 단 배지가 요소 위쪽 바깥에 뜰 때의 간격. private static let elementGap: CGFloat = 4 /// 창 단 배지가 창 안쪽 오른쪽 위에 붙을 때의 인셋 (오른쪽, 위). private static let windowInset = (horizontal: CGFloat(12), vertical: CGFloat(6)) + /// 화면 테두리의 선 굵기. 패널이 그릴 때도 이 값을 읽는다 — 라벨 인셋이 선 굵기에서 + /// 파생되므로 두 값이 따로 살면 라벨이 선에 겹친다. + static let borderStrokeWidth: CGFloat = 4 + /// 화면 테두리의 모서리 라벨이 테두리 안쪽 모서리에서 떨어지는 거리 (선 굵기 + 여백). + private static let borderLabelInset: CGFloat = borderStrokeWidth + 8 /// 화면 하나 (전부 AppKit 좌표). **고르는 축과 놓는 축이 다르다**: `frame`은 앵커가 어느 /// 디스플레이에 속하는지 판정하고, `visibleFrame`은 알약을 실제로 놓을 수 있는 범위다. @@ -37,37 +44,58 @@ nonisolated enum ModeIndicatorLayout { } /// 기하 리더가 읽어 오는 것 — **AX 좌표계**(좌상단 원점, y는 아래로 증가)의 rect들이다. - /// 캐럿 단은 PR 3에서 이 위에 얹힌다. + /// `caret`은 flash가 있는 요청에서만 읽힌다 — 배지만 다시 놓는 앵커 이벤트는 `nil`로 온다. struct Anchors: Sendable, Equatable { var element: CGRect? var window: CGRect? + var caret: CGRect? - init(element: CGRect? = nil, window: CGRect? = nil) { + init(element: CGRect? = nil, window: CGRect? = nil, caret: CGRect? = nil) { self.element = element self.window = window + self.caret = caret } } /// 사다리가 고른 한 단. 배치 규칙이 단마다 다르기 때문에 rect만이 아니라 **어느 단인지**가 /// 결과에 남아야 한다. enum Anchor: Equatable { + case caret(CGRect) case element(CGRect) case window(CGRect) var rect: CGRect { switch self { - case .element(let rect), .window(let rect): rect + case .caret(let rect), .element(let rect), .window(let rect): rect } } } - /// 앵커 사다리 — 요소 → 창 → 없음. 위 단이 실패하면 다음 단으로 내려간다. + /// 사다리의 첫 단 — **묻는 알약에 따라 다르다.** flash는 `caretFirst`(캐럿 → 요소 → 창), + /// 상시 배지는 `elementFirst`(요소 → 창)다. + /// + /// 배지가 캐럿을 따라다니지 않는 것이 결정이다: 따라다니려면 키마다(그리고 관측되지 않는 + /// 마우스 스크롤마다) AX를 다시 읽어야 하고, 안 읽으면 배지가 엉뚱한 자리에 남는다. 1초 뒤 + /// 사라지는 flash에는 그 드리프트가 없어 캐럿을 쓸 수 있다 + /// (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 2). 알약 이름(flash/badge)이 + /// 아니라 첫 단 이름인 것은 `ModeIndicatorStyle`과 케이스 이름이 겹치지 않게 하기 위해서다. + enum Ladder { + case caretFirst + case elementFirst + } + + /// 앵커 사다리 — 위 단이 실패하면 다음 단으로 내려간다. 마지막은 "없음"이다. /// /// **면적이 있는 rect만 한 단으로 인정한다**: 요소가 0×0(또는 음수)을 보고하는 앱이 있고, /// 그런 rect에 배지를 붙이면 화면 구석에 떠 있는 것처럼 보인다. 이 판정을 리더가 아니라 /// 여기 두는 것이 계약이다 — 리더는 AX가 답한 것을 그대로 옮기고, 무엇이 쓸 만한 - /// 앵커인지는 순수 계층이 정해야 표로 검증된다. - static func anchor(_ anchors: Anchors) -> Anchor? { + /// 앵커인지는 순수 계층이 정해야 표로 검증된다. 캐럿 단의 유효성(`isUsableCaret`)도 같다. + static func anchor(_ anchors: Anchors, for ladder: Ladder) -> Anchor? { + if case .caretFirst = ladder, let caret = anchors.caret, + isUsableCaret(caret, element: anchors.element) + { + return .caret(caret) + } if let element = anchors.element, isUsable(element) { return .element(element) } if let window = anchors.window, isUsable(window) { return .window(window) } return nil @@ -80,13 +108,36 @@ nonisolated enum ModeIndicatorLayout { rect.width > 0 && rect.height > 0 } + /// 캐럿 rect를 한 단으로 인정하는가. `isUsable`과 같은 계약이다 — 리더가 캐럿 변형 + /// 셋(`(loc,1)` → `(loc-1,1)` → 텍스트 마커)을 차례로 시도하며 다음 변형으로 넘어갈지를 + /// 이것으로 판정하므로, 규칙은 여기 한 벌이다. + /// + /// - **높이만 있으면 된다** — Slack 컴포저는 캐럿을 0×18로 보고하고 그것이 정확한 캐럿이다. + /// - **요소 rect와 같으면 아니다** — 마커 경로는 선택이 내용 끝에 있으면 캐럿 대신 요소 + /// 전체 rect로 퇴화한다 (Chromium 실측). + /// - **요소 rect가 쓸 만하면 그 안에 걸쳐야 한다** — 스크롤로 시야 밖에 나간 캐럿이나 + /// 쓰레기값이 입력칸 밖에 떠 있으면 안 된다. `CGRect.intersects`는 폭 0 rect에도 + /// 참을 돌려준다 (이 플랫폼에서 확인). + static func isUsableCaret(_ caret: CGRect, element: CGRect?) -> Bool { + guard caret.height > 0, caret != element else { return false } + guard let element, isUsable(element) else { return true } + return caret.intersects(element) + } + /// 단별 배지 배치 (AX 좌표계). /// + /// - 캐럿 단: 캐럿 **바로 아래**, 왼쪽 정렬 — macOS가 입력 소스 전환 때 캐럿 아래에 띄우는 + /// 인디케이터와 같은 자리다. 줄 첫머리에서는 `(loc-1,1)`이 개행 문자라 rect가 이전 줄 + /// 끝에 있어 알약도 거기 뜬다 — 실측에서 수용한 quirk이고 특별 취급하지 않는다. /// - 요소 단: 요소 **바깥 오른쪽 위** — 오른쪽 모서리에 맞추고 요소 top 위로 띄운다. /// 텍스트 영역 안에 겹치면 사용자가 쓰고 있는 글자를 가린다. /// - 창 단: 창 **안쪽 오른쪽 위** — 창 밖으로 나가면 어느 창의 상태인지 읽히지 않는다. static func badgeFrameInAXSpace(anchor: Anchor, size: CGSize) -> CGRect { switch anchor { + case .caret(let caret): + return CGRect( + x: caret.minX, y: caret.maxY + caretGap, + width: size.width, height: size.height) case .element(let element): return CGRect( x: element.maxX - size.width, @@ -110,6 +161,12 @@ nonisolated enum ModeIndicatorLayout { width: rect.width, height: rect.height) } + /// 앵커가 속한 화면 — 판정 축은 `frame`이다 (`Screen` 주석의 메뉴바 띠 사유). 클램프와 + /// 화면 테두리가 같은 규칙으로 화면을 고르도록 한 자리에 둔다. + private static func screen(near anchor: CGRect, in screens: [Screen]) -> Screen? { + screens.first { $0.frame.intersects(anchor) } + } + /// 알약을 **앵커가 있는 화면의 `visibleFrame`** 안으로 민다 (전부 AppKit 좌표). /// /// 주 화면 기준이 아닌 것이 요점이다: 보조 디스플레이의 창 모서리에 있는 앵커를 주 화면으로 @@ -120,7 +177,7 @@ nonisolated enum ModeIndicatorLayout { /// `Screen` 주석의 메뉴바 띠 사유 참고. static func clamp(_ frame: CGRect, nearAnchor anchor: CGRect, screens: [Screen]) -> CGRect { guard - let screen = screens.first(where: { $0.frame.intersects(anchor) }) + let screen = screen(near: anchor, in: screens) ?? screens.first(where: { $0.frame.intersects(frame) }) ?? screens.first else { // 화면 목록이 비는 것은 실기기에서 일어나지 않지만, 순수 함수라 답이 정의돼야 한다. @@ -133,16 +190,17 @@ nonisolated enum ModeIndicatorLayout { width: frame.width, height: frame.height) } - /// 컨트롤러가 부르는 단일 진입점 — 사다리 → 배치 → 좌표 변환 → 클램프. + /// 컨트롤러가 부르는 알약 배치의 단일 진입점 — 사다리 → 배치 → 좌표 변환 → 클램프. /// 앵커가 없으면 `nil`이고, 그때 오버레이는 표시하지 않는다 (사다리의 마지막 단 "없음"). /// /// `screens`·`primaryScreenMaxY`는 호출자가 메인에서 `NSScreen`을 읽어 넘긴다. /// `primaryScreenMaxY`는 **`frame`의 maxY**다 — 좌표계를 뒤집는 기준이지 배치 범위가 /// 아니라서, 여기에 `visibleFrame`을 넣으면 전 좌표가 메뉴바 높이만큼 밀린다. static func panelFrame( - anchors: Anchors, size: CGSize, screens: [Screen], primaryScreenMaxY: CGFloat + anchors: Anchors, ladder: Ladder, size: CGSize, screens: [Screen], + primaryScreenMaxY: CGFloat ) -> CGRect? { - guard let anchor = anchor(anchors) else { return nil } + guard let anchor = anchor(anchors, for: ladder) else { return nil } let badge = flip( badgeFrameInAXSpace(anchor: anchor, size: size), primaryScreenMaxY: primaryScreenMaxY) @@ -150,4 +208,36 @@ nonisolated enum ModeIndicatorLayout { badge, nearAnchor: flip(anchor.rect, primaryScreenMaxY: primaryScreenMaxY), screens: screens) } + + /// 화면 테두리 스타일의 배치 (전부 AppKit 화면 좌표). `frame`이 테두리 패널이 덮는 범위이고 + /// `labelFrame`이 그 안 오른쪽 위 모서리의 라벨 알약이다. + struct BorderLayout: Equatable { + var frame: CGRect + var labelFrame: CGRect + } + + /// 화면 테두리 배치 — 상시 배지의 사다리(요소 → 창)로 앵커를 고르고, **그 앵커가 속한 + /// 디스플레이의 `visibleFrame`**을 두른다. 앵커가 없으면 배지와 마찬가지로 아무것도 없다. + /// + /// 화면은 앵커 rect로만 고른다 — AX를 더 읽지 않는다. 요소는 창 안에 있으므로 요소 단이든 + /// 창 단이든 같은 디스플레이다. 겹치는 화면이 없으면 첫 화면으로 내려간다(클램프의 가운데 + /// 단 "알약과 겹치는 화면"은 여기서 뜻이 없다 — 테두리 프레임이 곧 화면이다). + /// + /// `frame`이 아니라 `visibleFrame`인 이유: `frame`을 두르면 위 변이 메뉴바 뒤에, 아래 변이 + /// Dock 뒤에 숨어 테두리가 세 변짜리로 보인다. 라벨은 인셋만큼 안쪽이라 메뉴바와 겹치지 않는다. + static func borderLayout( + anchors: Anchors, labelSize: CGSize, screens: [Screen], primaryScreenMaxY: CGFloat + ) -> BorderLayout? { + guard let anchor = anchor(anchors, for: .elementFirst) else { return nil } + let anchorFrame = flip(anchor.rect, primaryScreenMaxY: primaryScreenMaxY) + guard let screen = screen(near: anchorFrame, in: screens) ?? screens.first else { + return nil + } + let frame = screen.visibleFrame + let label = CGRect( + x: frame.maxX - borderLabelInset - labelSize.width, + y: frame.maxY - borderLabelInset - labelSize.height, + width: labelSize.width, height: labelSize.height) + return BorderLayout(frame: frame, labelFrame: label) + } } diff --git a/VimAction/ModeIndicatorPanel.swift b/VimAction/ModeIndicatorPanel.swift index f24f89a..ad46319 100644 --- a/VimAction/ModeIndicatorPanel.swift +++ b/VimAction/ModeIndicatorPanel.swift @@ -6,12 +6,17 @@ import AppKit /// 알약 두 겹의 외양 차이. 크기·모서리·창 층만 다르고 나머지(비활성화 패널 설정, 표시 경로)는 -/// 같아서 값 하나로 갈라 둔다. +/// 같아서 값 하나로 갈라 둔다. (상시 표시를 배지로 할지 화면 테두리로 할지는 다른 축 — +/// `ModeIndicatorPresentationStyle`이다.) /// /// **배지가 한 층 아래인 것이 "flash가 배지 위에 그려진다"의 유일한 보장이다.** 같은 층이면 /// 마지막 `orderFrontRegardless()` 순서에 의존하는데, 창이 움직여 배지가 나중에 재배치되면 -/// 뒤집힌다. 두 알약은 요소 오른쪽 위 모서리에 오른쪽·아래 정렬로 붙고 flash가 양방향으로 더 -/// 커서 배지를 완전히 덮는다 — 전환 직후 1초간 flash만 보이고 페이드아웃하며 배지가 드러난다. +/// 뒤집힌다. 캐럿이 없는 앱에서는 두 알약이 요소 오른쪽 위 모서리에 오른쪽·아래 정렬로 붙고 +/// flash가 양방향으로 더 커서 배지를 완전히 덮는다 — 전환 직후 1초간 flash만 보이고 +/// 페이드아웃하며 배지가 드러난다. 캐럿이 있는 앱에서는 flash가 캐럿 아래, 배지가 요소 모서리라 +/// 전환 직후 1초간 같은 라벨 둘이 함께 보인다 — flash는 캐럿부터, 배지는 요소부터라는 결정의 +/// 직접 결과다 (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 2). 화면 테두리 +/// 스타일의 모서리 라벨도 배지 스타일이고 같은 층이다. enum ModeIndicatorStyle { /// 모드 전환 순간 표시. 한 단계 크고 진하다. case flash @@ -52,7 +57,10 @@ enum ModeIndicatorStyle { /// 모드 라벨을 그리는 알약. 색만으로 구분하지 않는다는 PRD 접근성 NFR 때문에 **항상 텍스트가 /// 동반**되며, 그래서 배경은 라벨 폭에 맞춰 늘어난다 (`size(for:style:)`가 그 폭의 소유자다). /// 같은 이유로 글씨 색은 고정이 아니라 강조색에서 파생된다 (`textColor(on:)`). -private final class ModeIndicatorPillView: NSView { +/// +/// `private`이 아닌 것은 화면 테두리 패널이 모서리 라벨로 같은 뷰를 쓰기 때문이다 — 알약 +/// 그리기(강조색·글씨색·크기)가 두 벌이 되면 한쪽만 고쳐도 컴파일이 통과한다. +final class ModeIndicatorPillView: NSView { private let style: ModeIndicatorStyle var label = "" { @@ -109,8 +117,9 @@ private final class ModeIndicatorPillView: NSView { /// 순환시켜 검증한 것 그대로다 (플랜 `20260906_onscreen-mode-indicator.md`). /// /// 인스턴스는 둘이다 — 순간 표시용과 상시 배지용. 스타일만 다르고 나머지는 같아서 타입을 -/// 나누지 않는다: 위 패널 설정이 이 타입의 섬세한 부분이라 두 벌이 되면 한쪽만 고쳐도 -/// 컴파일이 통과한다. +/// 나누지 않는다. 화면 테두리 패널(`ModeIndicatorBorderPanel`)만 내용이 달라 타입이 다르지만, +/// 패널 설정은 아래 `makeOverlayPanel` 하나에서 받는다 — 그 설정이 이 파일의 섬세한 부분이라 +/// 두 벌이 되면 한쪽만 고쳐도 컴파일이 통과한다. @MainActor final class ModeIndicatorPanel { /// 페이드인 → 홀드 → 페이드아웃. 합이 대략 1초다 (`flash`만 쓴다). @@ -125,22 +134,30 @@ final class ModeIndicatorPanel { init(style: ModeIndicatorStyle) { pill = ModeIndicatorPillView(style: style) - panel = NSPanel( + panel = Self.makeOverlayPanel(level: style.windowLevel) + panel.contentView = pill + } + + /// 비활성화 오버레이 패널의 **유일한** 생성 지점 — 알약 둘과 화면 테두리가 전부 여기서 받는다. + /// 설정 조합은 스파이크에서 검증한 것 그대로이며, 어느 하나가 빠지면 증상이 조용하다 + /// (최전면을 빼앗거나, 뜨자마자 사라지거나, 다른 Space에서 안 보인다). + static func makeOverlayPanel(level: NSWindow.Level) -> NSPanel { + let panel = NSPanel( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = false - panel.level = style.windowLevel + panel.level = level panel.ignoresMouseEvents = true // 우리가 활성화되지 않으므로 `hidesOnDeactivate`가 켜져 있으면 뜨자마자 사라진다. panel.hidesOnDeactivate = false - // 배지도 flash와 같은 조합이다 — Space를 옮기면 앱 활성화 알림이 재앵커를 걸어 새 - // Space의 요소로 즉시 옮겨 붙으므로, 검증된 조합을 배지 때문에 갈라 놓지 않는다. + // 배지·테두리도 flash와 같은 조합이다 — Space를 옮기면 앱 활성화 알림이 재앵커를 걸어 새 + // Space의 요소로 즉시 옮겨 붙으므로, 검증된 조합을 상시 표시 때문에 갈라 놓지 않는다. panel.collectionBehavior = [ .canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle, ] - panel.contentView = pill + return panel } static func size(for label: String, style: ModeIndicatorStyle) -> CGSize { diff --git a/VimAction/Preferences.swift b/VimAction/Preferences.swift index 5a09a32..9cebace 100644 --- a/VimAction/Preferences.swift +++ b/VimAction/Preferences.swift @@ -7,7 +7,7 @@ import Foundation import VimEngine /// UserDefaults 키 — 가로채기·탈출 두 키는 EventTapController가, 온스크린 인디케이터 -/// 토글은 ModeIndicatorController가 로드(init)·저장(didSet)하는 단일 소유다. +/// 토글·스타일 두 키는 ModeIndicatorController가 로드(init)·저장(didSet)하는 단일 소유다. /// (단수형 `PreferenceKey`는 SwiftUI 프로토콜과 이름이 충돌해 피한다.) /// `nonisolated` — 프로젝트 기본이 MainActor 격리라 키 상수까지 메인 격리가 붙는데, /// 킬스위치 발동은 전용 스레드에서 이 키로 영속하므로 격리 밖에서 읽을 수 있어야 한다. @@ -18,6 +18,9 @@ nonisolated enum PreferenceKeys { static let normalModeEscapeEnabled = "normalModeEscapeEnabled" /// 온스크린 모드 인디케이터(순간 표시 + 상시 배지) on/off. 기본 on. static let onScreenModeIndicatorEnabled = "onScreenModeIndicatorEnabled" + /// 온스크린 인디케이터의 상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열. + /// 기본 배지. + static let onScreenModeIndicatorStyle = "onScreenModeIndicatorStyle" /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 셋과 달리 AppState가 소유한다. static let didShowOnboarding = "didShowOnboarding" @@ -27,6 +30,9 @@ nonisolated enum PreferenceKeys { /// 이 기능은 "모드를 모르고 타이핑하는 사고"를 막는 안전 장치라 첫 실행 사용자를 보호해야 /// 한다 (`20260906_mode-indicator-settings-in-userdefaults.md`). static let onScreenModeIndicatorEnabledDefault = true + /// 스타일의 제품 기본값 — 배지다. 화면 테두리는 설정에서 고르는 대체 스타일이다 + /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5). 없는 값·모르는 값도 이것이다. + static let onScreenModeIndicatorStyleDefault = ModeIndicatorPresentationStyle.badge } /// 런치 시 설정 창을 밀어 올릴지 — 미허용 + 아직 한 번도 안 띄운 경우만. diff --git a/VimAction/SettingsView.swift b/VimAction/SettingsView.swift index fd8c61f..bb2a770 100644 --- a/VimAction/SettingsView.swift +++ b/VimAction/SettingsView.swift @@ -124,8 +124,18 @@ private struct GeneralTab: View { .foregroundStyle(.secondary) // 값·영속·표시 반영 모두 컨트롤러 프로퍼티(didSet)가 책임진다 — 위 토글과 동일 모델. Toggle("Show on-screen mode indicator", isOn: $modeIndicator.isEnabled) + Text("A label flashes near the caret (or the focused field) whenever the mode changes.") + .font(.footnote) + .foregroundStyle(.secondary) + // 상시 표시의 형태 — 토글과 같은 소유 모델(컨트롤러 프로퍼티 didSet). 표시 문구는 + // 탭의 다른 항목처럼 뷰에 둔다. 인디케이터가 꺼져 있으면 고를 이유가 없어 비활성. + Picker("Indicator style", selection: $modeIndicator.style) { + Text("Badge near the focused field").tag(ModeIndicatorPresentationStyle.badge) + Text("Screen border").tag(ModeIndicatorPresentationStyle.screenBorder) + } + .disabled(!modeIndicator.isEnabled) Text( - "A label appears next to the focused text field when the mode changes, and a small badge stays while you're in Normal or Visual mode." + "While you're in Normal or Visual mode, a small badge stays next to the field — or a colored frame surrounds the screen with the mode label in its top-right corner." ) .font(.footnote) .foregroundStyle(.secondary) diff --git a/VimActionTests/ModeIndicatorControllerTests.swift b/VimActionTests/ModeIndicatorControllerTests.swift index f0f11c4..f308d44 100644 --- a/VimActionTests/ModeIndicatorControllerTests.swift +++ b/VimActionTests/ModeIndicatorControllerTests.swift @@ -10,7 +10,7 @@ import VimEngine @testable import VimAction -/// 온스크린 인디케이터의 **표시 판정**(순수 계층)과 **토글 영속**. 패널도 AX도 만들지 +/// 온스크린 인디케이터의 **표시 판정**(순수 계층)과 **토글·스타일 영속**. 패널도 AX도 만들지 /// 않는다 — 판정은 순수 함수라 값만 오가고, 컨트롤러 테스트는 트리거를 부르지 않는다. struct ModeIndicatorPresentationTests { private func inputs( @@ -20,10 +20,11 @@ struct ModeIndicatorPresentationTests { } private func presentation( - _ mode: Mode, _ indicator: MenuBarIndicator, pid: pid_t? = 42, isEnabled: Bool = true + _ mode: Mode, _ indicator: MenuBarIndicator, pid: pid_t? = 42, isEnabled: Bool = true, + style: ModeIndicatorPresentationStyle = .badge ) -> ModeIndicatorController.Presentation? { ModeIndicatorController.presentation( - isEnabled: isEnabled, inputs: inputs(mode, indicator, pid: pid)) + isEnabled: isEnabled, style: style, inputs: inputs(mode, indicator, pid: pid)) } @Test("비-Insert 모드는 라벨 + 상시 배지") @@ -68,15 +69,27 @@ struct ModeIndicatorPresentationTests { func missingProcessShowsNothing() { #expect(presentation(.normal, .mode(.normal), pid: nil) == nil) } + + /// 스타일이 판정 결과에 실려야 `desired != current`가 스타일 변경을 잡는다. + @Test("판정 결과가 스타일을 싣는다") + func presentationCarriesStyle() { + #expect(presentation(.normal, .mode(.normal), style: .badge)?.style == .badge) + #expect( + presentation(.normal, .mode(.normal), style: .screenBorder)?.style == .screenBorder) + } } /// 읽기를 띄울지 말지 — 조율의 핵심 판정이다. 틀려도 증상이 조용하다(모자라면 배지가 낡은 /// 자리에 남고, 넘치면 전환마다 AX 왕복이 곱해진다)는 것이 표로 고정하는 이유다. struct ModeIndicatorReadCoalescingTests { private let normal = ModeIndicatorController.Presentation( - label: "NORMAL", showsBadge: true, processID: 42) + label: "NORMAL", showsBadge: true, processID: 42, style: .badge) private let insert = ModeIndicatorController.Presentation( - label: "INSERT", showsBadge: false, processID: 42) + label: "INSERT", showsBadge: false, processID: 42, style: .badge) + private let normalBorder = ModeIndicatorController.Presentation( + label: "NORMAL", showsBadge: true, processID: 42, style: .screenBorder) + private let insertBorder = ModeIndicatorController.Presentation( + label: "INSERT", showsBadge: false, processID: 42, style: .screenBorder) private func needsRead( desired: ModeIndicatorController.Presentation, @@ -143,6 +156,19 @@ struct ModeIndicatorReadCoalescingTests { #expect(needsRead(desired: normal, current: insert)) #expect(needsRead(desired: normal, current: nil)) } + + /// 스타일 변경은 라벨·pid가 같아도 다른 상태다 — 새 형태로 그리려면 앵커를 다시 읽어야 한다. + @Test("상시 표시가 떠 있는 채로 스타일이 바뀌면 읽는다") + func styleSwitchWithBadgeShowingReads() { + #expect(needsRead(desired: normalBorder, current: normal)) + #expect(needsRead(desired: normal, current: normalBorder)) + } + + /// Insert는 상시 표시가 없으니 형태가 바뀌어도 읽을 것이 없다. + @Test("Insert에서 스타일 변경은 읽지 않는다") + func styleSwitchInInsertDoesNotRead() { + #expect(needsRead(desired: insertBorder, current: insert) == false) + } } @MainActor @@ -190,6 +216,63 @@ struct ModeIndicatorToggleTests { } } +/// 상시 표시 스타일의 영속 — 토글과 같은 소유 모델이고 같은 단언 함정을 지킨다. +@MainActor +struct ModeIndicatorStyleTests { + @Test("미설정 키 → 제품 기본값 배지") + func defaultsToBadgeWhenKeyAbsent() { + withTemporaryDefaults { defaults in + #expect(ModeIndicatorController(defaults: defaults).style == .badge) + } + } + + @Test("저장된 화면 테두리는 init에서 로드된다") + func storedScreenBorderLoadsAtInit() { + withTemporaryDefaults { defaults in + defaults.set( + ModeIndicatorPresentationStyle.screenBorder.rawValue, + forKey: PreferenceKeys.onScreenModeIndicatorStyle) + #expect(ModeIndicatorController(defaults: defaults).style == .screenBorder) + } + } + + /// 스타일이 늘거나 이름이 바뀌어도 저장된 값 때문에 아무것도 안 뜨는 일은 없어야 한다. + @Test("모르는 raw 값은 기본값으로 접힌다") + func unknownRawValueFallsBackToBadge() { + withTemporaryDefaults { defaults in + defaults.set("neon", forKey: PreferenceKeys.onScreenModeIndicatorStyle) + #expect(ModeIndicatorController(defaults: defaults).style == .badge) + } + } + + @Test("스타일 영속: didSet 저장 → 새 컨트롤러 init 로드") + func stylePersistsAcrossControllers() { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.style = .screenBorder + // 존재 확인이 먼저 — 이것 없이는 영속을 통째로 지워도 "기본값 배지"로 통과한다. + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorStyle) != nil) + #expect( + defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorStyle) + == ModeIndicatorPresentationStyle.screenBorder.rawValue) + + let second = ModeIndicatorController(defaults: defaults) + #expect(second.style == .screenBorder) + } + } + + /// 토글과 같은 근거 — 입력이 밀린 적 없으면 화면에도 AX에도 닿지 않는다. + @Test("입력이 밀린 적 없으면 스타일 변경은 무해하다") + func changingStyleWithoutInputsIsHarmless() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + controller.style = .screenBorder + controller.style = .badge + #expect(controller.style == .badge) + } + } +} + /// 알약 글씨 색 — 강조색은 사용자가 고르므로 흰 글씨가 늘 읽히지는 않는다. /// /// **표본이 `NSColor.systemYellow` 같은 동적 시스템 색이 아니라 고정 sRGB 성분인 것이 diff --git a/VimActionTests/ModeIndicatorLayoutTests.swift b/VimActionTests/ModeIndicatorLayoutTests.swift index 9b401c7..956b954 100644 --- a/VimActionTests/ModeIndicatorLayoutTests.swift +++ b/VimActionTests/ModeIndicatorLayoutTests.swift @@ -33,41 +33,127 @@ struct ModeIndicatorLayoutTests { private let badge = CGSize(width: 80, height: 22) + /// 실측표의 전형 — 요소 안의 캐럿 (AX 좌표). + private let element = CGRect(x: 100, y: 200, width: 300, height: 40) + private let window = CGRect(x: 50, y: 100, width: 800, height: 600) + private let caret = CGRect(x: 150, y: 210, width: 1, height: 18) + // MARK: - 사다리 - @Test("사다리는 요소 → 창 → 없음 순이다") - func ladderPrefersElementThenWindow() { - let element = CGRect(x: 100, y: 200, width: 300, height: 40) - let window = CGRect(x: 50, y: 100, width: 800, height: 600) + @Test("flash 사다리는 캐럿 → 요소 → 창 → 없음 순이다") + func caretFirstLadderPrefersCaretThenElementThenWindow() { + #expect( + ModeIndicatorLayout.anchor( + .init(element: element, window: window, caret: caret), for: .caretFirst) + == .caret(caret)) + #expect( + ModeIndicatorLayout.anchor(.init(element: element, window: window), for: .caretFirst) + == .element(element)) + #expect( + ModeIndicatorLayout.anchor(.init(window: window), for: .caretFirst) == .window(window)) + #expect(ModeIndicatorLayout.anchor(.init(), for: .caretFirst) == nil) + } + /// 배지가 캐럿을 따라다니면 키마다·스크롤마다 다시 읽어야 한다 — 배지는 캐럿을 보지 않는다. + @Test("배지 사다리는 요소 → 창 → 없음 순이고 캐럿을 보지 않는다") + func elementFirstLadderIgnoresCaret() { #expect( - ModeIndicatorLayout.anchor(.init(element: element, window: window)) + ModeIndicatorLayout.anchor( + .init(element: element, window: window, caret: caret), for: .elementFirst) == .element(element)) - #expect(ModeIndicatorLayout.anchor(.init(element: nil, window: window)) == .window(window)) - #expect(ModeIndicatorLayout.anchor(.init(element: nil, window: nil)) == nil) + #expect( + ModeIndicatorLayout.anchor(.init(window: window, caret: caret), for: .elementFirst) + == .window(window)) + #expect(ModeIndicatorLayout.anchor(.init(caret: caret), for: .elementFirst) == nil) } /// 요소가 0×0을 보고하는 앱이 있다 — 그 rect에 배지를 붙이면 화면 구석에 떠 있는 것처럼 /// 보이므로 한 단으로 인정하지 않고 창으로 내려가야 한다. @Test("면적 없는 rect는 한 단으로 안 친다") func degenerateRectFallsThrough() { - let window = CGRect(x: 50, y: 100, width: 800, height: 600) let empty = CGRect(x: 100, y: 200, width: 0, height: 0) let zeroHeight = CGRect(x: 100, y: 200, width: 300, height: 0) - #expect(ModeIndicatorLayout.anchor(.init(element: empty, window: window)) == .window(window)) #expect( - ModeIndicatorLayout.anchor(.init(element: zeroHeight, window: window)) + ModeIndicatorLayout.anchor(.init(element: empty, window: window), for: .elementFirst) == .window(window)) - #expect(ModeIndicatorLayout.anchor(.init(element: empty, window: empty)) == nil) + #expect( + ModeIndicatorLayout.anchor( + .init(element: zeroHeight, window: window), for: .elementFirst) + == .window(window)) + #expect( + ModeIndicatorLayout.anchor(.init(element: empty, window: empty), for: .elementFirst) + == nil) + } + + // MARK: - 캐럿 유효성 + + /// Slack 컴포저는 캐럿을 0×18로 보고하고 그것이 정확한 캐럿이다 — 폭은 보지 않는다. + @Test("폭 0 캐럿도 쓸 만하다") + func zeroWidthCaretIsUsable() { + let slack = CGRect(x: 150, y: 210, width: 0, height: 18) + #expect(ModeIndicatorLayout.isUsableCaret(slack, element: element)) + #expect( + ModeIndicatorLayout.anchor(.init(element: element, caret: slack), for: .caretFirst) + == .caret(slack)) + } + + @Test("높이 0 캐럿은 안 친다") + func zeroHeightCaretIsRejected() { + let flat = CGRect(x: 150, y: 210, width: 1, height: 0) + #expect(ModeIndicatorLayout.isUsableCaret(flat, element: element) == false) + #expect( + ModeIndicatorLayout.anchor(.init(element: element, caret: flat), for: .caretFirst) + == .element(element)) + } + + /// 마커 경로는 선택이 내용 끝에 있으면 캐럿 대신 요소 전체 rect를 돌려준다 — 그 퇴화를 + /// 캐럿으로 쓰면 알약이 입력칸 아래에 뜬다. + @Test("요소 rect와 같은 캐럿은 안 친다") + func caretEqualToElementIsRejected() { + #expect(ModeIndicatorLayout.isUsableCaret(element, element: element) == false) + #expect( + ModeIndicatorLayout.anchor(.init(element: element, caret: element), for: .caretFirst) + == .element(element)) + } + + /// 스크롤로 시야 밖에 나간 캐럿·쓰레기값이 입력칸 밖에 떠 있으면 안 된다. + @Test("요소 밖 캐럿은 안 친다") + func caretOutsideElementIsRejected() { + let scrolledOut = CGRect(x: 150, y: 500, width: 1, height: 18) + #expect(ModeIndicatorLayout.isUsableCaret(scrolledOut, element: element) == false) + #expect( + ModeIndicatorLayout.anchor( + .init(element: element, caret: scrolledOut), for: .caretFirst) + == .element(element)) + } + + /// 요소 rect가 없거나 면적이 없으면 "안에 있는가"를 물을 수 없다 — 높이만 본다. + @Test("요소 rect가 쓸 만하지 않으면 캐럿은 높이만 본다") + func caretWithUnusableElementNeedsOnlyHeight() { + let empty = CGRect(x: 100, y: 200, width: 0, height: 0) + #expect(ModeIndicatorLayout.isUsableCaret(caret, element: nil)) + #expect(ModeIndicatorLayout.isUsableCaret(caret, element: empty)) + #expect( + ModeIndicatorLayout.anchor(.init(element: empty, caret: caret), for: .caretFirst) + == .caret(caret)) } // MARK: - 배치 (AX 좌표: y는 아래로 증가) + /// macOS가 입력 소스 전환 때 캐럿 아래에 띄우는 인디케이터와 같은 자리다. + @Test("캐럿 단은 캐럿 바로 아래, 왼쪽 정렬") + func caretPillSitsBelowCaretLeftAligned() { + let frame = ModeIndicatorLayout.badgeFrameInAXSpace(anchor: .caret(caret), size: badge) + + #expect(frame.minX == caret.minX) + #expect(frame.minY == caret.maxY + 4) + #expect(frame.size == badge) + } + /// 텍스트 영역 안에 겹치면 사용자가 쓰고 있는 글자를 가린다 — 그래서 요소 **바깥** 위다. @Test("요소 단은 요소 바깥 오른쪽 위") func elementBadgeSitsOutsideTopRight() { - let element = CGRect(x: 100, y: 200, width: 300, height: 40) let frame = ModeIndicatorLayout.badgeFrameInAXSpace(anchor: .element(element), size: badge) #expect(frame.maxX == element.maxX) // 오른쪽 정렬 @@ -78,7 +164,6 @@ struct ModeIndicatorLayoutTests { /// 창 밖으로 나가면 어느 창의 상태인지 읽히지 않는다. @Test("창 단은 창 안쪽 오른쪽 위") func windowBadgeSitsInsideTopRight() { - let window = CGRect(x: 50, y: 100, width: 800, height: 600) let frame = ModeIndicatorLayout.badgeFrameInAXSpace(anchor: .window(window), size: badge) #expect(frame.maxX < window.maxX) @@ -167,16 +252,15 @@ struct ModeIndicatorLayoutTests { func noAnchorMeansNoPanel() { #expect( ModeIndicatorLayout.panelFrame( - anchors: .init(), size: badge, screens: [primaryScreen], + anchors: .init(), ladder: .caretFirst, size: badge, screens: [primaryScreen], primaryScreenMaxY: primaryMaxY) == nil) } @Test("요소 앵커의 전체 파이프라인이 화면 안 프레임을 낸다") func panelFrameEndToEnd() { - let element = CGRect(x: 100, y: 200, width: 300, height: 40) let frame = ModeIndicatorLayout.panelFrame( - anchors: .init(element: element), size: badge, screens: [primaryScreen], - primaryScreenMaxY: primaryMaxY) + anchors: .init(element: element), ladder: .elementFirst, size: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY) let expected = ModeIndicatorLayout.clamp( ModeIndicatorLayout.flip( @@ -187,4 +271,90 @@ struct ModeIndicatorLayoutTests { #expect(frame == expected) #expect(primaryScreen.visibleFrame.contains(frame!)) } + + /// 보조 디스플레이의 폭 0 캐럿(Slack) — 화면 선택이 폭 0 앵커에서도 자기 디스플레이를 + /// 찾아야 알약이 주 화면으로 튀지 않는다 (`CGRect.intersects`의 폭 0 동작에 기댄다). + @Test("보조 디스플레이의 폭 0 캐럿도 그 화면 안에 앉는다") + func caretPanelFrameEndToEnd() { + let element = CGRect(x: -900, y: 300, width: 300, height: 40) + let caret = CGRect(x: -800, y: 310, width: 0, height: 18) + let frame = ModeIndicatorLayout.panelFrame( + anchors: .init(element: element, caret: caret), ladder: .caretFirst, size: badge, + screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) + + let expected = ModeIndicatorLayout.flip( + ModeIndicatorLayout.badgeFrameInAXSpace(anchor: .caret(caret), size: badge), + primaryScreenMaxY: primaryMaxY) + #expect(frame == expected) // 이미 화면 안이라 클램프가 움직이지 않는다 + #expect(secondaryScreen.visibleFrame.contains(frame!)) + } + + // MARK: - 화면 테두리 + + @Test("테두리는 앵커가 있는 화면의 visibleFrame을 두른다") + func borderCoversVisibleFrameOfAnchorScreen() { + let layout = ModeIndicatorLayout.borderLayout( + anchors: .init(element: element), labelSize: badge, + screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) + + // `frame`이 아니라 `visibleFrame` — 위 변이 메뉴바 뒤에 숨으면 세 변짜리로 보인다. + #expect(layout?.frame == primaryScreen.visibleFrame) + } + + @Test("모서리 라벨은 테두리 안쪽 오른쪽 위") + func borderLabelSitsInsideTopRightCorner() { + let layout = ModeIndicatorLayout.borderLayout( + anchors: .init(element: element), labelSize: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY)! + + #expect(layout.labelFrame.size == badge) + #expect(layout.frame.contains(layout.labelFrame)) + // 선 굵기보다 안쪽이라 선과 겹치지 않는다. + #expect(layout.frame.maxX - layout.labelFrame.maxX > ModeIndicatorLayout.borderStrokeWidth) + #expect(layout.frame.maxY - layout.labelFrame.maxY > ModeIndicatorLayout.borderStrokeWidth) + // 왼쪽·아래로는 멀리 떨어져 있다 — 오른쪽 위 모서리다. + #expect(layout.labelFrame.minX > layout.frame.midX) + #expect(layout.labelFrame.minY > layout.frame.midY) + } + + /// 요소는 창 안에 있으므로 요소 단이든 창 단이든 같은 디스플레이다 — AX를 더 읽지 않고 + /// 앵커 rect로만 화면을 고른다. + @Test("테두리는 보조 디스플레이의 앵커를 따라간다") + func borderFollowsAnchorToSecondaryDisplay() { + let element = CGRect(x: -900, y: 300, width: 300, height: 40) + let byElement = ModeIndicatorLayout.borderLayout( + anchors: .init(element: element), labelSize: badge, + screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) + let window = CGRect(x: -1100, y: 100, width: 800, height: 600) + let byWindow = ModeIndicatorLayout.borderLayout( + anchors: .init(window: window), labelSize: badge, + screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) + + #expect(byElement?.frame == secondaryScreen.visibleFrame) + #expect(byWindow?.frame == secondaryScreen.visibleFrame) + } + + /// 앵커가 어느 화면과도 겹치지 않으면(화면 밖) 첫 화면이다 — 순수 함수라 답이 정의돼야 한다. + @Test("겹치는 화면이 없으면 첫 화면으로 내려간다") + func borderFallsBackToFirstScreenWhenNoneIntersects() { + let offScreen = CGRect(x: 5000, y: 5000, width: 300, height: 40) + let layout = ModeIndicatorLayout.borderLayout( + anchors: .init(element: offScreen), labelSize: badge, + screens: [secondaryScreen, primaryScreen], primaryScreenMaxY: primaryMaxY) + + #expect(layout?.frame == secondaryScreen.visibleFrame) + } + + /// 배지와 같은 사다리(요소 → 창)라 캐럿만으로는 테두리도 없다. + @Test("앵커가 없으면 테두리도 없다") + func borderWithoutAnchorIsNil() { + #expect( + ModeIndicatorLayout.borderLayout( + anchors: .init(), labelSize: badge, screens: [primaryScreen], + primaryScreenMaxY: primaryMaxY) == nil) + #expect( + ModeIndicatorLayout.borderLayout( + anchors: .init(caret: caret), labelSize: badge, screens: [primaryScreen], + primaryScreenMaxY: primaryMaxY) == nil) + } } From 2c00b13224ea26f9ca5f7d19e9ddce30a294cb35 Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 7 Sep 2026 00:05:17 +0900 Subject: [PATCH 2/9] docs(plans): record the pr 3 delegation, dogfooding and pr #68 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721 --- .claude/skills/plans/SKILL.md | 2 +- .../plans/references/20260906_onscreen-mode-indicator.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/skills/plans/SKILL.md b/.claude/skills/plans/SKILL.md index 6d2440e..83bcba7 100644 --- a/.claude/skills/plans/SKILL.md +++ b/.claude/skills/plans/SKILL.md @@ -51,4 +51,4 @@ description: VimAction 프로젝트의 진행 중 작업 플랜(단기 기억) S |---|---|---|---|---| | 2026-08-02 | 2026-08-18 | M5 — AX 어댑터 + auto 전략 | MVP 1단계(M1~M4) 완료 후의 1차 확장. AX 읽기 혼용이 축 — M3 수용 엣지 대부분이 여기서 해소된다. **PR 단위 7개로 분해됨**(A 읽기 기반 → B·C 혼용 → D AX 어댑터·auto → E 스키마), PR마다 worktree. **PR-A·B·C1·C2 + D1-설계 세션 + PR-D1a(#39) + PR-D1b([PR #41](https://github.com/pilyang/vim-action/pull/41), main `544005c`) 머지 완료 — D1 트랙 종료**(AX 쓰기 통로·단일 `.ax` 드라이버·`FocusedTextOffsets`·편집/삽입/Visual 하이브리드·`strategy` 파싱). **PR-D2 머지 완료**([#56](https://github.com/pilyang/vim-action/pull/56) main `d8d155c` + 문서 [#57](https://github.com/pilyang/vim-action/pull/57) main `1c62cb7`, 2026-08-18 — auto 프로브·거짓말 감지·force-text·기본값 auto + 세션 6 브라우저 클래스 규칙·숨은 입력 기하 신호). **다음은 PR-E**(스키마 확장·마감 — `per_element`, D2 이월: 브라우저 per-element 판정·value-슬라이스 폴백·킬스위치 #59) | [20260802_m5-ax-adapter.md](references/20260802_m5-ax-adapter.md) | | 2026-08-18 | 2026-08-18 | Normal mode exit 키 허용목록 | **미착수 — 예정 기록용.** `Exit Normal mode on ⌘/⌥ shortcuts`를 전체 on/off에서 "기본은 전환 안 함 + 등록한 키 조합에서만 Normal → Insert"로 고도화. 등록은 키를 직접 눌러 캡처하는 UI | [20260818_normal-mode-exit-key-allowlist.md](references/20260818_normal-mode-exit-key-allowlist.md) | -| 2026-09-06 | 2026-09-06 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 4건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + **PR 2 구현·도그푸딩 완료([#67](https://github.com/pilyang/vim-action/pull/67) 머지 대기 — 상시 배지·재앵커·Settings 토글)**. **다음은 PR 3**(캐럿 정밀도 + 화면 테두리 스타일, Fable 워커) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | +| 2026-09-06 | 2026-09-07 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 4건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + **PR 2 구현·도그푸딩 완료([#67](https://github.com/pilyang/vim-action/pull/67) 머지 대기 — 상시 배지·재앵커·Settings 토글)**. **PR 3 구현·도그푸딩 완료, [PR #68](https://github.com/pilyang/vim-action/pull/68) 오픈** — 다음은 머지 확인, 그 뒤 외양 커스텀 후속(색상·투명도, Visual 탭 검토) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | diff --git a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md index 6fec4b1..9e9c16e 100644 --- a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md +++ b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md @@ -1,7 +1,7 @@ # 온스크린 모드 인디케이터 (HUD) - **생성일**: 2026-09-06 -- **갱신일**: 2026-09-07 (PR 3 구현·도그푸딩 완료, PR 오픈 — 번호는 아래) +- **갱신일**: 2026-09-07 (PR 3 구현·도그푸딩 완료, PR #68 오픈) ## 목표 @@ -13,14 +13,14 @@ - [x] 구현 가능성 스파이크 (2026-09-06, 실기기·2디스플레이): AX 기하 가용성 실측 + 비활성화 NSPanel 오버레이 프로토타입 검증. 결과는 아래 "진행 중 컨텍스트". - [x] **PR 1 머지 완료** ([PR #66](https://github.com/pilyang/vim-action/pull/66) → main `48cbd28`, 2026-09-06; Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비활성화 패널·순수 레이아웃(테스트 10건)·전용 큐 기하 리더·모드 전환 훅. 실기기 도그푸딩(Developer ID Release 설치): TextEdit Esc/i/v, Chrome Esc, Slack Esc 전부 라벨 표시·최전면 불변·보조 디스플레이 정렬 확인. architecture reference [mode-indicator-overlay.md](../../architecture/references/mode-indicator-overlay.md) 추가. - [x] **PR 2 머지 완료** ([PR #67](https://github.com/pilyang/vim-action/pull/67) → main `4040fe4`, 2026-09-06, 브랜치 `feat/mode-indicator-persistent-badge`, Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비-Insert 상시 배지(flash보다 한 단 작고 한 층 아래)·재앵커 트리거 5종(모드 전환 / 포커스·앱 활성화·창 이동·리사이즈 / 디스플레이 재구성 / 사다리 관찰 루프 / 토글)이 단일 reconcile로 합류, 순수 판정 2종(`presentation`·`needsGeometryRead`) 표 테스트, `visibleFrame` 클램프(화면 선택은 `frame`), 강조색 luminance 기반 글씨색, General > Behavior 토글(`onScreenModeIndicatorEnabled`, 기본 on). 실기기 도그푸딩: 창 이동(−900,+262)·리사이즈 추종, Chrome 전환 재앵커, disabled 앱(Terminal)에서 숨김, 복귀 시 재표시, Insert 무배지, VISUAL 배지 — 전부 확인. architecture 4종 갱신(profiles-and-config의 키 반영 포함). -- [x] **PR 3 구현·도그푸딩 완료** (브랜치 `feat/mode-indicator-caret-and-border`, Fable 워커 위임 + 감독 리뷰 + 독립 검증, PR 번호는 인덱스 참조): flash 앵커가 캐럿부터(읽기 순서 `(loc,1)` → `(loc-1,1)` → 텍스트 마커, 유효성 규칙은 순수 계층 `isUsableCaret` 한 벌, flash 요청 때만 캐럿 읽기), 상시 표시 스타일 `onScreenModeIndicatorStyle`(배지 / 화면 테두리 — 앵커가 속한 디스플레이의 `visibleFrame` + 오른쪽 위 라벨, General 토글 아래 Picker). 실기기 도그푸딩(Developer ID Release, 2디스플레이): TextEdit·Notes는 `(loc,1)`, Safari 주소창은 `(loc-1,1)`(끝 캐럿이 필드 밖이라 첫 변형 기각), Slack·Notion은 마커 — 전부 flash가 캐럿 바로 아래(±1px); Chrome·Arc는 요소 모서리 폴백; 테두리 스타일은 창이 있는 디스플레이만 두르고 Insert에서 사라짐. architecture 3종 갱신. +- [x] **PR 3 구현·도그푸딩 완료** (브랜치 `feat/mode-indicator-caret-and-border`, Fable 워커 위임 + 감독 리뷰 + 독립 검증, [PR #68](https://github.com/pilyang/vim-action/pull/68)): flash 앵커가 캐럿부터(읽기 순서 `(loc,1)` → `(loc-1,1)` → 텍스트 마커, 유효성 규칙은 순수 계층 `isUsableCaret` 한 벌, flash 요청 때만 캐럿 읽기), 상시 표시 스타일 `onScreenModeIndicatorStyle`(배지 / 화면 테두리 — 앵커가 속한 디스플레이의 `visibleFrame` + 오른쪽 위 라벨, General 토글 아래 Picker). 실기기 도그푸딩(Developer ID Release, 2디스플레이): TextEdit·Notes는 `(loc,1)`, Safari 주소창은 `(loc-1,1)`(끝 캐럿이 필드 밖이라 첫 변형 기각), Slack·Notion은 마커 — 전부 flash가 캐럿 바로 아래(±1px); Chrome·Arc는 요소 모서리 폴백; 테두리 스타일은 창이 있는 디스플레이만 두르고 Insert에서 사라짐. architecture 3종 갱신. - [x] 방향 확정(2026-09-06, 권장안 채택) + decisions 4건 기록: 표시 정책 / 앵커 사다리·이벤트 기반 갱신(실측표 포함) / Chromium 스크린리더 모드 강제 안 함 / 설정은 UserDefaults·기본 on. ## 남은 것 -- [ ] PR 3 머지 확인 (CI 3잡 required) — 머지되면 `/Applications`의 도그푸딩 빌드(1.0 (1))를 정식 릴리스로 되돌릴지 결정. +- [ ] [PR #68](https://github.com/pilyang/vim-action/pull/68) 머지 확인 (CI 3잡 required) — 머지되면 `/Applications`의 도그푸딩 빌드(1.0 (1))를 정식 릴리스로 되돌릴지 결정. - [ ] **후속 — 인디케이터 외양 커스텀(색상·투명도).** 모드 라벨 알약(flash·배지)의 **색상**과 **투명도**를 사용자가 고를 수 있게 한다. 시점은 PR 3(캐럿 위치 지정) **이후**, 또는 작업 크기가 작으면 PR 3에 **함께**. 이러면서 설정 항목이 늘어나면(on/off·스타일·색상·투명도) 설정 창에 별도 **Visual 탭**을 두는 것도 함께 검토 — 탭 구성은 [20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)의 결정이라 바꾸면 decisions로 기록하고 app-shell reference를 갱신한다. 색상 커스텀에서도 라벨 텍스트는 항상 동반(색만으로 구분하지 않음 — PRD NFR)하고, 글씨색은 PR 2의 luminance 파생을 사용자 색에 그대로 적용한다. 설정 소유권은 UserDefaults([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). - [ ] 마무리: 로드맵 Stage 4 항목 체크, 플랜 완료 처리. - [ ] 후속 검토(별도 PR 후보, 작음): **자기 pid 경로의 토큰.** `reconcile`의 자기 pid 가드는 토큰을 올리지 않아, 다른 앱을 향해 이미 떠 있던 읽기가 그 경로가 배지를 감춘 뒤 착지해 다시 그릴 수 있다(PR 2부터, PR 3 리뷰에서 발견, ~50ms 창). 한 줄 `token &+= 1`로 닫히지만 밀린 flash가 복귀 시점으로 미뤄지는 부작용을 같이 판단해야 한다. From f020af7b3214619278fa180e85c4f3e8299974ca Mon Sep 17 00:00:00 2001 From: pilyang Date: Mon, 7 Sep 2026 00:06:04 +0900 Subject: [PATCH 3/9] docs(plans): mark pr 2 as merged in the plan index Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Gyfx8HjB6jpfgo8yGfe721 --- .claude/skills/plans/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/plans/SKILL.md b/.claude/skills/plans/SKILL.md index 83bcba7..220b3f3 100644 --- a/.claude/skills/plans/SKILL.md +++ b/.claude/skills/plans/SKILL.md @@ -51,4 +51,4 @@ description: VimAction 프로젝트의 진행 중 작업 플랜(단기 기억) S |---|---|---|---|---| | 2026-08-02 | 2026-08-18 | M5 — AX 어댑터 + auto 전략 | MVP 1단계(M1~M4) 완료 후의 1차 확장. AX 읽기 혼용이 축 — M3 수용 엣지 대부분이 여기서 해소된다. **PR 단위 7개로 분해됨**(A 읽기 기반 → B·C 혼용 → D AX 어댑터·auto → E 스키마), PR마다 worktree. **PR-A·B·C1·C2 + D1-설계 세션 + PR-D1a(#39) + PR-D1b([PR #41](https://github.com/pilyang/vim-action/pull/41), main `544005c`) 머지 완료 — D1 트랙 종료**(AX 쓰기 통로·단일 `.ax` 드라이버·`FocusedTextOffsets`·편집/삽입/Visual 하이브리드·`strategy` 파싱). **PR-D2 머지 완료**([#56](https://github.com/pilyang/vim-action/pull/56) main `d8d155c` + 문서 [#57](https://github.com/pilyang/vim-action/pull/57) main `1c62cb7`, 2026-08-18 — auto 프로브·거짓말 감지·force-text·기본값 auto + 세션 6 브라우저 클래스 규칙·숨은 입력 기하 신호). **다음은 PR-E**(스키마 확장·마감 — `per_element`, D2 이월: 브라우저 per-element 판정·value-슬라이스 폴백·킬스위치 #59) | [20260802_m5-ax-adapter.md](references/20260802_m5-ax-adapter.md) | | 2026-08-18 | 2026-08-18 | Normal mode exit 키 허용목록 | **미착수 — 예정 기록용.** `Exit Normal mode on ⌘/⌥ shortcuts`를 전체 on/off에서 "기본은 전환 안 함 + 등록한 키 조합에서만 Normal → Insert"로 고도화. 등록은 키를 직접 눌러 캡처하는 UI | [20260818_normal-mode-exit-key-allowlist.md](references/20260818_normal-mode-exit-key-allowlist.md) | -| 2026-09-06 | 2026-09-07 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 4건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + **PR 2 구현·도그푸딩 완료([#67](https://github.com/pilyang/vim-action/pull/67) 머지 대기 — 상시 배지·재앵커·Settings 토글)**. **PR 3 구현·도그푸딩 완료, [PR #68](https://github.com/pilyang/vim-action/pull/68) 오픈** — 다음은 머지 확인, 그 뒤 외양 커스텀 후속(색상·투명도, Visual 탭 검토) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | +| 2026-09-06 | 2026-09-07 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 4건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + PR 2([#67](https://github.com/pilyang/vim-action/pull/67)) 머지 — 상시 배지·재앵커·Settings 토글. **PR 3(캐럿 아래 flash + 화면 테두리 스타일) 구현·도그푸딩 완료, [PR #68](https://github.com/pilyang/vim-action/pull/68) 오픈** — 다음은 머지 확인, 그 뒤 외양 커스텀 후속(색상·투명도, Visual 탭 검토) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | From 7b92d9d791620ecdd04fa9b3bcc78a4aba4631d8 Mon Sep 17 00:00:00 2001 From: pilyang Date: Tue, 15 Sep 2026 09:39:52 +0900 Subject: [PATCH 4/9] fix(app): invalidate in-flight indicator reads when vimaction itself is frontmost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q --- VimAction/ModeIndicatorController.swift | 16 +++++++- .../ModeIndicatorControllerTests.swift | 40 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index 42e76a0..dff1ccb 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -169,7 +169,10 @@ final class ModeIndicatorController { /// 사다리를 벗어날 때 `pending`을 비우는 것만으로는 부족하다: 이미 떠 있는 읽기가 착지해 /// "가로채지 않는데 NORMAL이라고 말하는" 라벨을 띄운다 /// (`FocusedElementResolver.refreshToken`과 같은 장치·같은 이유). - @ObservationIgnored private var token = 0 + /// + /// 읽기만 열려 있는 것은 회귀 테스트 때문이다 — 자기 pid 경로가 토큰을 올리는지는 화면 + /// 없이 이 값으로만 관측된다 (`ModeIndicatorSelfProcessTests`). + @ObservationIgnored private(set) var token = 0 /// 패널은 첫 표시에서야 만든다 — 런치 시 `NSPanel`을 만들지 않고, 표시할 일이 없는 /// 실행(권한 미허용, 계속 Insert, 토글 off)에서는 끝까지 만들어지지 않는다. 테두리 패널은 @@ -246,6 +249,17 @@ final class ModeIndicatorController { // 설정 창이 열려 있으면 우리가 최전면이다 — 그래서 이전 스타일의 패널은 여기서 // 즉시 감추고, 새 스타일은 사용자가 앱으로 돌아가는 앵커 이벤트가 그린다 // (토글을 켤 때와 같은 수렴). + // + // **떠 있는 읽기도 무효화한다.** 토큰을 올리지 않으면 이전 앱을 향해 이미 떠 있던 + // 읽기가 여기서 감춘 뒤에 착지해 옛 배지·테두리를 다시 그린다 — 방금 바꾼 스타일이 + // 다음 앵커 이벤트까지 되돌아간 채로 보인다. 밀린 flash는 **만료**한다: 이 guard가 + // `pendingFlash`를 세우는 줄보다 앞이라 우리가 최전면인 동안의 전환은 애초에 flash를 + // 세우지 않으므로, 여기서 지우는 것은 언제나 "이전 앱에서 시작돼 순간을 놓친" flash다 + // (`finish`가 앵커 없는 flash를 버리는 것과 같은 계약). `inFlight`는 비우지 않는다 — + // `finish`만 비워야 `pump()`의 1-in-flight 가드가 유지된다. + token &+= 1 + pending = nil + pendingFlash = false hidePersistentPanels(keeping: desired.showsBadge ? desired.style : nil) return } diff --git a/VimActionTests/ModeIndicatorControllerTests.swift b/VimActionTests/ModeIndicatorControllerTests.swift index f308d44..a3b6987 100644 --- a/VimActionTests/ModeIndicatorControllerTests.swift +++ b/VimActionTests/ModeIndicatorControllerTests.swift @@ -11,7 +11,8 @@ import VimEngine @testable import VimAction /// 온스크린 인디케이터의 **표시 판정**(순수 계층)과 **토글·스타일 영속**. 패널도 AX도 만들지 -/// 않는다 — 판정은 순수 함수라 값만 오가고, 컨트롤러 테스트는 트리거를 부르지 않는다. +/// 않는다 — 판정은 순수 함수라 값만 오가고, 컨트롤러 테스트가 부르는 트리거는 자기 pid +/// 경로 하나뿐이다 (그 경로는 읽지도 그리지도 않는다 — `ModeIndicatorSelfProcessTests`). struct ModeIndicatorPresentationTests { private func inputs( _ mode: Mode, _ indicator: MenuBarIndicator, pid: pid_t? = 42 @@ -216,6 +217,43 @@ struct ModeIndicatorToggleTests { } } +/// 자기 pid 경로 — VimAction이 최전면일 때(메뉴바 아이콘 클릭·설정 창) 리졸버가 우리 pid에 +/// 붙는데, 그 경로는 읽지도 그리지도 않지만 **떠 있던 읽기는 무효화해야 한다**. 아니면 이전 +/// 앱을 향해 떠 있던 읽기가 착지해 방금 감춘 옛 배지·테두리를 다시 그린다 (PR #68 리뷰). +/// +/// 이 경로가 테스트에서 무해한 근거: 패널 셋은 첫 표시에서야 만들어지고(전부 nil), AX도 +/// `NSScreen`도 부르지 않는다 — 관측 가능한 부수효과가 토큰뿐이라 토큰을 단언한다. +@MainActor +struct ModeIndicatorSelfProcessTests { + private var selfInputs: ModeIndicatorController.Inputs { + .init( + mode: .normal, indicator: .mode(.normal), + processID: ProcessInfo.processInfo.processIdentifier) + } + + @Test("자기 pid의 앵커 이벤트는 진행 중 읽기를 무효화한다") + func selfProcessAnchorEventInvalidatesInFlightReads() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + let before = controller.token + controller.anchorDidChange(selfInputs) + #expect(controller.token != before) + } + } + + /// 설정 창에서 스타일을 바꾸는 경로 — didSet → reconcile이 자기 pid 가지를 탄다. + @Test("자기 pid 상태에서 스타일 변경도 진행 중 읽기를 무효화한다") + func styleChangeWhileSelfFrontmostInvalidatesInFlightReads() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + controller.anchorDidChange(selfInputs) + let before = controller.token + controller.style = .screenBorder + #expect(controller.token != before) + } + } +} + /// 상시 표시 스타일의 영속 — 토글과 같은 소유 모델이고 같은 단언 함정을 지킨다. @MainActor struct ModeIndicatorStyleTests { From 87e7529c7be380ce0f9e10036b0a7dc9ec227557 Mon Sep 17 00:00:00 2001 From: pilyang Date: Tue, 15 Sep 2026 09:42:58 +0900 Subject: [PATCH 5/9] feat(app): add a flash-only option for the on-screen mode indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q --- VimAction/ModeIndicatorController.swift | 48 +++++++--- VimAction/Preferences.swift | 11 ++- VimAction/SettingsView.swift | 13 ++- .../ModeIndicatorControllerTests.swift | 93 ++++++++++++++++++- 4 files changed, 144 insertions(+), 21 deletions(-) diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index dff1ccb..c397d45 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -27,11 +27,12 @@ nonisolated enum ModeIndicatorPresentationStyle: String, Sendable { /// /// 겹은 둘이다. **순간 표시(flash)** 는 모드가 바뀔 때마다 ~1초 떴다 사라지고, **상시 표시**는 /// 비-Insert 모드 동안 계속 붙어 있는다 (`20260906_mode-indicator-hybrid-display-policy.md`). -/// 상시 표시의 형태는 둘 중 하나다 — 요소 모서리의 배지(기본) 또는 화면 테두리 -/// (`ModeIndicatorPresentationStyle`). 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 -/// 재배치도 없다 (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 3). 트리거는 -/// 다섯이고 전부 아래 `reconcile` 하나로 모인다: 모드 전환 / 앵커 이벤트(포커스·앱 활성화·창 -/// 이동·리사이즈·디스플레이 재구성) / 사다리 변화 / 설정(토글·스타일). +/// 상시 표시는 설정으로 끌 수 있고(그러면 flash만 남는다 — `isPersistentEnabled`), 형태는 +/// 둘 중 하나다 — 요소 모서리의 배지(기본) 또는 화면 테두리 (`ModeIndicatorPresentationStyle`). +/// 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다 +/// (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 3). 트리거는 다섯이고 전부 +/// 아래 `reconcile` 하나로 모인다: 모드 전환 / 앵커 이벤트(포커스·앱 활성화·창 이동·리사이즈· +/// 디스플레이 재구성) / 사다리 변화 / 설정(토글·상시 표시·스타일). @MainActor @Observable final class ModeIndicatorController { @@ -64,8 +65,10 @@ final class ModeIndicatorController { /// 않는다. nonisolated struct Presentation: Equatable { var label: String - /// Insert만 `false`다 — flash는 뜨되 상시 표시는 남지 않는다. 상시 표시가 "지금 위험한 - /// 모드다"를 뜻하려면 기본 상태(Insert)에는 없어야 한다. + /// Insert는 항상 `false`다 — flash는 뜨되 상시 표시는 남지 않는다. 상시 표시가 "지금 + /// 위험한 모드다"를 뜻하려면 기본 상태(Insert)에는 없어야 한다. 상시 표시를 끈 + /// 설정(`isPersistentEnabled == false`)에서는 모든 모드가 `false`다 — Insert 경로가 + /// 곧 "순간 표시만"의 경로라 그 아래 코드는 설정을 모른다. var showsBadge: Bool var processID: pid_t /// 상시 표시의 형태. 여기 실리는 이유는 `desired != current`가 스타일 변경에도 참이 @@ -80,14 +83,15 @@ final class ModeIndicatorController { /// 띄우면 메뉴바에서 없앤 "가로채지 않는데 Normal이라고 말하는" 거짓말을 화면 한가운데서 /// 되풀이하게 된다 (`20260906_mode-indicator-hybrid-display-policy.md` 결정 3). nonisolated static func presentation( - isEnabled: Bool, style: ModeIndicatorPresentationStyle, inputs: Inputs + isEnabled: Bool, isPersistentEnabled: Bool, style: ModeIndicatorPresentationStyle, + inputs: Inputs ) -> Presentation? { guard isEnabled, case .mode = inputs.indicator, let processID = inputs.processID else { return nil } return Presentation( label: inputs.mode.overlayLabel, - showsBadge: inputs.mode.showsPersistentBadge, + showsBadge: inputs.mode.showsPersistentBadge && isPersistentEnabled, processID: processID, style: style) } @@ -115,8 +119,8 @@ final class ModeIndicatorController { /// 온스크린 인디케이터 on/off. **런타임 SSOT는 이 프로퍼티**이고 didSet이 저장을 /// 책임진다 — 실행 중 외부 `defaults write`는 재시작까지 무시된다 (탈출 옵션과 같은 - /// 소유 모델). `style`과 함께 `@ObservationIgnored`가 아닌 단 둘의 저장 프로퍼티다: - /// Settings 토글이 여기 바인딩된다. + /// 소유 모델). `isPersistentEnabled`·`style`과 함께 `@ObservationIgnored`가 아닌 단 셋의 + /// 저장 프로퍼티다: Settings 토글이 여기 바인딩된다. var isEnabled: Bool { didSet { guard oldValue != isEnabled else { return } @@ -128,6 +132,21 @@ final class ModeIndicatorController { } } + /// 상시 표시 on/off — off면 모드 전환 때의 flash만 남는다. `isEnabled`와 같은 소유 + /// 모델(런타임 SSOT는 이 프로퍼티, didSet이 저장). Settings의 "Show" Picker가 여기 + /// 바인딩된다. 판정에서는 `showsBadge`를 접을 뿐이라 그 아래(읽기 코얼레싱·숨김)는 Insert와 + /// 같은 경로를 탄다 — off 상태의 앵커 이벤트는 AX 왕복 0건이다. + var isPersistentEnabled: Bool { + didSet { + guard oldValue != isPersistentEnabled else { return } + defaults.set( + isPersistentEnabled, forKey: PreferenceKeys.onScreenModeIndicatorPersistentEnabled) + // off면 떠 있던 상시 표시가 즉시 사라지고, on이면 지금 Normal·Visual인 경우 + // `desired != current`로 다시 읽어 그린다 (토글과 같은 수렴). + reconcile(flashes: false, rereadGeometry: false) + } + } + /// 상시 표시의 형태 — `isEnabled`와 같은 소유 모델(런타임 SSOT는 이 프로퍼티, didSet이 /// 저장). Settings의 Picker가 여기 바인딩된다. var style: ModeIndicatorPresentationStyle { @@ -188,6 +207,9 @@ final class ModeIndicatorController { self.isEnabled = defaults.bool( forKey: PreferenceKeys.onScreenModeIndicatorEnabled, default: PreferenceKeys.onScreenModeIndicatorEnabledDefault) + self.isPersistentEnabled = defaults.bool( + forKey: PreferenceKeys.onScreenModeIndicatorPersistentEnabled, + default: PreferenceKeys.onScreenModeIndicatorPersistentEnabledDefault) // 없는 키도 모르는 raw 값도 기본으로 접힌다 — 스타일이 늘거나 이름이 바뀌어도 저장된 // 값 때문에 아무것도 안 뜨는 일은 없다. self.style = @@ -227,7 +249,9 @@ final class ModeIndicatorController { /// 늘지 않는다. private func reconcile(flashes: Bool, rereadGeometry: Bool) { guard let inputs = lastInputs, - let desired = Self.presentation(isEnabled: isEnabled, style: style, inputs: inputs) + let desired = Self.presentation( + isEnabled: isEnabled, isPersistentEnabled: isPersistentEnabled, style: style, + inputs: inputs) else { // 표시가 통째로 막힌 모든 사유(사다리 이탈·토글 off·pid 없음·아직 아무 입력도 // 받지 않음)가 이 한 자리로 모인다. 페이드 없이 즉시 감춘다. diff --git a/VimAction/Preferences.swift b/VimAction/Preferences.swift index 9cebace..a0ff095 100644 --- a/VimAction/Preferences.swift +++ b/VimAction/Preferences.swift @@ -7,7 +7,7 @@ import Foundation import VimEngine /// UserDefaults 키 — 가로채기·탈출 두 키는 EventTapController가, 온스크린 인디케이터 -/// 토글·스타일 두 키는 ModeIndicatorController가 로드(init)·저장(didSet)하는 단일 소유다. +/// 토글·상시 표시·스타일 세 키는 ModeIndicatorController가 로드(init)·저장(didSet)하는 단일 소유다. /// (단수형 `PreferenceKey`는 SwiftUI 프로토콜과 이름이 충돌해 피한다.) /// `nonisolated` — 프로젝트 기본이 MainActor 격리라 키 상수까지 메인 격리가 붙는데, /// 킬스위치 발동은 전용 스레드에서 이 키로 영속하므로 격리 밖에서 읽을 수 있어야 한다. @@ -18,10 +18,13 @@ nonisolated enum PreferenceKeys { static let normalModeEscapeEnabled = "normalModeEscapeEnabled" /// 온스크린 모드 인디케이터(순간 표시 + 상시 배지) on/off. 기본 on. static let onScreenModeIndicatorEnabled = "onScreenModeIndicatorEnabled" + /// 온스크린 인디케이터의 상시 표시(비-Insert 동안 남는 배지·테두리) on/off — off면 순간 + /// 표시만 남는다. 기본 on. + static let onScreenModeIndicatorPersistentEnabled = "onScreenModeIndicatorPersistentEnabled" /// 온스크린 인디케이터의 상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열. /// 기본 배지. static let onScreenModeIndicatorStyle = "onScreenModeIndicatorStyle" - /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 셋과 달리 AppState가 소유한다. + /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 넷과 달리 AppState가 소유한다. static let didShowOnboarding = "didShowOnboarding" /// 탈출 옵션의 제품 기본값 — EventTapController init이 읽는다. @@ -30,6 +33,10 @@ nonisolated enum PreferenceKeys { /// 이 기능은 "모드를 모르고 타이핑하는 사고"를 막는 안전 장치라 첫 실행 사용자를 보호해야 /// 한다 (`20260906_mode-indicator-settings-in-userdefaults.md`). static let onScreenModeIndicatorEnabledDefault = true + /// 상시 표시의 제품 기본값 — on. 하이브리드 정책(전환 시 순간 표시 + 비-Insert 상시 표시)이 + /// 기본이고, 순간 표시만 남기는 것은 설정에서 고르는 opt-out이다 + /// (`20260914_mode-indicator-optin-flash-only-and-window-border.md`). + static let onScreenModeIndicatorPersistentEnabledDefault = true /// 스타일의 제품 기본값 — 배지다. 화면 테두리는 설정에서 고르는 대체 스타일이다 /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5). 없는 값·모르는 값도 이것이다. static let onScreenModeIndicatorStyleDefault = ModeIndicatorPresentationStyle.badge diff --git a/VimAction/SettingsView.swift b/VimAction/SettingsView.swift index bb2a770..c78f26b 100644 --- a/VimAction/SettingsView.swift +++ b/VimAction/SettingsView.swift @@ -127,13 +127,20 @@ private struct GeneralTab: View { Text("A label flashes near the caret (or the focused field) whenever the mode changes.") .font(.footnote) .foregroundStyle(.secondary) - // 상시 표시의 형태 — 토글과 같은 소유 모델(컨트롤러 프로퍼티 didSet). 표시 문구는 - // 탭의 다른 항목처럼 뷰에 둔다. 인디케이터가 꺼져 있으면 고를 이유가 없어 비활성. + // 상시 표시 on/off — 토글과 같은 소유 모델(컨트롤러 프로퍼티 didSet). 상시 표시가 + // 소음인 사용자의 opt-out이고 기본은 on. 인디케이터가 꺼져 있으면 고를 이유가 없어 비활성. + Picker("Show", selection: $modeIndicator.isPersistentEnabled) { + Text("Only when the mode changes").tag(false) + Text("Also while in Normal or Visual mode").tag(true) + } + .disabled(!modeIndicator.isEnabled) + // 상시 표시의 형태 — 같은 소유 모델. 표시 문구는 탭의 다른 항목처럼 뷰에 둔다. + // 상시 표시가 없으면(인디케이터 off 또는 순간 표시만) 형태를 고를 이유가 없어 비활성. Picker("Indicator style", selection: $modeIndicator.style) { Text("Badge near the focused field").tag(ModeIndicatorPresentationStyle.badge) Text("Screen border").tag(ModeIndicatorPresentationStyle.screenBorder) } - .disabled(!modeIndicator.isEnabled) + .disabled(!modeIndicator.isEnabled || !modeIndicator.isPersistentEnabled) Text( "While you're in Normal or Visual mode, a small badge stays next to the field — or a colored frame surrounds the screen with the mode label in its top-right corner." ) diff --git a/VimActionTests/ModeIndicatorControllerTests.swift b/VimActionTests/ModeIndicatorControllerTests.swift index a3b6987..88ea0c6 100644 --- a/VimActionTests/ModeIndicatorControllerTests.swift +++ b/VimActionTests/ModeIndicatorControllerTests.swift @@ -22,10 +22,11 @@ struct ModeIndicatorPresentationTests { private func presentation( _ mode: Mode, _ indicator: MenuBarIndicator, pid: pid_t? = 42, isEnabled: Bool = true, - style: ModeIndicatorPresentationStyle = .badge + isPersistentEnabled: Bool = true, style: ModeIndicatorPresentationStyle = .badge ) -> ModeIndicatorController.Presentation? { ModeIndicatorController.presentation( - isEnabled: isEnabled, style: style, inputs: inputs(mode, indicator, pid: pid)) + isEnabled: isEnabled, isPersistentEnabled: isPersistentEnabled, style: style, + inputs: inputs(mode, indicator, pid: pid)) } @Test("비-Insert 모드는 라벨 + 상시 배지") @@ -58,10 +59,27 @@ struct ModeIndicatorPresentationTests { } } - @Test("토글 off면 모드·사다리와 무관하게 아무것도 표시하지 않는다") + /// 상시 표시를 끈 설정 — 순간 표시만 남는다. 결과가 `nil`이 아니어야 한다: `nil`이면 + /// flash까지 사라져 "인디케이터 off"와 구분이 없다. + @Test("상시 표시 off면 비-Insert도 라벨만 있고 배지는 없다") + func persistentOffFlashesWithoutBadge() { + for mode in [Mode.normal, .visualChar, .visualLine] { + let result = presentation(mode, .mode(mode), isPersistentEnabled: false) + #expect(result != nil) + #expect(result?.label == mode.overlayLabel) + #expect(result?.showsBadge == false) + } + } + + @Test("토글 off면 모드·사다리·상시 표시 설정과 무관하게 아무것도 표시하지 않는다") func disabledShowsNothing() { for mode in [Mode.insert, .normal, .visualChar, .visualLine] { - #expect(presentation(mode, .mode(mode), isEnabled: false) == nil) + for persistent in [true, false] { + #expect( + presentation( + mode, .mode(mode), isEnabled: false, isPersistentEnabled: persistent) + == nil) + } } } @@ -91,6 +109,9 @@ struct ModeIndicatorReadCoalescingTests { label: "NORMAL", showsBadge: true, processID: 42, style: .screenBorder) private let insertBorder = ModeIndicatorController.Presentation( label: "INSERT", showsBadge: false, processID: 42, style: .screenBorder) + /// 상시 표시를 끈 설정의 Normal — 판정 결과는 Insert와 같은 모양(배지 없음)이다. + private let normalFlashOnly = ModeIndicatorController.Presentation( + label: "NORMAL", showsBadge: false, processID: 42, style: .badge) private func needsRead( desired: ModeIndicatorController.Presentation, @@ -170,6 +191,70 @@ struct ModeIndicatorReadCoalescingTests { func styleSwitchInInsertDoesNotRead() { #expect(needsRead(desired: insertBorder, current: insert) == false) } + + /// 상시 표시를 끈 설정은 Insert와 같은 비용이어야 한다 — 앵커 이벤트에 AX 왕복 0건. + @Test("상시 표시 off의 앵커 이벤트는 읽지 않는다") + func persistentOffAnchorEventsDoNotRead() { + #expect( + needsRead(desired: normalFlashOnly, current: normalFlashOnly, rereadGeometry: true) + == false) + } + + /// on→off는 읽을 것이 없고(숨김은 `hidePersistentPanels`의 몫), off→on은 그려야 하니 읽는다. + @Test("상시 표시 토글은 켤 때만 읽는다") + func persistentToggleReadsOnlyWhenTurningOn() { + #expect(needsRead(desired: normalFlashOnly, current: normal) == false) + #expect(needsRead(desired: normal, current: normalFlashOnly)) + } +} + +/// 상시 표시 on/off의 영속 — 토글과 같은 소유 모델이고 같은 단언 함정을 지킨다. +@MainActor +struct ModeIndicatorPersistentVisibilityTests { + @Test("미설정 키 → 제품 기본값 on") + func defaultsToEnabled() { + withTemporaryDefaults { defaults in + #expect(ModeIndicatorController(defaults: defaults).isPersistentEnabled) + } + } + + @Test("저장된 false는 init에서 로드된다") + func storedFalseLoadsAtInit() { + withTemporaryDefaults { defaults in + defaults.set(false, forKey: PreferenceKeys.onScreenModeIndicatorPersistentEnabled) + #expect(ModeIndicatorController(defaults: defaults).isPersistentEnabled == false) + } + } + + @Test("상시 표시 영속: didSet 저장 → 새 컨트롤러 init 로드") + func persistsAcrossControllers() { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.isPersistentEnabled = false + // 존재 확인이 먼저 — `bool(forKey:)`는 미설정 키에도 false라 이것 없이는 + // 영속을 통째로 지워도 통과한다. + #expect( + defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorPersistentEnabled) + != nil) + #expect( + defaults.bool(forKey: PreferenceKeys.onScreenModeIndicatorPersistentEnabled) + == false) + + let second = ModeIndicatorController(defaults: defaults) + #expect(second.isPersistentEnabled == false) + } + } + + /// 토글과 같은 근거 — 입력이 밀린 적 없으면 화면에도 AX에도 닿지 않는다. + @Test("입력이 밀린 적 없으면 상시 표시 변경은 무해하다") + func togglingWithoutInputsIsHarmless() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + controller.isPersistentEnabled = false + controller.isPersistentEnabled = true + #expect(controller.isPersistentEnabled) + } + } } @MainActor From 2d2b738ed810a76de4f294cb5a7c2712590f7a0e Mon Sep 17 00:00:00 2001 From: pilyang Date: Tue, 15 Sep 2026 09:49:57 +0900 Subject: [PATCH 6/9] feat(app): add a focused-window border style for the mode indicator 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 Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q --- VimAction/ModeIndicatorBorderPanel.swift | 5 +- VimAction/ModeIndicatorController.swift | 63 ++++++++++--- VimAction/ModeIndicatorGeometryReader.swift | 36 +++++--- VimAction/ModeIndicatorLayout.swift | 57 ++++++++---- VimAction/ModeIndicatorPanel.swift | 10 +-- VimAction/Preferences.swift | 5 +- VimAction/SettingsView.swift | 3 +- .../ModeIndicatorControllerTests.swift | 59 +++++++++--- VimActionTests/ModeIndicatorLayoutTests.swift | 90 +++++++++++++++++-- 9 files changed, 257 insertions(+), 71 deletions(-) diff --git a/VimAction/ModeIndicatorBorderPanel.swift b/VimAction/ModeIndicatorBorderPanel.swift index aad515b..693604c 100644 --- a/VimAction/ModeIndicatorBorderPanel.swift +++ b/VimAction/ModeIndicatorBorderPanel.swift @@ -5,8 +5,9 @@ import AppKit -/// 화면 테두리 스타일의 상시 표시 — 포커스 창이 있는 디스플레이의 `visibleFrame`을 덮는 패널 -/// **하나**에 강조색 테두리와 모서리 라벨 알약을 함께 그린다. +/// 테두리 스타일(창·화면)의 상시 표시 — 호출자가 준 frame(포커스 창 rect 또는 디스플레이의 +/// `visibleFrame`)을 덮는 패널 **하나**에 강조색 테두리와 모서리 라벨 알약을 함께 그린다. 두 +/// 스타일이 인스턴스 하나를 나눠 쓴다 — 어느 형태로 떠 있는지는 컨트롤러가 기억한다. /// /// 라벨이 같은 패널의 서브뷰인 것이 요점이다: `hide()` 한 번에 테두리와 라벨이 같이 사라져, /// 스타일을 배지로 되돌린 직후 라벨만 화면 구석에 남는 일이 없다. 색만으로 구분하지 않는다는 diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index c397d45..33115df 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -17,10 +17,24 @@ import VimEngine nonisolated enum ModeIndicatorPresentationStyle: String, Sendable { /// 포커스 요소 모서리의 작은 알약 (기본). case badge + /// 포커스 창을 두르는 강조색 테두리 + 창 안쪽 모서리 라벨 — 설정에서 고르는 대체 스타일이다. + /// 창 단위 표시는 어느 입력칸인지 말하지 못해 기본이 아니다 + /// (`20260914_mode-indicator-optin-flash-only-and-window-border.md`). + case windowBorder /// 포커스 창이 있는 디스플레이를 두르는 강조색 테두리 + 모서리 라벨 — 설정에서 고르는 /// 대체 스타일이다. 계속 켜진 색 프레임은 피로가 커서 기본이 아니다 /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5). case screenBorder + + /// 두 테두리 스타일은 패널 하나(`ModeIndicatorBorderPanel`)를 나눠 쓴다. 숨김 판정이 이 + /// 술어를 보므로 스타일이 늘면 컴파일러가 여기서 잡는다 — `!= .screenBorder` 같은 비교로 + /// 두면 새 스타일이 조용히 "테두리 아님"이 되어 표시 직후 감춰진다. + var usesBorderPanel: Bool { + switch self { + case .badge: false + case .windowBorder, .screenBorder: true + } + } } /// 모드·사다리·기하를 잇는 조율자 — 화면에 떠 있는 것의 단일 소유자. @@ -28,7 +42,8 @@ nonisolated enum ModeIndicatorPresentationStyle: String, Sendable { /// 겹은 둘이다. **순간 표시(flash)** 는 모드가 바뀔 때마다 ~1초 떴다 사라지고, **상시 표시**는 /// 비-Insert 모드 동안 계속 붙어 있는다 (`20260906_mode-indicator-hybrid-display-policy.md`). /// 상시 표시는 설정으로 끌 수 있고(그러면 flash만 남는다 — `isPersistentEnabled`), 형태는 -/// 둘 중 하나다 — 요소 모서리의 배지(기본) 또는 화면 테두리 (`ModeIndicatorPresentationStyle`). +/// 셋 중 하나다 — 요소 모서리의 배지(기본), 포커스 창 테두리, 화면 테두리 +/// (`ModeIndicatorPresentationStyle`). /// 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다 /// (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 3). 트리거는 다섯이고 전부 /// 아래 `reconcile` 하나로 모인다: 모드 전환 / 앵커 이벤트(포커스·앱 활성화·창 이동·리사이즈· @@ -195,10 +210,15 @@ final class ModeIndicatorController { /// 패널은 첫 표시에서야 만든다 — 런치 시 `NSPanel`을 만들지 않고, 표시할 일이 없는 /// 실행(권한 미허용, 계속 Insert, 토글 off)에서는 끝까지 만들어지지 않는다. 테두리 패널은 - /// 그 스타일을 고른 적이 있어야 생긴다. + /// 테두리 스타일을 고른 적이 있어야 생긴다. @ObservationIgnored private var flashPanel: ModeIndicatorPanel? @ObservationIgnored private var badgePanel: ModeIndicatorPanel? @ObservationIgnored private var borderPanel: ModeIndicatorBorderPanel? + /// 테두리 패널이 지금 어느 스타일로 떠 있는가 — 창·화면 테두리가 패널 하나를 나눠 쓰므로 + /// `keeping`만으로는 "같은 패널, 다른 형태"를 가릴 수 없다. 이것 없이는 화면 테두리에서 창 + /// 테두리로 바꿔도 설정 창이 열린 동안 화면 크기 테두리가 그대로 남았다가 복귀 때 창 크기로 + /// 점프한다 — 배지↔테두리는 즉시 사라지는데 이 전환만 "설정이 안 먹은 것"처럼 보인다. + @ObservationIgnored private var borderPanelStyle: ModeIndicatorPresentationStyle? /// `defaults` 주입은 테스트용이다 — `.standard`를 쓰면 TEST_HOST가 앱 프로세스라 /// 실기기에서 영속된 값이 새어 들어온다 (`EventTapController`와 같은 이유). @@ -318,9 +338,15 @@ final class ModeIndicatorController { // `pendingFlash`로 다시 들어온다. flash를 세우는 유일한 경로(`modeDidChange`)가 항상 // 토큰을 올려 새 읽기를 띄우므로, 캐럿 없이 띄운 읽기로 flash가 그려지는 일은 없다. let includesCaret = pendingFlash + // 창 rect는 창 테두리 스타일의 상시 표시만 쓴다 — 조건이 소비자(`finish`의 `showsBadge` + // guard와 스타일 switch)와 같아야 Insert·순간 표시만인 요청에서 창 왕복 셋을 낭비하지 + // 않는다. `inFlight.request == desired` 비교에 스타일이 실려 있으므로 창 없이 띄운 읽기가 + // 창 테두리 요청에 재사용되는 일은 없다 (캐럿과 같은 불변식). + let includesWindow = request.showsBadge && request.style == .windowBorder Self.geometryQueue.async { [weak self] in let anchors = ModeIndicatorGeometryReader.read( - processID: request.processID, includesCaret: includesCaret) + processID: request.processID, includesCaret: includesCaret, + includesWindow: includesWindow) DispatchQueue.main.async { MainActor.assumeIsolated { self?.finish(request, token: token, anchors: anchors) @@ -387,16 +413,24 @@ final class ModeIndicatorController { } else { hidePersistentPanels(keeping: nil) } - case .screenBorder: - if let layout = ModeIndicatorLayout.borderLayout( - anchors: anchors, - labelSize: ModeIndicatorPanel.size(for: request.label, style: .badge), - screens: layoutScreens, primaryScreenMaxY: primary.frame.maxY) - { + case .windowBorder, .screenBorder: + let labelSize = ModeIndicatorPanel.size(for: request.label, style: .badge) + let layout = + request.style == .windowBorder + ? ModeIndicatorLayout.windowBorderLayout( + anchors: anchors, labelSize: labelSize, screens: layoutScreens, + primaryScreenMaxY: primary.frame.maxY) + : ModeIndicatorLayout.screenBorderLayout( + anchors: anchors, labelSize: labelSize, screens: layoutScreens, + primaryScreenMaxY: primary.frame.maxY) + if let layout { let panel = borderPanel ?? ModeIndicatorBorderPanel() borderPanel = panel + // 형태를 먼저 적는다 — 아래 `keeping`이 방금 그린 패널을 "다른 형태"로 보고 + // 감추면 안 된다. + borderPanelStyle = request.style panel.show(request.label, at: layout.frame, labelFrame: layout.labelFrame) - hidePersistentPanels(keeping: .screenBorder) + hidePersistentPanels(keeping: request.style) } else { hidePersistentPanels(keeping: nil) } @@ -405,10 +439,15 @@ final class ModeIndicatorController { /// 상시 표시 패널 중 `style`이 아닌 것을 감춘다 (`nil`이면 전부). **숨김 규칙의 단일 /// 지점이다** — 배지에 적용되는 모든 숨김 사유(Insert·사다리 이탈·토글 off·pid 없음· - /// 자기 pid·앵커 없음·스타일 변경)가 테두리에도 같은 자리에서 같이 적용된다. + /// 자기 pid·앵커 없음·스타일 변경)가 테두리에도 같은 자리에서 같이 적용된다. 테두리 패널은 + /// 두 테두리 스타일이 나눠 쓰므로, 유지할 스타일이 테두리라도 **지금 떠 있는 형태와 다르면** + /// 감춘다 — 그래야 창↔화면 테두리 전환에서도 이전 형태가 즉시 사라진다. private func hidePersistentPanels(keeping style: ModeIndicatorPresentationStyle?) { if style != .badge { badgePanel?.hide() } - if style != .screenBorder { borderPanel?.hide() } + if style?.usesBorderPanel != true || style != borderPanelStyle { + borderPanel?.hide() + borderPanelStyle = nil + } } /// VimAction 자신의 pid — 앵커로 삼지 않기 위한 비교값이다. diff --git a/VimAction/ModeIndicatorGeometryReader.swift b/VimAction/ModeIndicatorGeometryReader.swift index af3886b..dd342e1 100644 --- a/VimAction/ModeIndicatorGeometryReader.swift +++ b/VimAction/ModeIndicatorGeometryReader.swift @@ -16,9 +16,11 @@ import CoreGraphics /// 타임아웃은 새로 정하지 않는다 — 요소·앱 요소 양쪽을 `AXRead`에서 받으므로 50ms 단일 /// 상수가 그대로 상속된다. /// -/// **비용**: 요소 rect(포커스 요소 조회·position·size)에, 요소가 쓸 만하지 않을 때만 창(창 -/// 조회·position·size)이 더해진다. 캐럿은 `includesCaret`일 때만 읽고 최대 다섯 번(선택 범위· -/// bounds 두 변형·마커 범위·마커 bounds)이라, 콜드 앱의 flash 한 번이 AX 왕복 최대 열한 번이다. +/// **비용**: 요소 rect(포커스 요소 조회·position·size)에, 요소가 쓸 만하지 않거나 창 테두리가 +/// 물었을 때 창(창 조회·position·size)이 더해진다. 캐럿은 `includesCaret`일 때만 읽고 최대 +/// 다섯 번(선택 범위·bounds 두 변형·마커 범위·마커 bounds)이라, 콜드 앱의 flash 한 번이 AX +/// 왕복 최대 열한 번이다 — 창은 한 번만 읽으므로 이 최악치는 창 테두리 스타일에서도 같고, 그 +/// 스타일이 더하는 것은 요소가 쓸 만한 **정상 경로**의 창 왕복 셋이다. nonisolated enum ModeIndicatorGeometryReader { /// 포커스 요소 rect·(필요하면) 포커스 창 rect·(flash면) 캐럿 rect를 한 번에 읽는다 (AX 좌표계). /// @@ -30,20 +32,26 @@ nonisolated enum ModeIndicatorGeometryReader { /// 캐럿을 따라다니지 않는다), 앵커·사다리 이벤트의 배지 재배치는 캐럿 왕복 없이 오늘의 /// 비용에 머문다. 컨트롤러가 명시적으로 넘긴다 — 전역 상태로 두면 어느 읽기가 캐럿을 /// 물었는지 코드에서 보이지 않는다. - static func read(processID: pid_t, includesCaret: Bool) -> ModeIndicatorLayout.Anchors { + /// + /// `includesWindow`는 **창 테두리 스타일의 상시 표시가 있을 때만** 참이다 — 그 스타일은 + /// 사다리가 아니라 창 rect 자체를 두르므로 요소가 쓸 만해도 창을 읽어야 한다. 캐럿과 같은 + /// 이유로 컨트롤러가 명시적으로 넘긴다. + static func read(processID: pid_t, includesCaret: Bool, includesWindow: Bool) + -> ModeIndicatorLayout.Anchors + { let focused = AXRead.focusedElement(ofProcess: processID) let element = focused.flatMap(rect(of:)) - // 요소 rect 다음이어야 한다 — 캐럿 유효성 판정이 요소 rect를 본다. 그리고 아래 조기 - // 반환 **앞**이어야 한다: 요소가 쓸 만한 것이 실측표의 모든 앱에서 정상 경로다. + // 요소 rect 다음이어야 한다 — 캐럿 유효성 판정이 요소 rect를 본다. let caret = includesCaret ? focused.flatMap { caretRect(of: $0, within: element) } : nil - // 요소가 답했으면 창은 읽지 않는다 — 사다리가 어차피 요소를 고르므로 결과는 같고, - // 콜드 앱에서 AX 왕복 셋(창 조회·position·size)이 통째로 빠진다. 그래서 `window`가 - // `nil`인 것은 "읽기 실패"가 아니라 "필요 없었다"일 수도 있다. - if let element, ModeIndicatorLayout.isUsable(element) { - return ModeIndicatorLayout.Anchors(element: element, caret: caret) - } - return ModeIndicatorLayout.Anchors( - element: element, window: focusedWindowRect(processID: processID), caret: caret) + // 창은 한 번만, 창 테두리가 물었거나 요소가 쓸 만하지 않을 때만 읽는다. 요소가 답했으면 + // 사다리가 어차피 요소를 고르므로 창을 안 읽어도 결과는 같고, 콜드 앱에서 AX 왕복 + // 셋(창 조회·position·size)이 통째로 빠진다 — 요소가 쓸 만한 것이 실측표의 모든 앱에서 + // 정상 경로다. 그래서 `window`가 `nil`인 것은 "읽기 실패"만이 아니라 "필요 없었다"일 + // 수도 있다. + let elementIsUsable = element.map(ModeIndicatorLayout.isUsable) ?? false + let window = + includesWindow || !elementIsUsable ? focusedWindowRect(processID: processID) : nil + return ModeIndicatorLayout.Anchors(element: element, window: window, caret: caret) } private static func focusedWindowRect(processID: pid_t) -> CGRect? { diff --git a/VimAction/ModeIndicatorLayout.swift b/VimAction/ModeIndicatorLayout.swift index 684ebfe..6e896c4 100644 --- a/VimAction/ModeIndicatorLayout.swift +++ b/VimAction/ModeIndicatorLayout.swift @@ -19,10 +19,10 @@ nonisolated enum ModeIndicatorLayout { private static let elementGap: CGFloat = 4 /// 창 단 배지가 창 안쪽 오른쪽 위에 붙을 때의 인셋 (오른쪽, 위). private static let windowInset = (horizontal: CGFloat(12), vertical: CGFloat(6)) - /// 화면 테두리의 선 굵기. 패널이 그릴 때도 이 값을 읽는다 — 라벨 인셋이 선 굵기에서 + /// 테두리(창·화면)의 선 굵기. 패널이 그릴 때도 이 값을 읽는다 — 라벨 인셋이 선 굵기에서 /// 파생되므로 두 값이 따로 살면 라벨이 선에 겹친다. static let borderStrokeWidth: CGFloat = 4 - /// 화면 테두리의 모서리 라벨이 테두리 안쪽 모서리에서 떨어지는 거리 (선 굵기 + 여백). + /// 테두리의 모서리 라벨이 테두리 안쪽 모서리에서 떨어지는 거리 (선 굵기 + 여백). private static let borderLabelInset: CGFloat = borderStrokeWidth + 8 /// 화면 하나 (전부 AppKit 좌표). **고르는 축과 놓는 축이 다르다**: `frame`은 앵커가 어느 @@ -45,6 +45,7 @@ nonisolated enum ModeIndicatorLayout { /// 기하 리더가 읽어 오는 것 — **AX 좌표계**(좌상단 원점, y는 아래로 증가)의 rect들이다. /// `caret`은 flash가 있는 요청에서만 읽힌다 — 배지만 다시 놓는 앵커 이벤트는 `nil`로 온다. + /// `window`는 요소가 쓸 만하지 않을 때, 그리고 창 테두리 스타일의 요청에서 읽힌다. struct Anchors: Sendable, Equatable { var element: CGRect? var window: CGRect? @@ -183,11 +184,15 @@ nonisolated enum ModeIndicatorLayout { // 화면 목록이 비는 것은 실기기에서 일어나지 않지만, 순수 함수라 답이 정의돼야 한다. return frame } - let bounds = screen.visibleFrame - return CGRect( - x: min(max(frame.minX, bounds.minX), max(bounds.maxX - frame.width, bounds.minX)), - y: min(max(frame.minY, bounds.minY), max(bounds.maxY - frame.height, bounds.minY)), - width: frame.width, height: frame.height) + return clamp(frame, into: screen.visibleFrame) + } + + /// rect를 `bounds` 안으로 민다 — `bounds`가 rect보다 작으면 왼쪽·아래 변에 맞춘다. + private static func clamp(_ rect: CGRect, into bounds: CGRect) -> CGRect { + CGRect( + x: min(max(rect.minX, bounds.minX), max(bounds.maxX - rect.width, bounds.minX)), + y: min(max(rect.minY, bounds.minY), max(bounds.maxY - rect.height, bounds.minY)), + width: rect.width, height: rect.height) } /// 컨트롤러가 부르는 알약 배치의 단일 진입점 — 사다리 → 배치 → 좌표 변환 → 클램프. @@ -209,13 +214,21 @@ nonisolated enum ModeIndicatorLayout { screens: screens) } - /// 화면 테두리 스타일의 배치 (전부 AppKit 화면 좌표). `frame`이 테두리 패널이 덮는 범위이고 - /// `labelFrame`이 그 안 오른쪽 위 모서리의 라벨 알약이다. + /// 테두리 스타일(창·화면)의 배치 (전부 AppKit 화면 좌표). `frame`이 테두리 패널이 덮는 + /// 범위이고 `labelFrame`이 그 안 오른쪽 위 모서리의 라벨 알약이다. struct BorderLayout: Equatable { var frame: CGRect var labelFrame: CGRect } + /// 테두리 안쪽 오른쪽 위 모서리의 라벨 자리 — 창·화면 테두리가 같은 규칙이다. + private static func borderLabelFrame(in frame: CGRect, labelSize: CGSize) -> CGRect { + CGRect( + x: frame.maxX - borderLabelInset - labelSize.width, + y: frame.maxY - borderLabelInset - labelSize.height, + width: labelSize.width, height: labelSize.height) + } + /// 화면 테두리 배치 — 상시 배지의 사다리(요소 → 창)로 앵커를 고르고, **그 앵커가 속한 /// 디스플레이의 `visibleFrame`**을 두른다. 앵커가 없으면 배지와 마찬가지로 아무것도 없다. /// @@ -225,7 +238,7 @@ nonisolated enum ModeIndicatorLayout { /// /// `frame`이 아니라 `visibleFrame`인 이유: `frame`을 두르면 위 변이 메뉴바 뒤에, 아래 변이 /// Dock 뒤에 숨어 테두리가 세 변짜리로 보인다. 라벨은 인셋만큼 안쪽이라 메뉴바와 겹치지 않는다. - static func borderLayout( + static func screenBorderLayout( anchors: Anchors, labelSize: CGSize, screens: [Screen], primaryScreenMaxY: CGFloat ) -> BorderLayout? { guard let anchor = anchor(anchors, for: .elementFirst) else { return nil } @@ -234,10 +247,24 @@ nonisolated enum ModeIndicatorLayout { return nil } let frame = screen.visibleFrame - let label = CGRect( - x: frame.maxX - borderLabelInset - labelSize.width, - y: frame.maxY - borderLabelInset - labelSize.height, - width: labelSize.width, height: labelSize.height) - return BorderLayout(frame: frame, labelFrame: label) + return BorderLayout(frame: frame, labelFrame: borderLabelFrame(in: frame, labelSize: labelSize)) + } + + /// 창 테두리 배치 — 사다리를 타지 않고 **포커스 창 rect 자체**를 두른다. 창이 없거나 면적이 + /// 없으면 아무것도 없다. 폴백이 없는 것이 계약이다: "창 테두리"인데 요소나 화면을 두르면 + /// 사용자가 고른 스타일이 아니다 (배지가 앵커 없을 때와 같은 답 "없음"). + /// + /// 창 frame은 클램프하지 않는다 — 화면 밖으로 나간 창은 나간 만큼 테두리도 잘리는 것이 + /// 맞다. 라벨은 두 번 민다: 앵커 화면의 `visibleFrame` 안으로(창의 오른쪽 위가 메뉴바 뒤나 + /// 화면 밖이어도 라벨은 보이게), 그 다음 창 frame 안으로(라벨 폭에 인셋을 더한 것보다 좁은 + /// 창에서 라벨이 패널 밖으로 나가면 패널 경계에서 잘린다 — 화면 클램프는 이것을 못 막는다). + static func windowBorderLayout( + anchors: Anchors, labelSize: CGSize, screens: [Screen], primaryScreenMaxY: CGFloat + ) -> BorderLayout? { + guard let window = anchors.window, isUsable(window) else { return nil } + let frame = flip(window, primaryScreenMaxY: primaryScreenMaxY) + let onScreen = clamp( + borderLabelFrame(in: frame, labelSize: labelSize), nearAnchor: frame, screens: screens) + return BorderLayout(frame: frame, labelFrame: clamp(onScreen, into: frame)) } } diff --git a/VimAction/ModeIndicatorPanel.swift b/VimAction/ModeIndicatorPanel.swift index ad46319..ad37287 100644 --- a/VimAction/ModeIndicatorPanel.swift +++ b/VimAction/ModeIndicatorPanel.swift @@ -6,7 +6,7 @@ import AppKit /// 알약 두 겹의 외양 차이. 크기·모서리·창 층만 다르고 나머지(비활성화 패널 설정, 표시 경로)는 -/// 같아서 값 하나로 갈라 둔다. (상시 표시를 배지로 할지 화면 테두리로 할지는 다른 축 — +/// 같아서 값 하나로 갈라 둔다. (상시 표시를 배지로 할지 창·화면 테두리로 할지는 다른 축 — /// `ModeIndicatorPresentationStyle`이다.) /// /// **배지가 한 층 아래인 것이 "flash가 배지 위에 그려진다"의 유일한 보장이다.** 같은 층이면 @@ -15,7 +15,7 @@ import AppKit /// flash가 양방향으로 더 커서 배지를 완전히 덮는다 — 전환 직후 1초간 flash만 보이고 /// 페이드아웃하며 배지가 드러난다. 캐럿이 있는 앱에서는 flash가 캐럿 아래, 배지가 요소 모서리라 /// 전환 직후 1초간 같은 라벨 둘이 함께 보인다 — flash는 캐럿부터, 배지는 요소부터라는 결정의 -/// 직접 결과다 (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 2). 화면 테두리 +/// 직접 결과다 (`20260906_mode-indicator-anchor-ladder-event-driven.md` 결정 2). 테두리 /// 스타일의 모서리 라벨도 배지 스타일이고 같은 층이다. enum ModeIndicatorStyle { /// 모드 전환 순간 표시. 한 단계 크고 진하다. @@ -58,7 +58,7 @@ enum ModeIndicatorStyle { /// 동반**되며, 그래서 배경은 라벨 폭에 맞춰 늘어난다 (`size(for:style:)`가 그 폭의 소유자다). /// 같은 이유로 글씨 색은 고정이 아니라 강조색에서 파생된다 (`textColor(on:)`). /// -/// `private`이 아닌 것은 화면 테두리 패널이 모서리 라벨로 같은 뷰를 쓰기 때문이다 — 알약 +/// `private`이 아닌 것은 테두리 패널이 모서리 라벨로 같은 뷰를 쓰기 때문이다 — 알약 /// 그리기(강조색·글씨색·크기)가 두 벌이 되면 한쪽만 고쳐도 컴파일이 통과한다. final class ModeIndicatorPillView: NSView { private let style: ModeIndicatorStyle @@ -117,7 +117,7 @@ final class ModeIndicatorPillView: NSView { /// 순환시켜 검증한 것 그대로다 (플랜 `20260906_onscreen-mode-indicator.md`). /// /// 인스턴스는 둘이다 — 순간 표시용과 상시 배지용. 스타일만 다르고 나머지는 같아서 타입을 -/// 나누지 않는다. 화면 테두리 패널(`ModeIndicatorBorderPanel`)만 내용이 달라 타입이 다르지만, +/// 나누지 않는다. 테두리 패널(`ModeIndicatorBorderPanel`)만 내용이 달라 타입이 다르지만, /// 패널 설정은 아래 `makeOverlayPanel` 하나에서 받는다 — 그 설정이 이 파일의 섬세한 부분이라 /// 두 벌이 되면 한쪽만 고쳐도 컴파일이 통과한다. @MainActor @@ -138,7 +138,7 @@ final class ModeIndicatorPanel { panel.contentView = pill } - /// 비활성화 오버레이 패널의 **유일한** 생성 지점 — 알약 둘과 화면 테두리가 전부 여기서 받는다. + /// 비활성화 오버레이 패널의 **유일한** 생성 지점 — 알약 둘과 테두리가 전부 여기서 받는다. /// 설정 조합은 스파이크에서 검증한 것 그대로이며, 어느 하나가 빠지면 증상이 조용하다 /// (최전면을 빼앗거나, 뜨자마자 사라지거나, 다른 Space에서 안 보인다). static func makeOverlayPanel(level: NSWindow.Level) -> NSPanel { diff --git a/VimAction/Preferences.swift b/VimAction/Preferences.swift index a0ff095..aab928d 100644 --- a/VimAction/Preferences.swift +++ b/VimAction/Preferences.swift @@ -37,8 +37,9 @@ nonisolated enum PreferenceKeys { /// 기본이고, 순간 표시만 남기는 것은 설정에서 고르는 opt-out이다 /// (`20260914_mode-indicator-optin-flash-only-and-window-border.md`). static let onScreenModeIndicatorPersistentEnabledDefault = true - /// 스타일의 제품 기본값 — 배지다. 화면 테두리는 설정에서 고르는 대체 스타일이다 - /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5). 없는 값·모르는 값도 이것이다. + /// 스타일의 제품 기본값 — 배지다. 창·화면 테두리는 설정에서 고르는 대체 스타일이다 + /// (`20260906_mode-indicator-hybrid-display-policy.md` 결정 5, + /// `20260914_mode-indicator-optin-flash-only-and-window-border.md`). 없는 값·모르는 값도 이것이다. static let onScreenModeIndicatorStyleDefault = ModeIndicatorPresentationStyle.badge } diff --git a/VimAction/SettingsView.swift b/VimAction/SettingsView.swift index c78f26b..b5301aa 100644 --- a/VimAction/SettingsView.swift +++ b/VimAction/SettingsView.swift @@ -138,11 +138,12 @@ private struct GeneralTab: View { // 상시 표시가 없으면(인디케이터 off 또는 순간 표시만) 형태를 고를 이유가 없어 비활성. Picker("Indicator style", selection: $modeIndicator.style) { Text("Badge near the focused field").tag(ModeIndicatorPresentationStyle.badge) + Text("Focused window border").tag(ModeIndicatorPresentationStyle.windowBorder) Text("Screen border").tag(ModeIndicatorPresentationStyle.screenBorder) } .disabled(!modeIndicator.isEnabled || !modeIndicator.isPersistentEnabled) Text( - "While you're in Normal or Visual mode, a small badge stays next to the field — or a colored frame surrounds the screen with the mode label in its top-right corner." + "While you're in Normal or Visual mode, a small badge stays next to the field — or a colored frame surrounds the focused window or the whole screen, with the mode label in its top-right corner." ) .font(.footnote) .foregroundStyle(.secondary) diff --git a/VimActionTests/ModeIndicatorControllerTests.swift b/VimActionTests/ModeIndicatorControllerTests.swift index 88ea0c6..6c5f1f0 100644 --- a/VimActionTests/ModeIndicatorControllerTests.swift +++ b/VimActionTests/ModeIndicatorControllerTests.swift @@ -92,9 +92,9 @@ struct ModeIndicatorPresentationTests { /// 스타일이 판정 결과에 실려야 `desired != current`가 스타일 변경을 잡는다. @Test("판정 결과가 스타일을 싣는다") func presentationCarriesStyle() { - #expect(presentation(.normal, .mode(.normal), style: .badge)?.style == .badge) - #expect( - presentation(.normal, .mode(.normal), style: .screenBorder)?.style == .screenBorder) + for style in [ModeIndicatorPresentationStyle.badge, .windowBorder, .screenBorder] { + #expect(presentation(.normal, .mode(.normal), style: style)?.style == style) + } } } @@ -180,10 +180,14 @@ struct ModeIndicatorReadCoalescingTests { } /// 스타일 변경은 라벨·pid가 같아도 다른 상태다 — 새 형태로 그리려면 앵커를 다시 읽어야 한다. + /// 창 테두리는 창 rect까지 필요해 테두리끼리의 전환도 새 읽기다. @Test("상시 표시가 떠 있는 채로 스타일이 바뀌면 읽는다") func styleSwitchWithBadgeShowingReads() { + let normalWindowBorder = ModeIndicatorController.Presentation( + label: "NORMAL", showsBadge: true, processID: 42, style: .windowBorder) #expect(needsRead(desired: normalBorder, current: normal)) #expect(needsRead(desired: normal, current: normalBorder)) + #expect(needsRead(desired: normalWindowBorder, current: normalBorder)) } /// Insert는 상시 표시가 없으니 형태가 바뀌어도 읽을 것이 없다. @@ -359,6 +363,33 @@ struct ModeIndicatorStyleTests { } } + @Test("저장된 창 테두리는 init에서 로드된다") + func storedWindowBorderLoadsAtInit() { + withTemporaryDefaults { defaults in + defaults.set( + ModeIndicatorPresentationStyle.windowBorder.rawValue, + forKey: PreferenceKeys.onScreenModeIndicatorStyle) + #expect(ModeIndicatorController(defaults: defaults).style == .windowBorder) + } + } + + /// 영속 문자열이 바뀌면 사용자 설정이 조용히 기본값으로 돌아간다 — `.rawValue`끼리 비교하는 + /// 다른 테스트는 그 회귀를 못 잡으므로 리터럴로 고정한다. + @Test("raw 값은 영속 계약이다") + func rawValuesArePersistenceContract() { + #expect(ModeIndicatorPresentationStyle.badge.rawValue == "badge") + #expect(ModeIndicatorPresentationStyle.windowBorder.rawValue == "windowBorder") + #expect(ModeIndicatorPresentationStyle.screenBorder.rawValue == "screenBorder") + } + + /// 숨김 판정의 전제 — 테두리 패널을 나눠 쓰는 스타일이 어느 것인지. + @Test("창·화면 테두리만 테두리 패널을 쓴다") + func borderStylesShareTheBorderPanel() { + #expect(ModeIndicatorPresentationStyle.badge.usesBorderPanel == false) + #expect(ModeIndicatorPresentationStyle.windowBorder.usesBorderPanel) + #expect(ModeIndicatorPresentationStyle.screenBorder.usesBorderPanel) + } + /// 스타일이 늘거나 이름이 바뀌어도 저장된 값 때문에 아무것도 안 뜨는 일은 없어야 한다. @Test("모르는 raw 값은 기본값으로 접힌다") func unknownRawValueFallsBackToBadge() { @@ -370,17 +401,19 @@ struct ModeIndicatorStyleTests { @Test("스타일 영속: didSet 저장 → 새 컨트롤러 init 로드") func stylePersistsAcrossControllers() { - withTemporaryDefaults { defaults in - let first = ModeIndicatorController(defaults: defaults) - first.style = .screenBorder - // 존재 확인이 먼저 — 이것 없이는 영속을 통째로 지워도 "기본값 배지"로 통과한다. - #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorStyle) != nil) - #expect( - defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorStyle) - == ModeIndicatorPresentationStyle.screenBorder.rawValue) + for style in [ModeIndicatorPresentationStyle.windowBorder, .screenBorder] { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.style = style + // 존재 확인이 먼저 — 이것 없이는 영속을 통째로 지워도 "기본값 배지"로 통과한다. + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorStyle) != nil) + #expect( + defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorStyle) + == style.rawValue) - let second = ModeIndicatorController(defaults: defaults) - #expect(second.style == .screenBorder) + let second = ModeIndicatorController(defaults: defaults) + #expect(second.style == style) + } } } diff --git a/VimActionTests/ModeIndicatorLayoutTests.swift b/VimActionTests/ModeIndicatorLayoutTests.swift index 956b954..22598f9 100644 --- a/VimActionTests/ModeIndicatorLayoutTests.swift +++ b/VimActionTests/ModeIndicatorLayoutTests.swift @@ -293,7 +293,7 @@ struct ModeIndicatorLayoutTests { @Test("테두리는 앵커가 있는 화면의 visibleFrame을 두른다") func borderCoversVisibleFrameOfAnchorScreen() { - let layout = ModeIndicatorLayout.borderLayout( + let layout = ModeIndicatorLayout.screenBorderLayout( anchors: .init(element: element), labelSize: badge, screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) @@ -303,7 +303,7 @@ struct ModeIndicatorLayoutTests { @Test("모서리 라벨은 테두리 안쪽 오른쪽 위") func borderLabelSitsInsideTopRightCorner() { - let layout = ModeIndicatorLayout.borderLayout( + let layout = ModeIndicatorLayout.screenBorderLayout( anchors: .init(element: element), labelSize: badge, screens: [primaryScreen], primaryScreenMaxY: primaryMaxY)! @@ -322,11 +322,11 @@ struct ModeIndicatorLayoutTests { @Test("테두리는 보조 디스플레이의 앵커를 따라간다") func borderFollowsAnchorToSecondaryDisplay() { let element = CGRect(x: -900, y: 300, width: 300, height: 40) - let byElement = ModeIndicatorLayout.borderLayout( + let byElement = ModeIndicatorLayout.screenBorderLayout( anchors: .init(element: element), labelSize: badge, screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) let window = CGRect(x: -1100, y: 100, width: 800, height: 600) - let byWindow = ModeIndicatorLayout.borderLayout( + let byWindow = ModeIndicatorLayout.screenBorderLayout( anchors: .init(window: window), labelSize: badge, screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY) @@ -338,7 +338,7 @@ struct ModeIndicatorLayoutTests { @Test("겹치는 화면이 없으면 첫 화면으로 내려간다") func borderFallsBackToFirstScreenWhenNoneIntersects() { let offScreen = CGRect(x: 5000, y: 5000, width: 300, height: 40) - let layout = ModeIndicatorLayout.borderLayout( + let layout = ModeIndicatorLayout.screenBorderLayout( anchors: .init(element: offScreen), labelSize: badge, screens: [secondaryScreen, primaryScreen], primaryScreenMaxY: primaryMaxY) @@ -349,12 +349,88 @@ struct ModeIndicatorLayoutTests { @Test("앵커가 없으면 테두리도 없다") func borderWithoutAnchorIsNil() { #expect( - ModeIndicatorLayout.borderLayout( + ModeIndicatorLayout.screenBorderLayout( anchors: .init(), labelSize: badge, screens: [primaryScreen], primaryScreenMaxY: primaryMaxY) == nil) #expect( - ModeIndicatorLayout.borderLayout( + ModeIndicatorLayout.screenBorderLayout( anchors: .init(caret: caret), labelSize: badge, screens: [primaryScreen], primaryScreenMaxY: primaryMaxY) == nil) } + + // MARK: - 창 테두리 + + /// 사다리를 타지 않는다 — 요소가 있어도 창 rect 자체를 두른다. + @Test("창 테두리는 요소가 있어도 창 rect를 그대로 두른다") + func windowBorderCoversWindowFrameEvenWithElement() { + let layout = ModeIndicatorLayout.windowBorderLayout( + anchors: .init(element: element, window: window, caret: caret), labelSize: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY) + + #expect(layout?.frame == ModeIndicatorLayout.flip(window, primaryScreenMaxY: primaryMaxY)) + } + + /// "창 테두리"인데 창이 없으면 아무것도 없다 — 요소나 화면으로 내려가지 않는다. + @Test("창이 없거나 면적이 없으면 창 테두리도 없다") + func windowBorderWithoutUsableWindowIsNil() { + let flat = CGRect(x: 50, y: 100, width: 0, height: 600) + for anchors in [ + ModeIndicatorLayout.Anchors(), .init(element: element), .init(element: element, window: flat), + ] { + #expect( + ModeIndicatorLayout.windowBorderLayout( + anchors: anchors, labelSize: badge, screens: [primaryScreen], + primaryScreenMaxY: primaryMaxY) == nil) + } + } + + @Test("창 테두리의 라벨은 창 안쪽 오른쪽 위") + func windowBorderLabelSitsInsideTopRightCorner() { + let layout = ModeIndicatorLayout.windowBorderLayout( + anchors: .init(window: window), labelSize: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY)! + + #expect(layout.labelFrame.size == badge) + #expect(layout.frame.contains(layout.labelFrame)) + #expect(layout.frame.maxX - layout.labelFrame.maxX > ModeIndicatorLayout.borderStrokeWidth) + #expect(layout.frame.maxY - layout.labelFrame.maxY > ModeIndicatorLayout.borderStrokeWidth) + #expect(layout.labelFrame.minX > layout.frame.midX) + #expect(layout.labelFrame.minY > layout.frame.midY) + } + + /// 보조 디스플레이(AX x 음수)의 창 — 전역 flip 그대로이고, 라벨 클램프가 주 화면으로 튀지 않는다. + @Test("보조 디스플레이의 창 테두리는 그 화면 안에 앉는다") + func windowBorderOnSecondaryDisplay() { + let window = CGRect(x: -1100, y: 100, width: 800, height: 600) + let layout = ModeIndicatorLayout.windowBorderLayout( + anchors: .init(window: window), labelSize: badge, + screens: [primaryScreen, secondaryScreen], primaryScreenMaxY: primaryMaxY)! + + #expect(layout.frame == ModeIndicatorLayout.flip(window, primaryScreenMaxY: primaryMaxY)) + #expect(secondaryScreen.visibleFrame.contains(layout.labelFrame)) + } + + /// 창이 메뉴바 띠까지 올라가 있어도(AX y=0) 라벨은 메뉴바 뒤로 들어가지 않는다. + @Test("메뉴바 띠에 걸친 창의 라벨은 띠 아래로 내려온다") + func windowBorderLabelStaysBelowMenuBar() { + let tall = CGRect(x: 50, y: 0, width: 800, height: 1000) + let layout = ModeIndicatorLayout.windowBorderLayout( + anchors: .init(window: tall), labelSize: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY)! + + #expect(layout.labelFrame.maxY <= primaryScreen.visibleFrame.maxY) + #expect(layout.frame.contains(layout.labelFrame)) + } + + /// 라벨보다 조금 넓은 작은 창 — 인셋을 지키면 라벨이 창 밖으로 나가 패널 경계에서 잘린다. + /// 화면 클램프는 이것을 못 막으므로 창 frame으로 한 번 더 민다. + @Test("작은 창에서는 라벨이 창 안으로 밀린다") + func windowBorderLabelIsClampedIntoSmallWindow() { + let small = CGRect(x: 300, y: 300, width: 90, height: 50) + let layout = ModeIndicatorLayout.windowBorderLayout( + anchors: .init(window: small), labelSize: badge, + screens: [primaryScreen], primaryScreenMaxY: primaryMaxY)! + + #expect(layout.frame.contains(layout.labelFrame)) + } } From 3ebe3be5b58f4140227f21d459a10a63bf2e6d97 Mon Sep 17 00:00:00 2001 From: pilyang Date: Tue, 15 Sep 2026 10:05:25 +0900 Subject: [PATCH 7/9] docs: record the opt-in indicator options and update the overlay architecture - 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 Claude-Session: https://claude.ai/code/session_01AM8GQweRFmbicNtrXj239q --- .../architecture/references/app-shell.md | 2 +- .../references/mode-indicator-overlay.md | 45 ++++++++++--------- .../references/profiles-and-config.md | 2 +- .claude/skills/decisions/SKILL.md | 1 + ...ator-optin-flash-only-and-window-border.md | 38 ++++++++++++++++ .claude/skills/plans/SKILL.md | 2 +- .../20260906_onscreen-mode-indicator.md | 9 ++-- VimAction/ModeIndicatorBorderPanel.swift | 2 + 8 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 .claude/skills/decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md diff --git a/.claude/skills/architecture/references/app-shell.md b/.claude/skills/architecture/references/app-shell.md index 7793b80..c49c5cf 100644 --- a/.claude/skills/architecture/references/app-shell.md +++ b/.claude/skills/architecture/references/app-shell.md @@ -20,7 +20,7 @@ ### 설정 창 — `TabView` 3탭, 460×560 고정 -**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출·온스크린 모드 인디케이터 토글과 그 바로 아래 "Indicator style" Picker(배지 / 화면 테두리 — 토글이 꺼지면 `.disabled`, [mode-indicator-overlay.md](mode-indicator-overlay.md))] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Apps**(설정 파일 상태 — [profiles-and-config.md](profiles-and-config.md)) / **About**(버전 + 업데이트 확인 + 링크). 탭 순서는 General이 먼저지만 **기본 선택은 Apps**이고, 권한 섹션은 미허용이면 General 최상단·허용되면 아래쪽(Behavior·Updates 뒤)에 한 줄로 자리를 옮긴다. 높이를 명시하지 않으면 greedy한 grouped `Form` 때문에 창이 임의 높이로 굳는다 ([20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)). +**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출·온스크린 모드 인디케이터 토글과 그 아래 "Show" Picker(전환 시만 / Normal·Visual 동안도 — 토글이 꺼지면 `.disabled`)·"Indicator style" Picker(배지 / 창 테두리 / 화면 테두리 — 토글이 꺼지거나 상시 표시가 없으면 `.disabled`, [mode-indicator-overlay.md](mode-indicator-overlay.md))] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Apps**(설정 파일 상태 — [profiles-and-config.md](profiles-and-config.md)) / **About**(버전 + 업데이트 확인 + 링크). 탭 순서는 General이 먼저지만 **기본 선택은 Apps**이고, 권한 섹션은 미허용이면 General 최상단·허용되면 아래쪽(Behavior·Updates 뒤)에 한 줄로 자리를 옮긴다. 높이를 명시하지 않으면 greedy한 grouped `Form` 때문에 창이 임의 높이로 굳는다 ([20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)). ### 로그인 시 자동 시작 — `SMAppService.mainApp` diff --git a/.claude/skills/architecture/references/mode-indicator-overlay.md b/.claude/skills/architecture/references/mode-indicator-overlay.md index 7601d25..dd157fe 100644 --- a/.claude/skills/architecture/references/mode-indicator-overlay.md +++ b/.claude/skills/architecture/references/mode-indicator-overlay.md @@ -1,34 +1,34 @@ # 온스크린 모드 인디케이터 (오버레이) -- **Last updated**: 2026-09-06 +- **Last updated**: 2026-09-15 ## 현재 구조 -화면의 표시는 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 **캐럿 아래**(캐럿이 없으면 포커스 요소 근처)에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 **상시 표시**가 계속 남는다 (Insert는 상시 표시 없음). 상시 표시의 형태는 Settings에서 고른다 — 기본은 요소 모서리의 한 단계 작은 **배지**, 대체 스타일은 포커스 창이 있는 디스플레이를 두르는 **화면 테두리**(강조색 프레임 + 오른쪽 위 모서리 라벨). 어느 스타일이든 flash는 캐럿/요소 사다리를 탄다. 전부 Settings General 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. +화면의 표시는 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 **캐럿 아래**(캐럿이 없으면 포커스 요소 근처)에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 **상시 표시**가 계속 남는다 (Insert는 상시 표시 없음). 상시 표시는 Settings에서 끌 수도 있고("Only when the mode changes" — 그러면 flash만 남는다) 형태도 고른다 — 기본은 요소 모서리의 한 단계 작은 **배지**, 대체 스타일은 포커스 창 rect를 두르는 **창 테두리**와 포커스 창이 있는 디스플레이를 두르는 **화면 테두리**(둘 다 강조색 프레임 + 오른쪽 위 모서리 라벨). 어느 스타일이든 flash는 캐럿/요소 사다리를 탄다. 전부 Settings General 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. ```mermaid graph LR T1["① EventTapController.updateMode
(탭 콜백 동기 구간)"] --> R T2["② FocusedElementResolver
포커스·앱 활성화·창 이동/리사이즈"] --> R T3["③ AppState.trackIndicatorLadder
(withObservationTracking 재무장)"] --> R - T4["④ isEnabled·style.didSet (Settings)"] --> R + T4["④ isEnabled·isPersistentEnabled·style.didSet (Settings)"] --> R T5["⑤ didChangeScreenParameters"] --> R R["ModeIndicatorController.reconcile
presentation(순수) + token·pending 코얼레싱"] - R -->|전용 큐 .utility| G["ModeIndicatorGeometryReader.read(pid, includesCaret)
요소 rect → (없으면) 창 rect, flash면 캐럿 rect"] - G -->|rect만 메인 홉| L["ModeIndicatorLayout (순수)
panelFrame(ladder) · borderLayout"] + R -->|전용 큐 .utility| G["ModeIndicatorGeometryReader.read(pid, includesCaret, includesWindow)
요소 rect → (없거나 창 테두리면) 창 rect, flash면 캐럿 rect"] + G -->|rect만 메인 홉| L["ModeIndicatorLayout (순수)
panelFrame(ladder) · windowBorderLayout · screenBorderLayout"] L --> P["flash 패널 / 배지 패널 / 테두리 패널
비활성화 NSPanel 3개"] ``` | 구성 요소 | 격리 | 역할 | |---|---|---| -| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 두 개(`isEnabled`·`style: ModeIndicatorPresentationStyle`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리다 | -| `ModeIndicatorGeometryReader` | `nonisolated` | pid만 받아 `AXRead.focusedElement` → `AXPosition`+`AXSize`, 요소 rect가 쓸 만하지 않을 때만 `AXFocusedWindow`. **`includesCaret`(= 요청에 flash가 있음)일 때만** 캐럿 rect를 추가로 읽는다 — 순서는 `AXSelectedTextRange`의 `loc`으로 `AXBoundsForRange (loc,1)` → 안 되고 `loc>0`이면 `(loc-1,1)` → `AXSelectedTextMarkerRange`(불투명 CFType, 변환 없음)+`AXBoundsForTextMarkerRange`, 첫 쓸 만한 것에서 멈춘다. 길이 0 범위는 쓰지 않고 `AXNumberOfCharacters`도 미리 묻지 않는다. 50ms 타임아웃은 `AXRead` 상속 | -| `ModeIndicatorLayout` | `nonisolated` 순수 | 앵커 사다리는 **묻는 알약에 따라 첫 단이 다르다**(`Ladder`): flash는 `caretFirst`(캐럿 → 요소 → 창 → 없음), 상시 표시는 `elementFirst`(요소 → 창 → 없음). 면적 있는 rect만 한 단이고, 캐럿의 유효성(`isUsableCaret`)도 여기 소유 — 높이 > 0(폭 0 허용), 요소 rect와 다름, 요소 rect가 쓸 만하면 그 안에 걸침. 배치는 캐럿 단 캐럿 아래 왼쪽 정렬 4pt / 요소 단 요소 바깥 오른쪽 위 4pt / 창 단 창 안쪽 오른쪽 위 12/6pt → AX→AppKit flip → 클램프. `borderLayout`은 상시 사다리의 앵커가 속한 화면의 `visibleFrame`과 그 안 오른쪽 위 라벨 프레임(인셋 = 선 굵기 4 + 8)을 낸다. 화면은 호출자가 `Screen`(frame + visibleFrame) 배열로 넘긴다 | +| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:isPersistentEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 셋(`isEnabled`·`isPersistentEnabled`·`style: ModeIndicatorPresentationStyle`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리이고, 테두리 패널이 지금 어느 스타일로 떠 있는지(`borderPanelStyle`)를 기억해 창↔화면 테두리 전환도 이전 형태를 즉시 감춘다 | +| `ModeIndicatorGeometryReader` | `nonisolated` | pid만 받아 `AXRead.focusedElement` → `AXPosition`+`AXSize`, 요소 rect가 쓸 만하지 않거나 **`includesWindow`(= 창 테두리 스타일의 상시 표시)** 일 때 `AXFocusedWindow`(한 번만). **`includesCaret`(= 요청에 flash가 있음)일 때만** 캐럿 rect를 추가로 읽는다 — 순서는 `AXSelectedTextRange`의 `loc`으로 `AXBoundsForRange (loc,1)` → 안 되고 `loc>0`이면 `(loc-1,1)` → `AXSelectedTextMarkerRange`(불투명 CFType, 변환 없음)+`AXBoundsForTextMarkerRange`, 첫 쓸 만한 것에서 멈춘다. 길이 0 범위는 쓰지 않고 `AXNumberOfCharacters`도 미리 묻지 않는다. 50ms 타임아웃은 `AXRead` 상속 | +| `ModeIndicatorLayout` | `nonisolated` 순수 | 앵커 사다리는 **묻는 알약에 따라 첫 단이 다르다**(`Ladder`): flash는 `caretFirst`(캐럿 → 요소 → 창 → 없음), 상시 표시는 `elementFirst`(요소 → 창 → 없음). 면적 있는 rect만 한 단이고, 캐럿의 유효성(`isUsableCaret`)도 여기 소유 — 높이 > 0(폭 0 허용), 요소 rect와 다름, 요소 rect가 쓸 만하면 그 안에 걸침. 배치는 캐럿 단 캐럿 아래 왼쪽 정렬 4pt / 요소 단 요소 바깥 오른쪽 위 4pt / 창 단 창 안쪽 오른쪽 위 12/6pt → AX→AppKit flip → 클램프. `screenBorderLayout`은 상시 사다리의 앵커가 속한 화면의 `visibleFrame`과 그 안 오른쪽 위 라벨 프레임(인셋 = 선 굵기 4 + 8)을, `windowBorderLayout`은 사다리 없이 창 rect 자체(flip만, 클램프 없음)와 같은 규칙의 라벨을 낸다 — 라벨은 앵커 화면 `visibleFrame`으로 민 뒤 창 frame 안으로 한 번 더 민다(라벨보다 좁은 창). 창이 없거나 면적이 없으면 nil(폴백 없음). 화면은 호출자가 `Screen`(frame + visibleFrame) 배열로 넘긴다 | | `ModeIndicatorPanel` × 2 | `@MainActor` | flash·배지 알약. `ModeIndicatorStyle`이 폰트·여백·radius·창 층을 가른다: flash는 bold 12pt/`.statusBar`, 배지는 semibold 10pt/`.statusBar - 1`. `flash`는 0.15/0.7/0.3s 페이드, `show`는 애니메이션도 타이머도 없다. 각각 첫 표시에서 lazy 생성 | -| `ModeIndicatorBorderPanel` × 1 | `@MainActor` | 화면 테두리 — 디스플레이의 `visibleFrame`을 덮는 패널 **하나**에 강조색 테두리(`ModeIndicatorBorderView`)와 모서리 라벨(`ModeIndicatorPillView(style: .badge)` 서브뷰)을 함께 그린다. 층은 배지와 같다. `show`/`hide`는 배지와 같은 계약(멱등, 무애니메이션). 그 스타일을 고른 뒤 첫 표시에서 lazy 생성 | +| `ModeIndicatorBorderPanel` × 1 | `@MainActor` | 창·화면 테두리 공용 — 호출자가 준 frame(창 rect 또는 디스플레이의 `visibleFrame`)을 덮는 패널 **하나**에 강조색 테두리(`ModeIndicatorBorderView`, 모서리 반경은 선 굵기의 2배로 두 스타일 공통)와 모서리 라벨(`ModeIndicatorPillView(style: .badge)` 서브뷰)을 함께 그린다. 층은 배지와 같다. `show`/`hide`는 배지와 같은 계약(멱등, 무애니메이션). 테두리 스타일을 고른 뒤 첫 표시에서 lazy 생성. 두 스타일이 인스턴스 하나를 나눠 쓰고 어느 형태인지는 컨트롤러가 기억한다 | | `ModeIndicatorPanel.makeOverlayPanel(level:)` | `@MainActor` | 비활성화 패널 설정의 **유일한** 생성 지점 — `NSPanel(.borderless, .nonactivatingPanel)`, `ignoresMouseEvents`, `hidesOnDeactivate = false`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]`. 알약 둘과 테두리가 전부 여기서 받는다 | -라벨 문자열은 `Mode.overlayLabel`, 상시 표시 여부는 `Mode.showsPersistentBadge`(Insert만 false)로 둘 다 `AppState.swift`에 있다. 스타일 enum `ModeIndicatorPresentationStyle`(`badge`/`screenBorder`, raw 문자열이 UserDefaults 값)은 `ModeIndicatorController.swift`에 있다 — 알약 외양 enum `ModeIndicatorStyle`(flash/badge)과는 다른 축이다. +라벨 문자열은 `Mode.overlayLabel`, 모드별 상시 표시 여부는 `Mode.showsPersistentBadge`(Insert만 false)로 둘 다 `AppState.swift`에 있다 — 설정의 상시 표시 off는 판정(`presentation`)에서 이 값에 AND될 뿐 모드 속성을 건드리지 않는다. 스타일 enum `ModeIndicatorPresentationStyle`(`badge`/`windowBorder`/`screenBorder`, raw 문자열이 UserDefaults 값, `usesBorderPanel`이 테두리 둘을 묶는다)은 `ModeIndicatorController.swift`에 있다 — 알약 외양 enum `ModeIndicatorStyle`(flash/badge)과는 다른 축이다. ### 트리거 다섯 @@ -37,49 +37,50 @@ graph LR | ① | 모드 전환 | `updateMode` → `onModeChange` → `modeDidChange` | flash + 상시 표시, 항상 읽는다 (캐럿 포함) | | ② | 포커스 요소 변경·앱 활성화·창 이동·리사이즈 | `FocusedElementResolver.onFocusGeometryChanged` → (`EventTapController` 통로) → `anchorDidChange` | 상시 표시가 보여야 할 때만 읽는다 (캐럿 없이) | | ③ | 사다리 변화 (탭 고장·마스터 off·킬스위치·앱별 disabled·Secure Input·설정 리로드) | `AppState.trackIndicatorLadder()`의 `withObservationTracking` 재무장 루프 → `stateDidChange` | 표시할 것이 실제로 달라졌을 때만 읽는다 | -| ④ | Settings 토글·스타일 | `isEnabled.didSet`·`style.didSet` | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | +| ④ | Settings 토글·상시 표시·스타일 | `isEnabled.didSet`·`isPersistentEnabled.didSet`·`style.didSet` | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 상시 표시 off는 `showsBadge`가 접혀 Insert와 같은 경로 — 앵커 이벤트에 AX 왕복 0건; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | | ⑤ | 디스플레이 재구성 | `NSApplication.didChangeScreenParametersNotification` → `anchorDidChange` | ②와 같다 | ## 불변식·계약 - **훅은 탭 콜백의 동기 구간에서 불린다.** 거기서 하는 일은 판정(순수)·`pending` 대입·`queue.async` 1회까지다. AX 호출과 `NSScreen` 조회는 전부 큐/메인 홉 뒤다 — 어기면 콜백 경량 불변식이 깨진다. - **AX는 전용 큐에서만, pid만 건너간다.** `AXUIElement`는 큐 경계를 넘지 않고 돌아오는 것은 rect뿐이다. **리졸버 `readQueue`를 재사용하지 않는다** — 콜드 앱에서 이 읽기는 flash면 캐럿 변형까지 AX 호출 11회×50ms까지 걸릴 수 있고, 리졸버 큐는 키 디스패치가 의존하는 포커스 계열 캐시를 먹인다. -- **flash는 캐럿부터, 상시 표시는 요소부터 시작하고 상시 표시는 캐럿을 따라다니지 않는다.** 캐럿은 flash가 있는 요청(`pendingFlash`)에서만 읽으며 그 필요는 `pump()`가 `includesCaret`으로 리더에 명시적으로 넘긴다 — 앵커·사다리 이벤트의 재배치는 캐럿 왕복 없이 기존 비용에 머문다. flash를 세우는 유일한 경로(`modeDidChange`)가 항상 토큰을 올려 새 읽기를 띄우므로 캐럿 없이 띄운 읽기로 flash가 그려지는 일은 없다. 예산: flash 한 번에 최대 AX 호출 5회가 더 든다. +- **flash는 캐럿부터, 상시 표시는 요소부터 시작하고 상시 표시는 캐럿을 따라다니지 않는다.** 캐럿은 flash가 있는 요청(`pendingFlash`)에서만 읽으며 그 필요는 `pump()`가 `includesCaret`으로 리더에 명시적으로 넘긴다 — 앵커·사다리 이벤트의 재배치는 캐럿 왕복 없이 기존 비용에 머문다. flash를 세우는 유일한 경로(`modeDidChange`)가 항상 토큰을 올려 새 읽기를 띄우므로 캐럿 없이 띄운 읽기로 flash가 그려지는 일은 없다. 예산: flash 한 번에 최대 AX 호출 5회가 더 든다. **창 rect도 같은 규칙이다** — `includesWindow`는 `showsBadge && style == .windowBorder`(소비자 조건 그대로)일 때만 참이라 Insert·순간 표시만인 요청은 창 왕복 셋을 내지 않고, `inFlight.request == desired` 비교에 스타일이 실려 있어 창 없이 띄운 읽기가 창 테두리 요청에 재사용되는 일은 없다. - **캐럿 유효성은 순수 계층(`isUsableCaret`) 한 벌이다.** 리더가 다음 변형을 시도할지도 같은 술어로 판정한다 — 두 벌이면 리더가 넘긴 것을 사다리가 버린다. `(loc-1,1)`이 줄 첫머리에서 개행 문자라 rect가 이전 줄 끝에 오는 quirk는 수용하고 특별 취급하지 않는다. Chromium 브라우저(Chrome·Arc)는 캐럿 변형이 전부 빈 값이라 조용히 요소 단으로 내려간다 — `AXEnhancedUserInterface`를 세우지 않는다. - **표시 조건은 메뉴바 사다리와 동일하다.** `MenuBarIndicator.resolve`가 `.mode`가 아니면 즉시 세 패널 모두 `hide()`. 읽는 도중 사다리를 벗어나면 토큰 불일치로 그 읽기를 버린다 — "가로채지 않는데 NORMAL"을 띄우면 안 된다. -- **상시 표시의 숨김 규칙은 스타일 불문 한 자리다** — `hidePersistentPanels(keeping:)`가 유일한 숨김 지점이고, 배지에 적용되는 모든 사유(Insert·사다리 이탈·토글 off·pid 없음·자기 pid·앵커 없음·스타일 변경)가 테두리에도 같이 적용된다. 스타일이 바뀌면 이전 형태의 패널은 `reconcile`에서 즉시 감춰지고 새 형태는 읽기가 착지한 뒤 그려진다. -- **자기 pid 경로가 곧 설정 창의 경로다.** 설정 창이 열려 있으면 VimAction이 최전면이라 리졸버가 우리 pid에 붙어 있고, `reconcile`은 읽지도 `current`를 건드리지도 않는다 — 다만 거짓말은 지운다(Insert면 상시 표시 숨김, 스타일이 바뀌면 이전 형태 숨김). 그래서 Picker의 새 형태는 사용자가 앱으로 돌아가는 앵커 이벤트에서 그려진다 (토글을 켤 때와 같은 수렴). -- **사다리 관찰 루프는 레벨 트리거다.** `onChange`는 willSet에서(킬스위치 경로면 메인 밖에서도) 불리므로 거기서는 홉만 걸고, 재무장이 **현재 값**을 다시 읽어 수렴한다. 그래서 `reconcile`은 직전 이벤트가 아니라 **자기 현재 표시(`current`)와만** 비교한다. 관찰 블록은 값만 만들고 부수효과가 없어야 한다 — 컨트롤러 메서드를 그 안에서 부르면 컨트롤러 내부 상태까지 추적돼 읽기가 끝날 때마다 루프가 스스로를 깨운다 (그래서 `isEnabled`·`style` 외 저장 프로퍼티는 전부 `@ObservationIgnored`). +- **상시 표시의 숨김 규칙은 스타일 불문 한 자리다** — `hidePersistentPanels(keeping:)`가 유일한 숨김 지점이고, 배지에 적용되는 모든 사유(Insert·사다리 이탈·토글 off·pid 없음·자기 pid·앵커 없음·스타일 변경)가 테두리에도 같이 적용된다. 스타일이 바뀌면 이전 형태의 패널은 `reconcile`에서 즉시 감춰지고 새 형태는 읽기가 착지한 뒤 그려진다. 테두리 패널은 창·화면 테두리가 나눠 쓰므로 `keeping`이 테두리라도 지금 떠 있는 형태(`borderPanelStyle`)와 다르면 감춘다 — 창↔화면 테두리 전환에도 같은 계약이다. +- **자기 pid 경로가 곧 설정 창의 경로다.** 설정 창이 열려 있으면(메뉴바 아이콘 클릭도 같다) VimAction이 최전면이라 리졸버가 우리 pid에 붙어 있고, `reconcile`은 읽지도 `current`를 건드리지도 않는다 — 다만 거짓말은 지우고(Insert면 상시 표시 숨김, 스타일·상시 표시 설정이 바뀌면 이전 형태 숨김) **떠 있던 읽기는 무효화한다**(토큰 증가·`pending` 비움·밀린 flash 만료 — 이전 앱을 향해 떠 있던 읽기가 착지해 방금 감춘 것을 되살리면 안 된다; `inFlight`는 `finish`만 비운다). 그래서 Picker의 새 형태는 사용자가 앱으로 돌아가는 앵커 이벤트(`anchorDidChange` — 앱 활성화 알림은 pid가 같아도 온다)에서 그려진다 (토글을 켤 때와 같은 수렴). `stateDidChange`만으로 복귀하는 경로가 생기면 `current`가 이미 그 값이라 다시 그리지 않으므로 깨진다. +- **사다리 관찰 루프는 레벨 트리거다.** `onChange`는 willSet에서(킬스위치 경로면 메인 밖에서도) 불리므로 거기서는 홉만 걸고, 재무장이 **현재 값**을 다시 읽어 수렴한다. 그래서 `reconcile`은 직전 이벤트가 아니라 **자기 현재 표시(`current`)와만** 비교한다. 관찰 블록은 값만 만들고 부수효과가 없어야 한다 — 컨트롤러 메서드를 그 안에서 부르면 컨트롤러 내부 상태까지 추적돼 읽기가 끝날 때마다 루프가 스스로를 깨운다 (그래서 `isEnabled`·`isPersistentEnabled`·`style` 외 저장 프로퍼티는 전부 `@ObservationIgnored`). - **flash 요청은 `pending`과 따로 산다.** 같이 담으면 읽는 사이에 온 앵커·사다리 이벤트가 `pending`을 덮어써 정당한 flash가 사라진다(Cmd-Tab 직후 Esc). 다만 붙일 앵커가 없어 그리지 못한 경우에는 **버린다** — 들고 있으면 한참 뒤 앵커 이벤트에 얹혀 살아나 엉뚱한 때 번쩍인다. flash 사다리가 상시 사다리를 포함하므로 "flash 프레임 없음 = 앵커 없음"이고, 그 반대(캐럿만 있고 요소·창이 없음)는 flash만 뜨고 상시 표시는 감춘다. - **아무것도 안 바뀐 reconcile은 토큰을 올리지 않고, 같은 상태를 같은 토큰으로 이미 읽는 중이면 새 읽기를 띄우지 않는다**(`needsGeometryRead`가 그 판정의 순수 소유자다). 둘 다 같은 사고를 막는다: 토큰이 올라가면 진행 중인 읽기가 폐기된다. 후자가 없으면 **모드 전환마다 읽기가 두 번 난다** — 사다리 관찰 루프의 재무장이 `mode`의 willSet에서 예약돼 전환 읽기가 착지하기 전에 뒤따라 오는데, 그때 밀린 flash 때문에 판정이 참이 되기 때문이다. - **패널은 절대 앱을 활성화하지 않는다.** `orderFrontRegardless()`만 쓰고 `makeKey…`·`NSApp.activate` 계열은 없다. 활성화되면 `FrontmostAppGate`의 최전면 캐시가 자기 자신으로 덮인다. 화면을 덮는 테두리 패널도 `ignoresMouseEvents`라 클릭을 먹지 않는다. - **자기 자신(VimAction) pid는 앵커로 삼지 않는다.** 메뉴바 클릭·설정 창이 우리를 최전면으로 만들면 리졸버가 우리 pid에 붙는데, 사다리는 비자신 캐시 축이라 여전히 `.mode`다 — 그대로 두면 배지가 우리 설정 창에 붙는다. 감추지는 않는다(메뉴를 열 때마다 깜빡이면 더 나쁘다). - **pid 출처는 리졸버**(`EventTapController.observedProcessID`)다 — 디스패치 경로와 같은 앱을 겨눈다. `FrontmostAppGate`가 아니다. - **좌표 변환은 전역 flip 하나**: `y' = NSScreen.screens[0].frame.maxY - (y + h)`. 보조 디스플레이(AX x 음수)에서도 그대로다. -- **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. 화면 테두리도 같은 축이다 — 앵커 rect(요소 또는 창)와 `frame`이 겹치는 화면을 고르고(AX를 더 읽지 않는다 — 요소는 창 안에 있어 같은 디스플레이다, 없으면 첫 화면), 그 화면의 **`visibleFrame`**을 두른다(`frame`을 두르면 위 변이 메뉴바 뒤, 아래 변이 Dock 뒤에 숨는다). +- **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. 화면 테두리도 같은 축이다 — 앵커 rect(요소 또는 창)와 `frame`이 겹치는 화면을 고르고(AX를 더 읽지 않는다 — 요소는 창 안에 있어 같은 디스플레이다, 없으면 첫 화면), 그 화면의 **`visibleFrame`**을 두른다(`frame`을 두르면 위 변이 메뉴바 뒤, 아래 변이 Dock 뒤에 숨는다). 창 테두리는 이 축을 타지 않는다 — 창 rect를 그대로 두르고(화면 밖으로 나간 만큼 잘린다), 라벨만 앵커 화면의 `visibleFrame`과 창 frame 안으로 민다. - **앵커가 없으면 재시도하지 않는다** — 그것이 답이고, 다음 앵커 이벤트가 다시 부른다. - **글씨 색은 강조색에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 강조색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다 — 화면 테두리도 모서리 라벨이 같은 알약 뷰라 규칙이 하나다. - **flash가 배지 위에 그려지는 것은 창 층의 차이로 보장된다**(flash `.statusBar`, 상시 표시 `.statusBar - 1`). 캐럿이 없는 앱에서는 flash가 배지를 완전히 덮고, 캐럿이 있는 앱에서는 flash가 캐럿 아래·배지가 요소 모서리라 **전환 직후 1초간 같은 라벨 둘이 함께 보인다** — flash는 캐럿부터라는 결정의 직접 결과다. -- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종), `ModeIndicatorControllerTests`(표시 판정 표·읽기 코얼레싱 판정 표(스타일 변경 행 포함)·토글 영속·스타일 영속·대비). -- 테스트에서는 아무 일도 하지 않는다 — 배선이 `bootstrap()`의 XCTest 가드 뒤이고, 입력이 밀린 적 없으면 판정이 통째로 막히며, 패널은 첫 표시에서야 만들어진다. +- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종·창 테두리 6종), `ModeIndicatorControllerTests`(표시 판정 표(상시 표시 off 행 포함)·읽기 코얼레싱 판정 표(스타일 변경·상시 표시 토글 행 포함)·토글 영속·상시 표시 영속·스타일 영속(raw 값 리터럴 포함)·자기 pid 토큰 무효화·대비). +- 테스트에서는 아무 일도 하지 않는다 — 배선이 `bootstrap()`의 XCTest 가드 뒤이고, 입력이 밀린 적 없으면 판정이 통째로 막히며, 패널은 첫 표시에서야 만들어진다. 자기 pid 경로는 그 전제 위에서 테스트가 직접 부른다(읽지도 그리지도 않아 `token`만 관측된다 — 그래서 `token`은 `private(set)`). ### 알려진 한계 - **킬스위치 발동 중에는 상시 표시가 남는다.** 발동 상황은 메인이 굳은 상황이라 `orderOut`이 불릴 수 없다 — 그동안 배지(또는 테두리)는 "NORMAL"을 계속 주장한다. 1초짜리 flash에는 없던 노출이다(스스로 사라졌다). - **Secure Input 진입은 워치독 폴링으로만 관측**되므로 상시 표시가 한 틱만큼 늦게 사라진다 (메뉴바와 같은 기존 지연, 화면에서 더 잘 보일 뿐). -- **창 드래그 추종은 알림이 오는 만큼이다.** 실측에서 창 기하 변경은 알림이 한 번이 아니라 여러 건 몰려 오고, 1-in-flight 코얼레싱이 그 버스트를 접는다. +- **창 드래그 추종은 알림이 오는 만큼이다.** 실측에서 창 기하 변경은 알림이 한 번이 아니라 여러 건 몰려 오고, 1-in-flight 코얼레싱이 그 버스트를 접는다. 배지는 작아서 티가 안 났지만 **창 테두리에서는 이 지연이 눈에 보인다** — 창 크기 테두리가 창을 뒤따라온다. +- **두 디스플레이에 걸친 창의 라벨은 `NSScreen.screens` 순서로 화면을 고른다** — 최대 교집합이 아니다. - **Visual 선택의 캐럿은 선택 시작(`loc`)이다** — `AXSelectedTextRange`의 위치만 쓰므로 선택을 뒤로 늘린 상태의 flash는 선택 시작 아래에 뜬다. - **전체화면 앱 위의 테두리는 위 변이 메뉴바 띠만큼 내려온다** — `visibleFrame`은 메뉴바가 자동 숨김이어도 그 띠를 뺀다. ## 근거 요약 -메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 화면 테두리는 대체 스타일), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권은 각각 결정 문서에 있다. +메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 창·화면 테두리는 대체 스타일, 순간 표시만은 opt-out), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권은 각각 결정 문서에 있다. -- 관련 결정: [20260906_mode-indicator-hybrid-display-policy.md](../../decisions/references/20260906_mode-indicator-hybrid-display-policy.md), [20260906_mode-indicator-anchor-ladder-event-driven.md](../../decisions/references/20260906_mode-indicator-anchor-ladder-event-driven.md), [20260906_no-forced-chromium-screen-reader-mode.md](../../decisions/references/20260906_no-forced-chromium-screen-reader-mode.md), [20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md), [20260725_callback-light-invariant.md](../../decisions/references/20260725_callback-light-invariant.md), [20260725_tap-main-runloop-retention.md](../../decisions/references/20260725_tap-main-runloop-retention.md) +- 관련 결정: [20260906_mode-indicator-hybrid-display-policy.md](../../decisions/references/20260906_mode-indicator-hybrid-display-policy.md), [20260914_mode-indicator-optin-flash-only-and-window-border.md](../../decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md), [20260906_mode-indicator-anchor-ladder-event-driven.md](../../decisions/references/20260906_mode-indicator-anchor-ladder-event-driven.md), [20260906_no-forced-chromium-screen-reader-mode.md](../../decisions/references/20260906_no-forced-chromium-screen-reader-mode.md), [20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md), [20260725_callback-light-invariant.md](../../decisions/references/20260725_callback-light-invariant.md), [20260725_tap-main-runloop-retention.md](../../decisions/references/20260725_tap-main-runloop-retention.md) ## 관련 -- 코드: `VimAction/ModeIndicatorController.swift`(`ModeIndicatorPresentationStyle` 포함), `VimAction/ModeIndicatorGeometryReader.swift`, `VimAction/ModeIndicatorLayout.swift`, `VimAction/ModeIndicatorPanel.swift`(`makeOverlayPanel`·`ModeIndicatorPillView`), `VimAction/ModeIndicatorBorderPanel.swift`, `VimAction/FocusedElementResolver.swift`(`onFocusGeometryChanged`·창 알림), `VimAction/EventTapController.swift`(`updateMode`·`onModeChange`·`onFocusGeometryChanged`·`observedProcessID`), `VimAction/AppState.swift`(배선·`indicatorInputs`·`trackIndicatorLadder`·`Mode.overlayLabel`·`Mode.showsPersistentBadge`), `VimAction/SettingsView.swift`(토글·스타일 Picker), `VimAction/Preferences.swift`(키 둘) +- 코드: `VimAction/ModeIndicatorController.swift`(`ModeIndicatorPresentationStyle` 포함), `VimAction/ModeIndicatorGeometryReader.swift`, `VimAction/ModeIndicatorLayout.swift`, `VimAction/ModeIndicatorPanel.swift`(`makeOverlayPanel`·`ModeIndicatorPillView`), `VimAction/ModeIndicatorBorderPanel.swift`, `VimAction/FocusedElementResolver.swift`(`onFocusGeometryChanged`·창 알림), `VimAction/EventTapController.swift`(`updateMode`·`onModeChange`·`onFocusGeometryChanged`·`observedProcessID`), `VimAction/AppState.swift`(배선·`indicatorInputs`·`trackIndicatorLadder`·`Mode.overlayLabel`·`Mode.showsPersistentBadge`), `VimAction/SettingsView.swift`(토글·"Show" Picker·스타일 Picker), `VimAction/Preferences.swift`(키 셋) - 표시 사다리: [reentrancy-and-safety.md](reentrancy-and-safety.md)(메뉴바 글리프 우선순위) — 오버레이는 그 사다리의 두 번째 소비자다 - pid 출처·읽기 큐 규율·리졸버 알림: [focus-and-dispatch-reads.md](focus-and-dispatch-reads.md) - 앱 셸·설정 토글·Picker: [app-shell.md](app-shell.md), [profiles-and-config.md](profiles-and-config.md) diff --git a/.claude/skills/architecture/references/profiles-and-config.md b/.claude/skills/architecture/references/profiles-and-config.md index 97175c1..7282281 100644 --- a/.claude/skills/architecture/references/profiles-and-config.md +++ b/.claude/skills/architecture/references/profiles-and-config.md @@ -96,7 +96,7 @@ actions: # 명령 계열: 그 액션 자신의 키 교 **"사용자가 파일로 관리하고 싶은 설정"은 YAML, "앱이 스스로 쓰는 상태"는 UserDefaults.** -- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본) — 둘 다 표시 취향이지 앱별 동작 정의가 아니고, YAML로 두면 Settings UI가 YAML을 써야 한다. 소유자는 `ModeIndicatorController` ([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). +- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorPersistentEnabled`(상시 표시 on/off — off면 순간 표시만, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`windowBorder`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본) — 셋 다 표시 취향이지 앱별 동작 정의가 아니고, YAML로 두면 Settings UI가 YAML을 써야 한다. 소유자는 `ModeIndicatorController` ([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md), [20260914_mode-indicator-optin-flash-only-and-window-border.md](../../decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md)). - YAML: 앱별 on/off, 프로파일 전부. - **어느 쪽도 아닌 셋째 칸이 있다 — 시스템이 소유하는 상태.** 로그인 시 자동 시작은 `SMAppService.mainApp.status`가 SSOT이고 앱은 값을 저장하지 않는다(사용자가 시스템 설정에서 통지 없이 바꾸므로 미러는 반드시 어긋난다 — [app-shell.md](app-shell.md)). Sparkle 자동 확인도 같은 성격으로, 값의 영속은 Sparkle이 소유한다. diff --git a/.claude/skills/decisions/SKILL.md b/.claude/skills/decisions/SKILL.md index 67890ad..458f7fc 100644 --- a/.claude/skills/decisions/SKILL.md +++ b/.claude/skills/decisions/SKILL.md @@ -102,6 +102,7 @@ description: VimAction 프로젝트의 기술 결정 히스토리 SSOT — 아 | 09-06 | 앵커 사다리 — 캐럿→요소→창, 이벤트 기반 갱신 | 순간 표시는 캐럿부터·상시 배지는 요소부터, 키마다 재배치·폴링 없음(20260725 재검토 트리거 회피), AX는 메인 밖 50ms·pid만, 캐럿은 `(loc,1)`→`(loc-1,1)`→텍스트 마커 — 앱 계열별 실측표 포함 | [20260906_mode-indicator-anchor-ladder-event-driven.md](references/20260906_mode-indicator-anchor-ladder-event-driven.md) | | 09-06 | Chromium 스크린리더 모드 강제 안 함 | `AXEnhancedUserInterface`를 켜면 Chrome·Arc 캐럿이 나옴을 실측했으나 브라우저 전역 완전 모드 비용이 비례하지 않음 — 캐럿 없음 수용·요소 폴백, Electron `AXManualAccessibility` 기상은 유지 | [20260906_no-forced-chromium-screen-reader-mode.md](references/20260906_no-forced-chromium-screen-reader-mode.md) | | 09-06 | 인디케이터 설정은 UserDefaults | on/off·스타일은 Settings General 토글(20260801 경계 기준), config.yaml 비노출, 기본 on — PRD 초안의 YAML 스타일 항목 대체 | [20260906_mode-indicator-settings-in-userdefaults.md](references/20260906_mode-indicator-settings-in-userdefaults.md) | +| 09-14 | 인디케이터 opt-in 확장 — 순간 표시만 + 창 테두리 | 상시 표시 on/off Bool 키 추가(off면 flash만), 상시 스타일에 포커스 창 테두리(폴백 없음) 추가, 기본값 불변 — 3단 enum 기각(마이그레이션), 자기 pid 경로는 떠 있는 읽기 무효화·flash 만료 | [20260914_mode-indicator-optin-flash-only-and-window-border.md](references/20260914_mode-indicator-optin-flash-only-and-window-border.md) | ### 이벤트 탭 — 진입·번역·수명 diff --git a/.claude/skills/decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md b/.claude/skills/decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md new file mode 100644 index 0000000..1e00066 --- /dev/null +++ b/.claude/skills/decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md @@ -0,0 +1,38 @@ +# 온스크린 인디케이터 opt-in 확장 — 순간 표시만 옵션 + 포커스 창 테두리 스타일 + + + +- **결정일**: 2026-09-14 + +## 결정 + +온스크린 모드 인디케이터에 **opt-in 옵션 둘**을 추가한다. 기본값은 바꾸지 않는다 (전환 시 순간 표시 + 비-Insert 상시 배지). + +1. **상시 표시 on/off** (`onScreenModeIndicatorPersistentEnabled`, 기본 on): off면 모드 전환 때의 순간 표시(flash)만 남고 Normal·Visual 동안 아무것도 남지 않는다. 기존 on/off 토글은 그대로 두고 그 아래 Picker("Only when the mode changes" / "Also while in Normal or Visual mode")로 고른다 — Bool 키 하나를 더하는 additive 방식이라 저장값 마이그레이션이 없다. +2. **상시 표시 스타일에 포커스 창 테두리 추가** (`ModeIndicatorPresentationStyle.windowBorder`): 포커스 창 rect 자체를 강조색 테두리로 두르고 창 안쪽 오른쪽 위에 모드 라벨을 놓는다. 배지 / 창 테두리 / 화면 테두리 셋 중 고른다. 창이 없으면 **폴백 없이** 아무것도 표시하지 않는다. + +같이 확정한 것: 자기 pid 경로(설정 창·메뉴바 클릭으로 VimAction이 최전면)는 떠 있는 기하 읽기를 **무효화**하고 밀린 flash를 **만료**한다. 두 옵션 모두 설정 창에서 바꾸므로 이 경로의 정합성이 전제다 (PR #68 리뷰에서 지적된 구멍). + +## 배경·근거 (왜) + +PR 3 도그푸딩 피드백. [20260906_mode-indicator-hybrid-display-policy.md](20260906_mode-indicator-hybrid-display-policy.md)가 "순간 표시만"과 "창 단위 하이라이트"를 **기본으로는** 기각한 이유는 유지된다 — 순간 표시만으로는 사후 인지("딴 데 갔다 와서 지금 Normal이었나?")를 못 풀고, 창 단위 표시는 어느 입력칸인지 말하지 못한다. 그래서 기본값은 그대로다. 다만: + +- 상시 배지가 **소음**으로 느껴지는 사용자에게는 순간 표시만이 더 낫다. 안전 장치를 통째로 끄는 것(토글 off)과 전환 피드백은 남기는 것 사이에 단계가 없었다. +- 창 테두리는 라벨을 동반하므로 "어느 모드인지"는 말한다(색만으로 구분하지 않는다는 NFR 유지). "어느 입력칸인지"를 포기하는 대신 시야 어디서든 보이는 큰 신호를 원하는 사용자의 선택지다 — 화면 테두리보다 덜 압도적이고 배지보다 눈에 띈다. +- 두 옵션은 기존 구조에 거의 공짜다: 순간 표시만은 Insert 경로(`showsBadge == false`)와 같은 코드이고, 창 테두리는 이미 있는 테두리 패널과 창 rect 읽기·창 이동/리사이즈 알림을 재사용한다. +- 자기 pid 토큰: 두 새 설정도 기존 스타일 설정과 같이 설정 창(= 자기 pid 경로)에서 바뀐다. 이 경로가 떠 있는 읽기를 살려 두면 이전 앱을 향해 떠 있던 읽기가 착지해 방금 감춘 옛 형태를 되살린다. 밀린 flash를 만료시키는 것은 "앵커를 못 찾은 flash는 버린다"는 기존 계약과 같은 원칙이다. + +## 검토한 대안 + +- **on/off 토글을 3단 Picker(off / 전환 시만 / 항시)로 교체**: 기각 — 기존 Bool 키를 enum으로 바꾸려면 마이그레이션 코드와 테스트가 따라온다. 토글 + 하위 Picker면 UX가 같고 코드가 절반이다. +- **창 테두리를 창 사다리 폴백으로만 흡수**(09-06 결정의 원안): 유지 — 폴백은 그대로 있고, 이번 것은 사용자가 **고르는** 스타일이다. 둘은 다른 축이다. +- **창 테두리에서 창이 없을 때 요소·화면으로 폴백**: 기각 — "창 테두리"인데 다른 것을 두르면 사용자가 고른 스타일이 아니다. 배지가 앵커 없을 때와 같은 답("없음")이 일관된다. +- **테두리 모서리 반경을 스타일별로 가르기**: 보류 — 선 굵기의 2배(8pt)를 두 테두리가 공유한다. 실기기에서 창 모서리와 어긋나 보이지 않아 갈라야 할 이유가 아직 없다. +- **자기 pid 경로에서 밀린 flash 보존**: 기각 — 보존하면 복귀 시점의 앵커 이벤트에 얹혀 한참 전 전환의 라벨이 뒤늦게 번쩍인다. + +## 영향 범위 + +- 갱신한 architecture reference: [mode-indicator-overlay.md](../../architecture/references/mode-indicator-overlay.md), [profiles-and-config.md](../../architecture/references/profiles-and-config.md)(UserDefaults 키 셋), [app-shell.md](../../architecture/references/app-shell.md)(Behavior 항목) +- 코드: `ModeIndicatorController`(설정 프로퍼티 셋, `borderPanelStyle`, 자기 pid 토큰 무효화, `usesBorderPanel`), `ModeIndicatorGeometryReader.read(processID:includesCaret:includesWindow:)`, `ModeIndicatorLayout.windowBorderLayout`/`screenBorderLayout`(개명), `Preferences.swift`, `SettingsView.swift`. +- Settings General > Behavior의 인디케이터 항목이 셋(토글·"Show" Picker·"Indicator style" Picker)이 됐다. 색상·투명도 후속에서 Visual 탭 분리를 검토할 때 이 셋도 같이 옮긴다 (탭 구성은 [20260809_settings-window-three-tabs.md](20260809_settings-window-three-tabs.md)). +- 09-06 결정 다섯 항목을 뒤집지 않는다 — Supersedes 없음. diff --git a/.claude/skills/plans/SKILL.md b/.claude/skills/plans/SKILL.md index 220b3f3..e7fbedb 100644 --- a/.claude/skills/plans/SKILL.md +++ b/.claude/skills/plans/SKILL.md @@ -51,4 +51,4 @@ description: VimAction 프로젝트의 진행 중 작업 플랜(단기 기억) S |---|---|---|---|---| | 2026-08-02 | 2026-08-18 | M5 — AX 어댑터 + auto 전략 | MVP 1단계(M1~M4) 완료 후의 1차 확장. AX 읽기 혼용이 축 — M3 수용 엣지 대부분이 여기서 해소된다. **PR 단위 7개로 분해됨**(A 읽기 기반 → B·C 혼용 → D AX 어댑터·auto → E 스키마), PR마다 worktree. **PR-A·B·C1·C2 + D1-설계 세션 + PR-D1a(#39) + PR-D1b([PR #41](https://github.com/pilyang/vim-action/pull/41), main `544005c`) 머지 완료 — D1 트랙 종료**(AX 쓰기 통로·단일 `.ax` 드라이버·`FocusedTextOffsets`·편집/삽입/Visual 하이브리드·`strategy` 파싱). **PR-D2 머지 완료**([#56](https://github.com/pilyang/vim-action/pull/56) main `d8d155c` + 문서 [#57](https://github.com/pilyang/vim-action/pull/57) main `1c62cb7`, 2026-08-18 — auto 프로브·거짓말 감지·force-text·기본값 auto + 세션 6 브라우저 클래스 규칙·숨은 입력 기하 신호). **다음은 PR-E**(스키마 확장·마감 — `per_element`, D2 이월: 브라우저 per-element 판정·value-슬라이스 폴백·킬스위치 #59) | [20260802_m5-ax-adapter.md](references/20260802_m5-ax-adapter.md) | | 2026-08-18 | 2026-08-18 | Normal mode exit 키 허용목록 | **미착수 — 예정 기록용.** `Exit Normal mode on ⌘/⌥ shortcuts`를 전체 on/off에서 "기본은 전환 안 함 + 등록한 키 조합에서만 Normal → Insert"로 고도화. 등록은 키를 직접 눌러 캡처하는 UI | [20260818_normal-mode-exit-key-allowlist.md](references/20260818_normal-mode-exit-key-allowlist.md) | -| 2026-09-06 | 2026-09-07 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 4건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + PR 2([#67](https://github.com/pilyang/vim-action/pull/67)) 머지 — 상시 배지·재앵커·Settings 토글. **PR 3(캐럿 아래 flash + 화면 테두리 스타일) 구현·도그푸딩 완료, [PR #68](https://github.com/pilyang/vim-action/pull/68) 오픈** — 다음은 머지 확인, 그 뒤 외양 커스텀 후속(색상·투명도, Visual 탭 검토) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | +| 2026-09-06 | 2026-09-15 | 온스크린 모드 인디케이터 (HUD) | PRD §7.7·로드맵 Stage 4. 조사·보고 + 실기기 스파이크 완료(AX 기하 가용성 실측표, 비활성화 NSPanel 오버레이 검증). 권장안 채택 + decisions 5건 기록 + PR 1([#66](https://github.com/pilyang/vim-action/pull/66)) 머지 + PR 2([#67](https://github.com/pilyang/vim-action/pull/67)) 머지 — 상시 배지·재앵커·Settings 토글. **PR 3(캐럿 아래 flash + 화면 테두리) + 확장(순간 표시만 옵션·창 테두리 스타일·자기 pid 토큰 수정) 구현·스크립트 도그푸딩 완료, [PR #68](https://github.com/pilyang/vim-action/pull/68) 오픈** — 다음은 사용자 직접 도그푸딩(Settings UI 경로·창 테두리 엣지) → 머지 확인, 그 뒤 외양 커스텀 후속(색상·투명도, Visual 탭 검토) | [20260906_onscreen-mode-indicator.md](references/20260906_onscreen-mode-indicator.md) | diff --git a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md index 9e9c16e..247e1ee 100644 --- a/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md +++ b/.claude/skills/plans/references/20260906_onscreen-mode-indicator.md @@ -1,7 +1,7 @@ # 온스크린 모드 인디케이터 (HUD) - **생성일**: 2026-09-06 -- **갱신일**: 2026-09-07 (PR 3 구현·도그푸딩 완료, PR #68 오픈) +- **갱신일**: 2026-09-15 (PR 3 확장 — 순간 표시만 옵션·창 테두리 스타일·자기 pid 토큰 수정을 같은 브랜치에 커밋, 스크립트 도그푸딩 완료, Settings UI 경로·창 테두리 엣지는 사용자 직접 도그푸딩으로 인계) ## 목표 @@ -14,16 +14,17 @@ - [x] **PR 1 머지 완료** ([PR #66](https://github.com/pilyang/vim-action/pull/66) → main `48cbd28`, 2026-09-06; Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비활성화 패널·순수 레이아웃(테스트 10건)·전용 큐 기하 리더·모드 전환 훅. 실기기 도그푸딩(Developer ID Release 설치): TextEdit Esc/i/v, Chrome Esc, Slack Esc 전부 라벨 표시·최전면 불변·보조 디스플레이 정렬 확인. architecture reference [mode-indicator-overlay.md](../../architecture/references/mode-indicator-overlay.md) 추가. - [x] **PR 2 머지 완료** ([PR #67](https://github.com/pilyang/vim-action/pull/67) → main `4040fe4`, 2026-09-06, 브랜치 `feat/mode-indicator-persistent-badge`, Opus 워커 위임 + 감독 리뷰 + 독립 검증): 비-Insert 상시 배지(flash보다 한 단 작고 한 층 아래)·재앵커 트리거 5종(모드 전환 / 포커스·앱 활성화·창 이동·리사이즈 / 디스플레이 재구성 / 사다리 관찰 루프 / 토글)이 단일 reconcile로 합류, 순수 판정 2종(`presentation`·`needsGeometryRead`) 표 테스트, `visibleFrame` 클램프(화면 선택은 `frame`), 강조색 luminance 기반 글씨색, General > Behavior 토글(`onScreenModeIndicatorEnabled`, 기본 on). 실기기 도그푸딩: 창 이동(−900,+262)·리사이즈 추종, Chrome 전환 재앵커, disabled 앱(Terminal)에서 숨김, 복귀 시 재표시, Insert 무배지, VISUAL 배지 — 전부 확인. architecture 4종 갱신(profiles-and-config의 키 반영 포함). - [x] **PR 3 구현·도그푸딩 완료** (브랜치 `feat/mode-indicator-caret-and-border`, Fable 워커 위임 + 감독 리뷰 + 독립 검증, [PR #68](https://github.com/pilyang/vim-action/pull/68)): flash 앵커가 캐럿부터(읽기 순서 `(loc,1)` → `(loc-1,1)` → 텍스트 마커, 유효성 규칙은 순수 계층 `isUsableCaret` 한 벌, flash 요청 때만 캐럿 읽기), 상시 표시 스타일 `onScreenModeIndicatorStyle`(배지 / 화면 테두리 — 앵커가 속한 디스플레이의 `visibleFrame` + 오른쪽 위 라벨, General 토글 아래 Picker). 실기기 도그푸딩(Developer ID Release, 2디스플레이): TextEdit·Notes는 `(loc,1)`, Safari 주소창은 `(loc-1,1)`(끝 캐럿이 필드 밖이라 첫 변형 기각), Slack·Notion은 마커 — 전부 flash가 캐럿 바로 아래(±1px); Chrome·Arc는 요소 모서리 폴백; 테두리 스타일은 창이 있는 디스플레이만 두르고 Insert에서 사라짐. architecture 3종 갱신. +- [x] **PR 3 확장 구현 완료** (2026-09-14~15, 도그푸딩 피드백, 같은 브랜치·PR #68에 커밋 3개 `7b92d9d`·`87e7529`·`2d2b738` + 문서 커밋; 이 세션이 직접 구현): ① 자기 pid 경로 토큰 무효화 + 밀린 flash 만료(Copilot 코멘트, `token`을 `private(set)`으로 열어 회귀 테스트) ② 상시 표시 on/off `onScreenModeIndicatorPersistentEnabled`(off면 flash만 — Insert 경로 재사용, "Show" Picker, 스타일 Picker는 상시 표시 없으면 비활성) ③ 상시 스타일 `windowBorder`(포커스 창 rect 테두리 + 창 안쪽 오른쪽 위 라벨, 창 rect는 `includesWindow`로 그 스타일에서만 읽음, 테두리 패널 공유 + `borderPanelStyle`로 창↔화면 전환도 즉시 숨김, 라벨은 화면·창 이중 클램프, `borderLayout` → `screenBorderLayout` 개명). decisions 1건([20260914_mode-indicator-optin-flash-only-and-window-border.md](../../decisions/references/20260914_mode-indicator-optin-flash-only-and-window-border.md)) + architecture 3종 갱신. 스크립트 도그푸딩(Developer ID Release 설치, 2디스플레이): 기본값 회귀(flash+배지), 창 테두리 bounds == TextEdit 창(이동·리사이즈 추종, Slack 전환, 최소화 시 숨김·복원 시 재표시, 보조 디스플레이 x=−2094 정합, 외양 스크린샷 확인), flash-only 두 스타일(flash만·Slack 전환 시 무표시), Insert 숨김 — 전부 수치 확인. - [x] 방향 확정(2026-09-06, 권장안 채택) + decisions 4건 기록: 표시 정책 / 앵커 사다리·이벤트 기반 갱신(실측표 포함) / Chromium 스크린리더 모드 강제 안 함 / 설정은 UserDefaults·기본 on. ## 남은 것 -- [ ] [PR #68](https://github.com/pilyang/vim-action/pull/68) 머지 확인 (CI 3잡 required) — 머지되면 `/Applications`의 도그푸딩 빌드(1.0 (1))를 정식 릴리스로 되돌릴지 결정. +- [ ] **사용자 직접 도그푸딩(2026-09-15 인계, `/Applications`에 PR 3 확장 빌드 설치됨, 설정은 제품 기본값으로 리셋)** — Settings UI 경로: "Show" Picker 전환(상시 표시 즉시 숨김·앱 복귀 시 재표시, 스타일 Picker 비활성), 스타일 Picker 3종 전환(설정 창 열린 채 이전 형태 즉시 숨김·복귀 시 새 형태 — 특히 창↔화면 테두리), 배지 떠 있는 채로 메뉴바 아이콘 반복 클릭(배지 되살아남·깜빡임 없음), Settings 창에서 Esc·i(이전 앱 배지 즉시 숨김·늦은 flash 없음). 창 테두리 엣지: 실제 마우스 드래그 추종(지연·잔상·CPU), 전체화면 창, 화면 밖으로 일부 나간 창(라벨 클램프), 작은 창(계산기), 두 디스플레이에 걸친 창, 시트·팝오버 열린 상태, 라벨이 툴바를 덮는 정도, 모서리 반경 외양, Chrome·Arc 창. 관측 1건(재현 안 됨): 디스플레이 간 창 이동 직후 flash가 창 오른쪽 아래(1571,1082)에 한 번 떴음 — 재현되면 보고. +- [ ] [PR #68](https://github.com/pilyang/vim-action/pull/68) 머지 확인 (CI 3잡 required, Copilot 스레드 resolve됨) — 머지되면 `/Applications`의 도그푸딩 빌드(1.0 (1))를 정식 릴리스로 되돌릴지 결정. - [ ] **후속 — 인디케이터 외양 커스텀(색상·투명도).** 모드 라벨 알약(flash·배지)의 **색상**과 **투명도**를 사용자가 고를 수 있게 한다. 시점은 PR 3(캐럿 위치 지정) **이후**, 또는 작업 크기가 작으면 PR 3에 **함께**. 이러면서 설정 항목이 늘어나면(on/off·스타일·색상·투명도) 설정 창에 별도 **Visual 탭**을 두는 것도 함께 검토 — 탭 구성은 [20260809_settings-window-three-tabs.md](../../decisions/references/20260809_settings-window-three-tabs.md)의 결정이라 바꾸면 decisions로 기록하고 app-shell reference를 갱신한다. 색상 커스텀에서도 라벨 텍스트는 항상 동반(색만으로 구분하지 않음 — PRD NFR)하고, 글씨색은 PR 2의 luminance 파생을 사용자 색에 그대로 적용한다. 설정 소유권은 UserDefaults([20260906_mode-indicator-settings-in-userdefaults.md](../../decisions/references/20260906_mode-indicator-settings-in-userdefaults.md)). - [ ] 마무리: 로드맵 Stage 4 항목 체크, 플랜 완료 처리. -- [ ] 후속 검토(별도 PR 후보, 작음): **자기 pid 경로의 토큰.** `reconcile`의 자기 pid 가드는 토큰을 올리지 않아, 다른 앱을 향해 이미 떠 있던 읽기가 그 경로가 배지를 감춘 뒤 착지해 다시 그릴 수 있다(PR 2부터, PR 3 리뷰에서 발견, ~50ms 창). 한 줄 `token &+= 1`로 닫히지만 밀린 flash가 복귀 시점으로 미뤄지는 부작용을 같이 판단해야 한다. - [ ] 후속 검토(별도 PR 후보): **막 실행된 앱의 AXObserver 등록 실패.** 앱을 실행하며 동시에 활성화하면 리졸버의 `AXObserverCreate`/`AddNotification`이 `cannotComplete`(−25204)로 실패하고, 리졸버는 다음 pid 전환까지 재시도하지 않는다(PR 2 도그푸딩에서 TextEdit·Chrome·Terminal 콜드 실행 3건 전부 재현, 다른 앱에 갔다 오면 정상). PR 2 이전부터 있던 동작이지만 배지가 생기며 체감이 커졌다 — 그 앱에서는 앱 전환 전까지 포커스·창 이벤트 재앵커가 없고 모드 전환만 배지를 옮긴다. 후보: 등록 실패 시 짧은 지연 뒤 1회 재시도. ## 진행 중 컨텍스트 @@ -63,7 +64,7 @@ - 텍스트 뷰가 창 전체인 앱(TextEdit)은 "요소 위쪽 바깥" 규칙으로 배지가 타이틀바 자리에 뜬다 — 글자를 안 가려 수용. Chrome은 페이지 로드 직후 앵커가 웹 영역/창으로 잡혀 창 오른쪽 위에 뜸(폴백 정상). - 라벨은 강조색 배경 — 글씨색은 PR 2부터 강조색 luminance로 파생(흰 글씨 대비 3:1 미만이면 검정: 노랑·주황·초록·다크 그래파이트). 실측 대비비는 `ModeIndicatorPanel.textColor(on:)` 주석에. - CLAUDE.md의 Swift 6 프로브(`SWIFT_VERSION=6.0` 오버라이드)가 이제 Yams SPM 체크아웃까지 적용돼 앱 타깃 전에 실패한다(깨끗한 HEAD도 동일). 별도 정리 필요. -- 도그푸딩 도구(scratchpad): `postkey [shift]`(HID 탭에 키 게시, 마커 없음), `vawin`(VimAction 소유 온스크린 창 나열 → 오버레이 표시 여부 판정), `dogfood.sh`(설치→앱 순환→키 게시→캡처). PR 2용 `dogfood3.sh`: 앱을 먼저 띄워 웜 상태로 만들고(콜드 실행은 AX 옵저버 등록 실패) AeroSpace 창을 `aerospace layout floating`으로 풀어 창 이동을 실제로 일으킨다 — 타일링 상태에서는 `set position`이 무시된다. PR 3용 `dogfood4.sh `: 앱마다 `axprobe fields`로 캐럿을 놓고 Esc 뒤 `vawin`(flash 창 bounds)과 `axprobe sample `(캐럿 rect)을 대조해 "flash.x == caret.minX, flash.y == caret.maxY+4"를 수치로 판정, 이어서 `onScreenModeIndicatorStyle`을 `defaults write`로 바꿔 재실행해 테두리 스타일 검증(스타일은 init에서 읽으므로 실행 중 `defaults write`는 무시). `screens`는 디스플레이 frame/visibleFrame을 CG 좌표로 출력. AeroSpace에서는 floating 창도 자기 워크스페이스 모니터에 묶여 스크립트로 다른 디스플레이에 못 옮긴다 — `aerospace move-node-to-monitor --focus-follows-window `를 쓴다. +- 도그푸딩 도구(scratchpad): `postkey [shift]`(HID 탭에 키 게시, 마커 없음), `vawin`(VimAction 소유 온스크린 창 나열 → 오버레이 표시 여부 판정), `dogfood.sh`(설치→앱 순환→키 게시→캡처). PR 2용 `dogfood3.sh`: 앱을 먼저 띄워 웜 상태로 만들고(콜드 실행은 AX 옵저버 등록 실패) AeroSpace 창을 `aerospace layout floating`으로 풀어 창 이동을 실제로 일으킨다 — 타일링 상태에서는 `set position`이 무시된다. PR 3용 `dogfood4.sh `: 앱마다 `axprobe fields`로 캐럿을 놓고 Esc 뒤 `vawin`(flash 창 bounds)과 `axprobe sample `(캐럿 rect)을 대조해 "flash.x == caret.minX, flash.y == caret.maxY+4"를 수치로 판정, 이어서 `onScreenModeIndicatorStyle`을 `defaults write`로 바꿔 재실행해 테두리 스타일 검증(스타일은 init에서 읽으므로 실행 중 `defaults write`는 무시). `screens`는 디스플레이 frame/visibleFrame을 CG 좌표로 출력. AeroSpace에서는 floating 창도 자기 워크스페이스 모니터에 묶여 스크립트로 다른 디스플레이에 못 옮긴다 — `aerospace move-node-to-monitor --focus-follows-window `를 쓴다. **PR 3 확장 세션(2026-09-15)에서 도구를 다시 만들었다** — `postkey`·`vawin`(스타일 인자로 소유 앱 지정, CG 좌표·layer·alpha 출력)·`relaunch.sh