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
15 changes: 15 additions & 0 deletions Sources/Vista/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ public final class FloatingPanel: NSPanel {
/// needing a relaunch.
public var sizeFraction: CGFloat = 0.6

/// Fired after every `orderOut` — including the AppKit-driven path
/// that runs when `hidesOnDeactivate` makes the panel disappear on
/// focus loss. PanelController hooks this to keep `lastHiddenAt`
/// accurate across every dismissal route, not just explicit ones.
public var onHide: (() -> Void)?

public init(contentView: NSView) {
// Borderless = no traffic-light buttons, no titlebar strip. The
// whole visible surface is our SwiftUI content, which handles its
Expand Down Expand Up @@ -83,6 +89,15 @@ public final class FloatingPanel: NSPanel {
NSApp.activate(ignoringOtherApps: true)
}

/// AppKit funnels every dismissal — explicit `orderOut`, hotkey
/// toggle, paste flow, and the `hidesOnDeactivate` auto-hide on
/// focus loss — through this method, so it's the single point we
/// need to fire `onHide` from.
public override func orderOut(_ sender: Any?) {
super.orderOut(sender)
onHide?()
}

/// Chosen by proximity to the cursor — multi-monitor users expect the
/// panel to appear on whichever screen they're currently looking at.
private static func activeScreen() -> NSScreen {
Expand Down
36 changes: 32 additions & 4 deletions Sources/Vista/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import VistaCore
public final class PanelController {

private var panel: FloatingPanel?
private var viewModel: SearchViewModel?
private let store: ScreenshotStore
private let thumbnails: ThumbnailCache
private let actions: ActionHandlers
Expand All @@ -21,6 +22,12 @@ public final class PanelController {
/// back at the original target rather than at vista itself.
private var previousFrontmostApp: NSRunningApplication?

/// Wall-clock time the panel was last hidden. Used to decide whether
/// to reset query/selection on the next show — see `show()`. nil
/// means we've never hidden the panel this session, in which case
/// the view-model is already fresh and there's nothing to reset.
private var lastHiddenAt: Date?

public init(
store: ScreenshotStore,
thumbnails: ThumbnailCache,
Expand All @@ -44,7 +51,7 @@ public final class PanelController {
/// This is what we bind the global hotkey to.
public func toggle() {
if let panel, panel.isVisible {
panel.orderOut(nil)
hidePanel()
} else {
capturePreviousFrontmost()
show()
Expand All @@ -54,12 +61,31 @@ public final class PanelController {
public func show() {
capturePreviousFrontmost()
let panel = ensurePanel()
// If the panel has been out of sight long enough that the
// previous query is unlikely to still be relevant, wipe back to
// a clean state before the window is visible. Decided on each
// show so changing the timeout in Settings takes effect
// immediately. Skip on the very first show of the session
// (lastHiddenAt == nil) — the view-model is already fresh.
if let last = lastHiddenAt,
let timeout = preferences.panelResetTimeout.seconds,
Date().timeIntervalSince(last) >= timeout {
viewModel?.resetState()
}
// Apply the latest panel-size preference every show so Appearance
// slider changes take effect without needing a relaunch.
panel.sizeFraction = preferences.panelSizeFraction
panel.show()
}

/// Hides the panel. The hide-time stamp is set by the FloatingPanel's
/// `onHide` hook (see `ensurePanel`) — that runs for every dismissal
/// path, including AppKit's `hidesOnDeactivate` auto-hide on focus
/// loss, not just calls that go through this method.
private func hidePanel() {
panel?.orderOut(nil)
}

/// Records whoever was frontmost before we activate. Skips vista
/// itself — if the user opens the panel while it's already visible
/// (e.g. via the menu bar) we don't want to overwrite the real
Expand All @@ -85,10 +111,10 @@ public final class PanelController {
/// clipboard copy already happened in ActionHandlers.
private func pasteToPreviousFrontmost() {
guard let target = previousFrontmostApp else {
panel?.orderOut(nil)
hidePanel()
return
}
panel?.orderOut(nil)
hidePanel()
// Activate after panel hides. A short delay gives AppKit time to
// process the orderOut before we ask another app to take focus;
// without it the frontmost-change can be dropped on the floor.
Expand Down Expand Up @@ -117,19 +143,21 @@ public final class PanelController {
if let existing = panel { return existing }

let viewModel = SearchViewModel(store: store)
self.viewModel = viewModel
let content = PanelContentView(
model: viewModel,
thumbnails: thumbnails,
actions: actions,
preferences: preferences,
dismiss: { [weak self] in self?.panel?.orderOut(nil) }
dismiss: { [weak self] in self?.hidePanel() }
)

// NSHostingView is the bridge between SwiftUI and AppKit — the
// panel owns it as its contentView.
let host = NSHostingView(rootView: content)
host.autoresizingMask = [.width, .height]
let panel = FloatingPanel(contentView: host)
panel.onHide = { [weak self] in self?.lastHiddenAt = Date() }
self.panel = panel
return panel
}
Expand Down
52 changes: 52 additions & 0 deletions Sources/Vista/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ public enum StorageDuration: String, CaseIterable, Identifiable, Sendable, Codab
}
}

/// How long the panel must be hidden before reopening it resets the
/// search query, scroll position, and selection. "Resume where I left
/// off" only feels right inside a single train-of-thought; come back an
/// hour later and a stale query is friction, not a feature.
public enum PanelResetTimeout: String, CaseIterable, Identifiable, Sendable, Codable {
case never
case thirtySeconds
case oneMinute
case twoMinutes
case fiveMinutes
case tenMinutes

public var id: String { rawValue }

public var label: String {
switch self {
case .never: return "Never"
case .thirtySeconds: return "30 seconds"
case .oneMinute: return "1 minute"
case .twoMinutes: return "2 minutes"
case .fiveMinutes: return "5 minutes"
case .tenMinutes: return "10 minutes"
}
}

/// Hidden-duration threshold above which the panel resets on next
/// show. nil = never reset.
public var seconds: TimeInterval? {
switch self {
case .never: return nil
case .thirtySeconds: return 30
case .oneMinute: return 60
case .twoMinutes: return 120
case .fiveMinutes: return 300
case .tenMinutes: return 600
}
}
}

/// A persistent, security-scoped reference to a folder the user added.
/// The bookmark is what survives app restarts — re-resolving it yields
/// back a URL we're allowed to read even in a hardened-runtime build.
Expand Down Expand Up @@ -130,6 +169,7 @@ public final class Preferences {
static let storageDuration = "vista.storageDuration"
static let launchAtLogin = "vista.launchAtLogin"
static let watchDefaultFolder = "vista.watchDefaultFolder"
static let panelResetTimeout = "vista.panelResetTimeout"
}

private let defaults: UserDefaults
Expand Down Expand Up @@ -230,6 +270,15 @@ public final class Preferences {
}
}

/// How long the panel can stay hidden before its query/selection are
/// reset on next show. Read on demand by PanelController; no
/// downstream observers need to react, so we don't emit a change.
public var panelResetTimeout: PanelResetTimeout {
didSet {
defaults.set(panelResetTimeout.rawValue, forKey: Key.panelResetTimeout)
}
}

// MARK: - Watched folders (bookmark-backed)

/// Folders the indexer will watch. Mutated through the add/remove
Expand Down Expand Up @@ -366,6 +415,9 @@ public final class Preferences {
// "just work" without requiring a folder to be added first.
self.watchDefaultFolder = defaults.object(forKey: Key.watchDefaultFolder) as? Bool ?? true

let resetRaw = defaults.string(forKey: Key.panelResetTimeout) ?? PanelResetTimeout.twoMinutes.rawValue
self.panelResetTimeout = PanelResetTimeout(rawValue: resetRaw) ?? .twoMinutes

// Watched folders live in a JSON file so we can store bookmark
// data cleanly. Application Support is the right location —
// survives upgrades, not subject to cache eviction.
Expand Down
15 changes: 15 additions & 0 deletions Sources/Vista/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ public final class SearchViewModel {
runQuery(queryText)
}

/// Wipes the query, scroll, and selection back to a clean "first
/// open" state. Called by PanelController when the panel has been
/// hidden longer than the user's reset timeout.
///
/// Setting `queryText` schedules a debounced query, but we cancel
/// it and run the empty query synchronously instead. Without the
/// cancel we'd hit `store.search` twice — once now, once again
/// ~120 ms later — which is noticeable on large indexes.
public func resetState() {
queryText = ""
debounceTask?.cancel()
debounceTask = nil
runQuery("")
}
Comment thread
GordonBeeming marked this conversation as resolved.

public var selectedRecord: ScreenshotRecord? {
guard results.indices.contains(selectedIndex) else { return nil }
return results[selectedIndex]
Expand Down
9 changes: 9 additions & 0 deletions Sources/Vista/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ private struct BehaviourTab: View {
Text("What ⏎ does when a result is selected. Every action is still available via the ⌘K menu.")
.font(.caption)
.foregroundStyle(.secondary)

Picker("Reset after", selection: $preferences.panelResetTimeout) {
ForEach(PanelResetTimeout.allCases) { option in
Text(option.label).tag(option)
}
}
Text("Clear the search and jump back to the top when Vista has been hidden this long. Set to Never to keep your last search between panel opens.")
.font(.caption)
.foregroundStyle(.secondary)
}
.formStyle(.grouped)
.padding(20)
Expand Down
Loading