docs/plans/2026-04-12-action-display-presentation-design.md
Unify the final Action button presentation in the Buttons preferences UI through a single semantic resolver and a single renderer. The goal is to stop scattering display rules across ButtonTableCellView, and instead make every binding state flow through one presentation pipeline.
This design focuses only on UI presentation. It does not change button-mapping behavior, matching, or execution.
The current Action button display is functionally correct in many cases, but its logic is split across multiple paths:
displayComponentsSystemShortcut.displayShortcut(matchingBindingName:)BrandTag.brandForAction(...)ButtonTableCellViewThis creates inconsistent output for semantically equivalent actions. The clearest current example is Logi actions:
ActionName + Logi tagForward Button + [Logi]The same structural issue already showed up in:
If we keep extending ButtonTableCellView with more branch-specific fixes, future display logic will continue to drift.
SystemShortcut as the source of shortcut metadataThe current view already has several useful building blocks:
SystemShortcut.displayShortcut(matchingBindingName:) can recognize some custom bindings as named shortcutsBrandTag.brandForAction(_:) and BrandTag.brandForCode(_:) already encode Logi brand knowledgecreateBadgeImage(from:) already provides a compact fallback renderer for generic custom combosButtonTableCellView already knows about transient UI states like recordingThe problem is not missing data. The problem is that each branch decides its own presentation.
ActionDisplayResolverCreate a small resolver responsible for translating current binding state into a semantic presentation model.
Its inputs should be view-facing state only:
Its output should be a single ActionPresentation value.
ActionDisplayRendererCreate a renderer responsible for turning ActionPresentation into the final NSPopUpButton placeholder content.
The renderer should be the only place that:
ButtonTableCellView should stop deciding whether a binding is:
Instead it should do only:
ActionPresentationThis keeps the table cell thin and predictable.
Use a compact semantic model instead of directly passing raw strings around.
struct ActionPresentation {
let kind: ActionPresentationKind
let title: String
let image: NSImage?
let badgeComponents: [String]
let brand: BrandTagConfig?
}
enum ActionPresentationKind {
case unbound
case recordingPrompt
case namedAction
case keyCombo
}
This is intentionally small:
title covers unbound, prompt, and named actionsimage covers menu-backed named actionsbadgeComponents covers generic keyboard-style combosbrand makes Logi-style brand rendering first-classIf we later need richer presentation, we can extend the model without re-splitting the logic.
The resolver should follow a strict priority order.
If recording is active, return:
kind: .recordingPromptThis prevents temporary fallback to unbound or stale content while the recorder is active.
If currentShortcut exists, resolve to:
kind: .namedActionBrandTag.brandForAction(shortcut.identifier)This preserves current menu-driven predefined action display.
If currentCustomName exists and maps uniquely to a predefined shortcut through SystemShortcut.displayShortcut(matchingBindingName:), resolve it exactly like a predefined shortcut.
This ensures:
custom::⌘⇧4 displays as 截取所选区域Forward Button + Logi tagThe crucial rule is semantic equivalence:
If the custom binding does not map uniquely to a named action:
This enables a better fallback for cases like:
For branded generic combos, the primary action name and the brand tag should still be separated semantically. The brand should not be represented as a gray text badge.
If none of the above apply, return:
kind: .unboundThe renderer should make equivalent semantic presentations look equivalent, regardless of source.
Named actions render as:
brand existsThis should reuse the existing visual style used by predefined menu items.
Generic combos render as:
For example:
[Logi]If the renderer cannot gracefully prefix a brand tag to the badge image, it may render the brand tag as part of a dedicated composite image. The important rule is still that brands are styled brands, not plain text badges.
Recording prompt renders as:
Unbound renders as:
unboundButtonTableCellViewThese responsibilities should move into the resolver:
resolvedDisplayShortcut()These responsibilities should move into the renderer:
setCustomTitle(...)ButtonTableCellView should retain only:
refreshActionDisplay()ShortcutManager.buildShortcutMenu(...) should remain unchanged in this iteration. The menu remains the source for selectable actions. The new presentation system only changes how the chosen action is shown afterwards.
Add tests at the semantic layer first, then light view-layer verification.
Add dedicated tests for:
Keep rendering tests lightweight and focused on output decisions:
Update button binding UI tests to verify that:
custom::⌘⇧4 displays as the named screenshot actionThis design gives us:
ButtonTableCellViewImplement the resolver and renderer in small steps:
ActionPresentation and ActionDisplayResolverActionDisplayRendererButtonTableCellView.refreshActionDisplay() to the new pipeline