From 8868613b84923b93561878b35d5a0f37d2911e28 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 15:01:22 -0700 Subject: [PATCH 1/2] Add a live-resize handle for the Paste Bar - BarResizeGeometry centralizes bar-height sizing rules (default/min/max, per-display clamping, and the macOS 26 glass content frame math) shared by both the persisted preference and the live drag session. - BarResizeHandle is a compact AppKit drag target (with accessibility increment/decrement support) shown in an optional overlay at the top of the bar. - A new "Show resize handle on the Paste Bar" Settings toggle gates it, off by default. The bar's own key monitor steps aside while the handle owns first responder, so Escape/arrows during a drag reach it directly. The height slider in Settings previews the same size. Clicking into Settings dismisses the bar, so a pixel count was previously the only feedback for a dimension that is only meaningful on screen. Dragging the slider now live-resizes the real bar when it happens to be up, and otherwise raises BarHeightGhostController: a translucent, click-through panel occupying exactly the frame the bar would take on the display under the pointer, drawing a faded real BarView behind a dashed outline and a large "N px" readout, and retiring itself about 1.1s after the last change. Showing the bar hides any outline still lingering. --- Sources/Pesty/AppController.swift | 43 +++- Sources/Pesty/Settings/Settings.swift | 23 +- Sources/Pesty/Settings/SettingsView.swift | 6 +- .../Pesty/UI/BarHeightGhostController.swift | 95 ++++++++ Sources/Pesty/UI/BarResizeGeometry.swift | 116 ++++++++++ Sources/Pesty/UI/BarResizeHandle.swift | 218 ++++++++++++++++++ Sources/Pesty/UI/BarView.swift | 11 + Sources/Pesty/UI/BarWindowController.swift | 82 ++++++- 8 files changed, 584 insertions(+), 10 deletions(-) create mode 100644 Sources/Pesty/UI/BarHeightGhostController.swift create mode 100644 Sources/Pesty/UI/BarResizeGeometry.swift create mode 100644 Sources/Pesty/UI/BarResizeHandle.swift diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 6d3f395..304594b 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -16,6 +16,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { private var previewWindow: NSWindow? private var previewedItemID: UUID? private var keyMonitor: Any? + private let barHeightGhost = BarHeightGhostController() private(set) var previousApp: NSRunningApplication? private(set) var lastActiveApp: NSRunningApplication? @@ -236,6 +237,41 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { ClipboardStore.shared.setICloudSync(enabling) } + func updateConfiguredBarHeight() { + barController?.applyConfiguredBarHeight() + } + + /// Feedback for the Settings height slider: resize the real bar when it is + /// up, and otherwise outline the proposed size where the bar would appear. + func previewBarHeight(_ height: Double) { + if barController?.isPresented == true { + barHeightGhost.hide() + updateConfiguredBarHeight() + } else { + barHeightGhost.show(height: height) + } + } + + func beginBarResize(at screenPoint: NSPoint) { + barController?.beginBarResize(at: screenPoint) + } + + func updateBarResize(at screenPoint: NSPoint) { + barController?.updateBarResize(at: screenPoint) + } + + func endBarResize(at screenPoint: NSPoint) { + barController?.endBarResize(at: screenPoint) + } + + func cancelBarResize() { + barController?.cancelBarResize() + } + + func adjustBarHeight(by delta: CGFloat) { + barController?.adjustBarHeight(by: delta) + } + static func restart() { let path = Bundle.main.bundlePath let task = Process() @@ -288,6 +324,8 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { if barController == nil || barController?.window == nil { barController = BarWindowController() } + // The real bar supersedes any height outline still lingering. + barHeightGhost.hide() barController?.show() startKeyMonitor() } @@ -492,7 +530,10 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate { // Events belonging to a native context menu, editor, alert, or the // Settings window must stay with their own responder chain. The bar // monitor is only responsible for keys delivered to the panel itself. - guard event.window === barController?.window else { return event } + guard let barWindow = barController?.window, + event.window === barWindow else { return event } + + if barWindow.firstResponder is BarResizeHandleResponder { return event } if handleBarCommandShortcut(event) { return nil } diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..5bf887f 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -82,6 +82,7 @@ final class Settings { static let ignoreConcealed = "ignoreConcealed" static let ignoredSourceAppBundleIDs = "ignoredSourceAppBundleIDs" static let barHeight = "barHeight" + static let showBarResizeHandle = "showBarResizeHandle" static let showMenuBarIcon = "showMenuBarIcon" static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" @@ -168,12 +169,19 @@ final class Settings { var barHeight: Double { didSet { guard isLoaded else { return } - let clamped = min(720, max(240, barHeight)) - if clamped != barHeight { barHeight = clamped; return } + let normalized = BarResizeGeometry.normalizedPersistedHeight(barHeight) + if normalized != barHeight { barHeight = normalized; return } d.set(barHeight, forKey: Keys.barHeight) } } + var showBarResizeHandle: Bool { + didSet { + guard isLoaded else { return } + d.set(showBarResizeHandle, forKey: Keys.showBarResizeHandle) + } + } + var showMenuBarIcon: Bool { didSet { guard isLoaded else { return } @@ -209,7 +217,8 @@ final class Settings { Keys.playSound: false, Keys.ignoreConcealed: true, Keys.ignoredSourceAppBundleIDs: [], - Keys.barHeight: 430.0, + Keys.barHeight: BarResizeGeometry.defaultHeight, + Keys.showBarResizeHandle: false, Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, @@ -230,12 +239,18 @@ final class Settings { ignoreConcealed = d.bool(forKey: Keys.ignoreConcealed) ignoredSourceAppBundleIDs = (d.stringArray(forKey: Keys.ignoredSourceAppBundleIDs) ?? []) .filter { !$0.isEmpty } - barHeight = d.double(forKey: Keys.barHeight) + let storedBarHeight = d.double(forKey: Keys.barHeight) + let normalizedBarHeight = BarResizeGeometry.normalizedPersistedHeight(storedBarHeight) + barHeight = normalizedBarHeight + showBarResizeHandle = d.bool(forKey: Keys.showBarResizeHandle) showMenuBarIcon = d.bool(forKey: Keys.showMenuBarIcon) onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) cloudKitSync = d.bool(forKey: Keys.cloudKitSync) isLoaded = true + if normalizedBarHeight != storedBarHeight { + d.set(normalizedBarHeight, forKey: Keys.barHeight) + } } var hotkeyDisplay: String { diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..145ab34 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -137,9 +137,10 @@ private struct GeneralSettings: View { Toggle("Launch at login", isOn: $settings.launchAtLogin) Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") + LabeledContent("Bar height", value: "\(Int(settings.barHeight)) pt") Slider(value: $settings.barHeight, in: 300...720, step: 10) } + Toggle("Show resize handle on the Paste Bar", isOn: $settings.showBarResizeHandle) #if MAS Text("Select a clip to copy it, then press ⌘V to paste it into your app.") .font(.caption).foregroundStyle(.secondary) @@ -206,6 +207,9 @@ private struct GeneralSettings: View { } } .formStyle(.grouped) + .onChange(of: settings.barHeight) { _, height in + AppController.shared.previewBarHeight(height) + } #if !MAS .onAppear { accessibilityGranted = AXIsProcessTrusted() } .onReceive(poll) { _ in diff --git a/Sources/Pesty/UI/BarHeightGhostController.swift b/Sources/Pesty/UI/BarHeightGhostController.swift new file mode 100644 index 0000000..7ad5ff4 --- /dev/null +++ b/Sources/Pesty/UI/BarHeightGhostController.swift @@ -0,0 +1,95 @@ +import AppKit +import SwiftUI + +/// A translucent stand-in for the Paste Bar, shown while the height slider in +/// Settings moves. Clicking into Settings dismisses the bar, so a number of +/// pixels is otherwise the only feedback available for a size that is only +/// meaningful on screen. +@MainActor +final class BarHeightGhostController { + private static let lingerAfterChange: TimeInterval = 1.1 + + private var panel: NSPanel? + private var hideWork: DispatchWorkItem? + + func show(height: Double) { + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) + ?? NSScreen.main ?? NSScreen.screens.first else { return } + + let panel = panel ?? makePanel() + self.panel = panel + + // Sized by the same rules the real bar uses, so the outline cannot + // promise a height the display would not actually give. + let frame = BarResizeGeometry.panelFrame(for: height, in: screen.visibleFrame) + panel.setFrame(frame, display: true) + (panel.contentView as? NSHostingView)?.rootView = + BarHeightGhostView(height: frame.height) + // orderFrontRegardless keeps Settings key: this is a readout, and + // stealing focus mid-drag would end the drag. + panel.orderFrontRegardless() + + hideWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.hide() } + hideWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.lingerAfterChange, execute: work) + } + + func hide() { + hideWork?.cancel() + hideWork = nil + panel?.orderOut(nil) + } + + private func makePanel() -> NSPanel { + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 800, height: 400), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isFloatingPanel = true + panel.level = .modalPanel + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isMovable = false + // Purely a readout: it must never swallow a click meant for whatever + // it happens to cover. + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = NSHostingView(rootView: BarHeightGhostView(height: 0)) + return panel + } +} + +private struct BarHeightGhostView: View { + let height: CGFloat + + private var shape: RoundedCorners { + RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight]) + } + + var body: some View { + ZStack { + // The real bar, faded: judging a height is much easier against + // actual cards than against an empty rectangle. + BarView() + .allowsHitTesting(false) + .opacity(0.55) + shape.stroke(Theme.selection, style: StrokeStyle(lineWidth: 2, dash: [9, 7])) + VStack(spacing: 3) { + Text("\(Int(height)) px") + .font(.system(size: 30, weight: .semibold, design: .rounded)) + Text("Paste Bar height") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.75)) + } + .foregroundStyle(.white) + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background(Color.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.45), radius: 10, y: 2) + } + .ignoresSafeArea() + } +} diff --git a/Sources/Pesty/UI/BarResizeGeometry.swift b/Sources/Pesty/UI/BarResizeGeometry.swift new file mode 100644 index 0000000..063418f --- /dev/null +++ b/Sources/Pesty/UI/BarResizeGeometry.swift @@ -0,0 +1,116 @@ +import CoreGraphics + +/// Pure sizing rules shared by the Paste Bar's persisted preference and its +/// live-resize interaction. +enum BarResizeGeometry { + static let defaultHeight = 430.0 + static let minimumHeight = 300.0 + static let maximumHeight = 720.0 + + /// Returns a value which is safe to persist independently of the display + /// the bar happens to be shown on. + static func normalizedPersistedHeight(_ height: Double) -> Double { + guard height.isFinite else { return defaultHeight } + return min(maximumHeight, max(minimumHeight, height)) + } + + /// Applies the portable preference range first, then caps the result to + /// the usable height of the target display. + static func effectiveHeight( + for requestedHeight: Double, + in visibleFrame: CGRect + ) -> CGFloat { + let displayHeight = visibleFrame.height + guard displayHeight.isFinite, displayHeight > 0 else { return 0 } + return min(CGFloat(normalizedPersistedHeight(requestedHeight)), displayHeight) + } + + /// Produces the complete bottom-docked panel frame for a target display. + static func panelFrame( + for requestedHeight: Double, + in visibleFrame: CGRect + ) -> CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: effectiveHeight(for: requestedHeight, in: visibleFrame) + ) + } + + /// The macOS 26 glass surface extends below the panel so its lower corners + /// remain clipped. Live resizing must preserve that oversized root view. + static func presentedContentFrame( + panelSize: CGSize, + bottomExtension: CGFloat + ) -> CGRect { + let extensionHeight = max(0, bottomExtension) + return CGRect( + x: 0, + y: -extensionHeight, + width: panelSize.width, + height: panelSize.height + extensionHeight + ) + } +} + +/// Immutable state captured at mouse-down so live resizing does not feed the +/// moving panel geometry back into the drag calculation. +struct BarResizeSession: Equatable { + let initialPanelHeight: CGFloat + let initialScreenY: CGFloat + let visibleFrame: CGRect + + init( + initialPanelHeight: CGFloat, + initialScreenY: CGFloat, + visibleFrame: CGRect + ) { + self.visibleFrame = visibleFrame + self.initialPanelHeight = BarResizeGeometry.effectiveHeight( + for: Double(initialPanelHeight), + in: visibleFrame + ) + self.initialScreenY = initialScreenY + } + + /// Global macOS screen coordinates increase upward, so upward pointer + /// travel adds to the bar height and downward travel subtracts from it. + func height(atScreenY screenY: CGFloat) -> CGFloat { + guard initialScreenY.isFinite, screenY.isFinite else { + return initialPanelHeight + } + let requestedHeight = initialPanelHeight + (screenY - initialScreenY) + return BarResizeGeometry.effectiveHeight( + for: Double(requestedHeight), + in: visibleFrame + ) + } + + func frame(atScreenY screenY: CGFloat) -> CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: height(atScreenY: screenY) + ) + } + + /// The portable value to write only after a successful drag completes. + func finalPersistedHeight(atScreenY screenY: CGFloat) -> Double { + BarResizeGeometry.normalizedPersistedHeight( + Double(height(atScreenY: screenY)) + ) + } + + var cancelledHeight: CGFloat { initialPanelHeight } + + var cancelledFrame: CGRect { + CGRect( + x: visibleFrame.minX, + y: visibleFrame.minY, + width: visibleFrame.width, + height: initialPanelHeight + ) + } +} diff --git a/Sources/Pesty/UI/BarResizeHandle.swift b/Sources/Pesty/UI/BarResizeHandle.swift new file mode 100644 index 0000000..89a5ce2 --- /dev/null +++ b/Sources/Pesty/UI/BarResizeHandle.swift @@ -0,0 +1,218 @@ +import AppKit +import SwiftUI + +/// Lets the Paste Bar's local key monitor leave Escape and drag-time keys to +/// the handle while it temporarily owns first-responder status. +protocol BarResizeHandleResponder: AnyObject {} + +/// A compact drag target for resizing the Paste Bar vertically. +/// +/// Drag callbacks use AppKit's global screen coordinate space so the owner can +/// keep its resize geometry independent of the handle's position in SwiftUI. +@MainActor +struct BarResizeHandle: View { + private static let size = NSSize(width: 42, height: 14) + + private let accessibilityValue: CGFloat? + private let onBegin: (NSPoint) -> Void + private let onChange: (NSPoint) -> Void + private let onEnd: (NSPoint) -> Void + private let onCancel: () -> Void + private let onAccessibilityAdjustment: (CGFloat) -> Void + + init( + accessibilityValue: CGFloat? = nil, + onBegin: @escaping (NSPoint) -> Void, + onChange: @escaping (NSPoint) -> Void, + onEnd: @escaping (NSPoint) -> Void, + onCancel: @escaping () -> Void, + onAccessibilityAdjustment: @escaping (CGFloat) -> Void + ) { + self.accessibilityValue = accessibilityValue + self.onBegin = onBegin + self.onChange = onChange + self.onEnd = onEnd + self.onCancel = onCancel + self.onAccessibilityAdjustment = onAccessibilityAdjustment + } + + var body: some View { + BarResizeHandleRepresentable( + accessibilityValue: accessibilityValue, + callbacks: .init( + onBegin: onBegin, + onChange: onChange, + onEnd: onEnd, + onCancel: onCancel, + onAccessibilityAdjustment: onAccessibilityAdjustment)) + .frame(width: Self.size.width, height: Self.size.height) + } +} + +@MainActor +private struct BarResizeHandleRepresentable: NSViewRepresentable { + let accessibilityValue: CGFloat? + let callbacks: BarResizeHandleView.Callbacks + + func makeNSView(context: Context) -> BarResizeHandleView { + BarResizeHandleView(callbacks: callbacks, accessibilityValue: accessibilityValue) + } + + func updateNSView(_ nsView: BarResizeHandleView, context: Context) { + nsView.callbacks = callbacks + nsView.accessibilityHeight = accessibilityValue + } +} + +@MainActor +private final class BarResizeHandleView: NSView, BarResizeHandleResponder { + static let accessibilityStep: CGFloat = 10 + + struct Callbacks { + let onBegin: (NSPoint) -> Void + let onChange: (NSPoint) -> Void + let onEnd: (NSPoint) -> Void + let onCancel: () -> Void + let onAccessibilityAdjustment: (CGFloat) -> Void + } + + var callbacks: Callbacks + var accessibilityHeight: CGFloat? { + didSet { updateAccessibilityValue() } + } + + private var isTrackingDrag = false + private var ownsResizeCursor = false + private weak var previousFirstResponder: NSResponder? + + override var acceptsFirstResponder: Bool { true } + override var intrinsicContentSize: NSSize { NSSize(width: 42, height: 14) } + + init(callbacks: Callbacks, accessibilityValue: CGFloat?) { + self.callbacks = callbacks + accessibilityHeight = accessibilityValue + super.init(frame: NSRect(x: 0, y: 0, width: 42, height: 14)) + + setAccessibilityElement(true) + setAccessibilityRole(.slider) + setAccessibilityLabel("Resize Paste Bar") + setAccessibilityHelp("Drag vertically, or increment and decrement in ten-point steps.") + updateAccessibilityValue() + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) unavailable") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let line = NSRect(x: bounds.midX - 21, y: bounds.midY - 2, width: 42, height: 4) + NSColor.secondaryLabelColor.withAlphaComponent(0.55).setFill() + NSBezierPath(roundedRect: line, xRadius: 2, yRadius: 2).fill() + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .resizeUpDown) + } + + override func mouseDown(with event: NSEvent) { + guard !isTrackingDrag else { return } + isTrackingDrag = true + previousFirstResponder = window?.firstResponder + window?.makeFirstResponder(self) + NSCursor.resizeUpDown.push() + ownsResizeCursor = true + callbacks.onBegin(screenPoint(for: event)) + } + + override func mouseDragged(with event: NSEvent) { + guard isTrackingDrag else { return } + callbacks.onChange(screenPoint(for: event)) + } + + override func mouseUp(with event: NSEvent) { + guard isTrackingDrag else { return } + let point = screenPoint(for: event) + finishTracking() + callbacks.onEnd(point) + } + + override func keyDown(with event: NSEvent) { + guard isTrackingDrag, event.keyCode == 53 else { + super.keyDown(with: event) + return + } + cancelTracking() + } + + override func cancelOperation(_ sender: Any?) { + guard isTrackingDrag else { + super.cancelOperation(sender) + return + } + cancelTracking() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if isTrackingDrag, newWindow !== window { + cancelTracking() + } + super.viewWillMove(toWindow: newWindow) + } + + override func accessibilityPerformIncrement() -> Bool { + callbacks.onAccessibilityAdjustment(Self.accessibilityStep) + return true + } + + override func accessibilityPerformDecrement() -> Bool { + callbacks.onAccessibilityAdjustment(-Self.accessibilityStep) + return true + } + + @objc private func windowDidResignKey(_ notification: Notification) { + guard isTrackingDrag, + let notificationWindow = notification.object as? NSWindow, + notificationWindow === window else { return } + cancelTracking() + } + + private func cancelTracking() { + guard isTrackingDrag else { return } + finishTracking() + callbacks.onCancel() + } + + private func finishTracking() { + isTrackingDrag = false + if ownsResizeCursor { + NSCursor.pop() + ownsResizeCursor = false + } + if window?.firstResponder === self { + window?.makeFirstResponder(previousFirstResponder) + } + previousFirstResponder = nil + } + + private func screenPoint(for event: NSEvent) -> NSPoint { + guard let eventWindow = event.window else { return NSEvent.mouseLocation } + return eventWindow.convertPoint(toScreen: event.locationInWindow) + } + + private func updateAccessibilityValue() { + let value = accessibilityHeight.map { NSNumber(value: Double($0)) } + setAccessibilityValue(value) + } +} diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 87dcd77..d2afb0e 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -14,6 +14,17 @@ struct BarView: View { strip } } + .overlay(alignment: .top) { + if settings.showBarResizeHandle { + BarResizeHandle( + accessibilityValue: CGFloat(settings.barHeight), + onBegin: AppController.shared.beginBarResize, + onChange: AppController.shared.updateBarResize, + onEnd: AppController.shared.endBarResize, + onCancel: AppController.shared.cancelBarResize, + onAccessibilityAdjustment: AppController.shared.adjustBarHeight) + } + } .clipShape(RoundedCorners(radius: Theme.cornerRadius, corners: [.topLeft, .topRight])) .ignoresSafeArea() } diff --git a/Sources/Pesty/UI/BarWindowController.swift b/Sources/Pesty/UI/BarWindowController.swift index 663be06..72af8f0 100644 --- a/Sources/Pesty/UI/BarWindowController.swift +++ b/Sources/Pesty/UI/BarWindowController.swift @@ -33,6 +33,8 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { private var phase: Phase = .hidden private var epoch = 0 + private var targetVisibleFrame: NSRect? + private var resizeSession: BarResizeSession? /// True while the bar is up or on its way up. `AppController.toggleBar` asks this /// instead of `window.isVisible`. @@ -145,8 +147,13 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { guard let screen = Self.targetScreen() else { return } panel.level = Settings.shared.hideOnClickOutside ? .modalPanel : .floating let vf = screen.visibleFrame - let height = min(CGFloat(Settings.shared.barHeight), vf.height) - let onScreen = NSRect(x: vf.minX, y: vf.minY, width: vf.width, height: height) + let onScreen = BarResizeGeometry.panelFrame( + for: Settings.shared.barHeight, + in: vf + ) + let height = onScreen.height + targetVisibleFrame = vf + resizeSession = nil // The panel stays parked at its final frame and the content slides up *inside* // it. Animating the window frame itself is not safe on multi-display setups: @@ -184,8 +191,10 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = Self.showDuration ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) - content.animator().frame = NSRect(x: 0, y: -bottomExtension, - width: onScreen.width, height: contentHeight) + content.animator().frame = BarResizeGeometry.presentedContentFrame( + panelSize: onScreen.size, + bottomExtension: bottomExtension + ) }, completionHandler: { DispatchQueue.main.async(execute: finish) }) DispatchQueue.main.asyncAfter(deadline: .now() + Self.showDuration + 0.05, execute: finish) } @@ -193,6 +202,7 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { func hide() { guard let panel = window, let content = panel.contentView else { return } guard isPresented else { return } + cancelBarResize() let token = beginTransition() phase = .hiding(token) @@ -218,11 +228,75 @@ final class BarWindowController: NSWindowController, NSWindowDelegate { /// Used after sleep or a display change, where an in-flight transition can be /// left stranded on a screen that no longer exists. func forceHide() { + resizeSession = nil _ = beginTransition() phase = .hidden window?.orderOut(nil) } + func beginBarResize(at screenPoint: NSPoint) { + guard phase == .shown, + resizeSession == nil, + let panel = window, + let visibleFrame = targetVisibleFrame else { return } + resizeSession = BarResizeSession( + initialPanelHeight: panel.frame.height, + initialScreenY: screenPoint.y, + visibleFrame: visibleFrame + ) + } + + func updateBarResize(at screenPoint: NSPoint) { + guard phase == .shown, let resizeSession else { return } + applyResizeFrame(resizeSession.frame(atScreenY: screenPoint.y)) + } + + func endBarResize(at screenPoint: NSPoint) { + guard phase == .shown, let resizeSession else { return } + let frame = resizeSession.frame(atScreenY: screenPoint.y) + let persistedHeight = resizeSession.finalPersistedHeight(atScreenY: screenPoint.y) + self.resizeSession = nil + applyResizeFrame(frame) + Settings.shared.barHeight = persistedHeight + } + + func cancelBarResize() { + guard let resizeSession else { return } + self.resizeSession = nil + guard phase == .shown else { return } + applyResizeFrame(resizeSession.cancelledFrame) + } + + func adjustBarHeight(by delta: CGFloat) { + guard phase == .shown, + resizeSession == nil, + let panel = window, + let visibleFrame = targetVisibleFrame else { return } + let requested = Double(panel.frame.height + delta) + let frame = BarResizeGeometry.panelFrame(for: requested, in: visibleFrame) + applyResizeFrame(frame) + Settings.shared.barHeight = Double(frame.height) + } + + func applyConfiguredBarHeight() { + guard phase == .shown, resizeSession == nil, + let visibleFrame = targetVisibleFrame else { return } + applyResizeFrame(BarResizeGeometry.panelFrame( + for: Settings.shared.barHeight, + in: visibleFrame + )) + } + + private func applyResizeFrame(_ frame: NSRect) { + guard let panel = window, let content = panel.contentView, + frame.width > 0, frame.height > 0 else { return } + panel.setFrame(frame, display: true) + content.frame = BarResizeGeometry.presentedContentFrame( + panelSize: frame.size, + bottomExtension: Self.contentBottomExtension + ) + } + func windowDidResignKey(_ notification: Notification) { guard Settings.shared.hideOnClickOutside, phase == .shown, From 3d5a8e222636e4c7ddb9f544ee37ff8458b6d198 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 20:23:17 -0700 Subject: [PATCH 2/2] Label the bar-height slider's unit as px, not pt Matches Alvie's Pesty's wording exactly - the value is a raw point count either way, this was just a wording mismatch caught during visual parity review of the settings redesign. --- Sources/Pesty/Settings/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 145ab34..412e32d 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -137,7 +137,7 @@ private struct GeneralSettings: View { Toggle("Launch at login", isOn: $settings.launchAtLogin) Toggle("Show Pesty in the menu bar", isOn: $settings.showMenuBarIcon) VStack(alignment: .leading) { - LabeledContent("Bar height", value: "\(Int(settings.barHeight)) pt") + LabeledContent("Bar height", value: "\(Int(settings.barHeight)) px") Slider(value: $settings.barHeight, in: 300...720, step: 10) } Toggle("Show resize handle on the Paste Bar", isOn: $settings.showBarResizeHandle)