Skip to content

feat(capslock): Phase 1 — doc 30 SET semantics (LED ON = Korean)#13

Open
hiking90 wants to merge 3 commits into
mainfrom
feat/capslock-doc30
Open

feat(capslock): Phase 1 — doc 30 SET semantics (LED ON = Korean)#13
hiking90 wants to merge 3 commits into
mainfrom
feat/capslock-doc30

Conversation

@hiking90
Copy link
Copy Markdown
Owner

Summary

Implements design/30-capslock-mode-sync.md
(previously "2차 검토 완료 — 구현 대기"): the system Caps Lock LED becomes a
mirror of the current input mode — LED ON = Korean, LED OFF = English. Any
mode change (CapsLock press, other toggle keys, per-app restore on app switch,
English Lock, ESC→English, focus-steal correction) auto-syncs the LED via
IOHIDSetModifierLockState. An expectedState + 100 ms guard filters the
software's own echo flagsChangeds from real user input.

This PR is the Phase 1 half of the broader CapsLock-toggle work.
Phase 2 (HID-based short/long-press parity) follows as a separate stacked PR.

Re-review of the earlier monolithic PR #12 recommended splitting into Phase 1 + Phase 2 for cleaner review and merge-isolation. This PR + the Phase 2 PR replace #12. See § Replaces PR #12.

What changes

Layer Change
OngeulApp/Sources/CapsLockSync.swift Adds setState(_: Bool), shouldHandle(capsLockOn:), reset(); old forceOff() retained as alias (removed in Phase 2 PR). expectedState + 100 ms timeout guard filters software echoes.
OngeulApp/Sources/InputStateCoordinator.swift setMode(_, syncCapsLock: Bool = true) and toggleEngineMode now sync LED when toggleKey == .capsLock. Default true; syncCapsLock: false for the CapsLock-press path where hardware already toggled. New public setModeFromCapsLockPress(korean:for:).
OngeulApp/Sources/KeyEventTap.swift flagsChanged CapsLock branch refactored from "always forceOff + toggle" to SET semantics with shouldHandle() filtering and performCapsLockModeSet(korean:) dispatch.
OngeulApp/Sources/OngeulInputController.swift New performCapsLockModeSet(korean:). IMK fallback handleFlagsChanged CapsLock branch migrated to same SET semantics.
OngeulApp/Resources/*.lproj/Localizable.strings prefs.capsLockDelay tooltip corrected — macOS has no UI to disable the Caps Lock delay; hidutil is required (see capslock.md).
docs/src/user/features/capslock.md "LED behavior" description reframed to match doc 30 (ON=Korean, OFF=English). "사전 설정" rewritten with the actual hidutil property --set '{"CapsLockDelayOverride":0}' command (with persistence note).
design/32-hid-capslock-press-duration.md Forward-looking design for Phase 2 (HID press-duration) included here for context. Code that depends on this design lands in the Phase 2 PR.

Includes PR #11's keyDown strip

This branch was branched off PR #11, so it contains commit 3acfafc — the equivalent of #11's CapsLock keyDown .maskAlphaShift strip (#10 hardening).

Recommended order: merge #11 first.

Verification

swiftc -typecheck of all sources (Generated + OngeulApp/Sources/*.swift) passes with zero warnings on this branch. Runtime verification is recommended on Sequoia (ongeul-sequoia repro VM) and Tahoe (no VM yet — separate Phase 0.5 infrastructure work).

Replaces PR #12

PR #12 bundled Phase 1 + Phase 2 in one ~990-line PR. Re-review identified:

  1. Phase 2 code was committed before the spike that determines whether IOHIDSetModifierLockState(true) actually delivers uppercase — a methodology violation
  2. Phase 2's exitRealCapsLock broke macOS-native parity (two-action exit vs single-action)
  3. Phase 2's commitSettings had a controller-injection race
  4. Doc 32 was missing a spike measurement for expectedState timeout OS-load sensitivity
  5. forceOff() alias was a code smell

Phase 1 (this PR) is the foundation. Phase 2 (the next PR) is gated on the spike and includes fixes for items 2–5 above. #12 will be closed in favor of this split.

What's NOT in this PR

  • ⏸ HID monitor / long-press support → Phase 2 PR
  • ⏸ Phase 1.5 spike validating IOHIDSetModifierLockState(true) → Phase 2 prerequisite
  • ⏸ Developer ID + notarization (doc 50) → independent Phase 4

🤖 Generated with Claude Code

hiking90 and others added 3 commits May 21, 2026 21:10
…capsLock

When CapsLock is configured as the Korean/English toggle key, the IOKit
alpha-lock state can briefly remain ON between the physical press and
CapsLockSync.forceOff() taking effect. A keyDown arriving in that race
window carries .maskAlphaShift, and in English passthrough mode the
event reaches the focused app as-is — the first English character after
a Korean→English toggle is uppercased, then later keys auto-correct.
This is the symptom reported in #10.

Strip .maskAlphaShift directly from the event in the CGEventTap callback
right after forceOff(). The tap runs before HIToolbox/IME/app, so both
the IMK path and the English passthrough path see the corrected flags
regardless of forceOff()'s IOKit propagation latency.
event.keyboardGetUnicodeString() also picks up the stripped flags,
keeping the focus-steal recorded character consistent with what the app
receives.

Scope is unchanged: only active when toggleKey == .capsLock and
maskAlphaShift is set. The flagsChanged CapsLock branch is intentionally
left as passthrough so forceOff()'s OFF correction remains visible to
downstream state tracking.

This is defensive hardening, not a definitive fix for #10. The reported
case is environment-dependent (not reproducible on a clean maintainer
setup) and likely involves coexisting low-level keyboard monitors (e.g.
SokIM) or macOS 26 Tahoe-specific timing. A planned HID-based CapsLock
handler will need to gate this strip on a future realCapsLockActive flag.

Verified: swiftc -typecheck of all sources passes with no warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ration)

User-facing capslock.md was telling users to "disable CapsLock delay in
System Settings → Keyboard" — but no such UI toggle exists in macOS
(Sequoia/Tahoe). The only legitimate path is the `hidutil` command
`hidutil property --set '{"CapsLockDelayOverride":0}'` from Terminal,
which doesn't persist across reboots (LaunchAgent needed for that).

Replace the misleading instruction with the actual `hidutil` command +
note on persistence. Also clarify the "Use Caps Lock to switch to and
from ABC" disable location (System Settings → Keyboard → Input Sources
→ Edit…) and reframe the LED behavior to match doc 30's planned SET
semantics (LED ON = Korean, LED OFF = English) — implemented in the
follow-up commit on this branch.

Adds design/32-hid-capslock-press-duration.md (design v2) — covers
HID-based CapsLock press-duration detection for macOS platform parity
("short tap = language switch, long press = uppercase", per the Apple
official UI text "Press and hold to enable typing in all uppercase").
This is a forward-looking design that gates on a Phase 1.5 spike
verifying that IOHIDSetModifierLockState(true) actually delivers
uppercase to apps on Tahoe (SokIM evidence suggests it may not, hence
the spike). The doc enumerates A/B/C scenario branches.

Other design/*.md files remain untracked per existing repo convention;
broader tracking decision deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements design/30-capslock-mode-sync.md: CapsLock LED mirrors the
current input mode via IOHIDSetModifierLockState. Replaces the previous
"always force off" stance with bidirectional SET — LED ON = Korean, LED
OFF = English. Mode changes from any path (CapsLock press, other toggle
keys, app switch, ESC→English, focus-steal correction, English Lock)
auto-sync the LED.

Changes:

CapsLockSync (rewrite)
- Add `setState(_: Bool)` — IOKit SET for either direction
- Add `shouldHandle(capsLockOn:)` — `expectedState` + 100ms timeout
  guard to filter our own setState() echoes from user input
- `reset()` for settings-change LED off
- `forceOff()` retained as thin alias for backward compat with existing
  callers (KeyEventTap PR #11 strip path, preferences-panel
  toggleKey-change defense)

InputStateCoordinator
- `setMode(_, syncCapsLock: Bool = true)` — LED sync built into every
  mode change. Default true; explicit `false` for the CapsLock-press
  path where hardware already toggled state
- `toggleEngineMode` — same LED sync (this path skips setMode)
- New public `setModeFromCapsLockPress(korean:for:)` — single
  setter-style entry for CapsLock-press SET semantics (calls setMode
  with syncCapsLock=false). Per-app mode store updated, Lock guarded.
  Korean→English transition flushes composing jamo.

KeyEventTap flagsChanged CapsLock branch
- Replaces "only handle capsLockOn=true + always force off + toggle"
  with SET semantics: `shouldHandle()` filters echoes, then
  `performCapsLockModeSet(korean: capsLockOn)`. Event still passed
  through (hardware already toggled — consuming would desync app's
  modifier tracking).

OngeulInputController
- New `performCapsLockModeSet(korean:)` for the CGEventTap callback
  entry point
- `handleFlagsChanged` IMK-fallback CapsLock branch migrated to the
  same SET semantics (uses `shouldHandle` + `setModeFromCapsLockPress`
  instead of `toggleMode + forceOff`)

Localizable strings (ko/en)
- `prefs.capsLockDelay` tooltip corrected: macOS has no UI to disable
  the Caps Lock delay; hidutil is required (matches docs/.../capslock.md)

Note on TICapsLockLanguageSwitchCapable: doc 30 prerequisite step
"remove from Info.plist" — verified key is already absent. No change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hiking90 added a commit that referenced this pull request May 21, 2026
…an mode)

User report: in 영문→long→short→short sequence, the first short press
correctly exits real Caps Lock (LED off), but the second short press —
intended to toggle to Korean — instead turns the LED back on, looking
to the user like Caps Lock re-activated.

Root cause: design conflict between doc 30 SET semantics (LED ON =
Korean) and doc 32 HID realLockOn (LED ON = real Caps Lock active). In
HID mode, the LED was carrying two meanings:
- LED on for Korean mode (doc 30 SET via setMode/toggleEngineMode)
- LED on for realLockOn (doc 32 explicit setState in enterRealCapsLock)

Visually identical, semantically distinct. After exiting realLockOn and
then toggling to Korean, the LED turned on for the second reason and
the user (correctly) interpreted it as Caps Lock — conflicting with
the macOS convention "Caps Lock LED on = uppercase locked".

Fix: in HID mode, LED only reflects realLockOn. Mode indicator moves to
the menu bar icon entirely.

- InputStateCoordinator.setMode and toggleEngineMode: LED sync gate
  changed from `mode != .hidRealLockOn` to `mode == .cgEventTapAuthority`.
  doc 30 SET semantics still apply when HID isn't active (fallback);
  in HID mode, mode changes don't touch LED.
- OngeulInputController.performExitRealCapsLock: was `setState(korean)`
  (LED on if restored to Korean per doc 30 SET); now unconditionally
  `setState(false)` (LED off — restored mode shows via menu bar icon).
- doc 32 updated: § Doc 30과의 관계 clarifies the LED scope split,
  § 단일 상태 enum module table updated, ASCII state diagram note
  changed, transitions table includes LED column showing OFF/ON
  explicitly, 동작 시나리오 #1 and #4 reworded.

End-user behavior after this fix:
- 영문 → long → short → short → Korean
- LED: off → on(realLockOn) → off(exit) → off(toggle)
- Mode: English → English+caps → English → Korean
- LED never turns on for "just being in Korean mode" — only when real
  Caps Lock is active. Matches macOS convention; resolves user confusion.

Phase 1 PR (#13) unaffected — doc 30 SET continues to apply in the
CGEventTap-only baseline (no HID).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hiking90 added a commit that referenced this pull request May 21, 2026
End-to-end test on user's environment (2026-05-21):
  Korean mode
  → long press CapsLock
  → English mode + LED on + uppercase typing works ("HELLO" produces uppercase)
  → short press
  → Korean mode restored + LED off + Korean input works

All four checkpoints passed → doc 32's largest single assumption
("IOHIDSetModifierLockState(handle, kIOHIDCapsLockState, true) actually
delivers uppercase to apps") is confirmed.

Notable: the OS-level stomp behavior SokIM works around with their
0/20/40/.../180ms repeated setState(false) sequence (the "Sonoma 이후
커서 밑에 생기는 버블/HUD/Indicator/Accessory 방지" pattern) was NOT
observed in our environment — a single setState(true) call holds. We
may not need that mitigation, though it's worth re-testing in
constrained environments later.

Also confirmed: the modeBeforeRealLock save/restore logic correctly
gives single-action exit per macOS native parity. The full sequence
("Korean → long → English+caps → short → Korean") is one atomic
gesture from the user's perspective.

Status header updated, § 가장 큰 단일 가정 gains a result subsection.
PR #14 spike gate released — ready to merge in order (#11#13#14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant