From 814e9c3d24ff87079d4c1487fe43d0a7bc0fccbc Mon Sep 17 00:00:00 2001 From: ZacariaXAsh Date: Tue, 3 Mar 2026 11:17:33 +0000 Subject: [PATCH 1/2] feat: add click-through overlay + configurable slideshow --- dist/index.html | 83 ++++++++++++++++++++++++- src-tauri/src/main.rs | 139 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 216 insertions(+), 6 deletions(-) diff --git a/dist/index.html b/dist/index.html index e9df086..accdd3c 100644 --- a/dist/index.html +++ b/dist/index.html @@ -41,6 +41,13 @@
+ +
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a02a7ed..9c42325 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -27,15 +27,34 @@ enum WindowSizeUnits { Physical, } -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] struct PersistedState { last_file: Option, aspect_lock: bool, + click_through: bool, + slideshow_enabled: bool, + slideshow_interval_ms: u64, window_w: Option, window_h: Option, window_size_units: Option, } +impl Default for PersistedState { + fn default() -> Self { + Self { + last_file: None, + aspect_lock: false, + click_through: false, + slideshow_enabled: false, + slideshow_interval_ms: 5000, + window_w: None, + window_h: None, + window_size_units: None, + } + } +} + #[derive(Clone, Debug, Serialize)] struct ActiveFilePayload { path: Option, @@ -66,6 +85,8 @@ struct AppState { aspect_ratio: Mutex>, // per-window aspect ratio adjusting_resize: Mutex>, // per-window resize guard aspect_toggle: Mutex>>, + click_through_toggle: Mutex>>, + slideshow_toggle: Mutex>>, pending_save: Mutex>>, selections: Mutex>, // per-window selections last_focused_window: Mutex>, // label of last focused window @@ -121,6 +142,8 @@ impl Default for AppState { aspect_ratio: Mutex::new(HashMap::new()), adjusting_resize: Mutex::new(HashSet::new()), aspect_toggle: Mutex::new(None), + click_through_toggle: Mutex::new(None), + slideshow_toggle: Mutex::new(None), pending_save: Mutex::new(HashMap::new()), selections: Mutex::new(HashMap::new()), last_focused_window: Mutex::new(None), @@ -249,10 +272,16 @@ fn reset_cache(app: &AppHandle) -> Result<(), Error> { state.adjusting_resize.lock().clear(); state.selections.lock().clear(); state.last_focused_window.lock().take(); - // Sync menu toggle to defaults + // Sync menu toggles to defaults if let Some(toggle) = state.aspect_toggle.lock().clone() { let _ = toggle.set_checked(false); } + if let Some(toggle) = state.click_through_toggle.lock().clone() { + let _ = toggle.set_checked(false); + } + if let Some(toggle) = state.slideshow_toggle.lock().clone() { + let _ = toggle.set_checked(false); + } } if let Ok(path) = config_path(app) { if path.exists() { @@ -393,6 +422,17 @@ fn navigate_selection(app: &AppHandle, window: &WebviewWindow, delta: isize) -> None } +fn apply_click_through_to_window(window: &WebviewWindow, enabled: bool) { + #[allow(deprecated)] + let _ = window.set_ignore_cursor_events(enabled); +} + +fn apply_click_through_to_all_windows(app: &AppHandle, enabled: bool) { + for (_, win) in app.webview_windows() { + apply_click_through_to_window(&win, enabled); + } +} + fn apply_initial_window_state(app: &AppHandle, window: &WebviewWindow, load_last_file: bool) { let _ = window.set_always_on_top(true); @@ -417,6 +457,8 @@ fn apply_initial_window_state(app: &AppHandle, window: &WebviewWindow, load_last } } + apply_click_through_to_window(window, st.click_through); + if load_last_file { if let Some(p) = st.last_file.clone() { if is_image_path(&p) && PathBuf::from(&p).exists() { @@ -605,6 +647,9 @@ fn get_settings(app: AppHandle) -> PersistedState { #[derive(Deserialize)] struct SettingsUpdate { aspect_lock: Option, + click_through: Option, + slideshow_enabled: Option, + slideshow_interval_ms: Option, } #[tauri::command] @@ -623,6 +668,26 @@ fn set_settings(app: AppHandle, update: SettingsUpdate) -> Result() { + if let Some(toggle) = state.click_through_toggle.lock().clone() { + let _ = toggle.set_checked(v); + } + } + } + if let Some(v) = update.slideshow_enabled { + st.slideshow_enabled = v; + if let Some(state) = app.try_state::() { + if let Some(toggle) = state.slideshow_toggle.lock().clone() { + let _ = toggle.set_checked(v); + } + } + } + if let Some(v) = update.slideshow_interval_ms { + st.slideshow_interval_ms = v.clamp(1000, 60000); + } save_state(&app, &win, st.clone()).map_err(|e| e.to_string())?; if let Some(state) = app.try_state::() { *state.settings.lock() = st.clone(); @@ -808,9 +873,28 @@ fn main() { ) .build()?; + let initial_settings = load_state(&app_handle); let aspect_toggle = CheckMenuItemBuilder::with_id("aspect_lock_toggle", "Lock aspect ratio on resize") - .checked(load_state(&app_handle).aspect_lock) + .checked(initial_settings.aspect_lock) + .build(&app_handle)?; + let click_through_toggle = + CheckMenuItemBuilder::with_id("click_through_toggle", "Click-through overlay") + .checked(initial_settings.click_through) + .accelerator(if cfg!(target_os = "macos") { + "Cmd+Shift+X" + } else { + "Ctrl+Shift+X" + }) + .build(&app_handle)?; + let slideshow_toggle = + CheckMenuItemBuilder::with_id("slideshow_toggle", "Slideshow mode") + .checked(initial_settings.slideshow_enabled) + .accelerator(if cfg!(target_os = "macos") { + "Cmd+Shift+S" + } else { + "Ctrl+Shift+S" + }) .build(&app_handle)?; let view_menu = SubmenuBuilder::new(&app_handle, "View") .item( @@ -840,7 +924,9 @@ fn main() { }) .build(&app_handle)?, ) - .item(&aspect_toggle); + .item(&aspect_toggle) + .item(&click_through_toggle) + .item(&slideshow_toggle); let app_menu = MenuBuilder::new(&app_handle) .item(&file_menu) .item(&view_menu.build()?) @@ -848,6 +934,8 @@ fn main() { app.set_menu(app_menu)?; if let Some(state) = app_handle.try_state::() { *state.aspect_toggle.lock() = Some(aspect_toggle.clone()); + *state.click_through_toggle.lock() = Some(click_through_toggle.clone()); + *state.slideshow_toggle.lock() = Some(slideshow_toggle.clone()); } if let Some(state) = app_handle.try_state::() { @@ -939,6 +1027,49 @@ fn main() { *state.settings.lock() = s; } } + "click_through_toggle" => { + if let Some(state) = app.try_state::() { + let mut s = state.settings.lock().clone(); + let new_state = if let Some(toggle) = state.click_through_toggle.lock().clone() { + if let Ok(current) = toggle.is_checked() { + let desired = !current; + let _ = toggle.set_checked(desired); + desired + } else { + !s.click_through + } + } else { + !s.click_through + }; + s.click_through = new_state; + apply_click_through_to_all_windows(app, new_state); + if let Some(win) = focused_window(app) { + let _ = save_state(app, &win, s.clone()); + } + *state.settings.lock() = s; + } + } + "slideshow_toggle" => { + if let Some(state) = app.try_state::() { + let mut s = state.settings.lock().clone(); + let new_state = if let Some(toggle) = state.slideshow_toggle.lock().clone() { + if let Ok(current) = toggle.is_checked() { + let desired = !current; + let _ = toggle.set_checked(desired); + desired + } else { + !s.slideshow_enabled + } + } else { + !s.slideshow_enabled + }; + s.slideshow_enabled = new_state; + if let Some(win) = focused_window(app) { + let _ = save_state(app, &win, s.clone()); + } + *state.settings.lock() = s; + } + } _ => {} }) .invoke_handler(tauri::generate_handler![ From e3d9304701dde710e70d0fc34086efdcc39320bf Mon Sep 17 00:00:00 2001 From: Zacaria Date: Sat, 14 Mar 2026 07:51:35 +0100 Subject: [PATCH 2/2] fix: stabilize ui test workflow --- .github/workflows/ui-tests.yml | 14 ++------- package.json | 3 +- tests/ui-mock.spec.ts | 54 +++++++++++++++++++++------------- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 8182739..6497ffe 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -1,8 +1,8 @@ -name: UI Tests (Playwright + tauri-driver) +name: UI Tests on: push: - branches: [ main ] + branches: [ master ] pull_request: jobs: @@ -16,19 +16,11 @@ jobs: with: node-version: '22' - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - name: Install JS deps run: npm install --no-fund - name: Install Playwright browsers run: npx playwright install --with-deps - - name: Install tauri-driver - run: cargo install tauri-driver --locked - - - name: Run Playwright tests - env: - PATH: "$HOME/.cargo/bin:${PATH}" + - name: Run Playwright smoke tests run: npm run test:ui diff --git a/package.json b/package.json index b2162bd..7ba6534 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "@playwright/test": "^1.50.0" }, "scripts": { - "test:ui": "playwright test tests/tauri-driver.spec.ts" + "test:ui": "playwright test tests/ui-mock.spec.ts", + "test:ui:tauri": "playwright test tests/tauri-driver.spec.ts" } } diff --git a/tests/ui-mock.spec.ts b/tests/ui-mock.spec.ts index 03c5191..5264c45 100644 --- a/tests/ui-mock.spec.ts +++ b/tests/ui-mock.spec.ts @@ -2,37 +2,49 @@ import { test, expect } from '@playwright/test'; import path from 'path'; /** - * Frontend-only smoke test: load dist/index.html with a mocked TAURI invoke implementation - * to verify the Open flow updates the UI. + * Frontend-only smoke test: load dist/index.html with a mocked Tauri v2 API + * and verify bootstrap renders the selected file state. */ -test('open flow updates UI with mocked tauri', async ({ page }) => { +test('bootstrap renders selected file with mocked tauri', async ({ page }) => { const distPath = path.resolve(__dirname, '..', 'dist', 'index.html'); const mockPath = '/tmp/icon.png'; + const pixelDataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sYpWJ0AAAAASUVORK5CYII='; - await page.addInitScript(({ mockPath }) => { - // Minimal __TAURI__ mock to satisfy invoke calls + await page.addInitScript(({ mockPath, pixelDataUrl }) => { + // Minimal __TAURI__ v2 mock to satisfy frontend bootstrap and image fallback calls. // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).__TAURI__ = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - invoke: (cmd: string, args: any = {}) => { - if (cmd === 'choose_file') return Promise.resolve(mockPath); - if (cmd === 'fit_now') return Promise.resolve(); - if (cmd === 'quick_look') return Promise.resolve(); - if (cmd === 'get_settings') return Promise.resolve({ aspect_lock: false, fit_window: true }); - if (cmd === 'set_settings') { - return Promise.resolve({ - aspect_lock: !!args.update?.aspect_lock, - fit_window: args.update?.fit_window ?? true, - }); - } - return Promise.resolve(); + core: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + invoke: (cmd: string, _args: any = {}) => { + if (cmd === 'get_settings') { + return Promise.resolve({ + last_file: mockPath, + aspect_lock: false, + fit_window: true, + }); + } + if (cmd === 'load_image_data') { + return Promise.resolve(pixelDataUrl); + } + if (cmd === 'previous_file' || cmd === 'next_file') { + return Promise.resolve(); + } + return Promise.resolve(null); + }, + convertFileSrc: () => 'file:///definitely-missing.png', + }, + event: { + listen: () => Promise.resolve(() => {}), }, }; - }, { mockPath }); + }, { mockPath, pixelDataUrl }); await page.goto(`file://${distPath}`); - await page.click('#openBtn'); - await expect(page.locator('#fileName')).toHaveText('icon.png'); + await expect(page.locator('#fileInfo')).toHaveText('icon.png'); await expect(page.locator('#status')).toHaveText(''); + await expect(page.locator('#placeholder')).toHaveText(''); + await expect(page.locator('#imageContainer')).not.toHaveClass(/placeholder/); });