From b3b44e2c72c1c3a5ab166bae7cdbf5175ead382e Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 21 May 2026 04:35:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(mirada):=20mirada-launcher=20=E2=80=94=20l?= =?UTF-8?q?anzador=20de=20aplicaciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un escritorio en «modo launcher» necesita un lanzador. `mirada-launcher` es una app nueva, sin dependencias: escanea los `.desktop` del estándar XDG y lanza el que elijas desde una lista de terminal que se filtra escribiendo. - Recorre los directorios `applications/` de XDG en orden de prioridad (el del usuario tapa a los del sistema, dedup por id de archivo), parsea el grupo `[Desktop Entry]` (salta `NoDisplay`/`Hidden`, exige `Type=Application`), y limpia los códigos de campo del `Exec`. - Interfaz de terminal sin raer modo: número = lanzar, texto = filtrar (si deja una sola, la lanza), Enter vacío = salir. Las apps con `Terminal=true` se envuelven en `foot -e`. - Pensado para abrirse en una terminal pequeña; al lanzar termina y el programa queda corriendo, reparentado a init. El keymap por defecto ata `Super+p` a `spawn:foot -e mirada-launcher` (`Super+d` ya era el layout CenteredMaster). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 4 + Cargo.toml | 1 + crates/apps/mirada-compositor/README.md | 27 +- crates/apps/mirada-launcher/Cargo.toml | 15 + crates/apps/mirada-launcher/src/main.rs | 274 ++++++++++++++++++ crates/modules/mirada/SDD.md | 4 + .../modules/mirada/mirada-brain/src/action.rs | 1 + vamos.txt | 1 + 8 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 crates/apps/mirada-launcher/Cargo.toml create mode 100644 crates/apps/mirada-launcher/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 18ec054..897097f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7740,6 +7740,10 @@ dependencies = [ "mirada-brain", ] +[[package]] +name = "mirada-launcher" +version = "0.1.0" + [[package]] name = "mirada-layout" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5ed1d57..99ea498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -288,6 +288,7 @@ members = [ "crates/apps/mirada", "crates/apps/mirada-compositor", "crates/apps/mirada-ctl", + "crates/apps/mirada-launcher", ] [workspace.package] diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md index ac43c2f..3ea2426 100644 --- a/crates/apps/mirada-compositor/README.md +++ b/crates/apps/mirada-compositor/README.md @@ -59,9 +59,9 @@ sólo probarlo: 1. Compila e instala los binarios en el `PATH`: ```sh - cargo build --release -p mirada-compositor -p mirada-ctl + cargo build --release -p mirada-compositor -p mirada-ctl -p mirada-launcher sudo install -m755 target/release/mirada-compositor \ - target/release/mirada-ctl /usr/local/bin/ + target/release/mirada-ctl target/release/mirada-launcher /usr/local/bin/ sudo install -m755 session/mirada-session /usr/local/bin/ ``` @@ -87,6 +87,17 @@ sólo probarlo: Dentro de la sesión, `Ctrl+Alt+F1…F12` salta a otra TTY y vuelve sin romper carmen. +## Lanzador de aplicaciones + +`mirada-launcher` escanea los `.desktop` del sistema y lanza el que +elijas. Es un programa de terminal sin dependencias: lo abres en una +terminal pequeña y filtras escribiendo. El keymap por defecto ata +`Super+p` a `spawn:foot -e mirada-launcher` — pulsa el atajo, escribe +unas letras del nombre, Enter. + +Necesita `mirada-launcher` y `foot` en el `PATH` (ver la instalación de +arriba). Suelto también vale: `mirada-launcher` en cualquier terminal. + ## Dos modos - **Autónomo** (por defecto) — lleva un `Desktop` (de `mirada-brain`) @@ -116,12 +127,12 @@ WAYLAND_DISPLAY=wayland-1 foot # o weston-terminal, alacritty, … ``` Las ventanas se teselan solas. El teclado, con la ventana del compositor -enfocada, maneja el escritorio con atajos `Super+…`: lanzar una terminal -`Super+Shift+Return`, foco `Super+j/k`, los 7 layouts en -`Super+t/m/g/c/r/d/s` (o ciclar con `Super+space`), área maestra -`Super+h/l`, `nmaster` `Super+,/.`, promover a maestra `Super+Return`, -escritorios `Super+1..9`, cerrar `Super+q`. Cierra la ventana del -compositor para salir. +enfocada, maneja el escritorio con atajos `Super+…`: el lanzador de +aplicaciones `Super+p`, una terminal `Super+Shift+Return`, foco +`Super+j/k`, los 7 layouts en `Super+t/m/g/c/r/d/s` (o ciclar con +`Super+space`), área maestra `Super+h/l`, `nmaster` `Super+,/.`, +promover a maestra `Super+Return`, escritorios `Super+1..9`, cerrar +`Super+q`. Cierra la ventana del compositor para salir. ## Atajos de teclado diff --git a/crates/apps/mirada-launcher/Cargo.toml b/crates/apps/mirada-launcher/Cargo.toml new file mode 100644 index 0000000..4f97a5c --- /dev/null +++ b/crates/apps/mirada-launcher/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mirada-launcher" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "mirada-launcher — lanzador de aplicaciones para carmen: escanea los .desktop del sistema, los lista en la terminal y lanza el que elijas. Sin dependencias; pensado para correr en una terminal pequeña que el compositor abre con un atajo." + +[[bin]] +name = "mirada-launcher" +path = "src/main.rs" + +[dependencies] diff --git a/crates/apps/mirada-launcher/src/main.rs b/crates/apps/mirada-launcher/src/main.rs new file mode 100644 index 0000000..01124eb --- /dev/null +++ b/crates/apps/mirada-launcher/src/main.rs @@ -0,0 +1,274 @@ +//! `mirada-launcher` — un lanzador de aplicaciones para carmen. +//! +//! Escanea los archivos `.desktop` del sistema (el estándar XDG), los +//! lista en la terminal y lanza el que elijas. No tiene dependencias: la +//! interfaz es una lista numerada que se filtra escribiendo. +//! +//! Pensado para correr dentro de una terminal pequeña que el compositor +//! abre con un atajo — p. ej. atando `Super+d` a +//! `spawn:foot -e mirada-launcher` en el keymap de mirada. Al elegir una +//! aplicación, la lanza y termina (la terminal se cierra sola); el +//! programa lanzado queda corriendo, reparentado a init. +//! +//! También sirve suelto: `mirada-launcher` en cualquier terminal. + +use std::collections::HashSet; +use std::io::{self, Write}; +use std::path::PathBuf; + +/// Una aplicación lista para lanzar, sacada de un `.desktop`. +struct DesktopApp { + /// Nombre visible (`Name=`). + name: String, + /// Comando a ejecutar, ya sin los códigos de campo (`%u`, `%F`…). + exec: String, + /// `true` si la app necesita una terminal (`Terminal=true`). + needs_terminal: bool, +} + +fn main() { + let mut apps = scan_apps(); + apps.sort_by_key(|a| a.name.to_lowercase()); + if apps.is_empty() { + eprintln!("mirada-launcher · no encontré ninguna aplicación .desktop."); + std::process::exit(1); + } + run_ui(&apps); +} + +// --------------------------------------------------------------------- +// Escaneo de los .desktop +// --------------------------------------------------------------------- + +/// Recorre los directorios XDG de aplicaciones y devuelve las que se +/// pueden lanzar. Un `.desktop` de un directorio de mayor prioridad +/// tapa a otro con el mismo nombre de archivo en uno de menor. +fn scan_apps() -> Vec { + let mut apps = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for dir in application_dirs() { + collect_desktop_files(&dir, &dir, &mut seen, &mut apps); + } + apps +} + +/// Los directorios `applications/` del estándar XDG, en orden de +/// prioridad: primero el del usuario, luego los del sistema. +fn application_dirs() -> Vec { + let mut dirs = Vec::new(); + + let data_home = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))); + if let Some(home) = data_home { + dirs.push(home.join("applications")); + } + + let data_dirs = std::env::var("XDG_DATA_DIRS") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string()); + for d in data_dirs.split(':').filter(|s| !s.is_empty()) { + dirs.push(PathBuf::from(d).join("applications")); + } + dirs +} + +/// Recoge los `.desktop` de `dir` (y subdirectorios) sin repetir id. +fn collect_desktop_files( + root: &PathBuf, + dir: &PathBuf, + seen: &mut HashSet, + apps: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_desktop_files(root, &path, seen, apps); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + // El id XDG: la ruta relativa al directorio raíz, con `/` → `-`. + let id = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('/', "-"); + if !seen.insert(id) { + continue; // ya lo tapó un directorio de más prioridad + } + if let Ok(text) = std::fs::read_to_string(&path) { + if let Some(app) = parse_desktop(&text) { + apps.push(app); + } + } + } +} + +/// Extrae una [`DesktopApp`] del texto de un `.desktop`. `None` si no es +/// una aplicación lanzable o está marcada para no mostrarse. +fn parse_desktop(text: &str) -> Option { + let mut in_entry = false; + let (mut name, mut exec, mut kind) = (None, None, None); + let (mut no_display, mut hidden, mut terminal) = (false, false, false); + + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + // Sólo nos interesa el grupo principal; otros (acciones, + // etc.) se ignoran. + in_entry = line == "[Desktop Entry]"; + continue; + } + if !in_entry || line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "Name" => name = Some(value.to_string()), + "Exec" => exec = Some(value.to_string()), + "Type" => kind = Some(value.to_string()), + "NoDisplay" => no_display = value == "true", + "Hidden" => hidden = value == "true", + "Terminal" => terminal = value == "true", + _ => {} // Name[es], Icon, Categories…: no los usamos + } + } + + if no_display || hidden { + return None; + } + if kind.as_deref() != Some("Application") { + return None; + } + let name = name?; + let exec = strip_field_codes(&exec?); + if name.is_empty() || exec.is_empty() { + return None; + } + Some(DesktopApp { name, exec, needs_terminal: terminal }) +} + +/// Quita los códigos de campo de un `Exec` de `.desktop` (`%u`, `%F`, +/// `%i`…), que sólo tienen sentido al abrir archivos. `%%` queda en `%`. +fn strip_field_codes(exec: &str) -> String { + let mut out = String::new(); + let mut chars = exec.chars(); + while let Some(c) = chars.next() { + if c == '%' { + // `%%` es un `%` literal; cualquier otro `%x` es un código de + // campo y se descarta entero. + if let Some('%') = chars.next() { + out.push('%'); + } + } else { + out.push(c); + } + } + out.trim().to_string() +} + +// --------------------------------------------------------------------- +// Interfaz de terminal +// --------------------------------------------------------------------- + +/// Cuántas aplicaciones se listan como mucho de una vez. +const MAX_SHOWN: usize = 40; + +/// El bucle de la interfaz: muestra la lista, lee una línea y según sea +/// un número lanza, texto filtra, o vacía sale. +fn run_ui(apps: &[DesktopApp]) { + let mut filter = String::new(); + loop { + let needle = filter.to_lowercase(); + let matches: Vec<&DesktopApp> = apps + .iter() + .filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle)) + .collect(); + + // Limpia la pantalla y dibuja la lista. + print!("\x1b[2J\x1b[H"); + if filter.is_empty() { + println!("mirada-launcher · {} aplicaciones", matches.len()); + } else { + println!( + "mirada-launcher · {} de {} · filtro «{filter}»", + matches.len(), + apps.len() + ); + } + println!(); + if matches.is_empty() { + println!(" (sin coincidencias)"); + } + for (i, a) in matches.iter().take(MAX_SHOWN).enumerate() { + println!(" {:>2} {}", i + 1, a.name); + } + if matches.len() > MAX_SHOWN { + println!(" … y {} más — afina el filtro", matches.len() - MAX_SHOWN); + } + println!(); + println!(" nº = lanzar · texto = filtrar · Enter vacío = salir"); + print!("> "); + io::stdout().flush().ok(); + + let mut line = String::new(); + if io::stdin().read_line(&mut line).unwrap_or(0) == 0 { + return; // fin de entrada (Ctrl+D) + } + let line = line.trim(); + if line.is_empty() { + return; + } + + // ¿Un número? Lanza esa entrada de la lista visible. + if let Ok(n) = line.parse::() { + if (1..=matches.len().min(MAX_SHOWN)).contains(&n) { + launch(matches[n - 1]); + return; + } + continue; // número fuera de rango: vuelve a pedir + } + + // Texto: es un filtro nuevo. Si deja una sola, lánzala directo. + filter = line.to_string(); + let needle = filter.to_lowercase(); + let now: Vec<&DesktopApp> = apps + .iter() + .filter(|a| a.name.to_lowercase().contains(&needle)) + .collect(); + if now.len() == 1 { + launch(now[0]); + return; + } + } +} + +/// Lanza la aplicación elegida como proceso hijo y devuelve. Hereda el +/// entorno —`WAYLAND_DISPLAY` incluido—; al terminar el lanzador, el +/// proceso queda corriendo, reparentado a init. +fn launch(app: &DesktopApp) { + let cmd = if app.needs_terminal { + format!("foot -e {}", app.exec) + } else { + app.exec.clone() + }; + print!("\x1b[2J\x1b[H"); + println!("mirada-launcher · lanzando «{}» …", app.name); + match std::process::Command::new("sh").arg("-c").arg(&cmd).spawn() { + Ok(_) => {} + Err(e) => { + eprintln!("mirada-launcher · no pude lanzar «{cmd}»: {e}"); + std::process::exit(1); + } + } +} diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md index 39787c8..00aca47 100644 --- a/crates/modules/mirada/SDD.md +++ b/crates/modules/mirada/SDD.md @@ -147,6 +147,10 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres: produce un `BrainCommand::Spawn`; el Cuerpo lo ejecuta con `sh -c`, y el hijo hereda `WAYLAND_DISPLAY`. `DesktopAction` deja de ser `Copy` por llevar el comando. +- **Lanzador de aplicaciones** — `mirada-launcher` (app aparte, sin + dependencias): escanea los `.desktop` XDG y lanza el elegido desde una + lista de terminal que se filtra escribiendo. El keymap ata `Super+p` a + `spawn:foot -e mirada-launcher`. - **HUD interactivo** (app `mirada`) — los pips de escritorio y las ventanas del lienzo son clicables: clic = `apply` de la acción. - **`mirada-ctl`** — control externo por línea de comandos diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs index 857b570..cdb2edd 100644 --- a/crates/modules/mirada/mirada-brain/src/action.rs +++ b/crates/modules/mirada/mirada-brain/src/action.rs @@ -231,6 +231,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> { ("Super+o".into(), DesktopAction::FocusOutputNext), ("Super+Return".into(), DesktopAction::PromoteToMaster), ("Super+Shift+Return".into(), DesktopAction::Spawn("foot".into())), + ("Super+p".into(), DesktopAction::Spawn("foot -e mirada-launcher".into())), ("Super+,".into(), DesktopAction::IncMaster), ("Super+.".into(), DesktopAction::DecMaster), ("Super+Shift+e".into(), DesktopAction::Quit), diff --git a/vamos.txt b/vamos.txt index bc09868..592958f 100644 --- a/vamos.txt +++ b/vamos.txt @@ -1006,6 +1006,7 @@ Super+arrastre mueve la ventana (botón izq.) o la redimensiona (der.) — al arrastrarla pasa a flotar. Fuerza xdg-decoration ServerSide y no dibuja marco: las ventanas teseladas van sin barra de título. Lanzar programas: acción spawn: del keymap (Super+Shift+Return → spawn:foot por defecto). + Lanzador de apps: mirada-launcher (escanea los .desktop, lista filtrable de terminal); atado a Super+p. Conmutación de VT: Ctrl+Alt+Fn salta a otra TTY y vuelve sin romper la sesión (pausa DRM + libinput). Sesión: ~/.config/mirada/autostart (un comando por línea) + script session/mirada-session + carmen.desktop. Ver crates/apps/mirada-compositor/README.md.