Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Copy/Shelf/ColorAdjustSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
76 changes: 56 additions & 20 deletions Copy/Shelf/PinboardEditPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
}

Expand All @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion Copy/Shelf/ShelfPanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Copy/Shelf/ShelfViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -372,6 +377,7 @@ final class ShelfViewModel {
cachedApps = []
isSearchFieldFocused = false
focusSearchRequested = false
endSearchEditingRequested = false
}

/// Called by `AppCoordinator.toggleShelf()` right before showing the panel — see
Expand Down
Loading