Popup Window & Presentation
Floating panel lifecycle, screen clamping math, mode transitions, and status toasts.
On this page (15 sections)
The floating popup panel subsystem presents contextual actions near the user's cursor or text selection. It consists of PopupPanel (an NSPanel subclass), static frame math in PopupPositioner, SwiftUI rendering in PopupView, and lifecycle coordination via PopupWindowController.
Window Components#
+-----------------------------------------------------------------------------+
| PopupWindowController |
| ├── PopupPanel (NSPanel, non-activating, borderless, popUpMenu level) |
| │ └── NSHostingView(PopupView) |
| │ ├── Action Buttons / Sub-menus |
| │ ├── ResultCardView (native result card in .content mode) |
| │ └── Status toast (floating `ToastPanelController`, outside the panel) |
| └── Event Monitors (Global / Local NSEvent tracking) |
+-----------------------------------------------------------------------------+1. PopupPanel#
- Base Class:
NSPanel - Window Style:
.nonactivatingPanel,.borderless - Window Level:
.popUpMenu(sits above all normal, floating, and status-bar windows; only system menus and the screen saver stack higher). The panel is deliberately never the key window by default; making it key would steal keyboard focus from the active app and swallow keystrokes. There are two scoped exceptions — action-search mode and content (AI-card) mode:PopupPanel.allowsKeygatescanBecomeKey/canBecomeMain, enabled byPopupWindowController.enterSearch()andenterKeyMode()(search and content both route through the sameenterKeyMode()/exitKeyMode()primitives). - Properties:
isOpaque = false,backgroundColor = .clear,hasShadow = false(SwiftUI draws its own shadow; a panel shadow causes double artifacts).pinBottomEdgeOnResize(search/content mode only) re-anchors content-driven growth — see Action-Search Palette & Panel Growth below. - Shadow inset:
PopupViewkeepsPopupMetrics.popupShadowInset(16pt) of SwiftUI padding around the bar and AI result card so the SwiftUI shadow renders inside the panel rather than being clipped at its edge. If a shadow looks cut off, increase the padding — never re-enable the panel shadow. That padding ring is fully transparent but still part of the window frame, which originally made shadow clicks do nothing: the panel was topmost at those pixels, the local event counted as "in the bar", and no app received the click. Two layers now handle it: (1) every click/right-click dismissal check usesisOverPanelContent(frame minus ring), so a press in the shadow always dismisses; (2) while the pointer hovers the ring,updatePopupHoversetspanel.ignoresMouseEvents = true, so the click genuinely falls through to the app underneath and the global monitor observes it. The ignores-toggle requires global monitoring (Accessibility); without AX, layer 1 alone still guarantees dismissal, though the underlying app won't receive the swallowed ring click.
2. PopupWindowController#
- Responsibility: Controls window creation, display lifecycle, event monitoring, hover tracking, and the popup mode state machine (actions bar ↔ search palette ↔ content/AI-card).
- Event Handling: Sets up local and global
NSEventmonitors (.leftMouseDown,.mouseMoved,.scrollWheel,.keyDown). The local monitor sees mouse events over the panel; the global monitor sees events system-wide. - Dismissal Threshold: Automatically dismisses the popup if the cursor moves beyond
PopupMetrics.popupDismissalDistance(suspended in search mode and while a content/AI-card is open). For the result card, un-dragged cards dismiss normally on outside click or app switch; if dragged by the user (hasUserMovedCard),cardIsModalpins the card against outside clicks and document scrolls until Copy, Paste, Close or Esc (switching active applications or spaces still dismisses). - Keyboard Dismissal: Requires Accessibility permission (the global monitor). In actions mode any key — including
Escape— dismisses the popup; the global monitor is observation-only, so the keystroke still lands in the source app's document and the panel never needs to become key. In search mode the panel is key, so keys go to the search field (Escapeclears a scoped query, then exits). In content mode the panel is also key: the AI result card owns all keys via SwiftUI.onKeyPress,Escapecloses the card (hide()), and the controller monitor stays observation-only so it never double-fires Esc — bar the one case where the panel has lost key (the card outlives clicks into the source app document), where the monitor answers Esc itself.
Hover Tracking & Preview Isolation#
- Shared hover state: the real popup observes
PopupHoverState.shared, a@MainActorObservableObject(location,usesGlobalMouseMonitoring) fed byPopupWindowController's global mouse monitor. - Injected, not hardcoded:
PopupViewreceiveshoverState: PopupHoverState = .sharedandisStatic: Bool = falsein its initializer. WhenisStaticistrue,updateHoveredTarget/useLocalHoverFallbackearly-return, so hover tracking is fully inert. - Static previews opt in:
PopupPreview(Preferences Appearance tab + onboarding Finish) is a static visual with a fixed canonical action set; it passes its ownPopupHoverState()andisStatic: trueso hovering the preview never reacts to — or leaks into — the real popup's shared state.
Positioning Math: PopupPositioner#
PopupPositioner is a pure static struct with zero state or singletons. It calculates panel coordinates relative to the mouse release point and drag direction, preventing the panel from obscuring selected text.
Calculation Rules#
public static func placeNearReleasePoint(
releasePoint: CGPoint,
mouseDownPoint: CGPoint? = nil,
popupSize: CGSize,
screenBounds: CGRect
) -> CGRect- Horizontal Alignment:
- Centers the popup horizontally over
releasePoint.x: $$\text{x} = \text{releasePoint.x} - \frac{\text{popupSize.width}}{2}$$ - Clamps $\text{x}$ within screen visible bounds with padding: $$\text{x} = \max(\text{minX} + \text{padding}, \min(\text{x}, \text{maxX} - \text{width} - \text{padding}))$$
- Vertical Alignment (Drag Direction Awareness):
- macOS Coordinates: $Y$ increases upwards ($0$ is bottom of screen).
- Top-to-Bottom Drag: When $(\text{releasePoint.y} - \text{mouseDownPoint.y}) < -10.0$, the selected text lies above the cursor release point. The popup is placed BELOW the cursor so selected text remains visible.
- Bottom-to-Top or Horizontal Drag: The text lies below/beside the cursor. The popup is placed ABOVE the cursor.
- Screen Edge Clamping: If placing above or below exceeds visible screen bounds, the algorithm automatically flips vertical placement.
Content Mode: Native Result Cards (AI & File Outputs)#
Action, AI, and file output content render inside the single PopupPanel — there is no second
floating panel; status feedback renders separately as a floating toast via ToastPanelController
(see Status below). A .content mode on PopupModeStore (mirroring .search) transforms the panel:
the bar is hidden and PopupView.barContent renders ResultCardView, a native SwiftUI card
that replaced the former interactive canvas.
Content Mode (AI & Text Results)#
- Entry: AI presets stream results into the card via
PopupView.onAIResult(text:isError:title:)→PopupWindowController.showResultCard; any other text-returning action (e.g. a shell/JS extension) lands there through the delivery snapshot inhandleEffect, which passes the performing action's customization-resolved icon alongside its title. Both setmodeStore.resultCard(ResultCardPayload { text, isError, title, icon, isStreaming, original }—originalis the selection the action ran on, carried so the card can diff it against the result),modeStore.mode = .content, and enter key mode. The card's chrome header (back chevron + the producing action's icon — sparkles when none, e.g. AI streaming — + title + optional diff toggle + close button✕) is rendered byResultCardView(Sources/OpenClip/UI/Popup/ResultCardView.swift), with the back chevron wired toPopupView.onExitContent→PopupWindowController.exitContent(), and both the✕button and Esc wired toPopupView.onDismissContent→hide().
File Output Results#
When an action produces a .file(FileOutputPayload) result (via openclip.file(), shell JSON, or plain-text file path detection), PopupWindowController.showResultCard switches the panel to .content mode with the payload set. ResultCardView routes on FileOutputKind (image / pdf / text / other):
- Card Layout & Previews:
- Image Files: Image formats (
.png,.jpg,.jpeg,.gif,.webp,.svg,.icns,.bmp,.tiff,.heicor MIMEimage/*) display an inline aspect-fitted preview. Vector SVGs are decoded and rendered natively viaSDWebImageSVGCoder. - PDF Files: An embedded
PDFKitPDFView(Sources/OpenClip/UI/Popup/FilePreviews/PDFPreviewView.swift) in continuous vertical mode, auto-scaled to width and displaying the page crop box. Card dimensions follow page 0's rotated crop box; an unopenable document falls back to the generic card. The embedded view owns its scrolling and is not wrapped in the card's outerScrollView. - Text Files: Up to 100 KB render inline in a monospaced, selectable body with a "Preview truncated" note; unreadable/binary content downgrades to other.
- Other Files: An embedded
QLPreviewViewwith the filename/size caption. - Fallback: The generic card — system file icon (
NSWorkspace.shared.icon(forFile:)), filename, localized file type, formatted size, and a Preview button that opens the Quick Look panel. Shown when an image/text/PDF preview can't be produced, so a file result is never blank. - Off-Main Processing: Image rendering, PDF page-size probing, file attributes, and MIME detection load asynchronously off the main thread to ensure smooth 60fps presentation.
- Image Files: Image formats (
- Drag-and-Drop: Image and text previews are draggable from the whole body; embedded PDF/Quick Look previews drag from the filename caption only, so a drag on the page stays with the preview's own gestures (e.g. PDF text selection). Dragging exports the file via
NSItemProvider. - Action Buttons & Keyboard Shortcuts:
- Copy File (
⌘C): Copies the file URL to the macOS clipboard pasteboard. - Save (
Return/⌘S): Copies the file into the user-configured destination folder (SettingKey.fileSaveLocation, defaulting to~/Downloads). Duplicate filenames are safely suffixed (e.g.filename (1).ext), followed by a"Saved to <Folder>"confirmation toast. - Preview: The fallback card's Preview button opens the Quick Look panel. Space is not bound to Quick Look — it is left to the focused preview.
- Secondary clicks on the popup trigger action execute
.copyFiledirectly.
- Copy File (
Text and Diff Results#
- Card surface: the card renders a scrollable body plus a compact Copy/Paste footer (or a Dismiss button when
isError; Paste also hidden whilemodeStore.canPaste == false), sized byPopupMetrics(aiCardMinWidth 220/aiCardIdealWidth 320/aiCardMinHeight 200/aiCardMaxHeight 280), measured against whichever body is showing (the diff is longer than the plain result) — unless a remembered size applies (see Resizable). The close action lives in the header (✕), keeping the footer clean and compact without requiring artificial width floors. Content mode is key exactly like search: the panel becomes key through the sameenterKeyMode()primitive, and the card owns all keys via SwiftUI.onKeyPress— Esc closes the card outright (hide(), not a collapse back to the bar — the back chevron is what collapses); Return pastes and Shift+Return copies (Return falls back to copy when paste is unavailable); ⌘D toggles the diff when available. The controller monitor stays observation-only, with one exception: once the panel has lost key (the user clicked into the source document while a dragged card stayed up) it answers Esc itself, so the two can never double-fire. - Diff view:
Core/Utils/TextDiff.swift(pure domain) diffsoriginal→textat character level — a Myers greedy diff over grapheme clusters, preceded by common prefix/suffix trimming, bounded bymaxComparableLength(2 500 chars of changed middle) andmaxEditDistance(300); exceeding either degrades to wholesale replacement. Diff availability is gated byTextDiff.isMeaningfulEdit(from:to:)— only genuine in-place revisions (proofreads, grammar fixes, tone tweaks with similarity $\ge 50%$) show the diff toggle; wholesale rewrites (summaries, explanations, translations) and large texts automatically hide the diff button without hardcoding action IDs. When available, the card renders the segments as one attributed string: removed characters red + struck through, added characters green, both on a tinted background so a changed space is visible. The header toggle (arrow.left.arrow.right, also ⌘D) switches between diff and plain result. A streaming response is never diffed; the comparison waits for the final settled text. - Dragged to pin: an un-dragged result card remains ephemeral and dismisses upon clicking outside, scrolling,
or switching apps. If the user drags the card by its header,
PopupWindowController.hasUserMovedCardsetscardIsModal = true, pinning the card against outside clicks in the source document. While pinned,MacSelectionMonitor.isSuppressedForAppsuppresses selection detection specifically for the card's source app (popup.sourceAppBundleID), so selecting words in the source document to edit by hand cannot pop the action bar over the card being referenced. All other applications remain completely unsuppressed. Switching to another application or space dismisses the card and resets pinning. - Resizable, and the size is remembered: the card's right edge, bottom edge and bottom-right
grip are
PopupResizeHandles(Sources/OpenClip/UI/Popup/PopupResizeHandles.swift) — SwiftUIDragGestures, for the same reason as Draggable below: the borderless panel has no AppKit resize edges — that report(PopupResizeEdge, ResultCardDragPhase)toPopupWindowController.handleResize. The controller computes the new size from the absolute cursor position against the anchor taken atbegan(PopupResizeGeometry.size), clamps it toaiCardMinWidth/aiCardMinHeightand to the screen (PopupResizeGeometry.clamp, the panel's top-left corner is the fixed point), publishes it asmodeStore.resultCardSizeand sets the panel frame to card + shadow ring with the top-left fixed. The size is a maximum, not a fixed size: the card renders at what its text needs — as wide as its longest unwrapped line, as tall as that wrapped text — floored ataiCardMinWidth×aiCardMinHeightand capped bymaxSize(the remembered size, oraiCardIdealWidth×aiCardMaxHeightby default), scrolling beyond it. That fit happens only when the card opens: once the user drags a handle (modeStore.isSurfaceUserSized, set atbeganand kept until the surface closes) the card renders the dragged size verbatim — any size they want, whatever the text does — with no settle on release. Onendedthe size is written toSettingKey.resultCardWidth/resultCardHeight(Sources/OpenClip/Settings/SettingKey+ResultCard.swift); every entry into content mode (showResultCard) reads it back, fitted to the current screen (PopupResizeGeometry.fit), and clears it again onexitContent()/hide(). Because a remembered card can be taller than the sharedpopupMaxHeight, content mode raisesPopupPanel.heightCapto the screen height (restored on exit) andfitPanelToContent()nudges an automatically placed panel back on-screen after each fit. Resizing does not pin the card (hasUserMovedCardstays false) but, like a move, it drops the horizontal re-centering anchor.ResultCardResizeTestscovers the geometry, the persistence round-trip and that the grip's gesture is actually delivered. Every text-returning action shares this surface: an extension's result, delivered inline (handleEffect) or through the loading re-show (settleLoadingResult→show(for:)→showResultCard), opens in the same card at the same remembered maximum —ExtensionResultCardResizeTestsdrives both paths with an extension-package action. - Draggable: the header between the chevron and the diff/close actions carries a SwiftUI
DragGesturethat reportsResultCardDragPhase(began/changed/ended) toPopupWindowController.handleCardDrag, which moves the panel. AppKit dragging is not available here and three obvious routes are dead ends: the panel has no title bar,isMovableByWindowBackgroundnever fires because the SwiftUI hosting view consumes the press, and anNSViewRepresentablehandle never receivesmouseDowneither —NSHostingViewanswershitTestwith itself for the whole card and dispatches through SwiftUI's gesture system (ResultCardModalTests.testHeaderDragGestureReachesTheCardpins this). The move is computed from the absolute cursor position against an anchor taken atbegan, never from the gesture's translation: the window moves out from under the pointer, so a translation-based move fights itself.prepareForUserDragsetshorizontalAnchor = .none, so a later width change (the diff toggle resizes the card) keeps the user's placement instead of re-centering, and raisesisUserDragging, which stopsupdatePopupHoverfrom togglingignoresMouseEventsmid-drag. - Follow-up field: above Copy/Paste the card carries an instruction field
(
ResultCardView.followUpField, shown whenever the host passesonFollowUpand the card is not an error). ⏎ with text runsPopupWindowController.runFollowUp→refineCard, which refines the card in place: nothing hides and no loading toast shows — the previous answer stays on screen dimmed under the field's spinner (ResultCardPayload.isRefining, field placeholder "Refining…", the field keeps focus so Esc still reaches it, Copy/Paste hidden — space kept — until the answer settles) until the first chunk, the new answer then streams into the same card throughshowResultCard, and it settles titled after the instruction withoriginal= the original selection — always, however many follow-ups came before — so the diff shows the net change from what the user selected to the latest answer. The card's exact size is frozen for the refinement (freezeCardSizeForRefinement: the panel minus the shadow ring becomesresultCardSizewithisSurfaceUserSized, the hand-resize path, so chunks never re-measure the card; a user-resized card is left alone). Follow-ups carry the session as context:cardConversation(AIConversation, seeded by the run that opened the card — original selection + instruction + result — and extended by every settled follow-up) rendersfollowUpTask(current:), which states the current instruction first and then a labelled "HISTORY — context only" block (already applied, not to be redone; original selection and earlier results quoted; the last result identified as the<text>block), capped at 5 steps / 1500 characters per text for small context windows. That composite is the provider's task; the<text>block stays the card's current text.refiningPreviousholds the card being refined: Esc (cancelFollowUp, viaonCancelFollowUp—ResultCardView.escapeCancelsFollowUpdecides Esc's meaning) and a failure put it back settled (an error shows as a toast, not an error card); leaving content mode (exitContent) drops the stream so a late chunk can never re-open the card.FollowUpInCardTestspins it. ⏎ on an empty field keeps its old meaning (paste / copy). AppKit handles Return in anNSTextFieldbefore SwiftUI's key-press path, and the field's focus is set by the controller (focusCardField, editable-field lookup so the selectable body is never focused) rather than throughFocusState, so both the field'sonSubmitand the card-level ⏎ handler go through one decision (followUpReturn, unit-tested).ResultCardFollowUpTestspins it. - Footer: Paste (right) and Copy (left of it) both route through
PopupView.onCardEffect→PopupWindowController.performCardEffect— an explicit request that bypasses the paste-vs-copy re-decision. Both dismiss the popup and perform (Paste pastes over the selection, Copy copies to the clipboard) — Copy behaves like Paste and closes too. An error card displays a "Dismiss" button. With the close action in the header, short result cards stay compact and neat without wrapping button labels. - Paste availability gating: the trigger sites (hotkey handler,
MacSelectionMonitor) startPopupWindowController.preparePasteProbe(for:policy:)in parallel with selection retrieval and hand the awaited result toshow(for:pasteAvailable:), which storesmodeStore.canPastebefore the first frame — no Paste/Cut flash. The result is the unifiedPasteAvailabilityanswer (pure Core): thedenyPasteper-app rule overrides the live AX probe, which fills in when no rule applies — one decision feeds gating and delivery, so rules are never hand-edited separately.falsehides the card's Paste button and dropsPasteRequiringActions (built-in Paste/Cut) from the bar and search palette, viaPopupView.hiddenForPasteAvailability.nil/unknown keeps everything visible — only a confirmed cannot-paste hides. Nothing is cached: with no rule, paste availability tracks the target app's focus context (editable field vs read-only view), so every show re-probes. The perform-time delivery re-decision reads the same unified value (resolveDelivery). - Status: every
StatusFeedbackrenders as a floating one-line toast anchored to the popup frame (flipping above when clamped, or centered on the main screen when no anchor exists) viaToastPanelController(ToastPanel+ SwiftUIToastView), independent of the popup — it shows whether the bar is up or already hidden. Info/error toasts auto-dismiss afterPopupMetrics.toastDurationNanoseconds(1.2 s) unlesskeepVisible: true, which disables auto-dismiss; the paste→copy downgrade surfaces a "Copied" toast, or an action's declared per-click toast (Action.deliveryprimaryToast/secondaryToast) when one is declared (a script-emitted.toastsuppresses these — one toast per run). The inline banner and its queue (modeStore.statusBanner,pendingStatus,flushPendingStatus) are gone.showsLoadingactions (manifest"loading") early-close the popup with a spinner toast, swapping to a description, the resolved companion toast, or fading on a description-free result (a keep-visible toast stays up rather than auto-dismissing). - Secondary-click threading: the click intent is resolved per run and threaded into both the
perform context (
ActionContext.isSecondaryClick) and the delivery snapshot (DeliveryContext.clickIntent, alongside the action's declaredAction.delivery). The bar and sub-bar read it from the mouse monitor at mouse-down (pendingClickIntent; right-click or ⇧-click). The palette resolves it itself —replacefor ⇧⏎ / the ⇧⏎ footer badge, else the captured mouse intent — and passes it explicitly throughonWillPerformAction/onRunLoadingAction, so the perform context and the delivery decision always agree (a keyboard ⇧⏎ must copy, not paste). Entering the palette resetspendingClickIntent, so the right-click that opened a group's scoped palette cannot leak.secondaryinto a later Return/⌘-digit run. Actions can branch on it —DefineActionreturns.copyDefinition(word)on a secondary click (with a declaredsecondaryToast"Copied definition") so the effect door copies the dictionary definition headlessly instead of opening Dictionary.app.
Action-Search Palette & Panel Growth#
The ⌥⌘C hotkey toggles the popup through a mode state machine: actions bar → action-search
palette → dismiss (HotkeyManager calls PopupWindowController.toggleMode() when the popup is
already visible; the bar's command-glyph button enters search via onEnterSearch).
Search Mode#
- Mode state:
PopupModeStoreholdsmode(.actions/.search/.content),searchResultsAbove(set fromcardAboveinshow(for:)), plus the content payloadresultCard(which carries theoriginalselection for the card's diff). Statuses live in the floating toast, not the store.PopupViewbranches onmodeStore.modeinunifiedHStackand rendersPopupSearchView— the field + result list rendered as one surface with the bar, results above or below the field bysearchResultsAbove. - Catalog, matching & prewarming: the palette lists what the user can actually run — anything switched off
in settings is absent, matching the bar: per-action (
disabledActionIDs), whole-package (disabledPackages), a disabled group (its members go with it, since the palette lists members rather than the row), and an AI preset whose toggle in AI → Actions is off (or with AI disabled wholesale), whichAIAction.isEnabledreports. Context gating drops the rest (isEnabled(for:), clipboard-fallback vsrequiresLiveSelection). ViaActionCoordinator.searchCatalog→ActionRegistry.searchCatalog;ActionSearchranks by case-insensitive substring (prefix > contains > keyword). Up toPopupMetrics.searchMaxRowsrows render (searchMaxRows = 5,searchResultRowHeight = 32, height capped byPopupMetrics.popupMaxHeight). To ensure instant palette presentation on hotkey (⌥⌘C),PopupSearchView.prewarmIndex(catalog:)builds and caches the search index during passive selection monitoring. The cache tracks bothcatalogIDsandusageRecency; if both match upon opening an unscoped palette, the prewarmed index is reused directly without re-indexing. - Row icons are strictly
[icon | text]: a.texticon falls back toConfigurableAction.preferenceIconName; Iconify-format symbols (prefix:name) render viaAnyIconView, matching the bar (PopupSearchView.swift:214,230). - AI rows in the palette: a query that matches nothing is offered to AI instead of ending in
"No matches". While AI is on (
AIServiceManager.isAIEnabled),PopupSearchViewappends two rows after the (empty) results — Ask AI: “<query>” and Save as AI tool — plus a key hint; when the only matches are recent prompts it appends Save alone. ⇧⏎ and ⇧-click paste AI's answer over the selection (PopupWindowController.runAIPromptReplacing: the popup hides, a cancellable "Replacing…" toast waits forprovider.process, the answer goes through the explicit paste doorhandleActionResult(.paste)under a "Replaced with AI result" toast — downgraded to a copy when the unified paste availability says no or the frontmost app is no longer the selection's,frontmostBundleIDProvider); ⏎, click and ⌘-digits show the result card first (runAIPreset, same streaming card as a preset, dynamically titled with<title>generated by the model). Save stores the instruction as a customAIActionPreset(AIServiceManager.addCustomPreset, or reuses an existing one viapreset(matchingPrompt:)) with a clean action name (<title>) and runs it the same way. The rules live inPaletteAIPrompt(rows(for:aiEnabled:results:),instruction(from:),toolTitle(for:),hint(canPaste:)); the palette reports throughonRunAIPrompt(instruction, replace)/onSaveAIPrompt→PopupView→PopupWindowController.runAIPrompt/saveAndRunAIPrompt. Presets keep their existing behaviour (the card). With AI off the plain "No matches" copy stays.PaletteAIPromptTestsandPaletteAIReplaceTestspin this. - Escape clears the query first, then exits to the actions bar. In a scoped sub-action
palette, Escape instead drops the scope (
PopupSearchView.exitSearch()→onExitScope) and closes back to the bar. - Row shortcuts: ⌘1…⌘9 run the first nine rows outright (
PopupSearchView.runRow(at:)), with the label drawn on the row (shortcutHint(forRow:)); rows past the ninth have none, and a digit with no matching row falls through to the field rather than being swallowed. Because OpenClip's popup is a non-activating panel (macOS routes command-modified keys to the active app), these shortcuts are registered as global Carbon hot keys viaPaletteRowShortcuts.swift, activated strictly while the palette is visible and parked the moment it closes — alongside arrows, Return, hover, and click. - Content-sized, not resizable: the palette has no resize handles. It is as tall as its
current results need (
PopupSearchView.height(forRows:), floored atsearchPaletteMinHeight276, capped at the defaultdefaultHeight—searchMaxRowsrows — beyond which the list scrolls) and as wide as the default column or its widest row (naturalRowWidth(for:), measured once per result set), capped at the defaultsearchPanelContentWidthcolumn. Because the height follows the result count, the entry growth's bottom-edge pin is one-shot (PopupPanel.releasesBottomPinAfterGrowth, armed byenterSearch()on a fresh entry only): later changes keep the field at the palette top fixed, a directly opened palette is never pinned, andexitSearch()puts the bottom edge back on the bar's original spot (preSearchFrame.minY) before the collapse pins it. Both entry paths raisePopupPanel.heightCapto the screen height and schedulekeepPanelOnScreen()after the hosting view's growth;exitSearch()restores the cap after its own frame restore. - Placement is the same for both entry points. A palette opened directly by the hotkey
(
show(for:initialMode:.search)) goes throughPopupPositioner.calculateFrame/positionPanelexactly like the bar the mouse opens: anchored on the selection, honoring thepopupAlignmentandpopupVerticalPositionpreferences, and clamped to the screen containing the cursor. It used to be centred on the main screen, which ignored both settings and put the palette nowhere near the text. Entering search from the bar still re-anchors to the bar instead (enterSearch(buttonLocalFrame:)+preSearchFrame), so the field opens over the row that was clicked.
Scoped Sub-Action Palette#
Opening a group/AI bar row and reaching the palette from hotkey are the same surface, differing only
in modeStore.scope:
- Entering scoped: a bar click on a group row (
.openSubActions) or the AI Tools launcher (chrome.launchesAI) callsPopupWindowController.enterScopedSearch(for:)(PopupWindowController.swift:158), which resolves the parent's children via the CoreSubActionResolveroversearchCatalogand callsenterSearch(with: SearchScope(parent:children:)).modeStore.scope(added Task 5) carries the parent + pre-resolved children. - Membership is protocol-driven:
GroupActionandAIToolsActionconform toSubActionProviding(Core,SubAction.swift); resolution is id-prefix/.aidriven, neverswitch action.id. - Scoped view behavior:
PopupSearchViewmatches/search-catalogs onlyscope.children(PopupSearchView.swift:49), swaps the field's leading icon to the parent's, and the placeholder reads "Search within <parent.title>". Esc (onExitScope) drops the scope; the leading icon and placeholder come fromactionIcon(parent)/parent.displayTitle.
Key-Mode Exceptions#
Search and content (AI-card) modes both make the panel key — the only two exceptions to the
never-key rule. Both route through the same primitives: enterKeyMode()
(PopupWindowController.swift:196) captures previousFrontmostApp when none exists yet
(show(for:) captures it once at session start; mid-session re-entry never re-captures), sets
panel.allowsKey = true, and calls makeKeyAndOrderFront; exitKeyMode() (:206) restores the
invariant and re-activates previousFrontmostApp. Search then forces focus on the next run-loop
turn via focusSearchField()/findTextInput (:245) because a @FocusState-in-onAppear request
is silently dropped before the panel finishes becoming key; exitSearch() (:264) restores the
invariant. showResultCard enters content mode the same way; the result card owns all keys through
SwiftUI .onKeyPress. hide() is the only thing that clears previousFrontmostApp —
exitKeyMode() deliberately keeps it, so the same source app is re-activated on the next exit and
re-used on the next enter.
Panel-Growth Anchoring (content-driven resize)#
The NSHostingView auto-resizes the panel top-anchored when its SwiftUI content grows, with
no callback to the controller (onPreferenceChange/onContentSizeChange never fires;
sizingOptions has no effect). Two layers handle this:
resizePanel(to:)(PopupWindowController.swift:414) anchors the field's edge when the controller drives a size change: results-below (field at palette top) keepsmaxYfixed and grows down; results-above (field at palette bottom) keepsminYfixed and grows up.PopupPanel.setFrame(PopupPanel.swift:42) intercepts every resize the hosting view performs on its own and, whenpinBottomEdgeOnResizeis set, pins the bottom edge before the frame displays — so the auto-resize for results-above growth doesn't shove the popup off the cursor. The pin is set when entering search or content mode (whensearchResultsAbove). For search it is armed together withreleasesBottomPinAfterGrowth, so it spends itself on the entry growth: the palette's height then follows the result count, and those later changes keep the top edge (the field) fixed.exitSearch()re-arms the plain pin for the search→bar collapse after putting the bottom edge back on the bar's original spot, so the bar returns there (Esc no longer jumps the popup);show(for:)andhide()clear both before intentional placement.- Horizontal re-centering lives with the y-pin in
PopupPanel.setFrame, not the controller: whilerecenterXOnResizeis set (armed byshow(for:)right after placement, cleared before a fresh placement), a width change keeps the panel centered on its currentmidXinstead of the hosting view's top-left-anchored default — so swapping to the 280pt search palette or a shorter pagination page never drifts the bar off the cursor.PopupPositioner.centeredXclamps the initial placement; resize only preserves the existing center.
Behavioral contract is pinned by Tests/OpenClipTests/PopupPanelTests.swift (top/bottom edge fixed
on enter, bar returns to position on exit, panel key + field first-responder on re-entry).