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>
This commit is contained in:
Generated
+4
@@ -7740,6 +7740,10 @@ dependencies = [
|
||||
"mirada-brain",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mirada-launcher"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "mirada-layout"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -288,6 +288,7 @@ members = [
|
||||
"crates/apps/mirada",
|
||||
"crates/apps/mirada-compositor",
|
||||
"crates/apps/mirada-ctl",
|
||||
"crates/apps/mirada-launcher",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user