From f7ec88d6c7983058c6c09a6c81a3e93262ca68ec Mon Sep 17 00:00:00 2001 From: pilyang Date: Wed, 16 Sep 2026 10:18:32 +0900 Subject: [PATCH 1/2] feat(app): let users pick per-mode indicator colors normal and visual (visual + v-line) get their own color, alpha included, chosen with two color pickers in the indicator tab; insert keeps the system accent. both are unset by default, so nothing changes for existing users. the colors live in userdefaults as sRGB #RRGGBBAA strings owned by ModeIndicatorController, and a color change repaints the panels on screen without an accessibility re-read: the presentation now carries the mode instead of the label so the controller knows which color a live panel needs. the pure ModeIndicatorColor layer (hex round-trip with sRGB clamping, mode-to-color table) is table-tested, the persistence tests keep the object(forKey:) != nil guard, and the architecture references describe the new state. Co-Authored-By: Claude Fable 5.1 --- .../architecture/references/app-shell.md | 4 +- .../references/mode-indicator-overlay.md | 25 +- .../references/profiles-and-config.md | 4 +- VimAction/ModeIndicatorBorderPanel.swift | 22 +- VimAction/ModeIndicatorColor.swift | 59 +++++ VimAction/ModeIndicatorController.swift | 85 ++++++- VimAction/ModeIndicatorPanel.swift | 32 ++- VimAction/Preferences.swift | 12 +- VimAction/SettingsView.swift | 47 ++++ VimActionTests/ModeIndicatorColorTests.swift | 216 ++++++++++++++++++ .../ModeIndicatorControllerTests.swift | 12 +- 11 files changed, 480 insertions(+), 38 deletions(-) create mode 100644 VimAction/ModeIndicatorColor.swift create mode 100644 VimActionTests/ModeIndicatorColorTests.swift diff --git a/.claude/skills/architecture/references/app-shell.md b/.claude/skills/architecture/references/app-shell.md index 246a00b..100a502 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-15 (설정 창 Indicator 탭 — 4탭) +- **Last updated**: 2026-09-16 (Indicator 탭 색 항목) ## 현재 구조 @@ -20,7 +20,7 @@ ### 설정 창 — `TabView` 4탭, 460×560 고정 -**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Indicator**(탭 아이콘 `text.cursor`, 섹션 하나 — 온스크린 모드 인디케이터 토글과 그 아래 "Show" Picker(전환 시만 / Normal·Visual 동안도 — 토글이 꺼지면 `.disabled`)·"Indicator style" Picker(배지 / 창 테두리 / 화면 테두리 — 토글이 꺼지거나 상시 표시가 없으면 `.disabled`), 값·영속은 `ModeIndicatorController` 소유이고 뷰는 바인딩만, [mode-indicator-overlay.md](mode-indicator-overlay.md)) / **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), [20260915_settings-window-indicator-tab.md](../../decisions/references/20260915_settings-window-indicator-tab.md)). +**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Indicator**(탭 아이콘 `text.cursor`, 섹션 둘 — 무엇을 언제 띄우는가: 온스크린 모드 인디케이터 토글과 그 아래 "Show" Picker(전환 시만 / Normal·Visual 동안도 — 토글이 꺼지면 `.disabled`)·"Indicator style" Picker(배지 / 창 테두리 / 화면 테두리 — 토글이 꺼지거나 상시 표시가 없으면 `.disabled`) / "Colors": Normal·Visual `ColorPicker`(`supportsOpacity: true`, 미설정이면 그 시점 강조색의 sRGB 해석을 보여 준다 — **실효 색과 같은 값은 컨트롤러에 대입하지 않아** 패널을 열기만 해서는 강조색이 스냅샷으로 굳지 않는다)와 "Reset to system accent" 버튼(둘 다 미설정이면 `.disabled`), 섹션 통째로 토글에 `.disabled`. 값·영속·화면 반영은 `ModeIndicatorController` 소유이고 뷰는 바인딩만, [mode-indicator-overlay.md](mode-indicator-overlay.md)) / **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), [20260915_settings-window-indicator-tab.md](../../decisions/references/20260915_settings-window-indicator-tab.md)). ### 로그인 시 자동 시작 — `SMAppService.mainApp` diff --git a/.claude/skills/architecture/references/mode-indicator-overlay.md b/.claude/skills/architecture/references/mode-indicator-overlay.md index 4229548..c01ffd3 100644 --- a/.claude/skills/architecture/references/mode-indicator-overlay.md +++ b/.claude/skills/architecture/references/mode-indicator-overlay.md @@ -1,11 +1,13 @@ # 온스크린 모드 인디케이터 (오버레이) -- **Last updated**: 2026-09-15 +- **Last updated**: 2026-09-16 ## 현재 구조 화면의 표시는 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 **캐럿 아래**(캐럿이 없으면 포커스 요소 근처)에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 **상시 표시**가 계속 남는다 (Insert는 상시 표시 없음). 상시 표시는 Settings에서 끌 수도 있고("Only when the mode changes" — 그러면 flash만 남는다) 형태도 고른다 — 기본은 요소 모서리의 한 단계 작은 **배지**, 대체 스타일은 포커스 창 rect를 두르는 **창 테두리**와 포커스 창이 있는 디스플레이를 두르는 **화면 테두리**(둘 다 강조색 프레임 + 오른쪽 위 모서리 라벨). 어느 스타일이든 flash는 캐럿/요소 사다리를 탄다. 전부 Settings Indicator 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. +색은 **모드별로 고른다** — Normal 색과 Visual 색(VISUAL·V-LINE 공유) 둘이고 투명도는 색의 알파다. 둘 다 **미설정이 기본**이라 그때는 시스템 강조색으로 그린다(설정을 건드리지 않은 사용자에게 외양 변화가 없다). INSERT flash는 설정 대상이 아니라 언제나 강조색이다. 알약 배경·테두리 선·모서리 라벨이 그 색을 쓰고, 글씨는 불투명하게 남는다. + ```mermaid graph LR T1["① EventTapController.updateMode
(탭 콜백 동기 구간)"] --> R @@ -21,11 +23,12 @@ graph LR | 구성 요소 | 격리 | 역할 | |---|---|---| -| `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`)를 기억해 창↔화면 테두리 전환도 이전 형태를 즉시 감춘다 | +| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:isPersistentEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 다섯(`isEnabled`·`isPersistentEnabled`·`style: ModeIndicatorPresentationStyle`·`normalColor`·`visualColor`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리이고, 테두리 패널이 지금 어느 스타일로 떠 있는지(`borderPanelStyle`)를 기억해 창↔화면 테두리 전환도 이전 형태를 즉시 감춘다. 색 변경만은 `reconcile`이 아니라 `repaintColors()`가 받는다 — `current.mode`의 색을 떠 있는 패널 셋에 바로 밀어 넣는다 | +| `ModeIndicatorColor` | `nonisolated` 순수 | 색의 순수 계층 — 저장 문자열 ↔ 색(`color(fromHex:)`·`hex(from:)`, sRGB `#RRGGBBAA` 대문자 8자리, `#` 필수·8자리 hex만·대소문자 관용, 그 밖은 전부 미설정으로 접힘)과 모드→색 표(`color(for:normal:visual:)` — `.normal`→Normal, `.visualChar`/`.visualLine`→Visual, `.insert`→`nil`). 직렬화는 sRGB 변환 뒤 성분을 `0...1`로 **clamp**하고 반올림한다 | | `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` | 창·화면 테두리 공용 — 호출자가 준 frame(창 rect 또는 디스플레이의 `visibleFrame`)을 덮는 패널 **하나**에 강조색 테두리(`ModeIndicatorBorderView`, 모서리 반경은 선 굵기의 2배로 두 스타일 공통)와 모서리 라벨(`ModeIndicatorPillView(style: .badge)` 서브뷰)을 함께 그린다. 층은 배지와 같다. `show`/`hide`는 배지와 같은 계약(멱등, 무애니메이션). 테두리 스타일을 고른 뒤 첫 표시에서 lazy 생성. 두 스타일이 인스턴스 하나를 나눠 쓰고 어느 형태인지는 컨트롤러가 기억한다 | +| `ModeIndicatorPanel` × 2 | `@MainActor` | flash·배지 알약. `flash`/`show`가 색을 함께 받고, `setColor(_:)`는 기하를 건드리지 않고 색만 갈아 끼운다. `ModeIndicatorStyle`이 폰트·여백·radius·창 층을 가른다: flash는 bold 12pt/`.statusBar`, 배지는 semibold 10pt/`.statusBar - 1`. `flash`는 0.15/0.7/0.3s 페이드, `show`는 애니메이션도 타이머도 없다. 각각 첫 표시에서 lazy 생성 | +| `ModeIndicatorBorderPanel` × 1 | `@MainActor` | 창·화면 테두리 공용 — 호출자가 준 frame(창 rect 또는 디스플레이의 `visibleFrame`)을 덮는 패널 **하나**에 테두리(`ModeIndicatorBorderView`, 모서리 반경은 선 굵기의 2배로 두 스타일 공통)와 모서리 라벨(`ModeIndicatorPillView(style: .badge)` 서브뷰)을 함께 그린다. 층은 배지와 같다. `show`/`hide`는 배지와 같은 계약(멱등, 무애니메이션)이고 `setColor(_:)`가 선과 라벨의 색을 한 자리에서 세운다. 테두리 스타일을 고른 뒤 첫 표시에서 lazy 생성. 두 스타일이 인스턴스 하나를 나눠 쓰고 어느 형태인지는 컨트롤러가 기억한다 | | `ModeIndicatorPanel.makeOverlayPanel(level:)` | `@MainActor` | 비활성화 패널 설정의 **유일한** 생성 지점 — `NSPanel(.borderless, .nonactivatingPanel)`, `ignoresMouseEvents`, `hidesOnDeactivate = false`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]`. 알약 둘과 테두리가 전부 여기서 받는다 | 라벨 문자열은 `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,7 +40,7 @@ graph LR | ① | 모드 전환 | `updateMode` → `onModeChange` → `modeDidChange` | flash + 상시 표시, 항상 읽는다 (캐럿 포함) | | ② | 포커스 요소 변경·앱 활성화·창 이동·리사이즈 | `FocusedElementResolver.onFocusGeometryChanged` → (`EventTapController` 통로) → `anchorDidChange` | 상시 표시가 보여야 할 때만 읽는다 (캐럿 없이) | | ③ | 사다리 변화 (탭 고장·마스터 off·킬스위치·앱별 disabled·Secure Input·설정 리로드) | `AppState.trackIndicatorLadder()`의 `withObservationTracking` 재무장 루프 → `stateDidChange` | 표시할 것이 실제로 달라졌을 때만 읽는다 | -| ④ | Settings 토글·상시 표시·스타일 | `isEnabled.didSet`·`isPersistentEnabled.didSet`·`style.didSet` | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 상시 표시 off는 `showsBadge`가 접혀 Insert와 같은 경로 — 앵커 이벤트에 AX 왕복 0건; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | +| ④ | Settings 토글·상시 표시·스타일 | `isEnabled.didSet`·`isPersistentEnabled.didSet`·`style.didSet` (색 둘은 여기 없다 — `reconcile`을 타지 않는다) | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 상시 표시 off는 `showsBadge`가 접혀 Insert와 같은 경로 — 앵커 이벤트에 AX 왕복 0건; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | | ⑤ | 디스플레이 재구성 | `NSApplication.didChangeScreenParametersNotification` → `anchorDidChange` | ②와 같다 | ## 불변식·계약 @@ -58,9 +61,11 @@ graph LR - **좌표 변환은 전역 flip 하나**: `y' = NSScreen.screens[0].frame.maxY - (y + h)`. 보조 디스플레이(AX x 음수)에서도 그대로다. - **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. 화면 테두리도 같은 축이다 — 앵커 rect(요소 또는 창)와 `frame`이 겹치는 화면을 고르고(AX를 더 읽지 않는다 — 요소는 창 안에 있어 같은 디스플레이다, 없으면 첫 화면), 그 화면의 **`visibleFrame`**을 두른다(`frame`을 두르면 위 변이 메뉴바 뒤, 아래 변이 Dock 뒤에 숨는다). 창 테두리는 이 축을 타지 않는다 — 창 rect를 그대로 두르고(화면 밖으로 나간 만큼 잘린다), 라벨만 앵커 화면의 `visibleFrame`과 창 frame 안으로 민다. - **앵커가 없으면 재시도하지 않는다** — 그것이 답이고, 다음 앵커 이벤트가 다시 부른다. -- **글씨 색은 강조색에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 강조색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다 — 화면 테두리도 모서리 라벨이 같은 알약 뷰라 규칙이 하나다. +- **색 변경은 기하 재읽기를 내지 않는다.** 색은 `Presentation`에 싣지 않고(실으면 `desired != current`가 참이 되어 기하가 그대로인데 AX 왕복이 난다) `needsGeometryRead`도 보지 않는다 — `normalColor`·`visualColor`의 didSet은 영속 + `repaintColors()`뿐이다. 그래서 **자기 pid 경로(설정 창이 열린 상태)에서도 색이 즉시 반영된다**: 그 경로는 읽지도 `current`를 건드리지도 않아 `current`가 화면에 남아 있는 이전 앱의 표시 그대로이고, 재도색은 그 값으로 돈다. +- **`Presentation`은 라벨이 아니라 모드를 싣는다.** 떠 있는 패널에 색을 밀어 넣으려면 그것이 어느 모드를 보여 주는지 알아야 한다 — `label`은 `mode.overlayLabel` 파생이라 동등성 의미는 라벨을 실었을 때와 같다. +- **글씨 색은 실효 배경색(사용자 색, 미설정이면 강조색)에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. **판정은 알파를 보지 않는다** — 성분 셋으로만 휘도를 내므로 반투명 배경이어도 라벨은 불투명한 흑·백으로 남는다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다 — 화면 테두리도 모서리 라벨이 같은 알약 뷰라 규칙이 하나다. - **flash가 배지 위에 그려지는 것은 창 층의 차이로 보장된다**(flash `.statusBar`, 상시 표시 `.statusBar - 1`). 캐럿이 없는 앱에서는 flash가 배지를 완전히 덮고, 캐럿이 있는 앱에서는 flash가 캐럿 아래·배지가 요소 모서리라 **전환 직후 1초간 같은 라벨 둘이 함께 보인다** — flash는 캐럿부터라는 결정의 직접 결과다. -- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종·창 테두리 6종), `ModeIndicatorControllerTests`(표시 판정 표(상시 표시 off 행 포함)·읽기 코얼레싱 판정 표(스타일 변경·상시 표시 토글 행 포함)·토글 영속·상시 표시 영속·스타일 영속(raw 값 리터럴 포함)·자기 pid 토큰 무효화·대비). +- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종·창 테두리 6종), `ModeIndicatorControllerTests`(표시 판정 표(상시 표시 off 행 포함)·읽기 코얼레싱 판정 표(스타일 변경·상시 표시 토글 행 포함)·토글 영속·상시 표시 영속·스타일 영속(raw 값 리터럴 포함)·자기 pid 토큰 무효화·대비), `ModeIndicatorColorTests`(hex 왕복·대소문자·잘못된 입력·색역 밖 색·모드→색 표·색 영속(키 둘 분리)·미설정 기본·Reset의 키 삭제·못 읽는 값·사용자 색 글씨 대비와 알파 무시). - 테스트에서는 아무 일도 하지 않는다 — 배선이 `bootstrap()`의 XCTest 가드 뒤이고, 입력이 밀린 적 없으면 판정이 통째로 막히며, 패널은 첫 표시에서야 만들어진다. 자기 pid 경로는 그 전제 위에서 테스트가 직접 부른다(읽지도 그리지도 않아 `token`만 관측된다 — 그래서 `token`은 `private(set)`). ### 알려진 한계 @@ -74,13 +79,13 @@ graph LR ## 근거 요약 -메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 창·화면 테두리는 대체 스타일, 순간 표시만은 opt-out), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권은 각각 결정 문서에 있다. +메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 창·화면 테두리는 대체 스타일, 순간 표시만은 opt-out), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권, 모드별 색(모드 구분은 Vim 상태줄 관례대로 색이 거드는 신호이고 라벨이 항상 동반된다)은 각각 결정 문서에 있다. -- 관련 결정: [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) +- 관련 결정: [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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.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`(토글·"Show" Picker·스타일 Picker), `VimAction/Preferences.swift`(키 셋) +- 코드: `VimAction/ModeIndicatorController.swift`(`ModeIndicatorPresentationStyle` 포함), `VimAction/ModeIndicatorColor.swift`, `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·색 `ColorPicker` 둘·Reset 버튼), `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 7282281..50ce331 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-09-06 (온스크린 인디케이터 스타일 키 추가) +- **Last updated**: 2026-09-16 (온스크린 인디케이터 모드별 색 키 둘 추가) ## 현재 구조 @@ -96,7 +96,7 @@ actions: # 명령 계열: 그 액션 자신의 키 교 **"사용자가 파일로 관리하고 싶은 설정"은 YAML, "앱이 스스로 쓰는 상태"는 UserDefaults.** -- 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)). +- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorPersistentEnabled`(상시 표시 on/off — off면 순간 표시만, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`windowBorder`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본)·`onScreenModeIndicatorNormalColor`·`onScreenModeIndicatorVisualColor`(모드별 인디케이터 색 — sRGB `#RRGGBBAA` 대문자 8자리, **키 부재가 기본**이고 그것이 "시스템 강조색"을 뜻한다; 못 읽는 값도 부재로 접히고, 미설정으로 되돌리면 키를 지운다) — 다섯 다 표시 취향이지 앱별 동작 정의가 아니고, 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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.md)). - YAML: 앱별 on/off, 프로파일 전부. - **어느 쪽도 아닌 셋째 칸이 있다 — 시스템이 소유하는 상태.** 로그인 시 자동 시작은 `SMAppService.mainApp.status`가 SSOT이고 앱은 값을 저장하지 않는다(사용자가 시스템 설정에서 통지 없이 바꾸므로 미러는 반드시 어긋난다 — [app-shell.md](app-shell.md)). Sparkle 자동 확인도 같은 성격으로, 값의 영속은 Sparkle이 소유한다. diff --git a/VimAction/ModeIndicatorBorderPanel.swift b/VimAction/ModeIndicatorBorderPanel.swift index fcfdf0f..ccc8efe 100644 --- a/VimAction/ModeIndicatorBorderPanel.swift +++ b/VimAction/ModeIndicatorBorderPanel.swift @@ -20,10 +20,11 @@ import AppKit @MainActor final class ModeIndicatorBorderPanel { private let panel: NSPanel + private let border: ModeIndicatorBorderView private let pill: ModeIndicatorPillView init() { - let border = ModeIndicatorBorderView(frame: .zero) + border = ModeIndicatorBorderView(frame: .zero) pill = ModeIndicatorPillView(style: .badge) border.addSubview(pill) panel = ModeIndicatorPanel.makeOverlayPanel(level: ModeIndicatorStyle.badge.windowLevel) @@ -33,21 +34,34 @@ final class ModeIndicatorBorderPanel { /// 테두리를 `frame`에, 라벨을 `labelFrame`에 띄우고 **그대로 둔다** — 배지의 `show`와 같은 /// 계약이다(애니메이션도 타이머도 없고 멱등). 둘 다 화면 좌표이고, 라벨은 패널 안 좌표로 /// 옮겨 앉힌다. - func show(_ label: String, at frame: NSRect, labelFrame: NSRect) { + func show(_ label: String, at frame: NSRect, labelFrame: NSRect, color: NSColor?) { + setColor(color) pill.label = label pill.frame = labelFrame.offsetBy(dx: -frame.minX, dy: -frame.minY) panel.setFrame(frame, display: true) panel.orderFrontRegardless() } + /// 떠 있는 채로 **색만** 갈아 끼운다 — 선과 모서리 라벨이 같은 색이라 한 자리에서 세운다 + /// (알약의 `setColor`와 같은 계약: 기하를 건드리지 않는다). + func setColor(_ color: NSColor?) { + border.strokeColor = color + pill.fillColor = color + } + /// 즉시 감춘다 — 배지의 `hide`와 같은 사유·같은 계약이다. func hide() { panel.orderOut(nil) } } -/// 강조색 테두리. 색은 알약과 같은 이유로 그리는 시점의 외양에서 해석한다. +/// 테두리 선. 색은 알약과 같은 규칙이다 — 사용자가 고른 모드 색, 미설정(`nil`)이면 그리는 시점의 +/// 외양에서 해석한 시스템 강조색. private final class ModeIndicatorBorderView: NSView { + var strokeColor: NSColor? { + didSet { needsDisplay = true } + } + override func draw(_ dirtyRect: NSRect) { // 선은 bounds 안쪽에 온전히 들어와야 한다 — 절반이 패널 밖으로 잘리면 굵기가 반이 된다. // 모서리 반경은 창·화면 테두리가 같은 값(선 굵기의 2배)을 쓴다 — 창 테두리에서도 창 @@ -57,7 +71,7 @@ private final class ModeIndicatorBorderView: NSView { roundedRect: bounds.insetBy(dx: width / 2, dy: width / 2), xRadius: width * 2, yRadius: width * 2) path.lineWidth = width - NSColor.controlAccentColor.setStroke() + (strokeColor ?? NSColor.controlAccentColor).setStroke() path.stroke() } } diff --git a/VimAction/ModeIndicatorColor.swift b/VimAction/ModeIndicatorColor.swift new file mode 100644 index 0000000..3d9dd5d --- /dev/null +++ b/VimAction/ModeIndicatorColor.swift @@ -0,0 +1,59 @@ +// +// ModeIndicatorColor.swift +// VimAction +// + +import AppKit +import VimEngine + +/// 인디케이터 색의 **순수 계층** — 저장 문자열 ↔ 색 변환과 모드→색 표다. 화면도 UserDefaults도 +/// 모르고(소유·영속·반영은 `ModeIndicatorController`), 그래서 전부 표로 검증된다. +/// +/// 저장 형식이 sRGB `#RRGGBBAA` 문자열인 이유: `defaults`로 읽고 쓸 수 있고, 파싱·직렬화가 순수 +/// 함수이며, 외양(라이트/다크)에 묶이지 않는다 (`20260916_mode-indicator-per-mode-colors.md`). +nonisolated enum ModeIndicatorColor { + /// 저장 문자열 → 색. `#` + 정확히 8자리 hex만 받고 대소문자는 관용한다 — 사용자가 + /// `defaults write`로 직접 넣을 수 있는 값이라서다. 그 밖은 전부 `nil`(= 미설정 = 강조색): + /// 못 읽는 값 하나 때문에 인디케이터가 안 뜨는 일은 없어야 한다. + /// + /// `UInt32(_:radix:)`가 부호(`+FFFFFFF`)를 받아 주므로 **hex 자릿수 검사가 따로 필요하다**. + static func color(fromHex hex: String) -> NSColor? { + let digits = hex.dropFirst() + guard hex.count == 9, hex.hasPrefix("#"), digits.allSatisfy(\.isHexDigit), + let value = UInt32(digits, radix: 16) + else { return nil } + func component(_ shift: UInt32) -> CGFloat { + CGFloat((value >> shift) & 0xFF) / 255 + } + return NSColor( + srgbRed: component(24), green: component(16), blue: component(8), alpha: component(0)) + } + + /// 색 → 저장 문자열. sRGB로 옮길 수 없는 색(패턴·이미지 색)은 `nil`이다. + /// + /// **성분 clamp가 계약이다**: `ColorPicker`는 Display P3 색을 주고, sRGB 변환은 색역 밖 성분을 + /// 음수나 1 초과로 돌려준다. 그대로 `%02X`에 넣으면 8자리가 아닌 문자열이 나와 다시 읽히지 + /// 않는다 — 저장은 됐는데 다음 실행에 미설정으로 돌아간다. + static func hex(from color: NSColor) -> String? { + guard let srgb = color.usingColorSpace(.sRGB) else { return nil } + func byte(_ component: CGFloat) -> Int { + Int((min(max(component, 0), 1) * 255).rounded()) + } + return String( + format: "#%02X%02X%02X%02X", byte(srgb.redComponent), byte(srgb.greenComponent), + byte(srgb.blueComponent), byte(srgb.alphaComponent)) + } + + /// 모드 → 사용자 색. `nil`이면 시스템 강조색으로 그린다(미설정의 표현이다). + /// + /// Insert는 설정 대상이 아니다 — 기본 상태(상시 표시 없음)라 flash의 강조색이 "평소 타이핑으로 + /// 돌아왔다"는 중립 신호로 남아야 한다. VISUAL·V-LINE은 한 색을 나눠 쓴다: 둘은 같은 모드 + /// 계열이고, 색은 모드 계열을 가르는 두 번째 신호다(첫째는 항상 동반되는 라벨). + static func color(for mode: Mode, normal: NSColor?, visual: NSColor?) -> NSColor? { + switch mode { + case .normal: normal + case .visualChar, .visualLine: visual + case .insert: nil + } + } +} diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index 33115df..f13306a 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -79,7 +79,12 @@ final class ModeIndicatorController { /// 지금 화면에 있어야 할 것. `nil`(= `presentation`이 답하지 않음)이면 아무것도 표시하지 /// 않는다. nonisolated struct Presentation: Equatable { - var label: String + /// 라벨이 아니라 **모드**를 싣는다 — 떠 있는 패널에 색을 밀어 넣으려면 그것이 지금 어느 + /// 모드를 보여 주는지 알아야 한다. `mode → label`은 단사라(NORMAL·INSERT·VISUAL·V-LINE) + /// 동등성 의미는 라벨을 실었을 때와 같다. **색 자체는 싣지 않는다**: 실으면 + /// `desired != current`가 색 변경에도 참이 되어 기하가 그대로인데 AX 재읽기가 난다 + /// (`20260916_mode-indicator-per-mode-colors.md`). + var mode: Mode /// Insert는 항상 `false`다 — flash는 뜨되 상시 표시는 남지 않는다. 상시 표시가 "지금 /// 위험한 모드다"를 뜻하려면 기본 상태(Insert)에는 없어야 한다. 상시 표시를 끈 /// 설정(`isPersistentEnabled == false`)에서는 모든 모드가 `false`다 — Insert 경로가 @@ -89,6 +94,9 @@ final class ModeIndicatorController { /// 상시 표시의 형태. 여기 실리는 이유는 `desired != current`가 스타일 변경에도 참이 /// 되어 다시 그리게 하기 위해서다. var style: ModeIndicatorPresentationStyle + + /// 알약에 찍히는 글자 — 모드에서 파생된다(두 벌이 되면 어긋난다). + var label: String { mode.overlayLabel } } /// 표시 판정 — **이 기능의 순수 계층이다.** AX도 AppKit도 부르지 않아 표로 검증된다. @@ -105,7 +113,7 @@ final class ModeIndicatorController { return nil } return Presentation( - label: inputs.mode.overlayLabel, + mode: inputs.mode, showsBadge: inputs.mode.showsPersistentBadge && isPersistentEnabled, processID: processID, style: style) @@ -174,6 +182,28 @@ final class ModeIndicatorController { } } + /// Normal 모드 인디케이터 색 — `nil`이 미설정(시스템 강조색)이다. `isEnabled`·`style`과 같은 + /// 소유 모델(런타임 SSOT는 이 프로퍼티, didSet이 영속)이고, Settings의 `ColorPicker`가 여기 + /// 바인딩된다. + /// + /// **`oldValue != new` 가드가 없는 것이 의도다**: 영속과 재도색은 AX도 기하도 건드리지 않아 + /// 반복 호출이 무해하고(Picker 드래그 중 연속 호출이 곧 라이브 프리뷰다), `NSColor` 동등성은 + /// 같은 색이라도 색공간 인스턴스에 따라 거짓 음성이 나 가드 쪽이 오히려 신뢰할 수 없다. + var normalColor: NSColor? { + didSet { + persist(normalColor, forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) + repaintColors() + } + } + + /// Visual 모드(VISUAL·V-LINE 공유) 인디케이터 색 — `normalColor`와 같은 소유 모델·같은 규칙. + var visualColor: NSColor? { + didSet { + persist(visualColor, forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) + repaintColors() + } + } + @ObservationIgnored private nonisolated(unsafe) let defaults: UserDefaults /// 아직 화면에 반영되지 않은 최신 요청. 읽는 도중 새 트리거가 오면 여기만 덮어써 @@ -236,6 +266,29 @@ final class ModeIndicatorController { defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorStyle) .flatMap(ModeIndicatorPresentationStyle.init(rawValue:)) ?? PreferenceKeys.onScreenModeIndicatorStyleDefault + // 없는 키도 못 읽는 값도 `nil`(미설정 = 강조색)이다 — 색 때문에 인디케이터가 안 뜨는 + // 경로를 만들지 않는다. + self.normalColor = Self.storedColor( + defaults, forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) + self.visualColor = Self.storedColor( + defaults, forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) + } + + private nonisolated static func storedColor(_ defaults: UserDefaults, forKey key: String) + -> NSColor? + { + defaults.string(forKey: key).flatMap(ModeIndicatorColor.color(fromHex:)) + } + + /// 색 하나의 영속 — **저장할 hex가 나올 때만 쓰고 그 밖은 전부 키를 지운다.** 미설정으로 + /// 되돌린 경우(`nil`)와 sRGB로 옮길 수 없는 색이 한 규칙으로 닫혀, 어느 쪽에서도 낡은 값이 + /// 키에 남지 않는다. + private func persist(_ color: NSColor?, forKey key: String) { + if let color, let hex = ModeIndicatorColor.hex(from: color) { + defaults.set(hex, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } // MARK: - 트리거 @@ -379,6 +432,10 @@ final class ModeIndicatorController { size: ModeIndicatorPanel.size(for: request.label, style: style), screens: layoutScreens, primaryScreenMaxY: primary.frame.maxY) } + // 사용자 색(미설정이면 `nil` → 패널이 강조색으로 그린다). 한 번 구해 flash·상시 표시가 + // 같은 값을 쓴다 — 둘은 같은 요청의 같은 모드다. + let color = ModeIndicatorColor.color( + for: request.mode, normal: normalColor, visual: visualColor) // flash 사다리(캐럿 → 요소 → 창)가 배지 사다리를 포함하므로, flash 프레임이 없으면 // 앵커가 아예 없다 — 사다리의 마지막 단이고, 붙일 곳이 없으면 표시하지 않는다. // **재시도는 없다**: 앵커가 없다는 것이 답이고, 다음 앵커 이벤트가 이 경로를 다시 부른다. @@ -395,7 +452,7 @@ final class ModeIndicatorController { pendingFlash = false let panel = flashPanel ?? ModeIndicatorPanel(style: .flash) flashPanel = panel - panel.flash(request.label, at: flashFrame) + panel.flash(request.label, at: flashFrame, color: color) } guard request.showsBadge else { hidePersistentPanels(keeping: nil) @@ -408,7 +465,7 @@ final class ModeIndicatorController { if let badgeFrame = frame(ladder: .elementFirst, style: .badge) { let panel = badgePanel ?? ModeIndicatorPanel(style: .badge) badgePanel = panel - panel.show(request.label, at: badgeFrame) + panel.show(request.label, at: badgeFrame, color: color) hidePersistentPanels(keeping: .badge) } else { hidePersistentPanels(keeping: nil) @@ -429,7 +486,8 @@ final class ModeIndicatorController { // 형태를 먼저 적는다 — 아래 `keeping`이 방금 그린 패널을 "다른 형태"로 보고 // 감추면 안 된다. borderPanelStyle = request.style - panel.show(request.label, at: layout.frame, labelFrame: layout.labelFrame) + panel.show( + request.label, at: layout.frame, labelFrame: layout.labelFrame, color: color) hidePersistentPanels(keeping: request.style) } else { hidePersistentPanels(keeping: nil) @@ -437,6 +495,23 @@ final class ModeIndicatorController { } } + /// 떠 있는 패널을 **기하 재읽기 없이** 다시 칠한다 — 색은 기하와 무관하므로 색 변경이 AX + /// 왕복을 내면 안 된다(`20260916_mode-indicator-per-mode-colors.md`). + /// + /// **`reconcile`을 타지 않는 별도 경로인 것이 요점이다**: 색을 바꾸는 자리는 설정 창이고, + /// 그때는 우리가 최전면이라 `reconcile`이 자기 pid 가지에서 읽지도 `current`를 건드리지도 + /// 않는다. 그 덕에 `current`는 **화면에 남아 있는 이전 앱의 표시 그대로**여서, 여기서 그 + /// 모드의 색을 밀어 넣으면 사용자가 보고 있는 배지·테두리가 그 자리에서 바뀐다. + /// 패널은 첫 표시에서야 만들어지므로 아무것도 뜬 적 없으면 통째로 no-op이다. + private func repaintColors() { + let color = current.flatMap { + ModeIndicatorColor.color(for: $0.mode, normal: normalColor, visual: visualColor) + } + flashPanel?.setColor(color) + badgePanel?.setColor(color) + borderPanel?.setColor(color) + } + /// 상시 표시 패널 중 `style`이 아닌 것을 감춘다 (`nil`이면 전부). **숨김 규칙의 단일 /// 지점이다** — 배지에 적용되는 모든 숨김 사유(Insert·사다리 이탈·토글 off·pid 없음· /// 자기 pid·앵커 없음·스타일 변경)가 테두리에도 같은 자리에서 같이 적용된다. 테두리 패널은 diff --git a/VimAction/ModeIndicatorPanel.swift b/VimAction/ModeIndicatorPanel.swift index ad37287..da6e71f 100644 --- a/VimAction/ModeIndicatorPanel.swift +++ b/VimAction/ModeIndicatorPanel.swift @@ -70,6 +70,12 @@ final class ModeIndicatorPillView: NSView { } } + /// 사용자가 고른 모드 색. `nil`은 미설정 — 그때는 시스템 강조색으로 그린다 + /// (`20260916_mode-indicator-per-mode-colors.md`). + var fillColor: NSColor? { + didSet { needsDisplay = true } + } + init(style: ModeIndicatorStyle) { self.style = style super.init(frame: .zero) @@ -91,8 +97,9 @@ final class ModeIndicatorPillView: NSView { override func draw(_ dirtyRect: NSRect) { // 강조색은 동적 색이라 **그리는 시점의 외양에서** 해석돼야 한다 — 그래서 배경색과 - // 글씨색 결정이 둘 다 여기 있다. - let background = NSColor.controlAccentColor + // 글씨색 결정이 둘 다 여기 있다. 사용자 색은 고정 sRGB라 그 해석이 필요 없지만, 아래 + // 글씨색 파생은 두 경우 모두 같은 자리에서 돈다. + let background = fillColor ?? NSColor.controlAccentColor background.setFill() NSBezierPath( roundedRect: bounds, xRadius: style.cornerRadius, yRadius: style.cornerRadius @@ -178,6 +185,10 @@ final class ModeIndicatorPanel { /// 해석되고 이 판정도 그 값으로 내려가므로 그것이 맞는 동작이다. 다만 **테스트는 동적 /// 시스템 색을 쓰면 안 된다** — 답이 테스트를 돌린 머신의 외양에 묶인다. /// + /// 사용자가 모드 색을 고른 경우도 같은 함수가 판정한다 — 밝은 색을 고르면 글씨가 검게 + /// 뒤집힌다. **알파는 보지 않는다**: 성분 셋으로만 휘도를 내므로 반투명 배경이어도 글씨는 + /// 불투명한 흑·백이고, 그래서 배경을 아무리 투명하게 해도 라벨은 남는다. + /// /// 뷰가 아니라 여기 있는 것은 뷰가 `private`이라 테스트가 닿지 않아서다. static func textColor(on background: NSColor) -> NSColor { guard let srgb = background.usingColorSpace(.sRGB) else { return .white } @@ -194,9 +205,9 @@ final class ModeIndicatorPanel { /// 라벨을 `frame`에 띄우고 ~1초 뒤 페이드아웃한다. 표시 중 다시 부르면 **라벨·위치를 갈아 /// 끼우고 타이머를 재시작**한다 — 연속 전환에서 앞 라벨이 사라지는 도중에 뒤 라벨이 겹쳐 /// 뜨면 어느 쪽이 현재인지 읽히지 않는다. - func flash(_ label: String, at frame: NSRect) { + func flash(_ label: String, at frame: NSRect, color: NSColor?) { fadeOutWork?.cancel() - place(label, at: frame) + place(label, at: frame, color: color) // 이미 떠 있으면 알파를 0으로 되돌리지 않는다 — 연속 전환마다 깜빡인다. 페이드아웃 // 도중이면 현재 알파에서 1로 되올라가 끊김 없이 이어진다. if !panel.isVisible { panel.alphaValue = 0 } @@ -226,10 +237,10 @@ final class ModeIndicatorPanel { /// 애니메이션도 타이머도 없는 것이 요점이다: 배지는 포커스·창 이벤트마다 다시 놓이는데 /// 그때마다 페이드가 걸리면 창을 옮길 때 배지가 출렁이고, 페이드아웃이 예약되면 가만히 /// 있는 동안 배지가 사라진다. 멱등이라 같은 위치로 다시 불러도 안전하다. - func show(_ label: String, at frame: NSRect) { + func show(_ label: String, at frame: NSRect, color: NSColor?) { fadeOutWork?.cancel() fadeOutWork = nil - place(label, at: frame) + place(label, at: frame, color: color) panel.alphaValue = 1 panel.orderFrontRegardless() } @@ -242,8 +253,15 @@ final class ModeIndicatorPanel { panel.orderOut(nil) } - private func place(_ label: String, at frame: NSRect) { + /// 떠 있는 채로 **색만** 갈아 끼운다 — Settings에서 색을 바꾸는 경로다. 기하를 건드리지 + /// 않으므로 AX 재읽기가 필요 없다. + func setColor(_ color: NSColor?) { + pill.fillColor = color + } + + private func place(_ label: String, at frame: NSRect, color: NSColor?) { pill.label = label + pill.fillColor = color panel.setFrame(frame, display: true) } } diff --git a/VimAction/Preferences.swift b/VimAction/Preferences.swift index aab928d..fbc29c2 100644 --- a/VimAction/Preferences.swift +++ b/VimAction/Preferences.swift @@ -7,7 +7,8 @@ import Foundation import VimEngine /// UserDefaults 키 — 가로채기·탈출 두 키는 EventTapController가, 온스크린 인디케이터 -/// 토글·상시 표시·스타일 세 키는 ModeIndicatorController가 로드(init)·저장(didSet)하는 단일 소유다. +/// 토글·상시 표시·스타일·모드별 색 두 개까지 다섯 키는 ModeIndicatorController가 +/// 로드(init)·저장(didSet)하는 단일 소유다. /// (단수형 `PreferenceKey`는 SwiftUI 프로토콜과 이름이 충돌해 피한다.) /// `nonisolated` — 프로젝트 기본이 MainActor 격리라 키 상수까지 메인 격리가 붙는데, /// 킬스위치 발동은 전용 스레드에서 이 키로 영속하므로 격리 밖에서 읽을 수 있어야 한다. @@ -24,7 +25,14 @@ nonisolated enum PreferenceKeys { /// 온스크린 인디케이터의 상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열. /// 기본 배지. static let onScreenModeIndicatorStyle = "onScreenModeIndicatorStyle" - /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 넷과 달리 AppState가 소유한다. + /// Normal 모드 인디케이터 색 — sRGB `#RRGGBBAA` 문자열(대문자 8자리). **기본값 상수가 없는 + /// 것이 설계다**: 기본은 값의 부재(키 없음 = 시스템 강조색)라 설정을 건드리지 않은 사용자에게 + /// 외양 변화가 없다. 못 읽는 값도 부재로 접힌다 + /// (`20260916_mode-indicator-per-mode-colors.md`). + static let onScreenModeIndicatorNormalColor = "onScreenModeIndicatorNormalColor" + /// Visual 모드(VISUAL·V-LINE 공유) 인디케이터 색 — 위와 같은 형식·같은 기본값 규칙. + static let onScreenModeIndicatorVisualColor = "onScreenModeIndicatorVisualColor" + /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 키들과 달리 AppState가 소유한다. static let didShowOnboarding = "didShowOnboarding" /// 탈출 옵션의 제품 기본값 — EventTapController init이 읽는다. diff --git a/VimAction/SettingsView.swift b/VimAction/SettingsView.swift index a956768..4de43bc 100644 --- a/VimAction/SettingsView.swift +++ b/VimAction/SettingsView.swift @@ -220,9 +220,56 @@ private struct IndicatorTab: View { .font(.footnote) .foregroundStyle(.secondary) } + // 색은 별도 Section이다 — 위가 "무엇을 언제 띄우는가", 여기가 "어떤 색으로"라 + // 축이 다르다. 인디케이터가 꺼져 있으면 셋 다 고를 이유가 없어 통째로 비활성. + Section("Colors") { + ColorPicker("Normal", selection: colorBinding(\.normalColor), supportsOpacity: true) + ColorPicker("Visual", selection: colorBinding(\.visualColor), supportsOpacity: true) + // 미설정으로 되돌린다 — 컨트롤러가 키를 지우고 다시 강조색으로 그린다. + // 이미 둘 다 미설정이면 할 일이 없다. + Button("Reset to system accent") { + modeIndicator.normalColor = nil + modeIndicator.visualColor = nil + } + .disabled(modeIndicator.normalColor == nil && modeIndicator.visualColor == nil) + Text( + "VISUAL and V-LINE share the Visual color, and INSERT always uses the system accent color. The mode label stays fully opaque, so it still reads on a translucent background." + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + .disabled(!modeIndicator.isEnabled) } .formStyle(.grouped) } + + /// 미설정일 때 Picker가 보여 줄 실효 색 — **그 시점 강조색의 sRGB 해석**이다. + private var accentColor: NSColor { + NSColor.controlAccentColor.usingColorSpace(.sRGB) ?? .controlAccentColor + } + + /// `ColorPicker` 바인딩. get은 실효 색(사용자 색 ?? 강조색), set은 컨트롤러 프로퍼티에 + /// 대입한다 — 영속도 화면 반영도 컨트롤러 몫이고 뷰는 값을 나르기만 한다. + /// + /// **실효 색과 같으면 대입하지 않는 것이 계약이다**: 색 패널은 열기만 해도 현재 색을 set으로 + /// 돌려주는데, 그대로 받으면 미설정(= 동적 강조색)이 강조색 **스냅샷**으로 굳어 강조색을 + /// 바꿔도 따라오지 않고 Reset 버튼만 켜진다. 비교는 hex 문자열로 한다 — `NSColor` 동등성은 + /// 같은 색이라도 색공간 인스턴스에 흔들린다. 미설정 상태에서 강조색과 똑같은 색을 일부러 + /// 고르면 계속 미설정으로 남지만, 외양이 같아 관측되지 않는다. + private func colorBinding( + _ keyPath: ReferenceWritableKeyPath + ) -> Binding { + let controller = appState.modeIndicator + return Binding( + get: { Color(nsColor: controller[keyPath: keyPath] ?? accentColor) }, + set: { picked in + let new = NSColor(picked) + let effective = controller[keyPath: keyPath] ?? accentColor + guard ModeIndicatorColor.hex(from: new) != ModeIndicatorColor.hex(from: effective) + else { return } + controller[keyPath: keyPath] = new + }) + } } /// 설정 파일 상태 — **읽기 전용이다.** 설정 편집은 파일이 소유한다. UI가 쓰는 것은 메뉴바의 diff --git a/VimActionTests/ModeIndicatorColorTests.swift b/VimActionTests/ModeIndicatorColorTests.swift new file mode 100644 index 0000000..0fe57a2 --- /dev/null +++ b/VimActionTests/ModeIndicatorColorTests.swift @@ -0,0 +1,216 @@ +// +// ModeIndicatorColorTests.swift +// VimActionTests +// + +import AppKit +import Foundation +import Testing +import VimEngine + +@testable import VimAction + +/// 저장 문자열 ↔ 색 변환 — 순수 함수라 표로 고정한다. 값은 `defaults write`로 사용자가 직접 +/// 넣을 수 있으므로 **관용(대소문자)과 거절(그 밖 전부)의 경계가 곧 계약**이다. +struct ModeIndicatorColorHexTests { + @Test("hex 왕복 — 파싱한 색을 다시 쓰면 같은 문자열") + func roundTripsThroughColor() throws { + for hex in ["#FF8000CC", "#00000000", "#FFFFFFFF", "#123456AB"] { + let color = try #require(ModeIndicatorColor.color(fromHex: hex)) + #expect(ModeIndicatorColor.hex(from: color) == hex) + } + } + + /// 손으로 쓴 값이 소문자인 것은 흔하다 — 읽어 주되 쓸 때는 한 형태(대문자)로 모은다. + @Test("소문자 입력을 읽고 대문자로 직렬화한다") + func lowercaseInputNormalizesToUppercase() throws { + let color = try #require(ModeIndicatorColor.color(fromHex: "#ff8000cc")) + #expect(ModeIndicatorColor.hex(from: color) == "#FF8000CC") + } + + @Test("성분 순서는 RGBA다") + func componentsAreRedGreenBlueAlpha() throws { + let color = try #require(ModeIndicatorColor.color(fromHex: "#FF000080")) + let srgb = try #require(color.usingColorSpace(.sRGB)) + #expect(srgb.redComponent == 1) + #expect(srgb.greenComponent == 0) + #expect(srgb.blueComponent == 0) + #expect(abs(srgb.alphaComponent - 128.0 / 255) < 0.0001) + } + + /// 못 읽는 값은 전부 미설정(강조색)으로 접힌다 — 오타 하나로 인디케이터가 사라지면 안 된다. + /// `#+FFFFFFF`가 표에 있는 이유: `UInt32(_:radix:)`는 부호를 받아 주므로 자릿수 검사가 없으면 + /// 조용히 통과한다. + @Test("잘못된 입력은 전부 nil") + func malformedInputIsRejected() { + for hex in [ + "FF8000CC", "#FF8000", "#FF8000CCC", "#GGGGGGGG", "", "#", "#+FFFFFFF", "#FF8000C", + ] { + #expect(ModeIndicatorColor.color(fromHex: hex) == nil, "\(hex)") + } + } + + /// `ColorPicker`는 Display P3 색을 준다 — sRGB 변환이 색역 밖 성분(음수·1 초과)을 내므로 + /// clamp가 없으면 `%02X`가 8자리가 아닌 문자열을 만들어 **저장은 되는데 다시 읽히지 않는다**. + @Test("색역 밖 색도 유효한 8자리로 직렬화되고 다시 읽힌다") + func outOfGamutColorSerializesAndParsesBack() throws { + let p3 = NSColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1) + let hex = try #require(ModeIndicatorColor.hex(from: p3)) + #expect(hex.count == 9) + #expect(hex.hasPrefix("#")) + // `#expect` 안에서 `allSatisfy`를 부르면 매크로 확장이 rethrows를 잃는다 — 밖에서 값을 + // 만들어 넘긴다. + let digitsAreHex = hex.dropFirst().allSatisfy(\.isHexDigit) + #expect(digitsAreHex) + #expect(ModeIndicatorColor.color(fromHex: hex) != nil) + } +} + +/// 모드 → 색 표. Insert가 설정 대상이 아닌 것과 VISUAL·V-LINE이 한 색을 나눠 쓰는 것이 결정이다 +/// (`20260916_mode-indicator-per-mode-colors.md`). +struct ModeIndicatorColorForModeTests { + private let normal = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + private let visual = NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + + private func color(_ mode: Mode) -> NSColor? { + ModeIndicatorColor.color(for: mode, normal: normal, visual: visual) + } + + @Test("Normal은 Normal 색, Visual 둘은 Visual 색") + func modesMapToTheirColor() { + #expect(color(.normal) == normal) + #expect(color(.visualChar) == visual) + #expect(color(.visualLine) == visual) + } + + /// Insert는 "평소 타이핑으로 돌아왔다"는 중립 신호라 계속 강조색이다. + @Test("Insert는 언제나 nil(강조색)") + func insertNeverTakesAUserColor() { + #expect(color(.insert) == nil) + } + + @Test("미설정이면 모든 모드가 nil") + func unsetColorsFallBackForEveryMode() { + for mode in [Mode.normal, .insert, .visualChar, .visualLine] { + #expect(ModeIndicatorColor.color(for: mode, normal: nil, visual: nil) == nil) + } + } +} + +/// 색의 소유·영속 — 토글·스타일과 같은 소유 모델(런타임 SSOT는 프로퍼티, didSet이 저장)이고 +/// 같은 단언 함정(`object(forKey:) != nil` 선행)을 지킨다. 패널도 AX도 만들지 않는다. +@MainActor +struct ModeIndicatorColorPersistenceTests { + /// `#FF8000CC` = (1, 0.5, 0, 0.8) — 0.5·0.8이 각각 0x80·0xCC로 떨어져 반올림까지 고정된다. + private let orange = NSColor(srgbRed: 1, green: 0.5, blue: 0, alpha: 0.8) + + /// 기본은 값의 **부재**다 — 설정을 건드리지 않은 사용자는 지금까지와 똑같은 강조색을 본다. + @Test("미설정 키 → 둘 다 nil") + func defaultsToUnset() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + #expect(controller.normalColor == nil) + #expect(controller.visualColor == nil) + } + } + + @Test("Normal 색 영속: didSet 저장 → 새 컨트롤러 init 로드") + func normalColorPersistsAcrossControllers() { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.normalColor = orange + // 존재 확인이 먼저 — 이것 없이는 영속을 통째로 지워도 "미설정"으로 통과한다. + #expect( + defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) != nil) + #expect( + defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) + == "#FF8000CC") + + let second = ModeIndicatorController(defaults: defaults) + #expect( + second.normalColor.flatMap(ModeIndicatorColor.hex(from:)) == "#FF8000CC") + } + } + + /// 키가 갈려 있어야 Normal을 고르는 것이 Visual을 덮지 않는다. + @Test("Visual 색은 별도 키에 영속된다") + func visualColorUsesItsOwnKey() { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.visualColor = orange + #expect( + defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) != nil) + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) == nil) + + let second = ModeIndicatorController(defaults: defaults) + #expect(second.visualColor.flatMap(ModeIndicatorColor.hex(from:)) == "#FF8000CC") + #expect(second.normalColor == nil) + } + } + + /// "Reset to system accent"의 경로 — 키를 지워야 강조색이 **동적으로** 따라온다. 값을 남긴 + /// 채 무시하면 다음 실행에 되살아난다. + @Test("nil로 되돌리면 키가 지워진다") + func resettingRemovesTheKeys() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + controller.normalColor = orange + controller.visualColor = orange + controller.normalColor = nil + controller.visualColor = nil + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) == nil) + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) == nil) + + let second = ModeIndicatorController(defaults: defaults) + #expect(second.normalColor == nil) + #expect(second.visualColor == nil) + } + } + + /// 손으로 쓴 값이 깨져 있어도 인디케이터는 떠야 한다 — 미설정으로 접힌다. + @Test("못 읽는 저장 값은 미설정으로 접힌다") + func unreadableStoredValueFallsBackToUnset() { + withTemporaryDefaults { defaults in + defaults.set("neon", forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) + #expect(ModeIndicatorController(defaults: defaults).normalColor == nil) + } + } + + /// 토글·스타일과 같은 근거 — 입력이 밀린 적 없으면 화면에도 AX에도 닿지 않는다(패널은 첫 + /// 표시에서야 만들어진다). + @Test("입력이 밀린 적 없으면 색 변경은 무해하다") + func changingColorsWithoutInputsIsHarmless() { + withTemporaryDefaults { defaults in + let controller = ModeIndicatorController(defaults: defaults) + controller.normalColor = orange + controller.visualColor = nil + #expect(controller.normalColor == orange) + #expect(controller.visualColor == nil) + } + } +} + +/// 사용자 색 위의 글씨 — 강조색과 같은 판정을 그대로 탄다. **알파를 보지 않는 것이 계약이다**: +/// 배경을 아무리 투명하게 해도 라벨은 불투명한 흑·백으로 남아야 한다(라벨은 항상 동반된다는 +/// PRD 접근성 NFR). +@MainActor +struct ModeIndicatorUserColorTextTests { + @Test("밝은 사용자 색은 검은 글씨로 뒤집힌다") + func brightUserColorFlipsToBlackText() { + #expect( + ModeIndicatorPanel.textColor(on: NSColor(srgbRed: 1, green: 0.84, blue: 0, alpha: 1)) + == .black) + } + + @Test("알파는 글씨색 판정을 바꾸지 않는다") + func alphaDoesNotChangeTheVerdict() { + for alpha in [CGFloat(0), 0.2, 1] { + #expect( + ModeIndicatorPanel.textColor( + on: NSColor(srgbRed: 1, green: 0.84, blue: 0, alpha: alpha)) == .black) + #expect( + ModeIndicatorPanel.textColor( + on: NSColor(srgbRed: 0, green: 0.48, blue: 1, alpha: alpha)) == .white) + } + } +} diff --git a/VimActionTests/ModeIndicatorControllerTests.swift b/VimActionTests/ModeIndicatorControllerTests.swift index 6c5f1f0..401707e 100644 --- a/VimActionTests/ModeIndicatorControllerTests.swift +++ b/VimActionTests/ModeIndicatorControllerTests.swift @@ -102,16 +102,16 @@ struct ModeIndicatorPresentationTests { /// 자리에 남고, 넘치면 전환마다 AX 왕복이 곱해진다)는 것이 표로 고정하는 이유다. struct ModeIndicatorReadCoalescingTests { private let normal = ModeIndicatorController.Presentation( - label: "NORMAL", showsBadge: true, processID: 42, style: .badge) + mode: .normal, showsBadge: true, processID: 42, style: .badge) private let insert = ModeIndicatorController.Presentation( - label: "INSERT", showsBadge: false, processID: 42, style: .badge) + mode: .insert, showsBadge: false, processID: 42, style: .badge) private let normalBorder = ModeIndicatorController.Presentation( - label: "NORMAL", showsBadge: true, processID: 42, style: .screenBorder) + mode: .normal, showsBadge: true, processID: 42, style: .screenBorder) private let insertBorder = ModeIndicatorController.Presentation( - label: "INSERT", showsBadge: false, processID: 42, style: .screenBorder) + mode: .insert, showsBadge: false, processID: 42, style: .screenBorder) /// 상시 표시를 끈 설정의 Normal — 판정 결과는 Insert와 같은 모양(배지 없음)이다. private let normalFlashOnly = ModeIndicatorController.Presentation( - label: "NORMAL", showsBadge: false, processID: 42, style: .badge) + mode: .normal, showsBadge: false, processID: 42, style: .badge) private func needsRead( desired: ModeIndicatorController.Presentation, @@ -184,7 +184,7 @@ struct ModeIndicatorReadCoalescingTests { @Test("상시 표시가 떠 있는 채로 스타일이 바뀌면 읽는다") func styleSwitchWithBadgeShowingReads() { let normalWindowBorder = ModeIndicatorController.Presentation( - label: "NORMAL", showsBadge: true, processID: 42, style: .windowBorder) + mode: .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)) From 6f4679a76e4a5c0f62ab9429273cdfedbf12859b Mon Sep 17 00:00:00 2001 From: pilyang Date: Wed, 16 Sep 2026 12:16:55 +0900 Subject: [PATCH 2/2] feat(app): add an insert color to the indicator color settings the insert flash gets its own color next to normal and visual, chosen in the same colors section (normal / insert / visual order); reset clears all three. same storage format, unset default and repaint path as the other two. this follows the insert color decision that partially supersedes the morning's per-mode color decision. Co-Authored-By: Claude Fable 5.1 --- .../architecture/references/app-shell.md | 4 +- .../references/mode-indicator-overlay.md | 18 ++++---- .../references/profiles-and-config.md | 4 +- VimAction/ModeIndicatorColor.swift | 13 +++--- VimAction/ModeIndicatorController.swift | 22 +++++++-- VimAction/Preferences.swift | 6 ++- VimAction/SettingsView.swift | 10 +++-- VimActionTests/ModeIndicatorColorTests.swift | 45 ++++++++++++++----- 8 files changed, 85 insertions(+), 37 deletions(-) diff --git a/.claude/skills/architecture/references/app-shell.md b/.claude/skills/architecture/references/app-shell.md index 100a502..b9657aa 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-16 (Indicator 탭 색 항목) +- **Last updated**: 2026-09-16 (Indicator 탭 색 항목 — 모드 셋) ## 현재 구조 @@ -20,7 +20,7 @@ ### 설정 창 — `TabView` 4탭, 460×560 고정 -**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Indicator**(탭 아이콘 `text.cursor`, 섹션 둘 — 무엇을 언제 띄우는가: 온스크린 모드 인디케이터 토글과 그 아래 "Show" Picker(전환 시만 / Normal·Visual 동안도 — 토글이 꺼지면 `.disabled`)·"Indicator style" Picker(배지 / 창 테두리 / 화면 테두리 — 토글이 꺼지거나 상시 표시가 없으면 `.disabled`) / "Colors": Normal·Visual `ColorPicker`(`supportsOpacity: true`, 미설정이면 그 시점 강조색의 sRGB 해석을 보여 준다 — **실효 색과 같은 값은 컨트롤러에 대입하지 않아** 패널을 열기만 해서는 강조색이 스냅샷으로 굳지 않는다)와 "Reset to system accent" 버튼(둘 다 미설정이면 `.disabled`), 섹션 통째로 토글에 `.disabled`. 값·영속·화면 반영은 `ModeIndicatorController` 소유이고 뷰는 바인딩만, [mode-indicator-overlay.md](mode-indicator-overlay.md)) / **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), [20260915_settings-window-indicator-tab.md](../../decisions/references/20260915_settings-window-indicator-tab.md)). +**General**(권한 상태 + Behavior[로그인 시 자동 시작·Normal 탈출] + Updates 토글 + 접힌 `DisclosureGroup` "Diagnostics") / **Indicator**(탭 아이콘 `text.cursor`, 섹션 둘 — 무엇을 언제 띄우는가: 온스크린 모드 인디케이터 토글과 그 아래 "Show" Picker(전환 시만 / Normal·Visual 동안도 — 토글이 꺼지면 `.disabled`)·"Indicator style" Picker(배지 / 창 테두리 / 화면 테두리 — 토글이 꺼지거나 상시 표시가 없으면 `.disabled`) / "Colors": Normal·Insert·Visual `ColorPicker`(그 순서, `supportsOpacity: true`, 미설정이면 그 시점 강조색의 sRGB 해석을 보여 준다 — **실효 색과 같은 값은 컨트롤러에 대입하지 않아** 패널을 열기만 해서는 강조색이 스냅샷으로 굳지 않는다)와 "Reset to system accent" 버튼(셋을 모두 지우고, 셋 다 미설정이면 `.disabled`), 섹션 통째로 토글에 `.disabled`. 값·영속·화면 반영은 `ModeIndicatorController` 소유이고 뷰는 바인딩만, [mode-indicator-overlay.md](mode-indicator-overlay.md)) / **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), [20260915_settings-window-indicator-tab.md](../../decisions/references/20260915_settings-window-indicator-tab.md)). ### 로그인 시 자동 시작 — `SMAppService.mainApp` diff --git a/.claude/skills/architecture/references/mode-indicator-overlay.md b/.claude/skills/architecture/references/mode-indicator-overlay.md index c01ffd3..3e20570 100644 --- a/.claude/skills/architecture/references/mode-indicator-overlay.md +++ b/.claude/skills/architecture/references/mode-indicator-overlay.md @@ -6,7 +6,7 @@ 화면의 표시는 **두 겹**이다. 모드가 바뀌면 **순간 표시**(flash)가 **캐럿 아래**(캐럿이 없으면 포커스 요소 근처)에 약 1초 뜨고 페이드아웃하며, **Insert가 아닌 동안**에는 **상시 표시**가 계속 남는다 (Insert는 상시 표시 없음). 상시 표시는 Settings에서 끌 수도 있고("Only when the mode changes" — 그러면 flash만 남는다) 형태도 고른다 — 기본은 요소 모서리의 한 단계 작은 **배지**, 대체 스타일은 포커스 창 rect를 두르는 **창 테두리**와 포커스 창이 있는 디스플레이를 두르는 **화면 테두리**(둘 다 강조색 프레임 + 오른쪽 위 모서리 라벨). 어느 스타일이든 flash는 캐럿/요소 사다리를 탄다. 전부 Settings Indicator 탭 토글 하나로 꺼진다. 갱신은 **이벤트 기반만**이다 — 타이머 폴링도, 키마다 재배치도 없다. -색은 **모드별로 고른다** — Normal 색과 Visual 색(VISUAL·V-LINE 공유) 둘이고 투명도는 색의 알파다. 둘 다 **미설정이 기본**이라 그때는 시스템 강조색으로 그린다(설정을 건드리지 않은 사용자에게 외양 변화가 없다). INSERT flash는 설정 대상이 아니라 언제나 강조색이다. 알약 배경·테두리 선·모서리 라벨이 그 색을 쓰고, 글씨는 불투명하게 남는다. +색은 **모드별로 고른다** — Normal·Insert·Visual(VISUAL·V-LINE 공유) 셋이고 투명도는 색의 알파다. 셋 다 **미설정이 기본**이라 그때는 시스템 강조색으로 그린다(설정을 건드리지 않은 사용자에게 외양 변화가 없다). Insert에는 상시 표시가 없으므로 Insert 색이 보이는 곳은 전환 순간의 INSERT flash뿐이다. 알약 배경·테두리 선·모서리 라벨이 그 색을 쓰고, 글씨는 불투명하게 남는다. ```mermaid graph LR @@ -23,8 +23,8 @@ graph LR | 구성 요소 | 격리 | 역할 | |---|---|---| -| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:isPersistentEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 다섯(`isEnabled`·`isPersistentEnabled`·`style: ModeIndicatorPresentationStyle`·`normalColor`·`visualColor`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리이고, 테두리 패널이 지금 어느 스타일로 떠 있는지(`borderPanelStyle`)를 기억해 창↔화면 테두리 전환도 이전 형태를 즉시 감춘다. 색 변경만은 `reconcile`이 아니라 `repaintColors()`가 받는다 — `current.mode`의 색을 떠 있는 패널 셋에 바로 밀어 넣는다 | -| `ModeIndicatorColor` | `nonisolated` 순수 | 색의 순수 계층 — 저장 문자열 ↔ 색(`color(fromHex:)`·`hex(from:)`, sRGB `#RRGGBBAA` 대문자 8자리, `#` 필수·8자리 hex만·대소문자 관용, 그 밖은 전부 미설정으로 접힘)과 모드→색 표(`color(for:normal:visual:)` — `.normal`→Normal, `.visualChar`/`.visualLine`→Visual, `.insert`→`nil`). 직렬화는 sRGB 변환 뒤 성분을 `0...1`로 **clamp**하고 반올림한다 | +| `ModeIndicatorController` (`AppState` 소유) | `@MainActor @Observable` | 다섯 트리거의 합류점. 표시 판정은 순수 `presentation(isEnabled:isPersistentEnabled:style:inputs:)`, 조율은 `reconcile(flashes:rereadGeometry:)` 하나. `pending` 한 칸이 최신 요청만 들고 읽기는 동시에 하나이며, 표시를 바꾸는 트리거가 `token`을 올려 늦게 착지한 읽기를 버린다. 설정 여섯(`isEnabled`·`isPersistentEnabled`·`style: ModeIndicatorPresentationStyle`·`normalColor`·`insertColor`·`visualColor`)의 소유자이자 전용 큐 `dev.pilyang.VimAction.mode-indicator-geometry`(`.utility`)의 소유자. 상시 표시 패널의 숨김은 `hidePersistentPanels(keeping:)` 한 자리이고, 테두리 패널이 지금 어느 스타일로 떠 있는지(`borderPanelStyle`)를 기억해 창↔화면 테두리 전환도 이전 형태를 즉시 감춘다. 색 변경만은 `reconcile`이 아니라 `repaintColors()`가 받는다 — `current.mode`의 색을 떠 있는 패널 셋에 바로 밀어 넣는다 | +| `ModeIndicatorColor` | `nonisolated` 순수 | 색의 순수 계층 — 저장 문자열 ↔ 색(`color(fromHex:)`·`hex(from:)`, sRGB `#RRGGBBAA` 대문자 8자리, `#` 필수·8자리 hex만·대소문자 관용, 그 밖은 전부 미설정으로 접힘)과 모드→색 표(`color(for:normal:insert:visual:)` — `.normal`→Normal, `.insert`→Insert, `.visualChar`/`.visualLine`→Visual). 직렬화는 sRGB 변환 뒤 성분을 `0...1`로 **clamp**하고 반올림한다 | | `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·배지 알약. `flash`/`show`가 색을 함께 받고, `setColor(_:)`는 기하를 건드리지 않고 색만 갈아 끼운다. `ModeIndicatorStyle`이 폰트·여백·radius·창 층을 가른다: flash는 bold 12pt/`.statusBar`, 배지는 semibold 10pt/`.statusBar - 1`. `flash`는 0.15/0.7/0.3s 페이드, `show`는 애니메이션도 타이머도 없다. 각각 첫 표시에서 lazy 생성 | @@ -40,7 +40,7 @@ graph LR | ① | 모드 전환 | `updateMode` → `onModeChange` → `modeDidChange` | flash + 상시 표시, 항상 읽는다 (캐럿 포함) | | ② | 포커스 요소 변경·앱 활성화·창 이동·리사이즈 | `FocusedElementResolver.onFocusGeometryChanged` → (`EventTapController` 통로) → `anchorDidChange` | 상시 표시가 보여야 할 때만 읽는다 (캐럿 없이) | | ③ | 사다리 변화 (탭 고장·마스터 off·킬스위치·앱별 disabled·Secure Input·설정 리로드) | `AppState.trackIndicatorLadder()`의 `withObservationTracking` 재무장 루프 → `stateDidChange` | 표시할 것이 실제로 달라졌을 때만 읽는다 | -| ④ | Settings 토글·상시 표시·스타일 | `isEnabled.didSet`·`isPersistentEnabled.didSet`·`style.didSet` (색 둘은 여기 없다 — `reconcile`을 타지 않는다) | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 상시 표시 off는 `showsBadge`가 접혀 Insert와 같은 경로 — 앵커 이벤트에 AX 왕복 0건; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | +| ④ | Settings 토글·상시 표시·스타일 | `isEnabled.didSet`·`isPersistentEnabled.didSet`·`style.didSet` (색 셋은 여기 없다 — `reconcile`을 타지 않는다) | 마지막 입력으로 다시 판정 (off는 즉시 숨김, on은 즉시 복귀; 상시 표시 off는 `showsBadge`가 접혀 Insert와 같은 경로 — 앵커 이벤트에 AX 왕복 0건; 스타일 변경은 `Presentation`에 실린 스타일로 `desired != current`가 되어 상시 표시가 있으면 다시 읽어 새 형태로 그리고, 이전 형태는 즉시 감춘다) | | ⑤ | 디스플레이 재구성 | `NSApplication.didChangeScreenParametersNotification` → `anchorDidChange` | ②와 같다 | ## 불변식·계약 @@ -61,11 +61,11 @@ graph LR - **좌표 변환은 전역 flip 하나**: `y' = NSScreen.screens[0].frame.maxY - (y + h)`. 보조 디스플레이(AX x 음수)에서도 그대로다. - **클램프는 `visibleFrame`, 화면 선택은 `frame`.** 텍스트 뷰가 창 전체인 앱을 최대화하면 "요소 위쪽 바깥" 규칙이 알약을 메뉴바·노치 뒤로 보낸다. 반대로 화면 선택까지 `visibleFrame`으로 하면 메뉴바 띠 안의 앵커가 자기 디스플레이를 못 찾고 주 화면으로 튄다. 화면 테두리도 같은 축이다 — 앵커 rect(요소 또는 창)와 `frame`이 겹치는 화면을 고르고(AX를 더 읽지 않는다 — 요소는 창 안에 있어 같은 디스플레이다, 없으면 첫 화면), 그 화면의 **`visibleFrame`**을 두른다(`frame`을 두르면 위 변이 메뉴바 뒤, 아래 변이 Dock 뒤에 숨는다). 창 테두리는 이 축을 타지 않는다 — 창 rect를 그대로 두르고(화면 밖으로 나간 만큼 잘린다), 라벨만 앵커 화면의 `visibleFrame`과 창 frame 안으로 민다. - **앵커가 없으면 재시도하지 않는다** — 그것이 답이고, 다음 앵커 이벤트가 다시 부른다. -- **색 변경은 기하 재읽기를 내지 않는다.** 색은 `Presentation`에 싣지 않고(실으면 `desired != current`가 참이 되어 기하가 그대로인데 AX 왕복이 난다) `needsGeometryRead`도 보지 않는다 — `normalColor`·`visualColor`의 didSet은 영속 + `repaintColors()`뿐이다. 그래서 **자기 pid 경로(설정 창이 열린 상태)에서도 색이 즉시 반영된다**: 그 경로는 읽지도 `current`를 건드리지도 않아 `current`가 화면에 남아 있는 이전 앱의 표시 그대로이고, 재도색은 그 값으로 돈다. +- **색 변경은 기하 재읽기를 내지 않는다.** 색은 `Presentation`에 싣지 않고(실으면 `desired != current`가 참이 되어 기하가 그대로인데 AX 왕복이 난다) `needsGeometryRead`도 보지 않는다 — 색 셋의 didSet은 영속 + `repaintColors()`뿐이다. 그래서 **자기 pid 경로(설정 창이 열린 상태)에서도 색이 즉시 반영된다**: 그 경로는 읽지도 `current`를 건드리지도 않아 `current`가 화면에 남아 있는 이전 앱의 표시 그대로이고, 재도색은 그 값으로 돈다. - **`Presentation`은 라벨이 아니라 모드를 싣는다.** 떠 있는 패널에 색을 밀어 넣으려면 그것이 어느 모드를 보여 주는지 알아야 한다 — `label`은 `mode.overlayLabel` 파생이라 동등성 의미는 라벨을 실었을 때와 같다. - **글씨 색은 실효 배경색(사용자 색, 미설정이면 강조색)에서 파생된다** — 흰 글씨 대비가 3:1 아래로 떨어지는 밝은 색(노랑 1.41:1·초록·주황·그래파이트)에서만 검은 글씨다. **판정은 알파를 보지 않는다** — 성분 셋으로만 휘도를 내므로 반투명 배경이어도 라벨은 불투명한 흑·백으로 남는다. 색만으로 구분하지 않는다는 PRD NFR 때문에 라벨 텍스트는 항상 동반된다 — 화면 테두리도 모서리 라벨이 같은 알약 뷰라 규칙이 하나다. - **flash가 배지 위에 그려지는 것은 창 층의 차이로 보장된다**(flash `.statusBar`, 상시 표시 `.statusBar - 1`). 캐럿이 없는 앱에서는 flash가 배지를 완전히 덮고, 캐럿이 있는 앱에서는 flash가 캐럿 아래·배지가 요소 모서리라 **전환 직후 1초간 같은 라벨 둘이 함께 보인다** — flash는 캐럿부터라는 결정의 직접 결과다. -- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종·창 테두리 6종), `ModeIndicatorControllerTests`(표시 판정 표(상시 표시 off 행 포함)·읽기 코얼레싱 판정 표(스타일 변경·상시 표시 토글 행 포함)·토글 영속·상시 표시 영속·스타일 영속(raw 값 리터럴 포함)·자기 pid 토큰 무효화·대비), `ModeIndicatorColorTests`(hex 왕복·대소문자·잘못된 입력·색역 밖 색·모드→색 표·색 영속(키 둘 분리)·미설정 기본·Reset의 키 삭제·못 읽는 값·사용자 색 글씨 대비와 알파 무시). +- **순수 계층은 테스트로 고정된다**: `ModeIndicatorLayoutTests`(알약별 사다리·퇴화 rect·캐럿 유효성 4종·배치·flip·보조 디스플레이·클램프·메뉴바 띠·nil·화면 테두리 5종·창 테두리 6종), `ModeIndicatorControllerTests`(표시 판정 표(상시 표시 off 행 포함)·읽기 코얼레싱 판정 표(스타일 변경·상시 표시 토글 행 포함)·토글 영속·상시 표시 영속·스타일 영속(raw 값 리터럴 포함)·자기 pid 토큰 무효화·대비), `ModeIndicatorColorTests`(hex 왕복·대소문자·잘못된 입력·색역 밖 색·모드→색 표·색 영속(키 셋 분리)·미설정 기본·Reset의 키 삭제·못 읽는 값·사용자 색 글씨 대비와 알파 무시). - 테스트에서는 아무 일도 하지 않는다 — 배선이 `bootstrap()`의 XCTest 가드 뒤이고, 입력이 밀린 적 없으면 판정이 통째로 막히며, 패널은 첫 표시에서야 만들어진다. 자기 pid 경로는 그 전제 위에서 테스트가 직접 부른다(읽지도 그리지도 않아 `token`만 관측된다 — 그래서 `token`은 `private(set)`). ### 알려진 한계 @@ -79,13 +79,13 @@ graph LR ## 근거 요약 -메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 창·화면 테두리는 대체 스타일, 순간 표시만은 opt-out), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권, 모드별 색(모드 구분은 Vim 상태줄 관례대로 색이 거드는 신호이고 라벨이 항상 동반된다)은 각각 결정 문서에 있다. +메뉴바 글리프는 시야 밖이라 모드 인지 문제를 풀지 못한다. 표시 정책(전환 시 순간 표시 + 비-Insert 상시 표시, 창·화면 테두리는 대체 스타일, 순간 표시만은 opt-out), 앵커 사다리(flash는 캐럿부터·상시 표시는 요소부터)와 이벤트 기반 갱신, Chromium 스크린리더 모드 미강제, 설정 소유권, 모드별 색 셋(모드 구분은 Vim 상태줄 관례대로 색이 거드는 신호이고 라벨이 항상 동반된다)은 각각 결정 문서에 있다. -- 관련 결정: [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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.md), [20260916_mode-indicator-insert-color.md](../../decisions/references/20260916_mode-indicator-insert-color.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/ModeIndicatorColor.swift`, `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·색 `ColorPicker` 둘·Reset 버튼), `VimAction/Preferences.swift`(키 다섯) +- 코드: `VimAction/ModeIndicatorController.swift`(`ModeIndicatorPresentationStyle` 포함), `VimAction/ModeIndicatorColor.swift`, `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·색 `ColorPicker` 셋·Reset 버튼), `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 50ce331..1fb979d 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-09-16 (온스크린 인디케이터 모드별 색 키 둘 추가) +- **Last updated**: 2026-09-16 (온스크린 인디케이터 모드별 색 키 셋 추가) ## 현재 구조 @@ -96,7 +96,7 @@ actions: # 명령 계열: 그 액션 자신의 키 교 **"사용자가 파일로 관리하고 싶은 설정"은 YAML, "앱이 스스로 쓰는 상태"는 UserDefaults.** -- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorPersistentEnabled`(상시 표시 on/off — off면 순간 표시만, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`windowBorder`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본)·`onScreenModeIndicatorNormalColor`·`onScreenModeIndicatorVisualColor`(모드별 인디케이터 색 — sRGB `#RRGGBBAA` 대문자 8자리, **키 부재가 기본**이고 그것이 "시스템 강조색"을 뜻한다; 못 읽는 값도 부재로 접히고, 미설정으로 되돌리면 키를 지운다) — 다섯 다 표시 취향이지 앱별 동작 정의가 아니고, 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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.md)). +- UserDefaults 잔류: `interceptionEnabled`(마스터 토글 — 킬스위치가 전용 스레드에서 직접 영속하는 안전 경로라 파일 IO에 의존시키지 않는다), `normalModeEscapeEnabled`(기존 Settings UI 토글 유지), `onScreenModeIndicatorEnabled`(온스크린 모드 인디케이터 on/off, 기본 on)·`onScreenModeIndicatorPersistentEnabled`(상시 표시 on/off — off면 순간 표시만, 기본 on)·`onScreenModeIndicatorStyle`(상시 표시 스타일 — `ModeIndicatorPresentationStyle`의 raw 문자열 `badge`/`windowBorder`/`screenBorder`, 기본 `badge`, 없는 값·모르는 값은 기본)·`onScreenModeIndicatorNormalColor`·`onScreenModeIndicatorInsertColor`·`onScreenModeIndicatorVisualColor`(모드별 인디케이터 색 — sRGB `#RRGGBBAA` 대문자 8자리, **키 부재가 기본**이고 그것이 "시스템 강조색"을 뜻한다; 못 읽는 값도 부재로 접히고, 미설정으로 되돌리면 키를 지운다) — 여섯 다 표시 취향이지 앱별 동작 정의가 아니고, 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), [20260916_mode-indicator-per-mode-colors.md](../../decisions/references/20260916_mode-indicator-per-mode-colors.md), [20260916_mode-indicator-insert-color.md](../../decisions/references/20260916_mode-indicator-insert-color.md)). - YAML: 앱별 on/off, 프로파일 전부. - **어느 쪽도 아닌 셋째 칸이 있다 — 시스템이 소유하는 상태.** 로그인 시 자동 시작은 `SMAppService.mainApp.status`가 SSOT이고 앱은 값을 저장하지 않는다(사용자가 시스템 설정에서 통지 없이 바꾸므로 미러는 반드시 어긋난다 — [app-shell.md](app-shell.md)). Sparkle 자동 확인도 같은 성격으로, 값의 영속은 Sparkle이 소유한다. diff --git a/VimAction/ModeIndicatorColor.swift b/VimAction/ModeIndicatorColor.swift index 3d9dd5d..47f69a5 100644 --- a/VimAction/ModeIndicatorColor.swift +++ b/VimAction/ModeIndicatorColor.swift @@ -46,14 +46,17 @@ nonisolated enum ModeIndicatorColor { /// 모드 → 사용자 색. `nil`이면 시스템 강조색으로 그린다(미설정의 표현이다). /// - /// Insert는 설정 대상이 아니다 — 기본 상태(상시 표시 없음)라 flash의 강조색이 "평소 타이핑으로 - /// 돌아왔다"는 중립 신호로 남아야 한다. VISUAL·V-LINE은 한 색을 나눠 쓴다: 둘은 같은 모드 - /// 계열이고, 색은 모드 계열을 가르는 두 번째 신호다(첫째는 항상 동반되는 라벨). - static func color(for mode: Mode, normal: NSColor?, visual: NSColor?) -> NSColor? { + /// 세 모드가 같은 규칙을 탄다 — 모델에 예외가 없다("모드별 색, 미설정은 강조색"). + /// Insert에는 상시 표시가 없으므로 그 색이 보이는 곳은 전환 순간의 INSERT flash뿐이고 + /// (`20260916_mode-indicator-insert-color.md`), VISUAL·V-LINE은 한 색을 나눠 쓴다: 둘은 같은 + /// 모드 계열이고, 색은 모드 계열을 가르는 두 번째 신호다(첫째는 항상 동반되는 라벨). + static func color(for mode: Mode, normal: NSColor?, insert: NSColor?, visual: NSColor?) + -> NSColor? + { switch mode { case .normal: normal + case .insert: insert case .visualChar, .visualLine: visual - case .insert: nil } } } diff --git a/VimAction/ModeIndicatorController.swift b/VimAction/ModeIndicatorController.swift index f13306a..dd8ff8f 100644 --- a/VimAction/ModeIndicatorController.swift +++ b/VimAction/ModeIndicatorController.swift @@ -142,8 +142,9 @@ final class ModeIndicatorController { /// 온스크린 인디케이터 on/off. **런타임 SSOT는 이 프로퍼티**이고 didSet이 저장을 /// 책임진다 — 실행 중 외부 `defaults write`는 재시작까지 무시된다 (탈출 옵션과 같은 - /// 소유 모델). `isPersistentEnabled`·`style`과 함께 `@ObservationIgnored`가 아닌 단 셋의 - /// 저장 프로퍼티다: Settings 토글이 여기 바인딩된다. + /// 소유 모델). `isPersistentEnabled`·`style`·모드별 색 셋과 함께 `@ObservationIgnored`가 + /// 아닌 저장 프로퍼티다 — Settings가 바인딩하는 것이 그 여섯뿐이고, 나머지 내부 상태가 전부 + /// ignored인 덕에 사다리 관찰 루프가 읽기 진행 상태까지 추적하지 않는다. var isEnabled: Bool { didSet { guard oldValue != isEnabled else { return } @@ -196,6 +197,16 @@ final class ModeIndicatorController { } } + /// Insert 모드 인디케이터 색 — `normalColor`와 같은 소유 모델·같은 규칙. Insert에는 상시 + /// 표시가 없으므로 이 색이 보이는 곳은 전환 순간의 INSERT flash뿐이다 + /// (`20260916_mode-indicator-insert-color.md`). + var insertColor: NSColor? { + didSet { + persist(insertColor, forKey: PreferenceKeys.onScreenModeIndicatorInsertColor) + repaintColors() + } + } + /// Visual 모드(VISUAL·V-LINE 공유) 인디케이터 색 — `normalColor`와 같은 소유 모델·같은 규칙. var visualColor: NSColor? { didSet { @@ -270,6 +281,8 @@ final class ModeIndicatorController { // 경로를 만들지 않는다. self.normalColor = Self.storedColor( defaults, forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) + self.insertColor = Self.storedColor( + defaults, forKey: PreferenceKeys.onScreenModeIndicatorInsertColor) self.visualColor = Self.storedColor( defaults, forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) } @@ -435,7 +448,7 @@ final class ModeIndicatorController { // 사용자 색(미설정이면 `nil` → 패널이 강조색으로 그린다). 한 번 구해 flash·상시 표시가 // 같은 값을 쓴다 — 둘은 같은 요청의 같은 모드다. let color = ModeIndicatorColor.color( - for: request.mode, normal: normalColor, visual: visualColor) + for: request.mode, normal: normalColor, insert: insertColor, visual: visualColor) // flash 사다리(캐럿 → 요소 → 창)가 배지 사다리를 포함하므로, flash 프레임이 없으면 // 앵커가 아예 없다 — 사다리의 마지막 단이고, 붙일 곳이 없으면 표시하지 않는다. // **재시도는 없다**: 앵커가 없다는 것이 답이고, 다음 앵커 이벤트가 이 경로를 다시 부른다. @@ -505,7 +518,8 @@ final class ModeIndicatorController { /// 패널은 첫 표시에서야 만들어지므로 아무것도 뜬 적 없으면 통째로 no-op이다. private func repaintColors() { let color = current.flatMap { - ModeIndicatorColor.color(for: $0.mode, normal: normalColor, visual: visualColor) + ModeIndicatorColor.color( + for: $0.mode, normal: normalColor, insert: insertColor, visual: visualColor) } flashPanel?.setColor(color) badgePanel?.setColor(color) diff --git a/VimAction/Preferences.swift b/VimAction/Preferences.swift index fbc29c2..4aea407 100644 --- a/VimAction/Preferences.swift +++ b/VimAction/Preferences.swift @@ -7,7 +7,7 @@ import Foundation import VimEngine /// UserDefaults 키 — 가로채기·탈출 두 키는 EventTapController가, 온스크린 인디케이터 -/// 토글·상시 표시·스타일·모드별 색 두 개까지 다섯 키는 ModeIndicatorController가 +/// 토글·상시 표시·스타일·모드별 색 셋까지 여섯 키는 ModeIndicatorController가 /// 로드(init)·저장(didSet)하는 단일 소유다. /// (단수형 `PreferenceKey`는 SwiftUI 프로토콜과 이름이 충돌해 피한다.) /// `nonisolated` — 프로젝트 기본이 MainActor 격리라 키 상수까지 메인 격리가 붙는데, @@ -30,6 +30,10 @@ nonisolated enum PreferenceKeys { /// 외양 변화가 없다. 못 읽는 값도 부재로 접힌다 /// (`20260916_mode-indicator-per-mode-colors.md`). static let onScreenModeIndicatorNormalColor = "onScreenModeIndicatorNormalColor" + /// Insert 모드 인디케이터 색 — 위와 같은 형식·같은 기본값 규칙. Insert에는 상시 표시가 + /// 없으므로 이 색이 보이는 곳은 전환 순간의 INSERT flash뿐이다 + /// (`20260916_mode-indicator-insert-color.md`). + static let onScreenModeIndicatorInsertColor = "onScreenModeIndicatorInsertColor" /// Visual 모드(VISUAL·V-LINE 공유) 인디케이터 색 — 위와 같은 형식·같은 기본값 규칙. static let onScreenModeIndicatorVisualColor = "onScreenModeIndicatorVisualColor" /// 최초 실행 온보딩(설정 창 자동 오픈)을 이미 띄웠는가. 위 키들과 달리 AppState가 소유한다. diff --git a/VimAction/SettingsView.swift b/VimAction/SettingsView.swift index 4de43bc..64fa22a 100644 --- a/VimAction/SettingsView.swift +++ b/VimAction/SettingsView.swift @@ -224,16 +224,20 @@ private struct IndicatorTab: View { // 축이 다르다. 인디케이터가 꺼져 있으면 셋 다 고를 이유가 없어 통째로 비활성. Section("Colors") { ColorPicker("Normal", selection: colorBinding(\.normalColor), supportsOpacity: true) + ColorPicker("Insert", selection: colorBinding(\.insertColor), supportsOpacity: true) ColorPicker("Visual", selection: colorBinding(\.visualColor), supportsOpacity: true) // 미설정으로 되돌린다 — 컨트롤러가 키를 지우고 다시 강조색으로 그린다. - // 이미 둘 다 미설정이면 할 일이 없다. + // 이미 셋 다 미설정이면 할 일이 없다. Button("Reset to system accent") { modeIndicator.normalColor = nil + modeIndicator.insertColor = nil modeIndicator.visualColor = nil } - .disabled(modeIndicator.normalColor == nil && modeIndicator.visualColor == nil) + .disabled( + modeIndicator.normalColor == nil && modeIndicator.insertColor == nil + && modeIndicator.visualColor == nil) Text( - "VISUAL and V-LINE share the Visual color, and INSERT always uses the system accent color. The mode label stays fully opaque, so it still reads on a translucent background." + "VISUAL and V-LINE share the Visual color. Insert mode has no persistent indicator, so its color only shows in the flash when you switch modes. The mode label stays fully opaque, so it still reads on a translucent background." ) .font(.footnote) .foregroundStyle(.secondary) diff --git a/VimActionTests/ModeIndicatorColorTests.swift b/VimActionTests/ModeIndicatorColorTests.swift index 0fe57a2..722a95d 100644 --- a/VimActionTests/ModeIndicatorColorTests.swift +++ b/VimActionTests/ModeIndicatorColorTests.swift @@ -66,33 +66,32 @@ struct ModeIndicatorColorHexTests { } } -/// 모드 → 색 표. Insert가 설정 대상이 아닌 것과 VISUAL·V-LINE이 한 색을 나눠 쓰는 것이 결정이다 -/// (`20260916_mode-indicator-per-mode-colors.md`). +/// 모드 → 색 표. 세 모드가 같은 규칙을 타고(모델에 예외가 없다) VISUAL·V-LINE만 한 색을 나눠 +/// 쓰는 것이 결정이다 (`20260916_mode-indicator-per-mode-colors.md`, +/// `20260916_mode-indicator-insert-color.md`). struct ModeIndicatorColorForModeTests { private let normal = NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + private let insert = NSColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) private let visual = NSColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) private func color(_ mode: Mode) -> NSColor? { - ModeIndicatorColor.color(for: mode, normal: normal, visual: visual) + ModeIndicatorColor.color(for: mode, normal: normal, insert: insert, visual: visual) } - @Test("Normal은 Normal 색, Visual 둘은 Visual 색") + /// 색이 섞이면 전환이 색으로 보이지 않는다 — 셋이 각자의 색을 받는지가 표의 핵심이다. + @Test("모드마다 자기 색, Visual 둘만 한 색을 나눠 쓴다") func modesMapToTheirColor() { #expect(color(.normal) == normal) + #expect(color(.insert) == insert) #expect(color(.visualChar) == visual) #expect(color(.visualLine) == visual) } - /// Insert는 "평소 타이핑으로 돌아왔다"는 중립 신호라 계속 강조색이다. - @Test("Insert는 언제나 nil(강조색)") - func insertNeverTakesAUserColor() { - #expect(color(.insert) == nil) - } - @Test("미설정이면 모든 모드가 nil") func unsetColorsFallBackForEveryMode() { for mode in [Mode.normal, .insert, .visualChar, .visualLine] { - #expect(ModeIndicatorColor.color(for: mode, normal: nil, visual: nil) == nil) + #expect( + ModeIndicatorColor.color(for: mode, normal: nil, insert: nil, visual: nil) == nil) } } } @@ -110,6 +109,7 @@ struct ModeIndicatorColorPersistenceTests { withTemporaryDefaults { defaults in let controller = ModeIndicatorController(defaults: defaults) #expect(controller.normalColor == nil) + #expect(controller.insertColor == nil) #expect(controller.visualColor == nil) } } @@ -132,6 +132,25 @@ struct ModeIndicatorColorPersistenceTests { } } + /// Insert에는 상시 표시가 없지만 색은 다른 둘과 같은 규칙으로 산다 — flash가 그 색을 쓴다. + @Test("Insert 색은 별도 키에 영속된다") + func insertColorUsesItsOwnKey() { + withTemporaryDefaults { defaults in + let first = ModeIndicatorController(defaults: defaults) + first.insertColor = orange + // 존재 확인이 먼저 — 이것 없이는 영속을 통째로 지워도 "미설정"으로 통과한다. + #expect( + defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorInsertColor) != nil) + #expect( + defaults.string(forKey: PreferenceKeys.onScreenModeIndicatorInsertColor) + == "#FF8000CC") + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) == nil) + + let second = ModeIndicatorController(defaults: defaults) + #expect(second.insertColor.flatMap(ModeIndicatorColor.hex(from:)) == "#FF8000CC") + } + } + /// 키가 갈려 있어야 Normal을 고르는 것이 Visual을 덮지 않는다. @Test("Visual 색은 별도 키에 영속된다") func visualColorUsesItsOwnKey() { @@ -155,14 +174,18 @@ struct ModeIndicatorColorPersistenceTests { withTemporaryDefaults { defaults in let controller = ModeIndicatorController(defaults: defaults) controller.normalColor = orange + controller.insertColor = orange controller.visualColor = orange controller.normalColor = nil + controller.insertColor = nil controller.visualColor = nil #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorNormalColor) == nil) + #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorInsertColor) == nil) #expect(defaults.object(forKey: PreferenceKeys.onScreenModeIndicatorVisualColor) == nil) let second = ModeIndicatorController(defaults: defaults) #expect(second.normalColor == nil) + #expect(second.insertColor == nil) #expect(second.visualColor == nil) } }