diff --git a/Sources/Vista/FloatingPanel.swift b/Sources/Vista/FloatingPanel.swift index 0a7b503..6858cf9 100644 --- a/Sources/Vista/FloatingPanel.swift +++ b/Sources/Vista/FloatingPanel.swift @@ -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 @@ -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 { diff --git a/Sources/Vista/PanelController.swift b/Sources/Vista/PanelController.swift index 8732dfe..1f019a0 100644 --- a/Sources/Vista/PanelController.swift +++ b/Sources/Vista/PanelController.swift @@ -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 @@ -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, @@ -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() @@ -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 @@ -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. @@ -117,12 +143,13 @@ 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 @@ -130,6 +157,7 @@ public final class PanelController { 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 } diff --git a/Sources/Vista/Preferences.swift b/Sources/Vista/Preferences.swift index 05f2274..5926620 100644 --- a/Sources/Vista/Preferences.swift +++ b/Sources/Vista/Preferences.swift @@ -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. @@ -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 @@ -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 @@ -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. diff --git a/Sources/Vista/SearchViewModel.swift b/Sources/Vista/SearchViewModel.swift index 016fb8a..6bbb114 100644 --- a/Sources/Vista/SearchViewModel.swift +++ b/Sources/Vista/SearchViewModel.swift @@ -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("") + } + public var selectedRecord: ScreenshotRecord? { guard results.indices.contains(selectedIndex) else { return nil } return results[selectedIndex] diff --git a/Sources/Vista/SettingsView.swift b/Sources/Vista/SettingsView.swift index 754087a..67ca207 100644 --- a/Sources/Vista/SettingsView.swift +++ b/Sources/Vista/SettingsView.swift @@ -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)