From d8a35f5bae67086187e70695dea7a3888b36ffa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonathan=20David=20Florez=20Salom=C3=B3n?= Date: Fri, 24 Jul 2026 07:11:37 -0500 Subject: [PATCH] =?UTF-8?q?Atr=C3=A1s/adelante=20en=20la=20barra=20+=20rea?= =?UTF-8?q?brir=20pesta=C3=B1a=20+=20globo=20de=20no-le=C3=ADdos=20en=20el?= =?UTF-8?q?=20icono?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ATRÁS/ADELANTE por fin: botones ← → en la barra de pestañas, ⌘[ / ⌘] en ambas plataformas, Alt+←/→ en Windows (fuera de campos de texto) y menú «Historial» en macOS. Cierra el callejón sin salida: una página sin menú propio (p. ej. el portal de revisión) dejaba al usuario atrapado sin forma de volver. - «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T, tope de 20): en la barra ⌘⇧T antes caía en «nueva pestaña» por no mirar el Shift. - Globo de NO-LEÍDOS en el icono: la web app emite `ls-badge {count}` (mismo conteo del «(N)» del título) y el shell pinta el número en el Dock (macOS), un punto rojo dibujado a mano sobre el icono de la barra de tareas (Windows, sin assets) y el tooltip de la bandeja («— N sin leer»). Verificado: cargo check limpio (macOS) y firmas de las APIs solo-Windows (set_overlay_icon, set_badge_count, Image::new_owned) cotejadas contra el código fuente de tauri 2.11.3. Co-Authored-By: Claude Fable 5 --- dist/index.html | 8 ++- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 128 +++++++++++++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- 7 files changed, 138 insertions(+), 10 deletions(-) diff --git a/dist/index.html b/dist/index.html index 85f8671..df72a3a 100644 --- a/dist/index.html +++ b/dist/index.html @@ -160,6 +160,8 @@
+ +
@@ -228,6 +230,8 @@ }); } + document.getElementById("atras").addEventListener("click", () => emitir("ls-nav", { dir: -1 })); + document.getElementById("adelante").addEventListener("click", () => emitir("ls-nav", { dir: 1 })); document.getElementById("nueva").addEventListener("click", () => emitir("ls-new-tab", {})); document.getElementById("recargar").addEventListener("click", () => emitir("ls-reload", {})); // Menú de OPCIONES de la app (nativo, lo pinta Rust): botón ⋮ o clic derecho en la barra. @@ -250,9 +254,11 @@ const mod = e.metaKey || e.ctrlKey; if (!mod) return; const k = e.key; - if (k === "t" || k === "T") { e.preventDefault(); emitir("ls-new-tab", {}); } + if (k === "t" || k === "T") { e.preventDefault(); emitir(e.shiftKey ? "ls-reopen" : "ls-new-tab", {}); } else if (k === "w" || k === "W") { e.preventDefault(); emitir("ls-close-tab", {}); } else if ((k === "r" || k === "R") && !e.shiftKey) { e.preventDefault(); emitir("ls-reload", {}); } + else if (k === "[") { e.preventDefault(); emitir("ls-nav", { dir: -1 }); } + else if (k === "]") { e.preventDefault(); emitir("ls-nav", { dir: 1 }); } else if (k === "=" || k === "+") { e.preventDefault(); emitir("ls-zoom", { dir: 1 }); } else if (k === "-") { e.preventDefault(); emitir("ls-zoom", { dir: -1 }); } else if (k === "0") { e.preventDefault(); emitir("ls-zoom", { dir: 0 }); } diff --git a/package-lock.json b/package-lock.json index aeba7d3..0d50372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labstream-desktop", - "version": "1.0.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labstream-desktop", - "version": "1.0.0", + "version": "1.4.0", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/package.json b/package.json index f953eeb..681fc7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "labstream-desktop", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f855fa5..484f3a8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1946,7 +1946,7 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.3.0" +version = "1.4.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02d6061..e4dff2b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.3.0" +version = "1.4.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 598452a..6469dfc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,11 @@ // - plugin `updater`: busca versión nueva en los Releases de GitHub y se actualiza sola. // - bandeja (tray) + "cerrar = ocultar": sigue corriendo y notificando en segundo plano. // Y lo nuevo: +// - ATRÁS/ADELANTE: botones ← → en la barra, ⌘[ / ⌘] (Alt+←/→ en Windows) y menú +// Historial (macOS). Antes una página sin menú propio era un callejón sin salida. +// - «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T), como en el navegador. +// - Globo de NO-LEÍDOS en el icono (evento `ls-badge` desde la web app): número en el +// Dock (macOS), punto rojo en la barra de tareas (Windows) y tooltip de la bandeja. // - ZOOM de interfaz (Cmd/Ctrl +/−/0 y Ctrl+rueda) con persistencia entre sesiones. // - Sesión restaurada: al reabrir vuelve con las mismas pestañas y tamaño de ventana. // - single-instance (solo release): abrir la app de nuevo enfoca la ventana existente. @@ -66,11 +71,12 @@ struct Shell { next_id: u64, win: Option, autostart: Option, // elección del usuario en el menú ⋮ (None = por defecto, activado) + closed: Vec, // URLs de pestañas cerradas (para «Reabrir pestaña cerrada», ⌘⇧T) } impl Default for Shell { fn default() -> Self { - Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None } + Self { tabs: Vec::new(), active: 0, zoom_idx: ZOOM_100, next_id: 1, win: None, autostart: None, closed: Vec::new() } } } @@ -216,9 +222,14 @@ const INIT_JS_TPL: &str = r#" var mod = e.metaKey || e.ctrlKey; if (!mod) return; var k = e.key; - if (k === 't' || k === 'T') { e.preventDefault(); newTab(null); } + if (k === 't' || k === 'T') { + e.preventDefault(); + if (e.shiftKey) emit('ls-reopen', {}); else newTab(null); + } else if (k === 'w' || k === 'W') { e.preventDefault(); emit('ls-close-tab', { label: LABEL }); } else if ((k === 'r' || k === 'R') && !e.shiftKey) { e.preventDefault(); location.reload(); } + else if (k === '[') { e.preventDefault(); history.back(); } + else if (k === ']') { e.preventDefault(); history.forward(); } else if (k === '=' || k === '+') { e.preventDefault(); emit('ls-zoom', { dir: 1 }); } else if (k === '-') { e.preventDefault(); emit('ls-zoom', { dir: -1 }); } else if (k === '0') { e.preventDefault(); emit('ls-zoom', { dir: 0 }); } @@ -226,6 +237,18 @@ const INIT_JS_TPL: &str = r#" else if (k >= '1' && k <= '9') { e.preventDefault(); emit('ls-activate-n', { n: +k }); } }, true); + // Alt+←/→ = atrás/adelante (convención de Windows/Linux; en Mac es ⌘[ / ⌘] y Alt+flecha + // se respeta para saltar palabras al escribir). Fuera de campos de texto solamente. + var MAC = /Mac/i.test(navigator.platform || ''); + function editable(el) { + return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')); + } + addEventListener('keydown', function (e) { + if (MAC || !e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || editable(e.target)) return; + if (e.key === 'ArrowLeft') { e.preventDefault(); history.back(); } + else if (e.key === 'ArrowRight') { e.preventDefault(); history.forward(); } + }, true); + // Ctrl+rueda = zoom (convención de navegador; en Mac también con Ctrl). addEventListener('wheel', function (e) { if (!e.ctrlKey) return; @@ -383,6 +406,12 @@ fn close_tab(app: &AppHandle, label: &str) { let mut s = shell.lock().unwrap(); let Some(i) = s.tabs.iter().position(|t| t.label == label) else { return }; let was_active = i == s.active; + // Recuerda la URL para «Reabrir pestaña cerrada» (tope de 20, las más recientes). + let url = s.tabs[i].url.clone(); + s.closed.push(url); + if s.closed.len() > 20 { + s.closed.remove(0); + } s.tabs.remove(i); if s.tabs.is_empty() { s.active = 0; @@ -489,6 +518,67 @@ fn active_url(app: &AppHandle) -> Option { s.tabs.get(s.active).map(|t| t.url.clone()) } +// Atrás/adelante en el HISTORIAL de la pestaña activa. Antes no existía por ningún lado y +// una página sin menú propio (p. ej. el portal de revisión) era un callejón sin salida. +fn nav_tab(app: &AppHandle, dir: i32) { + if let Some(l) = active_label(app) { + if let Some(wv) = get_webview(app, &l) { + let _ = wv.eval(if dir < 0 { "history.back()" } else { "history.forward()" }); + } + } +} + +// «Reabrir pestaña cerrada» (⌘/Ctrl+Shift+T): la más reciente primero, como en el navegador. +fn reopen_tab(app: &AppHandle) { + let url = { + let shell = app.state::(); + let mut s = shell.lock().unwrap(); + s.closed.pop() + }; + if let Some(u) = url { + create_tab(app, Some(u), true); + } +} + +// Punto rojo para el icono de la barra de tareas de Windows (dibujado a mano: sin assets). +#[cfg(target_os = "windows")] +fn red_dot() -> tauri::image::Image<'static> { + const S: usize = 16; + let mut px = vec![0u8; S * S * 4]; + let c = (S as f64 - 1.0) / 2.0; + for y in 0..S { + for x in 0..S { + let (dx, dy) = (x as f64 - c, y as f64 - c); + if (dx * dx + dy * dy).sqrt() <= c { + let i = (y * S + x) * 4; + px[i] = 225; + px[i + 1] = 29; + px[i + 2] = 72; + px[i + 3] = 255; + } + } + } + tauri::image::Image::new_owned(px, S as u32, S as u32) +} + +// Globo de NO-LEÍDOS en el icono, alimentado por la web app (evento `ls-badge {count}`): +// macOS = número en el icono del Dock; Windows = punto rojo sobre el icono de la barra de +// tareas; y en ambos, el tooltip de la bandeja dice cuántos hay. +fn set_badge(app: &AppHandle, count: i64) { + if let Some(win) = main_window(app) { + #[cfg(target_os = "macos")] + let _ = win.set_badge_count(if count > 0 { Some(count) } else { None }); + #[cfg(target_os = "windows")] + let _ = win.set_overlay_icon(if count > 0 { Some(red_dot()) } else { None }); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let _ = &win; + } + if let Some(tray) = app.tray_by_id("main-tray") { + let tip = if count > 0 { format!("Labstream OS — {count} sin leer") } else { "Labstream OS".to_string() }; + let _ = tray.set_tooltip(Some(tip)); + } +} + // Menú de OPCIONES de la app (botón ⋮ / clic derecho en la barra): un popup NATIVO del sistema, // igual en Windows y macOS. Se construye fresco en cada apertura para que el check de // "Iniciar con el sistema" y la versión estén siempre al día. Solo opciones del SHELL — la web @@ -501,8 +591,12 @@ fn show_app_menu(app: &AppHandle) { Ok(Menu::with_items( app, &[ + &MenuItem::with_id(app, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?, + &MenuItem::with_id(app, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?, + &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?, &MenuItem::with_id(app, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?, + &MenuItem::with_id(app, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?, &MenuItem::with_id(app, "reload", "Recargar", true, Some("CmdOrCtrl+R"))?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "zoom-in", "Acercar", true, Some("CmdOrCtrl+="))?, @@ -706,6 +800,7 @@ pub fn run() { &[ &MenuItem::with_id(h, "new-tab", "Nueva pestaña", true, Some("CmdOrCtrl+T"))?, &MenuItem::with_id(h, "close-tab", "Cerrar pestaña", true, Some("CmdOrCtrl+W"))?, + &MenuItem::with_id(h, "reopen-tab", "Reabrir pestaña cerrada", true, Some("CmdOrCtrl+Shift+T"))?, &PredefinedMenuItem::separator(h)?, &MenuItem::with_id(h, "open-browser", "Abrir esta página en el navegador", true, None::<&str>)?, ], @@ -738,6 +833,15 @@ pub fn run() { &PredefinedMenuItem::fullscreen(h, Some("Pantalla completa"))?, ], )?; + let historial = Submenu::with_items( + h, + "Historial", + true, + &[ + &MenuItem::with_id(h, "nav-back", "Atrás", true, Some("CmdOrCtrl+["))?, + &MenuItem::with_id(h, "nav-forward", "Adelante", true, Some("CmdOrCtrl+]"))?, + ], + )?; let ventana = Submenu::with_items( h, "Ventana", @@ -747,18 +851,21 @@ pub fn run() { &PredefinedMenuItem::maximize(h, Some("Zoom"))?, ], )?; - let menu = Menu::with_items(h, &[&app_menu, &archivo, &edicion, &vista, &ventana])?; + let menu = Menu::with_items(h, &[&app_menu, &archivo, &edicion, &vista, &historial, &ventana])?; app.set_menu(menu)?; } // Manejador de TODOS los menús (el de macOS y el popup ⋮ de ambas plataformas). app.on_menu_event(|app, event| match event.id().as_ref() { + "nav-back" => nav_tab(app, -1), + "nav-forward" => nav_tab(app, 1), "new-tab" => create_tab(app, None, true), "close-tab" => { if let Some(l) = active_label(app) { close_tab(app, &l); } } + "reopen-tab" => reopen_tab(app), "reload" => reload_tab(app, None), "zoom-in" => zoom_step(app, 1), "zoom-out" => zoom_step(app, -1), @@ -862,6 +969,21 @@ pub fn run() { } }); let h = handle.clone(); + handle.listen_any("ls-nav", move |e| { + let dir = field_i64(e.payload(), "dir").unwrap_or(-1) as i32; + nav_tab(&h, dir); + }); + let h = handle.clone(); + handle.listen_any("ls-reopen", move |_| { + reopen_tab(&h); + }); + // Globo de no-leídos: lo emite la web app cuando cambia el conteo del chat. + let h = handle.clone(); + handle.listen_any("ls-badge", move |e| { + let n = field_i64(e.payload(), "count").unwrap_or(0); + set_badge(&h, n); + }); + let h = handle.clone(); handle.listen_any("ls-menu", move |_| { show_app_menu(&h); }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc00447..d2f6fbe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.3.0", + "version": "1.4.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"