Skip to content
Open
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
43 changes: 42 additions & 1 deletion Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 }

Expand Down
23 changes: 19 additions & 4 deletions Sources/Pesty/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Pesty/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ private struct GeneralSettings: View {
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)
#if MAS
Text("Select a clip to copy it, then press ⌘V to paste it into your app.")
.font(.caption).foregroundStyle(.secondary)
Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions Sources/Pesty/UI/BarHeightGhostController.swift
Original file line number Diff line number Diff line change
@@ -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<BarHeightGhostView>)?.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()
}
}
116 changes: 116 additions & 0 deletions Sources/Pesty/UI/BarResizeGeometry.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Loading
Loading