diff --git a/QuickLookOptimize.md b/QuickLookOptimize.md new file mode 100644 index 0000000..7f9c82c --- /dev/null +++ b/QuickLookOptimize.md @@ -0,0 +1,54 @@ +# Quick Look 优化规划 + +## 1. 现状总结 + +- **功能层面**: Cardinal 当前通过 Tauri 指令将文件路径推送给 `QLPreviewPanel`,实现了基础的预览功能。 +- **控制层面**: `PreviewController` 作为数据源和代理,通过主线程的 `thread_local!` 管理,但缺少丰富的生命周期和事件管理。 +- **体验层面**: + - 前端仅在按下空格时**打开**面板,缺少“切换”语义,也无法感知面板的关闭状态。 + - 面板动画、键盘控制、当前预览项与UI列表的同步缺失,与系统 Finder 的原生体验差距明显。 + +## 2. 核心目标 + +1. **原生体验对齐**: 实现平滑的缩放动画、键盘导航等,让 Quick Look 的交互体感与系统原生行为一致。 +2. **双向状态同步**: 建立前端 UI 与原生 Quick Look 面板之间的状态同步机制,确保两者状态始终一致。 +3. **提升交互健壮性**: 优化指令设计,处理各种边界情况,提供更可靠、更符合直觉的用户体验。 + +## 3. 优化实施计划 + +### Phase 2: 原生视觉与动画打磨 + +此阶段专注于提升视觉效果,达到“原生感”。 + +**2.1. 实现平滑的缩放动画** +- **问题**: 面板生硬地出现,没有从列表项“放大”的动画效果。 +- **方案**: + - **前端**: 触发 `toggle_quicklook` 时,使用 `element.getBoundingClientRect()` 获取选中项的 DOM 坐标,并将其作为参数传递给 Rust。 + - **Rust**: + - 在 `PreviewController` 中存储每个文件路径及其对应的屏幕坐标 (`tauri::Rect`)。 + - 实现 `QLPreviewPanelDelegate` 的 `previewPanel:sourceFrameOnScreenForPreviewItem:` 方法。 + - 在该方法中,根据 `item` 的 `previewItemURL` 查找到其坐标,结合窗口位置计算出**屏幕绝对坐标**并返回。 + +**2.2. (可选) 提供过渡图像** +- **问题**: 动画过程中可能出现短暂的白色闪烁。 +- **方案**: + - 如果前端有文件图标或缩略图的缓存(例如 Base64 或 URL),可以将其一并传递给 Rust。 + - 在 Rust 中实现 `previewPanel:transitionImageForPreviewItem:contentRect:` 方法,返回对应的 `NSImage`,以提供更平滑的过渡效果。 + +### Phase 3: 高级交互 + +此阶段实现更精细的交互控制。 + +**3.1. 实现键盘事件透传** +- **问题**: Quick Look 激活时,它会成为 Key Window,导致文件列表无法响应方向键等导航快捷键。 +- **方案**: + - 在 `PreviewController` 中实现 `QLPreviewPanelDelegate` 的 `handleEvent:` 方法。 + - 捕获关键的键盘事件(如上/下箭头、删除键等)。 + - 判断事件类型后,通过 `app_handle.emit_all("quicklook-keydown", ...)` 将事件信息转发给前端。 + - 前端监听此事件,并执行对应的列表导航或操作逻辑,从而实现 Quick Look 面板对文件列表的“遥控”。 + +## 4. 预期收益 + +- **交互一致性**: 提供与系统 Finder 高度一致的交互与动画体验。 +- **状态可靠性**: 通过双向事件同步,确保面板与前端状态一致,减少用户困惑和无效的后台调用。 +- **架构融合度**: 通过键盘事件透传和几何信息传递,将原生 Quick Look 面板更无缝地融入到现有的虚拟列表架构中。 diff --git a/cardinal/src-tauri/Cargo.lock b/cardinal/src-tauri/Cargo.lock index 6ea342c..5fb2e7a 100644 --- a/cardinal/src-tauri/Cargo.lock +++ b/cardinal/src-tauri/Cargo.lock @@ -389,11 +389,16 @@ version = "0.1.12" dependencies = [ "anyhow", "base64 0.22.1", + "camino", "cardinal-sdk", "crossbeam-channel", "directories", "fs-icon", "fswalk", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "objc2-quick-look-ui", "once_cell", "parking_lot", "rayon", @@ -2719,6 +2724,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-pdf-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c14ed801ae810c6ba487cedd7616bb48f9a8e37940042f57e862538e2c7db117" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -2759,6 +2774,22 @@ dependencies = [ "objc2-ui-kit", ] +[[package]] +name = "objc2-quick-look-ui" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef5bce23577f7bcbb88339d4f4fcba83bcc376ca6a7f556d1707e49e7986d329" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-pdf-kit", + "objc2-uniform-type-identifiers", +] + [[package]] name = "objc2-security" version = "0.3.2" @@ -2782,6 +2813,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-web-kit" version = "0.3.2" diff --git a/cardinal/src-tauri/Cargo.toml b/cardinal/src-tauri/Cargo.toml index e40dda6..0ddd6ca 100644 --- a/cardinal/src-tauri/Cargo.toml +++ b/cardinal/src-tauri/Cargo.toml @@ -30,6 +30,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22" rayon = "1.10" +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSString", "NSURL"] } +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSWindow", "NSPanel", "NSResponder"] } +objc2-quick-look-ui = "0.3" parking_lot = "0.12" tauri-plugin-prevent-default = "4" directories = "6.0.0" @@ -42,6 +46,7 @@ search-cache.path = "../../search-cache" fswalk.path = "../../fswalk" fs-icon.path = "../../fs-icon" search-cancel.path = "../../search-cancel" +camino = "1.2.1" [features] default = [] diff --git a/cardinal/src-tauri/src/commands.rs b/cardinal/src-tauri/src/commands.rs index 4137681..4ba3992 100644 --- a/cardinal/src-tauri/src/commands.rs +++ b/cardinal/src-tauri/src/commands.rs @@ -1,6 +1,9 @@ use crate::{ LOGIC_START, lifecycle::{EXIT_REQUESTED, load_app_state}, + quicklook::{ + QuickLookItemInput, close_preview_panel, toggle_preview_panel, update_preview_panel, + }, window_controls::{WindowToggle, activate_window, hide_window, toggle_window}, }; use anyhow::Result; @@ -97,6 +100,45 @@ impl NodeInfoMetadata { } } +#[tauri::command] +pub fn close_quicklook(app_handle: AppHandle) -> Result<(), String> { + let app_handle_cloned = app_handle.clone(); + app_handle + .run_on_main_thread(move || { + close_preview_panel(app_handle_cloned); + }) + .map_err(|e| format!("Failed to dispatch quicklook action: {e:?}"))?; + Ok(()) +} + +#[tauri::command] +pub fn update_quicklook( + app_handle: AppHandle, + items: Vec, +) -> Result<(), String> { + let app_handle_cloned = app_handle.clone(); + app_handle + .run_on_main_thread(move || { + update_preview_panel(app_handle_cloned, items); + }) + .map_err(|e| format!("Failed to dispatch quicklook action: {e:?}"))?; + Ok(()) +} + +#[tauri::command] +pub fn toggle_quicklook( + app_handle: AppHandle, + items: Vec, +) -> Result<(), String> { + let app_handle_cloned = app_handle.clone(); + app_handle + .run_on_main_thread(move || { + toggle_preview_panel(app_handle_cloned, items); + }) + .map_err(|e| format!("Failed to dispatch quicklook action: {e:?}"))?; + Ok(()) +} + #[tauri::command] pub async fn search( query: String, @@ -222,16 +264,6 @@ pub fn open_path(path: String) -> Result<(), String> { Ok(()) } -#[tauri::command] -pub fn preview_with_quicklook(path: String) -> Result<(), String> { - Command::new("qlmanage") - .arg("-p") - .arg(&path) - .spawn() - .map_err(|e| format!("Failed to launch Quick Look preview: {e}"))?; - Ok(()) -} - #[tauri::command] pub fn request_app_exit(app_handle: AppHandle) -> Result<(), String> { EXIT_REQUESTED.store(true, Ordering::Relaxed); diff --git a/cardinal/src-tauri/src/lib.rs b/cardinal/src-tauri/src/lib.rs index 250f483..52093e3 100644 --- a/cardinal/src-tauri/src/lib.rs +++ b/cardinal/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod background; mod commands; mod lifecycle; +mod quicklook; mod window_controls; use anyhow::{Context, Result}; @@ -9,9 +10,9 @@ use background::{ }; use cardinal_sdk::EventWatcher; use commands::{ - SearchJob, SearchState, activate_main_window, get_app_status, get_nodes_info, hide_main_window, - open_in_finder, open_path, preview_with_quicklook, request_app_exit, search, start_logic, - toggle_main_window, trigger_rescan, update_icon_viewport, + SearchJob, SearchState, activate_main_window, close_quicklook, get_app_status, get_nodes_info, + hide_main_window, open_in_finder, open_path, request_app_exit, search, start_logic, + toggle_main_window, toggle_quicklook, trigger_rescan, update_icon_viewport, update_quicklook, }; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded, unbounded}; use lifecycle::{ @@ -118,7 +119,9 @@ pub fn run() -> Result<()> { trigger_rescan, open_in_finder, open_path, - preview_with_quicklook, + toggle_quicklook, + close_quicklook, + update_quicklook, request_app_exit, start_logic, hide_main_window, diff --git a/cardinal/src-tauri/src/quicklook.rs b/cardinal/src-tauri/src/quicklook.rs new file mode 100644 index 0000000..7ede96b --- /dev/null +++ b/cardinal/src-tauri/src/quicklook.rs @@ -0,0 +1,390 @@ +use crate::window_controls::activate_window; +use base64::{Engine as _, engine::general_purpose}; +use camino::Utf8Path; +use objc2::{ + AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, + rc::Retained, runtime::ProtocolObject, +}; +use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType, NSImage, NSWindowDelegate}; +use objc2_foundation::{ + NSData, NSInteger, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL, +}; +use objc2_quick_look_ui::{ + QLPreviewItem, QLPreviewPanel, QLPreviewPanelDataSource, QLPreviewPanelDelegate, +}; +use once_cell::unsync::OnceCell; +use serde::{Deserialize, Serialize}; +use std::{cell::RefCell, collections::HashMap}; +use tauri::{AppHandle, Emitter, Manager}; + +thread_local! { + static PREVIEW_CONTROLLER: OnceCell> = const { OnceCell::new() }; +} + +fn preview_controller( + mtm: MainThreadMarker, + panel: &Retained, +) -> Retained { + PREVIEW_CONTROLLER.with(|cell| { + cell.get_or_init(|| { + let controller = PreviewController::new(mtm); + let data_source = ProtocolObject::from_ref(&*controller); + let delegate: &ProtocolObject = + ProtocolObject::from_ref(&*controller); + unsafe { + panel.setDataSource(Some(data_source)); + panel.setDelegate(Some(delegate.as_ref())); + panel.updateController(); + } + controller + }) + .clone() + }) +} + +fn clear_preview_controller() { + PREVIEW_CONTROLLER.with(|cell| { + if let Some(x) = cell.get() { + x.ivars().clear() + } + }); +} + +#[derive(Debug, Clone, Copy, Deserialize, Default)] +pub struct ScreenRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl ScreenRect { + fn to_nsrect(self) -> NSRect { + NSRect::new( + NSPoint::new(self.x, self.y), + NSSize::new(self.width, self.height), + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuickLookItemInput { + pub path: String, + pub rect: Option, + pub transition_image: Option, +} + +const KEY_CODE_DOWN_ARROW: u16 = 125; +const KEY_CODE_UP_ARROW: u16 = 126; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct QuickLookKeyEvent { + key_code: u16, + characters: Option, + modifiers: QuickLookKeyModifiers, +} + +#[derive(Debug, Clone, Serialize, Default)] +struct QuickLookKeyModifiers { + shift: bool, + control: bool, + option: bool, + command: bool, +} + +struct PreviewItemState { + url: Retained, + title: Option>, +} + +impl QuickLookKeyEvent { + fn from_event(event: &NSEvent) -> Option { + let key_code = event.keyCode(); + let characters = event + .charactersIgnoringModifiers() + .map(|value| value.to_string()); + let flags = event.modifierFlags(); + Some(Self { + key_code, + characters, + modifiers: QuickLookKeyModifiers::from_flags(flags), + }) + } +} + +impl QuickLookKeyModifiers { + fn from_flags(flags: NSEventModifierFlags) -> Self { + Self { + shift: flags.contains(NSEventModifierFlags::Shift), + control: flags.contains(NSEventModifierFlags::Control), + option: flags.contains(NSEventModifierFlags::Option), + command: flags.contains(NSEventModifierFlags::Command), + } + } +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CardinalPreviewItem"] + #[ivars = PreviewItemState] + struct PreviewItemImpl; + + unsafe impl NSObjectProtocol for PreviewItemImpl {} + unsafe impl QLPreviewItem for PreviewItemImpl { + #[allow(non_snake_case)] + #[unsafe(method_id(previewItemURL))] + unsafe fn previewItemURL(&self) -> Option> { + Some(self.ivars().url.clone()) + } + + #[allow(non_snake_case)] + #[unsafe(method_id(previewItemTitle))] + unsafe fn previewItemTitle(&self) -> Option> { + self.ivars().title.clone() + } + } +); + +impl PreviewItemImpl { + fn new( + mtm: MainThreadMarker, + url: Retained, + title: Option>, + ) -> Retained { + let obj = PreviewItemImpl::alloc(mtm).set_ivars(PreviewItemState { url, title }); + unsafe { msg_send![super(obj), init] } + } +} + +#[derive(Default)] +struct PreviewControllerState { + items: RefCell>>>, + frames: RefCell>, + transitions: RefCell>>, + app_handle: RefCell>, +} + +impl PreviewControllerState { + fn clear(&self) { + self.items.borrow_mut().clear(); + self.frames.borrow_mut().clear(); + self.transitions.borrow_mut().clear(); + self.app_handle.borrow_mut().take(); + } +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "CardinalPreviewController"] + #[ivars = PreviewControllerState] + struct PreviewController; + + unsafe impl NSObjectProtocol for PreviewController {} + unsafe impl NSWindowDelegate for PreviewController {} + unsafe impl QLPreviewPanelDataSource for PreviewController { + #[allow(non_snake_case)] + #[unsafe(method(numberOfPreviewItemsInPreviewPanel:))] + fn numberOfPreviewItemsInPreviewPanel(&self, _panel: Option<&QLPreviewPanel>) -> NSInteger { + self.ivars().items.borrow().len() as NSInteger + } + + #[allow(non_snake_case)] + #[unsafe(method_id(previewPanel:previewItemAtIndex:))] + fn previewPanel_previewItemAtIndex( + &self, + _panel: Option<&QLPreviewPanel>, + index: NSInteger, + ) -> Option>> { + if index < 0 { + None + } else { + let index = index as usize; + self.ivars().items.borrow().get(index).cloned() + } + } + } + + unsafe impl QLPreviewPanelDelegate for PreviewController { + #[allow(non_snake_case)] + #[unsafe(method(previewPanel:handleEvent:))] + fn previewPanel_handleEvent(&self, _panel: &QLPreviewPanel, event: &NSEvent) -> bool { + if event.r#type() != NSEventType::KeyDown { + return false.into(); + } + + // Only handle Up/Down navigation to keep the payload surface minimal. + if !matches!(event.keyCode(), KEY_CODE_DOWN_ARROW | KEY_CODE_UP_ARROW) { + return false.into(); + } + + if let Some(app_handle) = self.ivars().app_handle.borrow().as_ref().cloned() { + if let Some(payload) = QuickLookKeyEvent::from_event(event) { + let _ = app_handle.emit("quicklook-keydown", payload); + } + } + + true + } + + #[allow(non_snake_case)] + #[unsafe(method(previewPanel:sourceFrameOnScreenForPreviewItem:))] + unsafe fn previewPanel_sourceFrameOnScreenForPreviewItem( + &self, + _panel: Option<&QLPreviewPanel>, + item: Option<&ProtocolObject>, + ) -> NSRect { + let Some(item) = item else { + return NSRect::ZERO; + }; + + let url_opt = unsafe { item.previewItemURL() }; + let Some(url) = url_opt else { + return NSRect::ZERO; + }; + + let path_opt = url.path(); + let Some(path_str) = path_opt else { + return NSRect::ZERO; + }; + let path = path_str.to_string(); + + if let Some(rect) = self.ivars().frames.borrow().get(&path).copied() { + return rect.to_nsrect(); + } + + NSRect::ZERO + } + + #[allow(non_snake_case)] + #[unsafe(method(previewPanel:transitionImageForPreviewItem:contentRect:))] + fn previewPanel_transitionImageForPreviewItem_contentRect( + &self, + _panel: &QLPreviewPanel, + item: &ProtocolObject, + _content_rect: *mut NSRect, + ) -> *mut NSImage { + let image: Option<*mut NSImage> = (|| { + let url = unsafe { item.previewItemURL()? }; + let path = url.path()?.to_string(); + let transitions = self.ivars().transitions.borrow(); + transitions + .get(&path) + .map(|x| Retained::as_ptr(x).cast_mut()) + })(); + + image.unwrap_or_default() + } + } +); + +impl PreviewController { + fn new(mtm: MainThreadMarker) -> Retained { + let obj = PreviewController::alloc(mtm).set_ivars(PreviewControllerState::default()); + unsafe { msg_send![super(obj), init] } + } +} + +fn build_preview_item( + mtm: MainThreadMarker, + path: &str, +) -> Retained> { + let url = NSURL::fileURLWithPath(&NSString::from_str(path)); + let title = Utf8Path::new(path) + .file_name() + .filter(|name| !name.is_empty()) + .map(NSString::from_str); + + let item = PreviewItemImpl::new(mtm, url, title); + ProtocolObject::from_retained(item) +} + +fn update_preview_items( + mtm: MainThreadMarker, + panel: &Retained, + app_handle: &AppHandle, + items: Vec, +) { + let controller = preview_controller(mtm, panel); + *controller.ivars().app_handle.borrow_mut() = Some(app_handle.clone()); + let preview_items: Vec<_> = items + .iter() + .map(|item| build_preview_item(mtm, &item.path)) + .collect(); + *controller.ivars().items.borrow_mut() = preview_items; + + let mut frames = controller.ivars().frames.borrow_mut(); + frames.clear(); + let mut transitions = controller.ivars().transitions.borrow_mut(); + transitions.clear(); + + for item in items { + if let Some(rect) = item.rect { + frames.insert(item.path.clone(), rect); + } + if let Some(base64_data_uri) = &item.transition_image { + if let Some(base64_data) = base64_data_uri.split(',').nth(1) { + if let Ok(image_bytes) = general_purpose::STANDARD.decode(base64_data) { + let data = NSData::from_vec(image_bytes); + if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) { + transitions.insert(item.path.clone(), image); + } + } + } + } + } + refresh_panel(panel); +} + +fn refresh_panel(panel: &Retained) { + unsafe { + panel.reloadData(); + } +} + +fn shared_panel() -> Option<(MainThreadMarker, Retained)> { + let mtm = MainThreadMarker::new()?; + let panel = unsafe { QLPreviewPanel::sharedPreviewPanel(mtm)? }; + Some((mtm, panel)) +} + +pub fn toggle_preview_panel(app_handle: AppHandle, items: Vec) { + let Some((mtm, panel)) = shared_panel() else { + return; + }; + + if panel.isVisible() { + close_preview_panel(app_handle); + return; + } + + update_preview_items(mtm, &panel, &app_handle, items); + panel.makeKeyAndOrderFront(None); +} + +pub fn update_preview_panel(app_handle: AppHandle, items: Vec) { + let Some((mtm, panel)) = shared_panel() else { + return; + }; + + if !panel.isVisible() { + return; + } + + update_preview_items(mtm, &panel, &app_handle, items); +} + +pub fn close_preview_panel(app_handle: AppHandle) { + if let Some((_, panel)) = shared_panel() { + if panel.isVisible() { + panel.close(); + if let Some(window) = app_handle.get_webview_window("main") { + activate_window(&window); + } + } + }; + clear_preview_controller(); +} diff --git a/cardinal/src/App.css b/cardinal/src/App.css index 305a4cb..fba9bae 100644 --- a/cardinal/src/App.css +++ b/cardinal/src/App.css @@ -553,6 +553,7 @@ button:active { :root:not([data-theme]) .empty-icon { color: var(--color-resizer); } + :root:not([data-theme]) .empty-title { color: var(--color-text); } @@ -750,10 +751,12 @@ button:active { 0 0 0 1px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.5); } + :root:not([data-theme]) .virtual-scrollbar-track:hover .virtual-scrollbar-thumb, :root:not([data-theme]) .virtual-scrollbar-track.is-dragging .virtual-scrollbar-thumb { background: rgba(255, 255, 255, 0.55); } + :root:not([data-theme]) .virtual-scrollbar-thumb:active { background: rgba(255, 255, 255, 0.75); } @@ -818,10 +821,12 @@ button:active { 0 0 0 1px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.5); } + [data-theme='dark'] .virtual-scrollbar-track:hover .virtual-scrollbar-thumb, [data-theme='dark'] .virtual-scrollbar-track.is-dragging .virtual-scrollbar-thumb { background: rgba(255, 255, 255, 0.55); } + [data-theme='dark'] .virtual-scrollbar-thumb:active { background: rgba(255, 255, 255, 0.75); } @@ -1037,6 +1042,7 @@ button:active { font-size: 12px; line-height: 1; } + :root:not([data-theme]) .status-tab { color: rgba(226, 232, 240, 0.72); } @@ -1093,6 +1099,7 @@ button:active { font-size: 12px; line-height: 1; } + [data-theme='dark'] .status-tab { color: rgba(226, 232, 240, 0.72); } @@ -1178,9 +1185,11 @@ button:active { .readiness-indicator.ready { color: #4caf50; } + .readiness-indicator.initializing { color: #ff9800; } + .readiness-indicator.updating { color: #2196f3; } @@ -1190,6 +1199,7 @@ button:active { font-weight: 500; white-space: nowrap; } + .status-value { color: var(--color-text); font-weight: 600; @@ -1202,6 +1212,7 @@ button:active { text-align: left; min-width: 36px; } + .status-right .status-value { text-align: right; min-width: 80px; diff --git a/cardinal/src/App.tsx b/cardinal/src/App.tsx index 4e347f7..1b2704f 100644 --- a/cardinal/src/App.tsx +++ b/cardinal/src/App.tsx @@ -21,6 +21,7 @@ import FSEventsPanel from './components/FSEventsPanel'; import type { FSEventsPanelHandle } from './components/FSEventsPanel'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { primaryMonitor, getCurrentWindow } from '@tauri-apps/api/window'; import type { UnlistenFn } from '@tauri-apps/api/event'; import { useTranslation } from 'react-i18next'; import { useFullDiskAccessPermission } from './hooks/useFullDiskAccessPermission'; @@ -30,6 +31,39 @@ import type { DisplayState } from './components/StateDisplay'; type ActiveTab = StatusTabKey; +type QuickLookRect = { + x: number; + y: number; + width: number; + height: number; +}; + +type QuickLookItemPayload = { + path: string; + rect?: QuickLookRect; + transitionImage?: string; +}; + +type QuickLookKeydownPayload = { + keyCode: number; + characters?: string | null; + modifiers: { + shift: boolean; + control: boolean; + option: boolean; + command: boolean; + }; +}; + +type WindowGeometry = { + windowOrigin: { x: number; y: number }; + mainScreenHeight: number; +}; + +const escapePathForSelector = (value: string): string => { + return window.CSS.escape(value); +}; + const isEditableTarget = (target: EventTarget | null): boolean => { const element = target as HTMLElement | null; if (!element) return false; @@ -37,6 +71,9 @@ const isEditableTarget = (target: EventTarget | null): boolean => { return tagName === 'INPUT' || tagName === 'TEXTAREA' || element.isContentEditable; }; +const QUICK_LOOK_KEYCODE_DOWN = 125; +const QUICK_LOOK_KEYCODE_UP = 126; + function App() { const { state, @@ -66,11 +103,9 @@ function App() { const [selectedPaths, setSelectedPaths] = useState(new Set()); const [activeRowIndex, setActiveRowIndex] = useState(null); const [shiftAnchorIndex, setShiftAnchorIndex] = useState(null); + const selectedPathsRef = useRef(selectedPaths); const [isWindowFocused, setIsWindowFocused] = useState(() => { - if (typeof document === 'undefined') { - return true; - } return document.hasFocus(); }); const eventsPanelRef = useRef(null); @@ -128,10 +163,168 @@ function App() { [shiftAnchorIndex, results], ); + const getQuickLookItems = useCallback(async (): Promise => { + if (activeTab !== 'files') { + return []; + } + + const paths = Array.from(selectedPaths); + if (!paths.length) { + return []; + } + + let windowGeometry: WindowGeometry | null | undefined; + + const resolveWindowGeometry = async (): Promise => { + if (windowGeometry !== undefined) { + return windowGeometry; + } + + if (typeof window === 'undefined') { + windowGeometry = null; + return windowGeometry; + } + + try { + const currentWindow = getCurrentWindow(); + const [position, monitor, scaleFactor] = await Promise.all([ + currentWindow.innerPosition(), + primaryMonitor(), + currentWindow.scaleFactor(), + ]); + + if (!monitor) { + windowGeometry = null; + return windowGeometry; + } + + const scale = scaleFactor || monitor.scaleFactor || window.devicePixelRatio || 1; + windowGeometry = { + windowOrigin: { + x: position.x / scale, + y: position.y / scale, + }, + mainScreenHeight: monitor.size.height / scale, + }; + } catch (error) { + console.warn('Failed to resolve window metrics for Quick Look', error); + windowGeometry = null; + } + + return windowGeometry; + }; + + const buildItem = async (path: string): Promise => { + const selector = `[data-row-path="${escapePathForSelector(path)}"]`; + const row = document.querySelector(selector); + if (!row) { + return { path }; + } + const anchor = row.querySelector('.file-icon, .file-icon-placeholder'); + if (!anchor) { + return { path }; + } + const iconImage = row.querySelector('img.file-icon'); + if (!iconImage) { + return { path }; + } + const transitionImage = iconImage.src; + + const rect = anchor.getBoundingClientRect(); + const geometry = await resolveWindowGeometry(); + if (!geometry) { + return { path }; + } + + // This compensates for a coordinate system mismatch on macOS: + // - `geometry.windowOrigin.y` (from Tauri's `innerPosition`) is relative to the *visible* screen area (below the menu bar). + // - `geometry.mainScreenHeight` is the *full* screen height. + // - `window.screen.availTop` provides the height of the menu bar, allowing us to correctly adjust `logicalYTop` + // to be relative to the absolute top of the screen for `QLPreviewPanel`'s `sourceFrameOnScreenForPreviewItem`. + const screenTopOffset = window.screen.availTop ?? 0; + + const logicalX = geometry.windowOrigin.x + rect.left; + const logicalYTop = geometry.windowOrigin.y + screenTopOffset + rect.top; + const logicalWidth = rect.width; + const logicalHeight = rect.height; + + const x = logicalX; + const y = geometry.mainScreenHeight - (logicalYTop + logicalHeight); + + return { + path, + rect: { + x, + y, + width: logicalWidth, + height: logicalHeight, + }, + transitionImage, + }; + }; + + const items = await Promise.all(paths.map((path) => buildItem(path))); + return items; + }, [activeTab, selectedPaths]); + + const toggleQuickLookPanel = useCallback(() => { + void (async () => { + const items = await getQuickLookItems(); + if (!items.length) { + return; + } + try { + await invoke('toggle_quicklook', { items }); + } catch (error) { + console.error('Failed to preview file with Quick Look', error); + } + })(); + }, [getQuickLookItems]); + + const updateQuickLookPanel = useCallback(() => { + void (async () => { + const items = await getQuickLookItems(); + if (!items.length) { + return; + } + try { + await invoke('update_quicklook', { items }); + } catch (error) { + console.error('Failed to update Quick Look', error); + } + })(); + }, [getQuickLookItems]); + + const moveSelection = useCallback( + (delta: 1 | -1) => { + if (activeTab !== 'files' || !results.length) { + return; + } + + const fallbackIndex = delta > 0 ? -1 : results.length; + const baseIndex = activeRowIndex ?? fallbackIndex; + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), results.length - 1); + + if (nextIndex === activeRowIndex) { + return; + } + + const nextPath = virtualListRef.current?.getItem?.(nextIndex)?.path; + if (nextPath) { + handleRowSelect(nextPath, nextIndex, { + isShift: false, + isMeta: false, + isCtrl: false, + }); + } + }, + [activeRowIndex, activeTab, handleRowSelect, results.length], + ); + const { showContextMenu: showFilesContextMenu, showHeaderContextMenu: showFilesHeaderContextMenu, - } = useContextMenu(autoFitColumns); + } = useContextMenu(autoFitColumns, toggleQuickLookPanel); const { showContextMenu: showEventsContextMenu, @@ -212,6 +405,10 @@ function App() { focusSearchInput(); }, [focusSearchInput]); + useEffect(() => { + selectedPathsRef.current = selectedPaths; + }, [selectedPaths]); + useEffect(() => { const handleOpenPreferences = () => setIsPreferencesOpen(true); @@ -248,6 +445,17 @@ function App() { } }, [activeTab]); + useEffect(() => { + if (activeTab === 'files') { + return; + } + + // Close Quick Look when leaving the files tab + invoke('close_quicklook').catch((error) => { + console.error('Failed to close Quick Look', error); + }); + }, [activeTab]); + useEffect(() => { if (activeTab !== 'files') { return; @@ -264,19 +472,25 @@ function App() { return; } - if (!activePath) { + if (!selectedPaths.size) { return; } event.preventDefault(); - invoke('preview_with_quicklook', { path: activePath }).catch((error) => { - console.error('Failed to preview file with Quick Look', error); - }); + toggleQuickLookPanel(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [activePath, activeTab]); + }, [activeTab, toggleQuickLookPanel, selectedPaths]); + + useEffect(() => { + if (activeTab !== 'files' || !selectedPaths.size) { + return; + } + + updateQuickLookPanel(); + }, [activeTab, selectedPaths, updateQuickLookPanel]); useEffect(() => { if (activeTab !== 'files') { @@ -296,34 +510,14 @@ function App() { return; } - if (!results.length) { - return; - } - event.preventDefault(); - const delta = event.key === 'ArrowDown' ? 1 : -1; - const fallbackIndex = delta > 0 ? -1 : results.length; - const baseIndex = activeRowIndex ?? fallbackIndex; - const nextIndex = Math.min(Math.max(baseIndex + delta, 0), results.length - 1); - - if (nextIndex === activeRowIndex) { - return; - } - - const nextPath = virtualListRef.current?.getItem?.(nextIndex)?.path; - if (nextPath) { - handleRowSelect(nextPath, nextIndex, { - isShift: false, - isMeta: false, - isCtrl: false, - }); - } + moveSelection(delta); }; window.addEventListener('keydown', handleArrowNavigation); return () => window.removeEventListener('keydown', handleArrowNavigation); - }, [activeRowIndex, activeTab, handleRowSelect, results.length]); + }, [activeTab, moveSelection]); useEffect(() => { const handleGlobalShortcuts = (event: KeyboardEvent) => { @@ -367,6 +561,46 @@ function App() { return () => window.removeEventListener('keydown', handleGlobalShortcuts); }, [focusSearchInput, activeTab, activePath]); + useEffect(() => { + let unlisten: UnlistenFn | null = null; + + const setup = async () => { + try { + unlisten = await listen('quicklook-keydown', (event) => { + if (activeTab !== 'files') { + return; + } + + const payload = event.payload; + if (!payload || !selectedPathsRef.current.size) { + return; + } + + const { keyCode, modifiers } = payload; + if (modifiers.command || modifiers.option || modifiers.control) { + return; + } + + if (keyCode === QUICK_LOOK_KEYCODE_DOWN) { + moveSelection(1); + } else if (keyCode === QUICK_LOOK_KEYCODE_UP) { + moveSelection(-1); + } + }); + } catch (error) { + console.error('Failed to subscribe to Quick Look key events', error); + } + }; + + void setup(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, [activeTab, moveSelection]); + useEffect(() => { if (activeRowIndex == null) { return; diff --git a/cardinal/src/components/FileRow.tsx b/cardinal/src/components/FileRow.tsx index 6040c23..2d8bc61 100644 --- a/cardinal/src/components/FileRow.tsx +++ b/cardinal/src/components/FileRow.tsx @@ -114,6 +114,7 @@ export const FileRow = memo(function FileRow({
) => void; }; -export function useContextMenu(autoFitColumns: (() => void) | null = null): UseContextMenuResult { +export function useContextMenu( + autoFitColumns: (() => void) | null = null, + onQuickLookRequest?: () => void | Promise, +): UseContextMenuResult { const { t } = useTranslation(); const buildFileMenuItems = useCallback( @@ -22,7 +25,7 @@ export function useContextMenu(autoFitColumns: (() => void) | null = null): UseC const segments = path.split(/[\\/]/).filter(Boolean); const filename = segments.length > 0 ? segments[segments.length - 1] : path; - return [ + const items: MenuItemOptions[] = [ { id: 'context_menu.open_in_finder', text: t('contextMenu.openInFinder'), @@ -50,17 +53,24 @@ export function useContextMenu(autoFitColumns: (() => void) | null = null): UseC } }, }, - { + ]; + + if (onQuickLookRequest) { + items.push({ id: 'context_menu.quicklook', text: t('contextMenu.quickLook'), accelerator: 'Space', action: () => { - void invoke('preview_with_quicklook', { path }); + if (onQuickLookRequest) { + void onQuickLookRequest(); + } }, - }, - ]; + }); + } + + return items; }, - [t], + [onQuickLookRequest, t], ); const buildHeaderMenuItems = useCallback((): MenuItemOptions[] => {