diff --git a/Sources/Vista/PanelContentView.swift b/Sources/Vista/PanelContentView.swift index c1734b4..2c482de 100644 --- a/Sources/Vista/PanelContentView.swift +++ b/Sources/Vista/PanelContentView.swift @@ -44,6 +44,15 @@ struct PanelContentView: View { // keep intercepting events when the panel is hidden. @State private var keyMonitor: Any? + // Shift+Space opens a large preview of the selected screenshot. Esc + // closes the preview first (leaving the panel up); a second Esc + // dismisses the panel. + @State private var previewVisible: Bool = false + + // ⌘K opens a Raycast-style actions popover anchored to the footer's + // "Actions ⌘K" hint. Esc cascade also closes this before the panel. + @State private var actionsVisible: Bool = false + var body: some View { VStack(spacing: 0) { searchBar @@ -58,8 +67,32 @@ struct PanelContentView: View { } .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + if previewVisible, let record = model.selectedRecord { + PreviewOverlay( + record: record, + thumbnails: thumbnails, + close: { previewVisible = false } + ) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } + .animation(.easeInOut(duration: 0.15), value: previewVisible) + // Drop the preview if the selection list empties out (e.g. the + // user typed a query that no longer matches anything). Otherwise + // we'd be stuck showing the overlay with no record behind it. + .onChange(of: model.selectedRecord == nil) { _, gone in + if gone { + previewVisible = false + actionsVisible = false + } + } .onAppear { installKeyMonitor() } - .onDisappear { removeKeyMonitor() } + .onDisappear { + removeKeyMonitor() + previewVisible = false + actionsVisible = false + } } // MARK: - Sections @@ -223,10 +256,39 @@ struct PanelContentView: View { .foregroundStyle(.secondary) } Divider().frame(height: 12) - HStack(spacing: 6) { - Text("Actions") - Text("⌘K") - .foregroundStyle(.secondary) + Button { + if model.selectedRecord != nil { actionsVisible.toggle() } + } label: { + HStack(spacing: 6) { + Text("Actions") + Text("⌘K") + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .disabled(model.selectedRecord == nil) + .popover(isPresented: $actionsVisible, arrowEdge: .top) { + if let record = model.selectedRecord { + ActionsPopover( + record: record, + preferences: preferences, + run: { action in + actionsVisible = false + actions.run(action, on: record) + // Pin and trash mutate the store — reload so + // the grid reflects the change immediately. + if action == .togglePin || action == .moveToTrash { + model.reload() + } else if action != .copyFilePath, action != .copyOCRText { + // Most actions are terminal — dismiss the + // panel afterwards, same as Enter. The two + // clipboard-text actions stay open since + // users typically copy then keep browsing. + dismiss() + } + } + ) + } } } .font(.caption) @@ -285,8 +347,31 @@ struct PanelContentView: View { runPrimary() return nil case kVK_Escape: - dismiss() + // Cascade: close transient overlays first, only dismiss the + // panel once nothing else is on top. Lets users peek or open + // the actions menu without losing their place in the grid. + if actionsVisible { + actionsVisible = false + } else if previewVisible { + previewVisible = false + } else { + dismiss() + } return nil + case kVK_Space: + // Shift+Space toggles the large preview. Plain space falls + // through to the search field so multi-word queries + // (`text:hello world`) still work. Strict equality on `mods` + // would fail when capsLock or function-key state bits ride + // along, so test the relevant modifiers individually. + let shiftOnly = mods.contains(.shift) + && !mods.contains(.command) + && !mods.contains(.option) + && !mods.contains(.control) + if shiftOnly, model.selectedRecord != nil { + previewVisible.toggle() + return nil + } default: break } @@ -307,6 +392,16 @@ struct PanelContentView: View { } return nil } + // ⌘K toggles the actions popover. Mirrors the footer hint and + // matches Raycast muscle memory for the same shortcut. Pass + // the event through when there's nothing selected — swallowing + // it would turn ⌘K into a dead key on empty result lists and + // block any future global binding from seeing it. + if chars == "k", !mods.contains(.shift) { + guard model.selectedRecord != nil else { return event } + actionsVisible.toggle() + return nil + } } return event @@ -383,6 +478,263 @@ private struct ResultCell: View { } } +// MARK: - Preview overlay + +/// Quick Look-style large preview triggered by Shift+Space. Sized to +/// ~85% of the panel area so the surrounding grid is still visible at +/// the edges, reinforcing that the panel is still up and arrow keys +/// will swap the previewed record. +@MainActor +private struct PreviewOverlay: View { + let record: ScreenshotRecord + let thumbnails: ThumbnailCache + let close: () -> Void + + @State private var image: NSImage? + + var body: some View { + GeometryReader { geo in + ZStack { + // Dimmed scrim — tap anywhere outside the card to close. + Color.black.opacity(0.45) + .contentShape(Rectangle()) + .onTapGesture(perform: close) + + card + .frame( + width: geo.size.width * 0.85, + height: geo.size.height * 0.85 + ) + .shadow(color: .black.opacity(0.35), radius: 30, y: 12) + } + .frame(width: geo.size.width, height: geo.size.height) + } + // Re-load when arrow keys change the selected record while the + // overlay is open. `task(id:)` cancels its own body when id + // changes, but `Task.detached` is unstructured and keeps running + // even after cancellation — without the isCancelled check below, + // a slow load from the previous record can land after a faster + // load for the new one and briefly show the wrong screenshot. + .task(id: record.id) { + let loaded = await Task.detached(priority: .userInitiated) { [record] in + try? thumbnails.thumbnail( + for: record.path, + size: .large, + sourceMtime: record.mtime, + sourceSize: record.size + ) + }.value + guard !Task.isCancelled else { return } + self.image = loaded + } + } + + private var card: some View { + HStack(spacing: 0) { + imagePane + Divider() + metadataPane + .frame(width: 280) + } + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.separator, lineWidth: 1) + ) + } + + private var imagePane: some View { + ZStack { + Color.black.opacity(0.25) + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + .padding(16) + } else { + ProgressView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var metadataPane: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 8) { + if record.pinned { + Image(systemName: "pin.fill") + .foregroundStyle(.yellow) + .padding(.top, 2) + } + Text(record.name) + .font(.headline) + .lineLimit(3) + .textSelection(.enabled) + } + + VStack(alignment: .leading, spacing: 4) { + Label(record.capturedAt.timeAgoStyle(), systemImage: "calendar") + Label(Self.byteFormatter.string(fromByteCount: record.size), systemImage: "internaldrive") + if record.width > 0, record.height > 0 { + Label("\(record.width) × \(record.height)", systemImage: "ruler") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + + Divider() + + Text("Text in screenshot") + .font(.caption) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + + ocrSnippet + + Spacer(minLength: 0) + + HStack { + Spacer() + Button(action: close) { + Label("Close", systemImage: "xmark") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + } + + @ViewBuilder + private var ocrSnippet: some View { + if let text = record.ocrText { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + Text("No text detected") + .font(.callout) + .foregroundStyle(.tertiary) + .italic() + } else { + ScrollView { + Text(trimmed) + .font(.callout) + .monospaced() + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("OCR still running…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + private static let byteFormatter: ByteCountFormatter = { + let f = ByteCountFormatter() + f.countStyle = .file + return f + }() +} + +// MARK: - Actions popover + +/// Raycast-style action list anchored to the footer's ⌘K hint. Lists +/// every `RowAction` in its canonical order, with the panel-internal +/// shortcuts shown on the right so users can learn them by looking. +@MainActor +private struct ActionsPopover: View { + let record: ScreenshotRecord + let preferences: Preferences + let run: (RowAction) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Actions") + .font(.caption) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + + ForEach(RowAction.allCases) { action in + Button { + run(action) + } label: { + HStack(spacing: 10) { + Image(systemName: Self.icon(for: action)) + .frame(width: 16) + .foregroundStyle(.secondary) + Text(label(for: action)) + .foregroundStyle(.primary) + Spacer() + let hints = shortcuts(for: action) + if !hints.isEmpty { + Text(hints.joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + .monospaced() + } + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + .padding(.bottom, 8) + } + .frame(width: 280) + } + + /// Pinned records flip the "Pin / Unpin" label to whichever action + /// the click will actually perform — clearer than always showing both. + private func label(for action: RowAction) -> String { + if action == .togglePin { + return record.pinned ? "Unpin" : "Pin" + } + return action.label + } + + /// Shortcuts wired up in `handlePanelKey`. Showing only the ones that + /// actually work avoids promising bindings that don't exist. Returns + /// a list because some actions have two bindings at once — e.g. when + /// the user's primary action is Copy OCR Text, both ↵ and ⌘⇧C fire it, + /// and hiding either hint would misrepresent the live bindings. + private func shortcuts(for action: RowAction) -> [String] { + var hints: [String] = [] + if action == preferences.primaryAction.rowAction { hints.append("↵") } + switch action { + case .togglePin: hints.append("⌘P") + case .copyOCRText: hints.append("⌘⇧C") + default: break + } + return hints + } + + private static func icon(for action: RowAction) -> String { + switch action { + case .open: return "arrow.up.right.square" + case .copyImage: return "doc.on.clipboard" + case .pasteToFrontApp: return "arrow.down.doc" + case .showInFinder: return "folder" + case .copyFilePath: return "link" + case .copyOCRText: return "text.quote" + case .togglePin: return "pin" + case .moveToTrash: return "trash" + } + } +} + private extension Date { /// "Today at 14:27" / "Yesterday at 09:12" / "Fri at 11:05" / /// "12 Apr at 16:40" — picks the most human-feeling form.