Enable font panel integration for RichTextView#1
Merged
zurillion merged 14 commits intoMay 17, 2026
Merged
Conversation
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
merged commit May 17, 2026
5347d4a
into
claude/macos-hotkey-popup-utility-8QfOS
1 check failed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Enable the font panel to properly interact with text selection in RichTextView by setting
usesFontPaneltotrue.Changes
textView.usesFontPanelfromfalsetotruein RichTextView initializationDetails
NSTextView requires
usesFontPanelto 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