Skip to content

Enable font panel integration for RichTextView#1

Merged
zurillion merged 14 commits into
claude/macos-hotkey-popup-utility-8QfOSfrom
claude/fix-font-settings-apply-YD9f6
May 17, 2026
Merged

Enable font panel integration for RichTextView#1
zurillion merged 14 commits into
claude/macos-hotkey-popup-utility-8QfOSfrom
claude/fix-font-settings-apply-YD9f6

Conversation

@zurillion

Copy link
Copy Markdown
Owner

Summary

Enable the font panel to properly interact with text selection in RichTextView by setting usesFontPanel to true.

Changes

  • Changed textView.usesFontPanel from false to true in RichTextView initialization
  • Added detailed comment explaining why this setting is necessary for proper font panel functionality

Details

NSTextView requires usesFontPanel to be enabled for the Fonts panel (⌘T) to properly respond to user interactions. Without this setting, while the panel opens, its controls don't affect the selected text. This change ensures that font changes made through the Fonts panel are correctly applied to the text selection, even when NSFontManager's target is set explicitly.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw

claude and others added 14 commits May 12, 2026 16:39
NSTextView's changeFont: / changeAttributes: implementations check
usesFontPanel before mutating the storage. With it set to false the
text view silently drops every font/size/color change from the system
Fonts panel — even though NSFontManager.shared.target was already
being pointed at the text view in beginEdit. The panel opened on ⌘T
but its controls had no effect on the selected text.

Flip usesFontPanel to true on the preview text view. We're already
overriding ⌘T to handle the show-and-raise dance ourselves, so this
just unlocks the message handling.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
The Coordinator stored the RichTextView struct captured at
makeCoordinator() time and never refreshed it. PreviewView builds
the editor first with model.isEditing == false (because beginEdit
calls buildPreviewPanelIfNeeded *before* flipping isEditing on),
which makes the onChange closure nil for that initial render. When
isEditing later flips to true SwiftUI builds a new RichTextView with
the real closure, but updateNSView wasn't writing it back, so the
delegate kept invoking the original nil closure.

Net effect: every keystroke and every font / size / colour change
landed in the NSTextView's storage but never made it into
model.editedAttributed. On confirm we pasted whatever beginEdit had
seeded — i.e. the untouched transformed source — instead of the
edited text.

Refresh context.coordinator.parent at the top of updateNSView so the
delegate always sees the current closures.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
The ⌘T handler calls NSApp.activate() so the Fonts panel is
responsive on the first click (without it, the panel-activation step
eats that click because our preview is a nonactivating panel). Once
we've forcibly become the foreground app, however,
NSRunningApplication.activate(target) on the previous frontmost is
not a synchronous handoff: it queues a request and resolves on a
later runloop. PasteSimulator's synthetic ⌘V fires from a fixed
~0.14s timer, which is frequently before that handoff completes, so
the keystroke lands in our own (now-empty) app instead of the target.
With no responder for ⌘V, AppKit beeps and nothing is pasted — even
when no editing was done.

Track the forced-activation in didActivateForFontPanel and, when set,
swap close()'s target.activate for NSApp.hide(nil), which yields
activation synchronously. show() calls NSApp.unhide(nil) so the next
invocation reveals the popup again.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
The previous commit added NSApp.unhide(nil) in show() as the
counterpart to the hide on close, on the assumption that unhide is a
no-op when the app isn't hidden. In practice it always activates the
app as a side effect, so every popup invocation — even one that
never touched ⌘T — left us as the frontmost app. That put close()'s
target.activate(...) right back into the asynchronous-handoff race
that the hide path was supposed to sidestep, so PasteSimulator's
synthetic ⌘V landed in our own app every time and AppKit beeped.

unhideWithoutActivation is the documented "restore windows without
becoming active" variant; using it here leaves the app inactive
unless the ⌘T handler explicitly activates it.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
hasFormatting drives whether writeAttributed puts RTF onto the
pasteboard or only plain text. It was checking
NSFont.fontDescriptor.symbolicTraits for .bold / .italic, which is
exactly the trait set NSFont.boldSystemFont does NOT use — the system
font expresses its weight via the descriptor and not the symbolic
trait, so a ⌘B in the editor produces a font that NSFontManager
considers bold but symbolicTraits don't. Result: pasting from a
plain-text action (Remove formatting, snake_case, …) after the user
applied styles in the editor came out as plain text, dropping the
styles.

