Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/architecture/references/app-shell.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 앱 셸

- **Last updated**: 2026-09-15 (설정 창 Indicator 탭 — 4탭)
- **Last updated**: 2026-09-16 (Indicator 탭 색 항목 — 모드 셋)

## 현재 구조

Expand All @@ -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·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`

Expand Down
25 changes: 15 additions & 10 deletions .claude/skills/architecture/references/mode-indicator-overlay.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .claude/skills/architecture/references/profiles-and-config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 프로파일과 설정

- **Last updated**: 2026-09-06 (온스크린 인디케이터 스타일 키 추가)
- **Last updated**: 2026-09-16 (온스크린 인디케이터 모드별 색 키 셋 추가)

## 현재 구조

Expand Down Expand Up @@ -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`·`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이 소유한다.

Expand Down
22 changes: 18 additions & 4 deletions VimAction/ModeIndicatorBorderPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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배)을 쓴다 — 창 테두리에서도 창
Expand All @@ -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()
}
}
62 changes: 62 additions & 0 deletions VimAction/ModeIndicatorColor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// 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에는 상시 표시가 없으므로 그 색이 보이는 곳은 전환 순간의 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
}
}
}
Loading