gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definiciones

feat(mirada): mirada-launcher — lanzador de aplicaciones

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 <noreply@anthropic.com>

b3b44e2c72c1c3a5ab166bae7cdbf5175ead382e · por sergio · (hace 5 meses) · padre 5ede927d3 · ver el árbol en este commit

Delta semántico · 11

crates/apps/mirada-launcher/src/main.rs

crates/modules/mirada/mirada-brain/src/action.rs

8 archivos · +319 −8

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<DesktopApp> {
+    let mut apps = Vec::new();
+    let mut seen: HashSet<String> = 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<PathBuf> {
+    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<String>,
+    apps: &mut Vec<DesktopApp>,
+) {
+    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<DesktopApp> {
+    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::<usize>() {
+            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:<comando> 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.