Ask NSFontManager.traits(of:) too — that's the trait API we use to
toggle the styles in the first place, so they round-trip correctly.
Also flag family / point-size differences from the default systemFont
so explicit Fonts-panel choices survive the paste.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
Lets the user enable/disable individual actions (Unvaried, snake_case,
…) and reorder them. Numeric shortcuts stay tied to the list position
in the popup, so disabling or moving an action changes which kind 1,
2, 3, … invoke.

- AppSettings: persist [ActionPreference] (kind + enabled, ordered).
  Decoder is lenient: unknown kinds are dropped and any TextActionKind
  not in the saved list is appended (enabled), so adding a new built-in
  in code automatically shows up for existing users.
- PopupViewModel: actions becomes @published with a clamp on
  selectedIndex so out-of-bounds state can't survive a config change.
- PopupWindowController: build() and show() both resolve the enabled
  list from settings, so opening the popup reflects the latest config
  without rebuilding the panel.
- SettingsView: replace the read-only list with a row per kind —
  toggle + up/down reorder buttons, position number only for enabled
  rows, and a Reset button.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
Replaces the chevron ↑/↓ buttons in the Actions section with a drag
handle on each enabled row and a thin strip between rows that acts as
the drop target. While dragging, the strip the cursor is over shows a
horizontal accent bar so it's clear where the row will land.

Per the spec:
- Disabled rows can't be dragged (handle is inert and dimmed); the
  only way to bring one back into the enabled section is the toggle.
- After any toggle, drop, or Reset, normalize() rewrites the list as
  enabled-first then disabled, preserving relative order within each
  group. That means disabling a row sinks it to the top of the
  disabled section, enabling a row appends it to the end of the
  enabled section — exactly as requested.
- Drop strips below the enabled/disabled boundary stay laid out (so
  the row spacing doesn't shift mid-drag) but refuse the drop.

Uses SwiftUI's macOS 14+ `.draggable` / `.dropDestination`; the
payload is the kind's rawValue (String, already Transferable), so we
don't need to make TextActionKind itself Transferable.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
Uses SMAppService.mainApp (the macOS 13+ replacement for the old
SMLoginItemSetEnabled API) so non-sandboxed agent apps like ours can
register themselves as login items without a separate helper bundle.

State is sourced from SMAppService itself rather than UserDefaults —
the system is the source of truth, so we just mirror its status into
an @published bool on init and apply register()/unregister() in the
didSet. SettingsWindowController.show() re-reads it before bringing
the window forward, so the toggle stays in sync with changes the user
made in System Settings → Login Items while the app was running.

Errors from register/unregister roll the published value back via a
suppress flag so the toggle reflects the actual on-disk state rather
than the failed intent.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
Launch at login now comes first; both rows wrap the description in
an HStack with a trailing Spacer so the switches sit flush against
the section's right edge instead of huddling next to the label.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
The runtime AppIcon.make() only wired the Dock icon via
NSApp.applicationIconImage, which Finder ignores — Finder reads the
bundle's static icon resource referenced by CFBundleIconFile. Without
one, the .app shows a generic file icon.

- Scripts/build-app-icon.swift: standalone Swift script that
  re-implements the same drawing as TextCleaner/AppIcon.swift,
  rasterises it to every size required by macOS (16…1024 with @2x
  variants), and invokes iconutil to produce TextCleaner/AppIcon.icns.
- Info.plist: declare CFBundleIconFile = AppIcon so the system finds
  the .icns at runtime.
- .gitignore: AppIcon.icns is a generated artefact, per the "icon as
  code" comment in AppIcon.swift.

Wiring in Xcode is a one-time manual step (the .xcodeproj isn't in
the repo): drag AppIcon.icns into the project navigator with the
TextCleaner target ticked, or add a "Run Script" build phase that
runs the script before "Copy Bundle Resources".

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
A separate floating panel, invoked by ⌃⌥⌘U (configurable in Settings),
that shows a sectioned grid of Unicode glyphs the user can pick by
mouse or by arrow-key navigation + Return. The chosen character is
pasted at the caret position in whichever app was frontmost when the
hotkey fired, the same way the main popup pastes a transform.

Mechanically:
- HotKeyManager refactored from a single-hotkey owner to a registry
  keyed by name. The Carbon event handler now inspects the
  EventHotKeyID on the incoming event and dispatches to the matching
  handler, so we can have N hotkeys live at once without each one
  firing the others.
- AppSettings.pickerShortcut persists the picker shortcut. Changing
  it posts .pickerHotKeyChanged so AppDelegate re-registers; mirrors
  the existing hotKeyChanged plumbing for the main popup.
- CharacterCatalog statically holds the section list provided in the
  spec, including the description annotations (xor, therefore, …) so
  the header bar can label the highlighted glyph.
- CharacterPickerView (SwiftUI) is a fixed-12-column LazyVGrid wrapped
  in a ScrollViewReader so arrow navigation can scroll the selection
  into view. The header bar shows a large preview of the current
  glyph plus its codepoint and description, refreshed on either
  keyboard selection or mouse hover.
- CharacterPickerController owns the PickerPanel (borderless,
  nonactivating, .floating — same recipe as PopupPanel). It captures
  previousFrontmost on show, reactivates it on commit, and posts the
  glyph via the existing PasteSimulator path so caret-paste reuses
  the same ⌘V machinery.
- SettingsView gets a new Shortcuts row for the picker hotkey.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
Window:
- Add .resizable to the picker panel's styleMask with a 320×260 min
  size, and observe NSWindow.didEndLiveResizeNotification so the
  panel snaps back to the centre of the screen when the user lets go
  of the resize handle.
- Grid column count derives from the available width via
  GeometryReader, so a wider window shows more glyphs per row instead
  of leaving empty trailing space. The Up/Down arrow stride tracks
  that count, so vertical navigation always lands in the visually
  aligned cell.

Search:
- TextField at the top of the picker filters entries by their custom
  description, the Unicode scalar's official `name` property (so
  "alpha" finds α even without a per-entry description), or an exact
  character literal match. Empty query shows everything.
