diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 0444c71..5ae67ce 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -331,6 +331,14 @@ final class AppCoordinator { self?.shelfViewModel.commandHeld = event.modifierFlags.contains(.command) self?.shelfViewModel.optionHeld = event.modifierFlags.contains(.option) } + // Clicking anywhere in the shelf outside an empty search field (a card, a tab, + // empty space) leaves the field, which hides its hint panel. + controller.onClickOutsideTextField = { [weak self] in + guard let viewModel = self?.shelfViewModel, + viewModel.isSearchFieldFocused, + viewModel.searchQuery.text.isEmpty else { return } + viewModel.endSearchEditingRequested = true + } controller.onForceClick = { [weak self] in // Force-click acts on the card under the cursor: editable kinds (text/rich // text/link) open the editor, everything else opens the preview. The click's diff --git a/Copy/Shelf/ColorAdjustSheet.swift b/Copy/Shelf/ColorAdjustSheet.swift index 02fc169..d770243 100644 --- a/Copy/Shelf/ColorAdjustSheet.swift +++ b/Copy/Shelf/ColorAdjustSheet.swift @@ -33,5 +33,12 @@ struct ColorAdjustSheet: View { } .padding(16) .frame(width: 260, height: 106) + // The color well opens the app-wide shared color panel, which would otherwise + // stay on screen, disconnected, after the sheet closes. + .onDisappear { + if NSColorPanel.sharedColorPanelExists { + NSColorPanel.shared.orderOut(nil) + } + } } } diff --git a/Copy/Shelf/PinboardEditPopover.swift b/Copy/Shelf/PinboardEditPopover.swift index 3f2c8f6..ba6d69f 100644 --- a/Copy/Shelf/PinboardEditPopover.swift +++ b/Copy/Shelf/PinboardEditPopover.swift @@ -254,27 +254,47 @@ struct PinboardEditPopover: View { .frame(height: 168) } - VStack(alignment: .leading, spacing: 6) { - Text("Color") - .font(.caption) - .foregroundStyle(.secondary) - HStack(spacing: 5) { - ForEach(Self.colorOptions, id: \.hex) { option in - ColorSwatch(name: option.name, hex: option.hex, isSelected: option.hex == tint) { - tint = option.hex - } + if #available(macOS 26, *) { + // From macOS 26 the system color well is a wide pill that no longer fits a + // 22pt slot and spills over the Graphite swatch, so it moves up beside the + // "Color" label at its natural size, where it can't cover (or take clicks + // from) the presets. + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Color") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("Custom") + .font(.caption) + .foregroundStyle(isCustomTint ? Color.accentColor : .secondary) + ColorPicker("", selection: customColorBinding, supportsOpacity: false) + .labelsHidden() + .help("Custom color") + } + HStack(spacing: 5) { + presetSwatches + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 5) { + presetSwatches + // Custom color: the system picker, selected when the tint isn't a preset. + ColorPicker("", selection: customColorBinding, supportsOpacity: false) + .labelsHidden() + .frame(width: 22, height: 22) + .help("Custom color") + .overlay( + Circle() + .strokeBorder(Color.accentColor, lineWidth: 2) + .padding(-2) + .opacity(isCustomTint ? 1 : 0) + ) } - // Custom color: the system picker, selected when the tint isn't a preset. - ColorPicker("", selection: customColorBinding, supportsOpacity: false) - .labelsHidden() - .frame(width: 22, height: 22) - .help("Custom color") - .overlay( - Circle() - .strokeBorder(Color.accentColor, lineWidth: 2) - .padding(-2) - .opacity(isCustomTint ? 1 : 0) - ) } } @@ -289,6 +309,22 @@ struct PinboardEditPopover: View { .padding(14) .frame(width: 280) .onAppear { nameFocused = true } + // The custom-color well opens the app-wide shared color panel, which would + // otherwise stay on screen, disconnected, after the popover closes. + .onDisappear { + if NSColorPanel.sharedColorPanelExists { + NSColorPanel.shared.orderOut(nil) + } + } + } + + /// The preset color dots, shared by both color-row layouts in `body`. + private var presetSwatches: some View { + ForEach(Self.colorOptions, id: \.hex) { option in + ColorSwatch(name: option.name, hex: option.hex, isSelected: option.hex == tint) { + tint = option.hex + } + } } /// True when the current tint isn't one of the presets — i.e. a custom color. diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index e63f799..236272b 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -64,6 +64,9 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { var onFlagsChanged: ((NSEvent) -> Void)? /// Called once when a force-click (pressure stage 2) begins over the shelf. var onForceClick: (() -> Void)? + /// Called on a mouse-down in the shelf that lands outside the text field currently + /// being edited (or anywhere, when none is), so the owner can end search editing. + var onClickOutsideTextField: (() -> Void)? private var lastPressureStage = 0 var onDidHide: (() -> Void)? @@ -297,7 +300,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { private func installKeyMonitor() { guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged, .pressure]) { [weak self] event in + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged, .pressure, .leftMouseDown]) { [weak self] event in guard let self, self.isVisible else { return event } switch event.type { case .keyDown: @@ -313,12 +316,27 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { } self.lastPressureStage = event.stage return event + case .leftMouseDown: + // Observe only: the click still reaches whatever was under the pointer. + if event.window === self.panel, !self.isInFocusedTextField(event) { + self.onClickOutsideTextField?() + } + return event default: return event } } } + /// Whether `event` lands inside the text field that is being edited. An editing + /// `NSTextField` hands its text to the window's field editor, whose delegate is the + /// field itself. + private func isInFocusedTextField(_ event: NSEvent) -> Bool { + guard let editor = panel?.firstResponder as? NSTextView, editor.isFieldEditor, + let field = editor.delegate as? NSView else { return false } + return field.bounds.contains(field.convert(event.locationInWindow, from: nil)) + } + private func removeKeyMonitor() { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index c9a7b68..ea6fba1 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -181,6 +181,12 @@ private struct ShelfHeader: View { // sync with the field's focus, and honor a focus request from the key handler // (type-to-search). .onChange(of: searchFocused) { _, focused in viewModel.isSearchFieldFocused = focused } + .onChange(of: viewModel.endSearchEditingRequested) { _, requested in + if requested { + searchFocused = false + viewModel.endSearchEditingRequested = false + } + } .onChange(of: viewModel.focusSearchRequested) { _, requested in if requested { searchFocused = true diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 01e9156..0e9d4f3 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -51,6 +51,11 @@ final class ShelfViewModel { /// Set by the key handler to ask `ShelfHeader` to move focus into the search field; /// the view flips it back to false after applying. var focusSearchRequested = false + /// The reverse of `focusSearchRequested`: asks `ShelfHeader` to take focus out of the + /// empty search field, which hides the search hint panel. Set by a click outside the + /// field (`ShelfPanelController.onClickOutsideTextField`); the view flips it back to + /// false after applying. + var endSearchEditingRequested = false /// Distinct history apps for app suggestions, loaded once per search session and /// cleared in `clearTransientState`. @ObservationIgnored private var cachedApps: [AppUsage] = [] @@ -372,6 +377,7 @@ final class ShelfViewModel { cachedApps = [] isSearchFieldFocused = false focusSearchRequested = false + endSearchEditingRequested = false } /// Called by `AppCoordinator.toggleShelf()` right before showing the panel — see