- The header bar now falls back to the lowercased Unicode name when
  the highlighted entry has no custom description.
- Focus auto-lands on the search field on open, so the user can type
  immediately. Arrow keys / Return / Esc are caught by an NSEvent
  local monitor on the panel so they navigate the grid and confirm
  the selection regardless of which view currently holds first
  responder. Esc with a non-empty query clears the query; Esc on an
  empty query closes the picker.

Internals:
- Replaced the PickerPanel keyDown override + per-key closures with a
  single local event monitor in CharacterPickerController, mirroring
  the pattern used by PopupWindowController's editing monitor.
- Cell flat-index is now passed in from the section (precomputed
  offsets), avoiding an O(N²) firstIndex scan on every render.
- commit() now delegates the panel-tear-down to close() and just
  schedules the paste after the activation handoff settles.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
The picker now opens with a Recent section above the static catalog
that lists the user's last 15 picks, newest first. Picking from
Recent (or anywhere else) pulls that glyph to the top of the list and
drops the oldest once the cap is hit.

- AppSettings.recentPickedCharacters: persisted [String] (UserDefaults
  picks up [String] natively, no encoding needed). A new
  recordPickedCharacter(_:) helper does the LRU dance.
- CharacterCatalog.entry(for:) hydrates a stored character back into
  a CharacterEntry by finding the first catalog match, so the header
  bar still labels recent picks with their description / Unicode
  name. If the catalog later drops a glyph that's still in recents
  (won't happen today but doesn't hurt), it falls back to a plain
  description-less entry rather than silently dropping the recent.
- CharacterPickerModel.displayedSections prepends a Recent section
  built from `recents` when non-empty. The existing filter runs over
  this combined list, so typing into the search box filters Recent
  the same way it filters the catalog.
- CharacterPickerController.show() reloads `model.recents` from
  AppSettings on every open (the model is reused across opens but
  the persisted list may have changed in the meantime), and
  commit() now records the pick before tearing the panel down.

https://claude.ai/code/session_01BGtDBUk5RuNvKV83z6HLWw
@zurillion
zurillion merged commit 5347d4a into claude/macos-hotkey-popup-utility-8QfOS May 17, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants