feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)

Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay
+ Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se
consume por git desde su repo publicado; las hojas compartidas (format,
auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van
vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el
compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:01:49 +00:00
commit 3dc85ebdcd
116 changed files with 31060 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
@@ -0,0 +1,15 @@
[package]
name = "llimphi-widget-menubar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { path = "../../../../shared/app-bus" }
+334
View File
@@ -0,0 +1,334 @@
//! `llimphi-widget-menubar` — la barra de menú principal de una app.
//!
//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar /
//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de
//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la
//! ventana de la app — para las apps que corren standalone y no bajo el
//! shell del launcher.
//!
//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz
//! está abierto (`Option<usize>`); el widget aplana el `AppMenu` y emite
//! `Msg` en cada interacción.
//!
//! Dos entradas:
//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`.
//! - [`menubar_overlay`] → el dropdown del menú abierto, para
//! `App::view_overlay` (devolvé `None` si no hay nada abierto).
//!
//! El `command` de cada ítem es el id que la app entiende (convención
//! `menu.<verbo>`, ver [`app_bus::AppMenu::standard`]); el widget lo
//! rebota por `on_command`.
#![forbid(unsafe_code)]
use std::sync::Arc;
use app_bus::{AppMenu, Menu};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_button::{button_styled, ButtonPalette};
use llimphi_widget_context_menu::{
context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette,
ContextMenuSpec,
};
type MsgFromMenu<Msg> = Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>;
type MsgFromStr<Msg> = Arc<dyn Fn(&str) -> Msg + Send + Sync>;
/// Todo lo que el render necesita. El host lo arma en cada `view()`.
pub struct MenuBarSpec<'a, Msg: Clone + 'static> {
/// El menú a pintar (típicamente `AppMenu::standard()` + menús propios).
pub menu: &'a AppMenu,
/// Índice del menú raíz abierto (estado del host). `None` = ninguno.
pub open: Option<usize>,
pub theme: &'a Theme,
/// Tamaño de la ventana — para clampear el dropdown.
pub viewport: (f32, f32),
/// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón.
pub height: f32,
/// Abrir/cerrar un menú raíz por índice (`None` = cerrar).
pub on_open: MsgFromMenu<Msg>,
/// command id → Msg, al elegir un ítem.
pub on_command: MsgFromStr<Msg>,
}
/// Alto recomendado de la barra de menú.
pub const DEFAULT_HEIGHT: f32 = 30.0;
fn title_palette(theme: &Theme) -> ButtonPalette {
ButtonPalette::from_theme(theme)
}
fn title_palette_active(theme: &Theme) -> ButtonPalette {
let base = ButtonPalette::from_theme(theme);
ButtonPalette {
bg: theme.accent,
bg_hover: theme.accent,
fg: theme.bg_panel,
radius: base.radius,
}
}
/// La fila de títulos (Archivo / Editar / …). Click sobre un título
/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent.
/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que,
/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú
/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay,
/// donde los títulos quedan por encima del scrim y son hovereables.
fn titles_row<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>, hover_switch: bool) -> View<Msg> {
let pal = title_palette(spec.theme);
let pal_on = title_palette_active(spec.theme);
let mut titles: Vec<View<Msg>> = Vec::with_capacity(spec.menu.menus.len());
for (i, root) in spec.menu.menus.iter().enumerate() {
let open = spec.open == Some(i);
let target = if open { None } else { Some(i) };
let mut title = button_styled(
root.label.clone(),
title_style(),
Alignment::Center,
if open { &pal_on } else { &pal },
(spec.on_open)(target),
);
// Con un menú abierto, hover sobre otro título lo abre.
if hover_switch && !open {
title = title.on_pointer_enter((spec.on_open)(Some(i)));
}
titles.push(title);
}
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(spec.height),
},
flex_shrink: 0.0,
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(spec.theme.bg_panel_alt)
.children(titles)
}
/// La barra de menú principal — primer hijo del column raíz de `view()`.
pub fn menubar_view<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> View<Msg> {
titles_row(spec, false)
}
/// Aplana un menú raíz al par alineado `(items, commands)` que consume el
/// context-menu (los separadores `separator_before` se insertan como
/// filas y llevan `command = None`). Es la única fuente de verdad del
/// orden de filas — la navegación por teclado y el render comparten esto.
fn dropdown_items(root: &Menu) -> (Vec<ContextMenuItem>, Vec<Option<String>>) {
let mut items: Vec<ContextMenuItem> = Vec::new();
let mut commands: Vec<Option<String>> = Vec::new();
for (k, src) in root.items.iter().enumerate() {
if src.separator_before && k != 0 {
items.push(ContextMenuItem::separator());
commands.push(None);
}
let mut cm = ContextMenuItem::action(src.label.clone());
if let Some(s) = &src.shortcut {
cm = cm.with_shortcut(s.clone());
}
if let Some(ic) = &src.icon {
cm = cm.icon(ic.clone());
}
if !src.enabled {
cm = cm.disabled();
}
items.push(cm);
commands.push(Some(src.command.clone()));
}
(items, commands)
}
/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no
/// hay menú abierto. Hospeda además una copia de la fila de títulos por
/// encima del scrim: así, con el menú abierto, mover el mouse a otro
/// título cambia de menú (hover-switch).
pub fn menubar_overlay<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> Option<View<Msg>> {
menubar_overlay_core(spec, usize::MAX, 1.0)
}
/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado;
/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil
/// para que el dropdown se deslice/funda al cambiar de menú por hover o
/// flechas). La app guarda el `active` y un `Tween` para el `appear`.
pub fn menubar_overlay_animated<Msg: Clone + 'static>(
spec: &MenuBarSpec<Msg>,
active: usize,
appear: f32,
) -> Option<View<Msg>> {
menubar_overlay_core(spec, active, appear)
}
fn menubar_overlay_core<Msg: Clone + 'static>(
spec: &MenuBarSpec<Msg>,
active: usize,
appear: f32,
) -> Option<View<Msg>> {
let idx = spec.open?;
let root = spec.menu.menus.get(idx)?;
let mut x = 6.0_f32;
for prev in spec.menu.menus.iter().take(idx) {
x += approx_title_width(&prev.label);
}
let (items, commands) = dropdown_items(root);
let on_command = spec.on_command.clone();
let on_open = spec.on_open.clone();
let commands = Arc::new(commands);
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
match commands.get(i).and_then(|c| c.clone()) {
Some(cmd) => (on_command)(&cmd),
None => (on_open)(None),
}
});
let dropdown = context_menu_view_ex(
ContextMenuSpec {
anchor: (x, spec.height),
viewport: spec.viewport,
header: Some(root.label.clone()),
items,
active,
on_pick,
on_dismiss: (spec.on_open)(None),
palette: ContextMenuPalette::from_theme(spec.theme),
},
ContextMenuExtras {
appear,
..ContextMenuExtras::default()
},
);
// Fila de títulos por encima del scrim del dropdown: queda hovereable
// para cambiar de menú con el mouse. Absoluta al tope para no consumir
// el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana
// el hit-test.
let titles = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(0.0_f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: percent(1.0_f32),
height: length(spec.height),
},
..Default::default()
})
.children(vec![titles_row(spec, true)]);
Some(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![dropdown, titles]),
)
}
/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el
/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el
/// próximo índice de fila válido (saltea separadores y deshabilitados).
/// `usize::MAX` si no hay menú abierto o sin filas elegibles.
pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize {
let Some(root) = menu.menus.get(menu_idx) else {
return usize::MAX;
};
let (items, _) = dropdown_items(root);
step_active(&items, active, dir)
}
/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con
/// Enter). `None` si el índice no es una fila-acción.
pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option<String> {
let root = menu.menus.get(menu_idx)?;
let (_, commands) = dropdown_items(root);
commands.get(active).cloned().flatten()
}
fn title_style() -> Style {
Style {
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
height: length(24.0_f32),
},
flex_shrink: 0.0,
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
}
}
/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi`
/// para anclar el dropdown sin medir la fuente.
fn approx_title_width(label: &str) -> f32 {
label.chars().count() as f32 * 8.0 + 22.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlay_none_si_no_hay_abierto() {
let menu = AppMenu::standard();
let spec = MenuBarSpec {
menu: &menu,
open: None,
theme: &Theme::dark(),
viewport: (800.0, 600.0),
height: DEFAULT_HEIGHT,
on_open: Arc::new(|_| 0u8),
on_command: Arc::new(|_| 1u8),
};
assert!(menubar_overlay(&spec).is_none());
}
#[test]
fn overlay_some_si_hay_abierto() {
let menu = AppMenu::standard();
let spec = MenuBarSpec {
menu: &menu,
open: Some(0),
theme: &Theme::dark(),
viewport: (800.0, 600.0),
height: DEFAULT_HEIGHT,
on_open: Arc::new(|_| 0u8),
on_command: Arc::new(|_| 1u8),
};
assert!(menubar_overlay(&spec).is_some());
}
}
@@ -0,0 +1,25 @@
[package]
name = "mirada-app-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada (Llimphi) — el Cerebro del compositor: ventana Llimphi que tesela el escritorio sobre mirada-brain y manda la geometría al Cuerpo (smithay) por mirada-link. Reemplazo del `mirada-app` GPUI; sin Cuerpo arranca en simulación."
[[bin]]
name = "mirada-llimphi"
path = "src/main.rs"
[dependencies]
mirada-brain = { path = "../mirada-brain" }
mirada-link = { path = "../mirada-link" }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
rimay-localize = { path = "../../../shared/rimay-localize" }
wawa-config = { path = "../../../shared/wawa-config" }
@@ -0,0 +1,9 @@
# mirada-app-llimphi
> Apps shell del compositor de [mirada](../README.md).
Conjunto de mini-apps que viven adentro del compositor (taskbar, notification center, screenshot tool). Llimphi nativo sobre [`mirada-protocol`](../mirada-protocol/README.md).
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
@@ -0,0 +1,9 @@
# mirada-app-llimphi
> Compositor shell apps of [mirada](../README.md).
Set of mini-apps that live inside the compositor (taskbar, notification center, screenshot tool). Llimphi native over [`mirada-protocol`](../mirada-protocol/README.md).
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md), [`llimphi-ui`](../../llimphi/)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
[package]
name = "mirada-bar-core"
description = "Barra — modelo de taskbar agnóstico: Task + render-to-html + sanitizadores. Sin dependencias web/DOM."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
+9
View File
@@ -0,0 +1,9 @@
# mirada-bar-core
> Trait de status bar de [mirada](../README.md).
`StatusBar` define qué slots tiene la barra (workspaces, clock, tray, battery, ...). Permite múltiples implementaciones (Llimphi nativa, HTML overlay).
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
@@ -0,0 +1,9 @@
# mirada-bar-core
> Status bar trait of [mirada](../README.md).
`StatusBar` defines what slots the bar has (workspaces, clock, tray, battery, ...). Allows multiple implementations (Llimphi native, HTML overlay).
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
+108
View File
@@ -0,0 +1,108 @@
//! Barra core — modelo agnóstico de taskbar.
//!
//! Provee la lista de `Task`, los helpers de sanitización para atributos
//! HTML, y `render_html` puro. El binding DOM vive en `barra-web`.
/// Una tarea (cajita) en la barra.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Task {
pub id: String,
pub label: String,
pub active: bool,
}
impl Task {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self { id: id.into(), label: label.into(), active: false }
}
pub fn active(mut self) -> Self {
self.active = true;
self
}
}
/// Renderiza un slice de tareas a markup HTML. Sanitiza IDs y escapa
/// labels. La salida es la lista de `<li>` que el host inyecta en su `<ul>`.
pub fn render_html(tasks: &[Task]) -> String {
let mut html = String::new();
for t in tasks {
let id_safe = sanitize_attr(&t.id);
let label_safe = escape_text(&t.label);
let active_cls = if t.active { " active" } else { "" };
html.push_str(&format!(
"<li><button class=\"taskbar-item{active_cls}\" data-task=\"{id_safe}\" type=\"button\">\
<span class=\"taskbar-item-dot\" aria-hidden=\"true\"></span>{label_safe}</button></li>"
));
}
html
}
/// Filtra a `[a-zA-Z0-9_-]` para uso seguro en atributos HTML.
pub fn sanitize_attr(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect()
}
/// HTML-escape de texto para insertarlo en posiciones de contenido.
pub fn escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_builder_defaults_inactive() {
let t = Task::new("aire", "AIRE");
assert!(!t.active);
assert!(Task::new("f", "F").active().active);
}
#[test]
fn sanitize_attr_strips_unsafe() {
assert_eq!(sanitize_attr("aire"), "aire");
assert_eq!(sanitize_attr("a-b_c"), "a-b_c");
assert_eq!(sanitize_attr("ai<re>"), "aire");
assert_eq!(sanitize_attr("a\"b"), "ab");
}
#[test]
fn escape_text_escapes_html() {
assert_eq!(escape_text("AIRE"), "AIRE");
assert_eq!(escape_text("<script>"), "&lt;script&gt;");
assert_eq!(escape_text("a & b"), "a &amp; b");
}
#[test]
fn render_html_emits_active_class() {
let tasks = [
Task::new("aire", "AIRE"),
Task::new("fuego", "FUEGO").active(),
];
let html = render_html(&tasks);
assert!(html.contains("data-task=\"aire\""));
assert!(html.contains("data-task=\"fuego\""));
assert!(html.contains("taskbar-item active"));
}
#[test]
fn render_html_escapes_label_and_sanitizes_id() {
let tasks = [Task::new("a<b", "x<script>y")];
let html = render_html(&tasks);
assert!(html.contains("data-task=\"ab\""));
assert!(html.contains("x&lt;script&gt;y"));
assert!(!html.contains("<script>"));
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "mirada-body"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — estado del Cuerpo del compositor: lleva la cuenta de salidas y superficies, traduce los BrainCommand a operaciones de backend y emite los BodyEvent. Agnóstico de smithay."
[dependencies]
mirada-protocol = { path = "../mirada-protocol" }
[dev-dependencies]
mirada-link = { path = "../mirada-link" }
+10
View File
@@ -0,0 +1,10 @@
# mirada-body
> Estado físico del display (monitors, modes) de [mirada](../README.md).
Inventario de outputs (HDMI/DP/eDP/...) y sus modos (resolution + refresh + scale). El operador puede cambiar layout/scale en runtime sin reiniciar el compositor.
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md)
- `drm`, `gbm` (Linux)
+10
View File
@@ -0,0 +1,10 @@
# mirada-body
> Display physical state (monitors, modes) of [mirada](../README.md).
Inventory of outputs (HDMI/DP/eDP/...) and their modes (resolution + refresh + scale). The operator can change layout/scale at runtime without restarting the compositor.
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md)
- `drm`, `gbm` (Linux)
@@ -0,0 +1,121 @@
//! `headless` — un Cuerpo de carmen sin gráficos, guiado por stdin.
//!
//! Es el banco de pruebas del Cerebro: implementa el lado del Cuerpo del
//! protocolo (escucha en un socket, lleva un [`BodyState`], manda
//! [`BodyEvent`]s y ejecuta —imprimiéndolas— las [`BodyOp`]s) sin tocar
//! `smithay` ni el hardware. Así se ejercita el bucle entero
//! Cerebro↔Cuerpo desde una terminal.
//!
//! ```text
//! # terminal 1 — el Cuerpo escucha
//! cargo run -p mirada-body --example headless -- /tmp/mirada.sock
//! # terminal 2 — el Cerebro se conecta
//! MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
//! ```
//!
//! Órdenes de stdin: `output <w> <h>`, `open <app>`, `close <id>`,
//! `title <id> <texto>`, `key <combo>`, `pointer <id>`, `tick`, `quit`.
use std::io::BufRead;
use std::time::Duration;
use mirada_body::BodyState;
use mirada_link::BodyLink;
fn main() {
let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/mirada.sock".to_string());
let _ = std::fs::remove_file(&path);
println!("Cuerpo headless · escuchando en {path} — esperando al Cerebro…");
let mut link: BodyLink = match BodyLink::listen(&path) {
Ok(l) => l,
Err(e) => {
eprintln!("no se pudo escuchar en {path}: {e}");
std::process::exit(1);
}
};
println!("Cerebro conectado. Órdenes: output / open / close / title / key / pointer / tick / quit");
let mut body = BodyState::new();
let mut next_id: u64 = 1;
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let mut parts = line.split_whitespace();
let cmd = parts.next().unwrap_or("");
let rest: Vec<&str> = parts.collect();
// Cada orden o bien manda un evento al Cerebro, o no manda nada.
let event = match cmd {
"output" if rest.len() == 2 => {
match (rest[0].parse(), rest[1].parse()) {
(Ok(w), Ok(h)) => Some(body.add_output(0, w, h)),
_ => {
eprintln!("uso: output <ancho> <alto>");
None
}
}
}
"open" if !rest.is_empty() => {
let app = rest[0];
let id = next_id;
next_id += 1;
println!(" → ventana {id} ({app})");
Some(body.open_surface(id, format!("org.brahman.{app}"), format!("{app} {id}")))
}
"close" if rest.len() == 1 => match rest[0].parse() {
Ok(id) => body.close_surface(id),
Err(_) => {
eprintln!("uso: close <id>");
None
}
},
"title" if rest.len() >= 2 => match rest[0].parse() {
Ok(id) => body.retitle_surface(id, rest[1..].join(" ")),
Err(_) => {
eprintln!("uso: title <id> <texto>");
None
}
},
"key" if rest.len() == 1 => Some(body.keybind(rest[0])),
"pointer" if rest.len() == 1 => match rest[0].parse() {
Ok(id) => Some(body.pointer_enter(id)),
Err(_) => {
eprintln!("uso: pointer <id>");
None
}
},
"tick" => None,
"quit" | "exit" => break,
"" => None,
other => {
eprintln!("orden desconocida: {other}");
None
}
};
if let Some(ev) = event {
if link.send(&ev).is_err() {
eprintln!("el Cerebro cerró la conexión.");
break;
}
}
// Deja que el Cerebro responda y ejecuta lo que ordene.
std::thread::sleep(Duration::from_millis(40));
for command in link.drain() {
for op in body.apply(command) {
println!(" · op: {op:?}");
}
}
}
println!("Cuerpo headless · adiós.");
let _ = std::fs::remove_file(&path);
}
+480
View File
@@ -0,0 +1,480 @@
//! `mirada-body` — el estado del Cuerpo del compositor.
//!
//! El "Cuerpo" de carmen (`mirada-compositor`, sobre `smithay`) tiene
//! dos mitades: el *backend*, que habla Wayland y posee el hardware, y
//! esta *contabilidad* — qué salidas y superficies existen y con qué
//! geometría. Aislarla deja el backend reducido a "ejecuta estas
//! [`BodyOp`]" y la hace testeable sin un servidor gráfico.
//!
//! El flujo es simétrico al del Cerebro:
//!
//! - El backend avisa de cambios de hardware/clientes con los mutadores
//! ([`BodyState::open_surface`], [`BodyState::add_output`], …), que
//! devuelven el [`BodyEvent`] a mandar al Cerebro.
//! - El Cerebro responde con [`BrainCommand`]s; [`BodyState::apply`] los
//! traduce a [`BodyOp`]s concretas que el backend ejecuta.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, Rect, WindowId};
/// Una superficie Wayland desde la óptica del Cuerpo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Surface {
pub app_id: String,
pub title: String,
/// Geometría aplicada — `None` hasta la primera [`BodyOp::Configure`].
pub geometry: Option<Rect>,
pub visible: bool,
pub focused: bool,
/// `true` si flota: el backend la pinta por encima de las teseladas.
pub floating: bool,
/// `true` si está en pantalla completa.
pub fullscreen: bool,
}
impl Surface {
fn new(app_id: String, title: String) -> Self {
Self {
app_id,
title,
geometry: None,
visible: false,
focused: false,
floating: false,
fullscreen: false,
}
}
}
/// Una orden concreta para el backend (smithay, headless, …).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BodyOp {
/// Recoloca una superficie, la muestra u oculta y dice si flota o
/// está en pantalla completa (el backend ajusta el orden de pintado
/// y el estado `xdg_toplevel` en consecuencia).
Configure {
id: WindowId,
rect: Rect,
visible: bool,
floating: bool,
fullscreen: bool,
},
/// Da el foco del teclado a una superficie.
Focus(WindowId),
/// Quita el foco a todas las superficies.
Unfocus,
/// Pide el cierre ordenado de un cliente.
CloseClient(WindowId),
/// Mata a un cliente que no responde.
KillClient(WindowId),
/// Registra los atajos globales a interceptar.
SetGrabs(Vec<String>),
/// Cambia el cursor del puntero.
SetCursor(String),
/// Fija los parámetros de decoración de las ventanas (marco, …).
SetDecorations(Decorations),
/// Lanza un programa como proceso hijo del compositor.
Spawn(String),
/// Apaga el compositor y libera el hardware.
Shutdown,
}
/// La contabilidad del Cuerpo: salidas y superficies.
#[derive(Debug, Default)]
pub struct BodyState {
outputs: Vec<(OutputId, Rect)>,
/// `BTreeMap` para que el orden de las `BodyOp` sea determinista.
surfaces: BTreeMap<WindowId, Surface>,
focused: Option<WindowId>,
}
impl BodyState {
/// Cuerpo recién arrancado: sin salidas ni superficies.
pub fn new() -> Self {
Self::default()
}
// --- Traducción de comandos del Cerebro --------------------------
/// Traduce un comando del Cerebro a las operaciones de backend que lo
/// materializan. Sólo emite lo que de verdad cambia: un `Place`
/// idéntico al estado actual no produce ninguna `BodyOp`.
pub fn apply(&mut self, cmd: BrainCommand) -> Vec<BodyOp> {
match cmd {
BrainCommand::Place(placements) => {
let mut ops = Vec::new();
let listed: Vec<WindowId> = placements.iter().map(|p| p.id).collect();
let mut new_focus = None;
// Reconfigura las superficies que aparecen en la lista.
for p in &placements {
if p.focused {
new_focus = Some(p.id);
}
if let Some(s) = self.surfaces.get_mut(&p.id) {
if s.geometry != Some(p.rect)
|| s.visible != p.visible
|| s.floating != p.floating
|| s.fullscreen != p.fullscreen
{
s.geometry = Some(p.rect);
s.visible = p.visible;
s.floating = p.floating;
s.fullscreen = p.fullscreen;
ops.push(BodyOp::Configure {
id: p.id,
rect: p.rect,
visible: p.visible,
floating: p.floating,
fullscreen: p.fullscreen,
});
}
}
}
// Oculta lo que el Cerebro ya no coloca.
for (id, s) in &mut self.surfaces {
if !listed.contains(id) && s.visible {
s.visible = false;
let rect = s.geometry.unwrap_or(Rect::new(0, 0, 0, 0));
ops.push(BodyOp::Configure {
id: *id,
rect,
visible: false,
floating: s.floating,
fullscreen: s.fullscreen,
});
}
}
// Reasigna el foco sólo si cambió.
if new_focus != self.focused {
self.focused = new_focus;
for (id, s) in &mut self.surfaces {
s.focused = Some(*id) == new_focus;
}
ops.push(match new_focus {
Some(id) => BodyOp::Focus(id),
None => BodyOp::Unfocus,
});
}
ops
}
BrainCommand::Close(id) => vec![BodyOp::CloseClient(id)],
BrainCommand::Kill(id) => vec![BodyOp::KillClient(id)],
BrainCommand::GrabKeys(keys) => vec![BodyOp::SetGrabs(keys)],
BrainCommand::SetCursor(name) => vec![BodyOp::SetCursor(name)],
BrainCommand::SetDecorations(d) => vec![BodyOp::SetDecorations(d)],
BrainCommand::Spawn(cmd) => vec![BodyOp::Spawn(cmd)],
BrainCommand::Shutdown => vec![BodyOp::Shutdown],
}
}
// --- Mutadores del backend → eventos para el Cerebro -------------
/// Registra una salida recién conectada.
pub fn add_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
self.outputs.push((id, Rect::new(0, 0, width, height)));
BodyEvent::OutputAdded { id, width, height }
}
/// Da de baja una salida desconectada.
pub fn remove_output(&mut self, id: OutputId) -> BodyEvent {
self.outputs.retain(|(o, _)| *o != id);
BodyEvent::OutputRemoved { id }
}
/// Cambia el área útil de una salida sin desconectarla — al
/// redimensionar la ventana anfitriona o al reservar/liberar la
/// franja del shell. Conserva el escritorio que muestra.
pub fn resize_output(&mut self, id: OutputId, width: i32, height: i32) -> BodyEvent {
if let Some((_, rect)) = self.outputs.iter_mut().find(|(o, _)| *o == id) {
rect.w = width;
rect.h = height;
}
BodyEvent::OutputResized { id, width, height }
}
/// Reserva —o libera— franjas en los bordes de una salida: las zonas
/// exclusivas (px desde cada borde) que el teselado debe esquivar. Las usa
/// el marco (`pata`) para acoplar sus barras sin que las ventanas las tapen;
/// cero en los cuatro libera la reserva. No toca el tamaño físico, así que
/// admite barras en varios bordes a la vez.
pub fn reserve_output(
&self,
id: OutputId,
top: i32,
bottom: i32,
left: i32,
right: i32,
) -> BodyEvent {
BodyEvent::OutputReserved {
id,
top,
bottom,
left,
right,
}
}
/// Registra una superficie recién creada por un cliente.
pub fn open_surface(
&mut self,
id: WindowId,
app_id: impl Into<String>,
title: impl Into<String>,
) -> BodyEvent {
let app_id = app_id.into();
let title = title.into();
self.surfaces
.insert(id, Surface::new(app_id.clone(), title.clone()));
BodyEvent::WindowOpened { id, app_id, title }
}
/// Da de baja una superficie destruida. `None` si no se conocía.
pub fn close_surface(&mut self, id: WindowId) -> Option<BodyEvent> {
self.surfaces.remove(&id)?;
if self.focused == Some(id) {
self.focused = None;
}
Some(BodyEvent::WindowClosed { id })
}
/// Actualiza el título de una superficie. `None` si no se conocía.
pub fn retitle_surface(&mut self, id: WindowId, title: impl Into<String>) -> Option<BodyEvent> {
let title = title.into();
let s = self.surfaces.get_mut(&id)?;
s.title = title.clone();
Some(BodyEvent::WindowRetitled { id, title })
}
/// Construye un evento de puntero entrando en una superficie.
pub fn pointer_enter(&self, id: WindowId) -> BodyEvent {
BodyEvent::PointerEntered { id }
}
/// Construye un evento de click (foco-al-click) sobre una superficie.
pub fn clicked(&self, id: WindowId) -> BodyEvent {
BodyEvent::Clicked { id }
}
/// Construye un evento de arrastre teselado al punto `(x, y)`.
pub fn window_dragged(&self, id: WindowId, x: i32, y: i32) -> BodyEvent {
BodyEvent::WindowDragged { id, x, y }
}
/// Construye un evento de atajo pulsado.
pub fn keybind(&self, combo: impl Into<String>) -> BodyEvent {
BodyEvent::Keybind(combo.into())
}
// --- Accesores de sólo lectura -----------------------------------
/// Las salidas conectadas.
pub fn outputs(&self) -> &[(OutputId, Rect)] {
&self.outputs
}
/// Una superficie conocida.
pub fn surface(&self, id: WindowId) -> Option<&Surface> {
self.surfaces.get(&id)
}
/// Número de superficies registradas.
pub fn surface_count(&self) -> usize {
self.surfaces.len()
}
/// Las superficies visibles, en orden de id.
pub fn visible(&self) -> impl Iterator<Item = (WindowId, &Surface)> {
self.surfaces.iter().filter(|(_, s)| s.visible).map(|(id, s)| (*id, s))
}
/// La superficie enfocada.
pub fn focused(&self) -> Option<WindowId> {
self.focused
}
}
#[cfg(test)]
mod tests {
use super::*;
use mirada_protocol::WindowPlacement;
fn placement(id: WindowId, visible: bool, focused: bool) -> WindowPlacement {
WindowPlacement {
id,
rect: Rect::new(0, 0, 800, 600),
visible,
focused,
floating: false,
fullscreen: false,
}
}
/// Cuerpo con dos superficies abiertas.
fn body_with_two() -> BodyState {
let mut b = BodyState::new();
b.add_output(0, 1920, 1080);
b.open_surface(1, "app1", "uno");
b.open_surface(2, "app2", "dos");
b
}
#[test]
fn opening_a_surface_yields_a_window_opened_event() {
let mut b = BodyState::new();
let ev = b.open_surface(7, "org.brahman.shuma", "shell");
assert_eq!(
ev,
BodyEvent::WindowOpened {
id: 7,
app_id: "org.brahman.shuma".into(),
title: "shell".into()
}
);
assert_eq!(b.surface_count(), 1);
}
#[test]
fn placing_surfaces_configures_and_focuses_them() {
let mut b = body_with_two();
let ops = b.apply(BrainCommand::Place(vec![
placement(1, true, false),
placement(2, true, true),
]));
// Dos Configure + un Focus.
let configures = ops.iter().filter(|o| matches!(o, BodyOp::Configure { .. })).count();
assert_eq!(configures, 2);
assert!(ops.contains(&BodyOp::Focus(2)));
assert_eq!(b.focused(), Some(2));
assert!(b.surface(2).unwrap().focused);
}
#[test]
fn an_identical_place_produces_no_ops() {
let mut b = body_with_two();
let cmd = BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]);
assert!(!b.apply(cmd.clone()).is_empty());
// Repetir el mismo Place no cambia nada.
assert!(b.apply(cmd).is_empty());
}
#[test]
fn dropping_a_surface_from_the_list_hides_it() {
let mut b = body_with_two();
b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
// El Cerebro deja de colocar la 2 (p. ej. cambió de escritorio).
let ops = b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
assert!(ops.contains(&BodyOp::Configure {
id: 2,
rect: Rect::new(0, 0, 800, 600),
visible: false,
floating: false,
fullscreen: false,
}));
assert!(!b.surface(2).unwrap().visible);
}
#[test]
fn placement_for_an_unknown_surface_is_ignored() {
let mut b = body_with_two();
// La 99 no existe — no debe producir Configure.
let ops = b.apply(BrainCommand::Place(vec![placement(99, true, true)]));
assert!(!ops.iter().any(|o| matches!(o, BodyOp::Configure { id: 99, .. })));
}
#[test]
fn close_and_kill_map_to_client_ops() {
let mut b = body_with_two();
assert_eq!(b.apply(BrainCommand::Close(1)), vec![BodyOp::CloseClient(1)]);
assert_eq!(b.apply(BrainCommand::Kill(2)), vec![BodyOp::KillClient(2)]);
}
#[test]
fn grab_keys_cursor_and_shutdown_pass_through() {
let mut b = BodyState::new();
assert_eq!(
b.apply(BrainCommand::GrabKeys(vec!["Super+q".into()])),
vec![BodyOp::SetGrabs(vec!["Super+q".into()])]
);
assert_eq!(
b.apply(BrainCommand::SetCursor("crosshair".into())),
vec![BodyOp::SetCursor("crosshair".into())]
);
assert_eq!(b.apply(BrainCommand::Shutdown), vec![BodyOp::Shutdown]);
}
#[test]
fn closing_a_surface_clears_its_focus() {
let mut b = body_with_two();
b.apply(BrainCommand::Place(vec![placement(1, true, true)]));
assert_eq!(b.focused(), Some(1));
let ev = b.close_surface(1);
assert_eq!(ev, Some(BodyEvent::WindowClosed { id: 1 }));
assert_eq!(b.focused(), None);
assert_eq!(b.surface_count(), 1);
}
#[test]
fn closing_an_unknown_surface_yields_nothing() {
let mut b = body_with_two();
assert!(b.close_surface(404).is_none());
}
#[test]
fn retitling_updates_the_surface() {
let mut b = body_with_two();
let ev = b.retitle_surface(1, "uno bis");
assert_eq!(ev, Some(BodyEvent::WindowRetitled { id: 1, title: "uno bis".into() }));
assert_eq!(b.surface(1).unwrap().title, "uno bis");
}
#[test]
fn outputs_are_tracked() {
let mut b = BodyState::new();
b.add_output(0, 2560, 1440);
b.add_output(1, 1920, 1080);
assert_eq!(b.outputs().len(), 2);
b.remove_output(0);
assert_eq!(b.outputs().len(), 1);
assert_eq!(b.outputs()[0].0, 1);
}
#[test]
fn moving_focus_emits_a_single_focus_op() {
let mut b = body_with_two();
b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, true, false)]));
// Cambia el foco a la 2; geometría igual → sólo un Focus.
let ops = b.apply(BrainCommand::Place(vec![
placement(1, true, false),
placement(2, true, true),
]));
assert_eq!(ops, vec![BodyOp::Focus(2)]);
}
#[test]
fn a_floating_change_alone_triggers_a_configure() {
let mut b = body_with_two();
let mut p1 = placement(1, true, true);
b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
// Sólo cambia `floating` — misma geometría y visibilidad.
p1.floating = true;
let ops = b.apply(BrainCommand::Place(vec![p1, placement(2, true, false)]));
assert!(ops
.iter()
.any(|o| matches!(o, BodyOp::Configure { id: 1, floating: true, .. })));
assert!(b.surface(1).unwrap().floating);
}
#[test]
fn visible_iterates_only_shown_surfaces() {
let mut b = body_with_two();
b.apply(BrainCommand::Place(vec![placement(1, true, true), placement(2, false, false)]));
let shown: Vec<_> = b.visible().map(|(id, _)| id).collect();
assert_eq!(shown, vec![1]);
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "mirada-brain"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — orquestador de escritorio del compositor: mantiene salidas, escritorios virtuales, ventanas y foco; consume BodyEvent y produce BrainCommand. Agnóstico de GPUI y de smithay."
[dependencies]
# `serde` activa los `derive` de los tipos de layout (este crate
# serializa el estado del escritorio a RON).
mirada-layout = { path = "../mirada-layout", features = ["serde"] }
mirada-protocol = { path = "../mirada-protocol" }
serde = { workspace = true }
ron = { workspace = true }
directories = { workspace = true }
notify = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
# mirada-brain
> Inteligencia del compositor (placement, focus) de [mirada](../README.md).
Reglas que deciden dónde se abre una ventana nueva, qué ventana recibe focus al cerrar otra, cómo se distribuye el espacio entre tiles. Reglas declarativas; permite scripting básico sin recompilar.
## Deps
- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
+9
View File
@@ -0,0 +1,9 @@
# mirada-brain
> Compositor intelligence (placement, focus) of [mirada](../README.md).
Rules that decide where a new window opens, which window gets focus when another closes, how space is distributed between tiles. Declarative rules; allows basic scripting without recompiling.
## Deps
- [`mirada-layout`](../mirada-layout/README.md), [`mirada-body`](../mirada-body/README.md)
@@ -0,0 +1,117 @@
//! Un Cerebro *headless* para probar el API de control sin gráficos.
//!
//! Abre el socket de `mirada-ctl`, arranca un [`Desktop`] con una pantalla
//! y unas ventanas de muestra, y atiende peticiones en bucle, imprimiendo
//! el estado tras cada una. Útil para ejercitar `mirada-ctl` en modo
//! desatendido.
//!
//! ```sh
//! cargo run -p mirada-brain --example headless-ctl # terminal 1
//! mirada-ctl windows # terminal 2
//! mirada-ctl focus-next
//! mirada-ctl focus-window 2
//! ```
use std::thread;
use std::time::Duration;
use mirada_brain::ctl::{self, CtlReply, CtlRequest, CtlServer};
use mirada_brain::{BodyEvent, BrainCommand, Desktop};
fn main() {
let path = ctl::default_socket_path();
let server = match CtlServer::bind(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("Cerebro headless · no pude abrir el control: {e}");
std::process::exit(1);
}
};
eprintln!("Cerebro headless · control en {}", path.display());
// Dos pantallas y tres ventanas de muestra.
let mut desktop = Desktop::new();
desktop.on_event(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 });
desktop.on_event(BodyEvent::OutputAdded { id: 1, width: 1920, height: 1080 });
for id in 1..=3 {
desktop.on_event(BodyEvent::WindowOpened {
id,
app_id: format!("org.brahman.app{id}"),
title: format!("ventana {id}"),
});
}
print_state(&desktop);
eprintln!(" esperando a mirada-ctl …");
loop {
if let Some(mut conn) = server.poll() {
if let Ok(Some(req)) = conn.read_request() {
let reply = match req {
CtlRequest::Do(action) => {
eprintln!("· {action}");
for cmd in desktop.apply(action) {
match cmd {
// La geometría que el Cerebro mandaría al Cuerpo.
BrainCommand::Place(places) => {
for p in places {
eprintln!(
" win {}{:>5}×{:<4} @ ({:>5},{:>4}){}{}",
p.id,
p.rect.w,
p.rect.h,
p.rect.x,
p.rect.y,
if p.fullscreen {
" ~pantalla"
} else if p.floating {
" ~flotante"
} else {
""
},
if p.focused { " *" } else { "" },
);
}
}
// Sin Cuerpo: simulamos nosotros el cierre.
BrainCommand::Close(id) | BrainCommand::Kill(id) => {
desktop.on_event(BodyEvent::WindowClosed { id });
}
_ => {}
}
}
print_state(&desktop);
CtlReply::Ok
}
CtlRequest::ListWindows => CtlReply::Windows(desktop.window_lines()),
// Las zonas son del Cuerpo (compositor); este ejemplo
// headless del Cerebro no las tiene.
CtlRequest::CycleZones => CtlReply::Ok,
};
let _ = conn.reply(&reply);
}
}
thread::sleep(Duration::from_millis(16));
}
}
fn print_state(d: &Desktop) {
let ws = d.active_workspace();
eprintln!(
" activo: escritorio {} · {:?} (maestra {:.0}%) · foco {:?}",
d.active_index() + 1,
ws.params().mode,
ws.params().master_ratio * 100.0,
d.focused_window(),
);
for (i, o) in d.outputs().iter().enumerate() {
let mark = if i == d.focused_output() { '*' } else { ' ' };
eprintln!(
" {mark} salida {} {}×{} @ x{} → escritorio {}",
o.id,
o.rect.w,
o.rect.h,
o.rect.x,
o.workspace + 1,
);
}
}
@@ -0,0 +1,11 @@
//! Imprime el keymap por defecto de mirada en format RON — exactamente
//! lo que la app escribe la primera vez en `~/.config/mirada/keymap.ron`.
//!
//! ```sh
//! cargo run -p mirada-brain --example keymap-default
//! cargo run -p mirada-brain --example keymap-default > ~/.config/mirada/keymap.ron
//! ```
fn main() {
print!("{}", mirada_brain::Keymap::default().documented_ron());
}
+454
View File
@@ -0,0 +1,454 @@
//! Acciones de escritorio y su mapa de teclas por defecto.
//!
//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
//! aplica sin saber qué combinación las disparó.
//!
//! Cada acción tiene una **forma textual** estable ([`Display`] /
//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que
//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]).
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use mirada_layout::{LayoutMode, WindowId};
/// Número de escritorios virtuales que mantiene el `Desktop`.
pub const WORKSPACE_COUNT: usize = 9;
/// Una dirección cardinal en pantalla — para el foco (y, a futuro, el
/// movimiento) espacial entre ventanas teseladas.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Direction {
Left,
Right,
Up,
Down,
}
impl Direction {
/// El sufijo textual de la dirección — `"left"`, `"right"`, …
fn slug(self) -> &'static str {
match self {
Direction::Left => "left",
Direction::Right => "right",
Direction::Up => "up",
Direction::Down => "down",
}
}
/// La dirección desde su sufijo, o `None` si no calza.
fn from_slug(s: &str) -> Option<Direction> {
Some(match s {
"left" => Direction::Left,
"right" => Direction::Right,
"up" => Direction::Up,
"down" => Direction::Down,
_ => return None,
})
}
}
/// Una orden de escritorio de alto nivel.
///
/// Es serializable (`postcard`) para viajar por el API de control
/// ([`crate::ctl`]) y tiene una forma textual estable ([`Display`] /
/// [`FromStr`]) para el keymap y `mirada-ctl`.
///
/// No es `Copy`: [`Spawn`](DesktopAction::Spawn) lleva su comando.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DesktopAction {
/// Mueve el foco a la ventana siguiente del escritorio activo.
FocusNext,
/// Mueve el foco a la ventana anterior.
FocusPrev,
/// Mueve el foco a la ventana teselada más cercana en una dirección
/// cardinal (espacial, no cíclico) — el `Super+flechas` clásico.
FocusDir(Direction),
/// Enfoca una ventana concreta por su id; si está en otro escritorio,
/// salta a él. Para clics de taskbar o `mirada-ctl focus-window`.
FocusWindow(WindowId),
/// Intercambia la ventana enfocada con su vecina teselada en una
/// dirección cardinal (mueve la ventana por geometría) — Super+Shift+flechas.
MoveDir(Direction),
/// Adelanta la ventana enfocada en el orden de teselado.
MoveForward,
/// Atrasa la ventana enfocada en el orden de teselado.
MoveBackward,
/// Cierra la ventana enfocada (cierre ordenado).
CloseFocused,
/// Alterna entre flotante y teselada la ventana enfocada.
ToggleFloat,
/// Alterna el escritorio entero entre teselado y flotante: si queda
/// alguna teselada, las hace flotar todas (en cascada); si ya están
/// todas flotando, las devuelve al teselado.
ToggleTiling,
/// Alterna pantalla completa en la ventana enfocada.
ToggleFullscreen,
/// Guarda la ventana enfocada en el scratchpad (la oculta).
SendToScratchpad,
/// Invoca u oculta la ventana del scratchpad — aparece flotando.
ToggleScratchpad,
/// Despliega/oculta la terminal dropdown estilo *quake* — un toplevel
/// real anclado arriba a todo el ancho, con foco de teclado normal. La
/// crea perezosamente la primera vez (patrón pypr).
ToggleDropterm,
/// Pasa al siguiente modo de teselado.
CycleLayout,
/// Fija un modo de teselado concreto.
SetLayout(LayoutMode),
/// Agranda el área de la ventana maestra (`MasterStack`/`CenteredMaster`).
GrowMaster,
/// Encoge el área de la ventana maestra.
ShrinkMaster,
/// Mete una ventana más en el área maestra (`nmaster`).
IncMaster,
/// Saca una ventana del área maestra.
DecMaster,
/// Lleva la ventana enfocada al puesto maestro (la inserta al frente,
/// desplazando al resto).
PromoteToMaster,
/// Intercambia la ventana enfocada con la maestra (sólo esas dos; el
/// resto del orden queda igual). El foco acompaña a la ventana.
SwapMaster,
/// Activa el escritorio virtual `n` (índice 0-based).
SwitchWorkspace(usize),
/// Manda la ventana enfocada al escritorio virtual `n` (queda donde está).
SendToWorkspace(usize),
/// Manda la ventana enfocada al escritorio `n` y salta con ella allí.
MoveToWorkspace(usize),
/// Mueve el foco a la siguiente salida (monitor).
FocusOutputNext,
/// Mueve el foco a la salida (monitor) vecina en una dirección cardinal.
FocusOutputDir(Direction),
/// Manda la ventana enfocada a la salida vecina en una dirección — pasa
/// al escritorio que muestra esa salida.
SendToOutputDir(Direction),
/// Redimensiona la ventana flotante enfocada hacia una dirección
/// (derecha/abajo agrandan; izquierda/arriba achican), por `float_step`
/// px. No hace nada sobre una teselada.
ResizeFloatDir(Direction),
/// Lanza un programa — abre una terminal, un navegador, lo que sea.
/// El comando se pasa a `sh -c` en el Cuerpo.
Spawn(String),
/// Apaga el compositor.
Quit,
}
/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
/// para identificadores: aquí van como valor de cadena, no de enum).
pub(crate) fn layout_slug(mode: LayoutMode) -> &'static str {
match mode {
LayoutMode::MasterStack => "master-stack",
LayoutMode::Monocle => "monocle",
LayoutMode::Grid => "grid",
LayoutMode::Columns => "columns",
LayoutMode::Rows => "rows",
LayoutMode::CenteredMaster => "centered-master",
LayoutMode::Spiral => "spiral",
}
}
/// Modo de teselado desde su `slug`.
pub(crate) fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
Some(match slug {
"master-stack" => LayoutMode::MasterStack,
"monocle" => LayoutMode::Monocle,
"grid" => LayoutMode::Grid,
"columns" => LayoutMode::Columns,
"rows" => LayoutMode::Rows,
"centered-master" => LayoutMode::CenteredMaster,
"spiral" => LayoutMode::Spiral,
_ => return None,
})
}
impl fmt::Display for DesktopAction {
/// La forma textual estable de la acción — el vocabulario del keymap.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DesktopAction::FocusNext => f.write_str("focus-next"),
DesktopAction::FocusPrev => f.write_str("focus-prev"),
DesktopAction::FocusDir(d) => write!(f, "focus-{}", d.slug()),
DesktopAction::MoveDir(d) => write!(f, "move-{}", d.slug()),
DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
DesktopAction::MoveForward => f.write_str("move-forward"),
DesktopAction::MoveBackward => f.write_str("move-backward"),
DesktopAction::CloseFocused => f.write_str("close-focused"),
DesktopAction::ToggleFloat => f.write_str("toggle-float"),
DesktopAction::ToggleTiling => f.write_str("toggle-tiling"),
DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
DesktopAction::SendToScratchpad => f.write_str("send-to-scratchpad"),
DesktopAction::ToggleScratchpad => f.write_str("toggle-scratchpad"),
DesktopAction::ToggleDropterm => f.write_str("toggle-dropterm"),
DesktopAction::CycleLayout => f.write_str("cycle-layout"),
DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
DesktopAction::GrowMaster => f.write_str("grow-master"),
DesktopAction::ShrinkMaster => f.write_str("shrink-master"),
DesktopAction::IncMaster => f.write_str("inc-master"),
DesktopAction::DecMaster => f.write_str("dec-master"),
DesktopAction::PromoteToMaster => f.write_str("promote-to-master"),
DesktopAction::SwapMaster => f.write_str("swap-master"),
// Los escritorios se numeran 1-based de cara al usuario.
DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
DesktopAction::MoveToWorkspace(n) => write!(f, "move-to-workspace:{}", n + 1),
DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
DesktopAction::FocusOutputDir(d) => write!(f, "focus-output-{}", d.slug()),
DesktopAction::SendToOutputDir(d) => write!(f, "send-to-output-{}", d.slug()),
DesktopAction::ResizeFloatDir(d) => write!(f, "resize-float-{}", d.slug()),
DesktopAction::Spawn(cmd) => write!(f, "spawn:{cmd}"),
DesktopAction::Quit => f.write_str("quit"),
}
}
}
impl FromStr for DesktopAction {
/// Mensaje de error ya formateado, listo para mostrar al usuario.
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let s = s.trim();
Ok(match s {
"focus-next" => Self::FocusNext,
"focus-prev" => Self::FocusPrev,
"move-forward" => Self::MoveForward,
"move-backward" => Self::MoveBackward,
"close-focused" => Self::CloseFocused,
"toggle-float" => Self::ToggleFloat,
"toggle-tiling" => Self::ToggleTiling,
"toggle-fullscreen" => Self::ToggleFullscreen,
"send-to-scratchpad" => Self::SendToScratchpad,
"toggle-scratchpad" => Self::ToggleScratchpad,
"toggle-dropterm" => Self::ToggleDropterm,
"cycle-layout" => Self::CycleLayout,
"grow-master" => Self::GrowMaster,
"shrink-master" => Self::ShrinkMaster,
"inc-master" => Self::IncMaster,
"dec-master" => Self::DecMaster,
"promote-to-master" => Self::PromoteToMaster,
"swap-master" => Self::SwapMaster,
"focus-output-next" => Self::FocusOutputNext,
"quit" => Self::Quit,
_ => {
if let Some(slug) = s.strip_prefix("layout:") {
Self::SetLayout(
layout_from_slug(slug)
.ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
)
} else if let Some(d) = s.strip_prefix("focus-").and_then(Direction::from_slug) {
Self::FocusDir(d)
} else if let Some(d) = s.strip_prefix("focus-output-").and_then(Direction::from_slug) {
Self::FocusOutputDir(d)
} else if let Some(d) = s.strip_prefix("send-to-output-").and_then(Direction::from_slug) {
Self::SendToOutputDir(d)
} else if let Some(d) = s.strip_prefix("resize-float-").and_then(Direction::from_slug) {
Self::ResizeFloatDir(d)
} else if let Some(d) = s.strip_prefix("move-").and_then(Direction::from_slug) {
Self::MoveDir(d)
} else if let Some(id) = s.strip_prefix("focus-window:") {
Self::FocusWindow(
id.trim()
.parse()
.map_err(|_| format!("id de ventana inválido: '{id}'"))?,
)
} else if let Some(n) = s.strip_prefix("send-to-workspace:") {
Self::SendToWorkspace(parse_workspace(n)?)
} else if let Some(n) = s.strip_prefix("move-to-workspace:") {
Self::MoveToWorkspace(parse_workspace(n)?)
} else if let Some(n) = s.strip_prefix("workspace:") {
Self::SwitchWorkspace(parse_workspace(n)?)
} else if let Some(cmd) = s.strip_prefix("spawn:") {
let cmd = cmd.trim();
if cmd.is_empty() {
return Err("spawn: necesita un comando".into());
}
Self::Spawn(cmd.to_string())
} else {
return Err(format!("acción desconocida: '{s}'"));
}
}
})
}
}
/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
/// acotado a [`WORKSPACE_COUNT`].
fn parse_workspace(s: &str) -> Result<usize, String> {
let n: usize = s
.trim()
.parse()
.map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
if (1..=WORKSPACE_COUNT).contains(&n) {
Ok(n - 1)
} else {
Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
}
}
/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
///
/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
/// también las que se registran con
/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
pub fn default_keymap() -> Vec<(String, DesktopAction)> {
let mut map = vec![
("Super+j".into(), DesktopAction::FocusNext),
("Super+k".into(), DesktopAction::FocusPrev),
// Foco espacial estilo i3/sway — el clásico Super+flechas.
("Super+Left".into(), DesktopAction::FocusDir(Direction::Left)),
("Super+Right".into(), DesktopAction::FocusDir(Direction::Right)),
("Super+Up".into(), DesktopAction::FocusDir(Direction::Up)),
("Super+Down".into(), DesktopAction::FocusDir(Direction::Down)),
// Mover la ventana enfocada por geometría — Super+Shift+flechas.
("Super+Shift+Left".into(), DesktopAction::MoveDir(Direction::Left)),
("Super+Shift+Right".into(), DesktopAction::MoveDir(Direction::Right)),
("Super+Shift+Up".into(), DesktopAction::MoveDir(Direction::Up)),
("Super+Shift+Down".into(), DesktopAction::MoveDir(Direction::Down)),
("Super+Shift+j".into(), DesktopAction::MoveForward),
("Super+Shift+k".into(), DesktopAction::MoveBackward),
("Super+q".into(), DesktopAction::CloseFocused),
("Super+f".into(), DesktopAction::ToggleFloat),
("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
// La tecla «quake» clásica baja la terminal dropdown.
("Super+`".into(), DesktopAction::ToggleDropterm),
// Scratchpad genérico: enviar la enfocada / invocar la guardada.
// (Shift+` produce «~» tras canonizar, así que ése es el combo.)
("Super+Shift+s".into(), DesktopAction::SendToScratchpad),
("Super+Shift+~".into(), DesktopAction::ToggleScratchpad),
("Super+space".into(), DesktopAction::CycleLayout),
("Super+Shift+space".into(), DesktopAction::ToggleTiling),
("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
("Super+r".into(), DesktopAction::SetLayout(LayoutMode::Rows)),
("Super+d".into(), DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
("Super+h".into(), DesktopAction::ShrinkMaster),
("Super+l".into(), DesktopAction::GrowMaster),
("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),
];
// Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
// `Super+Shift+1`.. mandan la ventana enfocada allí.
for n in 0..WORKSPACE_COUNT {
map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
map.push((
format!("Super+Shift+{}", n + 1),
DesktopAction::SendToWorkspace(n),
));
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keymap_has_no_duplicate_bindings() {
let map = default_keymap();
let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
keys.sort();
let unique = keys.len();
keys.dedup();
assert_eq!(keys.len(), unique, "hay un atajo repetido");
}
#[test]
fn keymap_covers_every_virtual_workspace() {
let map = default_keymap();
for n in 0..WORKSPACE_COUNT {
assert!(map
.iter()
.any(|(_, a)| a == &DesktopAction::SwitchWorkspace(n)));
assert!(map
.iter()
.any(|(_, a)| a == &DesktopAction::SendToWorkspace(n)));
}
}
#[test]
fn every_default_action_round_trips_through_its_text_form() {
for (_, action) in default_keymap() {
let text = action.to_string();
let back: DesktopAction = text.parse().unwrap();
assert_eq!(action, back, "no redondea: {text}");
}
}
#[test]
fn every_layout_mode_round_trips() {
for mode in LayoutMode::ALL {
let a = DesktopAction::SetLayout(mode);
assert_eq!(a, a.to_string().parse().unwrap());
}
}
#[test]
fn workspace_actions_are_one_based_in_text() {
assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
assert_eq!(
"workspace:1".parse::<DesktopAction>().unwrap(),
DesktopAction::SwitchWorkspace(0)
);
assert_eq!(
"send-to-workspace:9".parse::<DesktopAction>().unwrap(),
DesktopAction::SendToWorkspace(8)
);
}
#[test]
fn out_of_range_or_unknown_actions_are_rejected() {
assert!("workspace:0".parse::<DesktopAction>().is_err());
assert!("workspace:99".parse::<DesktopAction>().is_err());
assert!("layout:fractal".parse::<DesktopAction>().is_err());
assert!("focus-window:abc".parse::<DesktopAction>().is_err());
assert!("teleport".parse::<DesktopAction>().is_err());
}
#[test]
fn directional_actions_round_trip_in_every_direction() {
for d in [Direction::Left, Direction::Right, Direction::Up, Direction::Down] {
for a in [
DesktopAction::FocusDir(d),
DesktopAction::MoveDir(d),
DesktopAction::FocusOutputDir(d),
DesktopAction::SendToOutputDir(d),
DesktopAction::ResizeFloatDir(d),
] {
let text = a.to_string();
assert_eq!(text.parse::<DesktopAction>().unwrap(), a, "no redondea: {text}");
}
}
}
#[test]
fn focus_window_round_trips_with_its_id() {
let a = DesktopAction::FocusWindow(42);
assert_eq!(a.to_string(), "focus-window:42");
assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
}
#[test]
fn spawn_round_trips_keeping_the_whole_command() {
let a = DesktopAction::Spawn("foot --title diario".into());
assert_eq!(a.to_string(), "spawn:foot --title diario");
assert_eq!(a.to_string().parse::<DesktopAction>().unwrap(), a);
}
#[test]
fn spawn_without_a_command_is_rejected() {
assert!("spawn:".parse::<DesktopAction>().is_err());
assert!("spawn: ".parse::<DesktopAction>().is_err());
}
}
+843
View File
@@ -0,0 +1,843 @@
//! Config general del WM — los ajustes que no son atajos ([`crate::keymap`])
//! ni reglas de ventana ([`crate::rules`]): el comando de la terminal
//! dropdown, la geometría del cajón quake, los parámetros iniciales del
//! teselado y si el foco sigue al puntero.
//!
//! Mismo patrón que keymap/rules: RON de texto en
//! `~/.config/mirada/config.ron`, leído una vez al arrancar y aplicado al
//! [`Desktop`](crate::Desktop). Si el archivo no existe se escribe una
//! plantilla documentada y se usan los defaults; si está corrupto, se
//! avisa y se cae a los defaults.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use mirada_layout::{Disposicion, LayoutMode, LayoutParams, WallpaperFit};
use mirada_protocol::Decorations;
/// `app_id` con el que se marca y reconoce la terminal dropdown (quake).
/// El comando configurable [`Config::dropterm_cmd`] **debe** fijar este
/// `app_id` (con `kitty --class`, `foot --app-id`, etc.) o el Cerebro no
/// la reconocerá al abrirse.
pub const DROPTERM_APP_ID: &str = "mirada.dropterm";
/// El comando por defecto de la terminal dropdown. `kitty --class` fija el
/// `app_id` en Wayland, que es como se la reconoce.
const DEFAULT_DROPTERM_CMD: &str = "kitty --class mirada.dropterm";
/// (De)serializa un [`LayoutMode`] como su `slug` de cadena (`"grid"`,
/// `"master-stack"`, …), reusando el vocabulario de [`crate::action`].
mod layout_slug_serde {
use mirada_layout::LayoutMode;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(mode: &LayoutMode, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(crate::action::layout_slug(*mode))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<LayoutMode, D::Error> {
let slug = String::deserialize(d)?;
crate::action::layout_from_slug(&slug).ok_or_else(|| {
serde::de::Error::custom(format!(
"modo de teselado desconocido «{slug}» (usa master-stack, centered-master, \
spiral, grid, columns, rows o monocle)"
))
})
}
}
/// (De)serializa un [`WallpaperFit`] como su slug en kebab-case (`"stretch"`,
/// `"fit"`, `"fill"`, `"center"`, `"tile"`). El derive `serde` del propio
/// enum produce identificadores RON desnudos, incompatibles con la forma
/// quoteada que escribimos en la plantilla.
mod wallpaper_fit_slug_serde {
use mirada_layout::WallpaperFit;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(fit: &WallpaperFit, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(fit.slug())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<WallpaperFit, D::Error> {
let slug = String::deserialize(d)?;
WallpaperFit::from_slug(&slug).ok_or_else(|| {
serde::de::Error::custom(format!(
"modo de wallpaper desconocido «{slug}» (usa stretch, fit, fill, center o tile)"
))
})
}
}
/// Los ajustes del escritorio que el usuario puede configurar sin tocar el
/// keymap ni las reglas.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Comando que lanza la terminal dropdown (quake). Debe fijar el
/// `app_id` [`DROPTERM_APP_ID`] para que el Cerebro la reconozca.
pub dropterm_cmd: String,
/// Alto del cajón dropdown como porcentaje (`1..=100`) del alto de la
/// salida; baja anclado arriba a todo el ancho.
pub dropterm_height_pct: u32,
/// Modo de teselado inicial de cada escritorio. En RON va como cadena
/// con su `slug` (`"master-stack"`, `"grid"`, …): los guiones no son
/// identificadores válidos para un enum sin comillas.
#[serde(with = "layout_slug_serde")]
pub layout: LayoutMode,
/// Margen en píxeles alrededor de cada ventana teselada.
pub gap: i32,
/// Fracción del ancho de la ventana maestra (se acota a `0.05..=0.95`).
pub master_ratio: f32,
/// Cuántas ventanas van en el área maestra (`nmaster`; al menos 1).
pub master_count: usize,
/// Paso al agrandar/encoger el área maestra (`grow-master`/`shrink-master`).
/// Más chico = control más fino. Se acota al rango útil.
pub master_step: f32,
/// Paso en px al mover o redimensionar una ventana flotante por teclado.
pub float_step: i32,
/// El foco del teclado sigue al puntero, sin necesidad de click.
pub focus_follows_mouse: bool,
/// Grosor del marco de ventana en píxeles; `0` = sin marco.
pub border_width: i32,
/// Color RGBA (`0..=255`) del marco de la ventana enfocada.
pub border_focus: [u8; 4],
/// Color RGBA (`0..=255`) del marco de las ventanas sin foco.
pub border_normal: [u8; 4],
/// Alto de la barra de título en px; `0` = sin barra (sólo el título de la
/// ventana enfocada superpuesto). Se reserva arriba de cada ventana.
pub titlebar_height: i32,
/// Ruta a la fuente para las etiquetas del compositor (título, menú).
/// Vacía = se prueba una lista de fuentes comunes del sistema.
pub font_path: String,
/// Ruta a la imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía =
/// color sólido. Su colocación dentro de la salida la dicta
/// [`Self::wallpaper_fit`].
pub wallpaper_path: String,
/// Cómo se ajusta el wallpaper a la salida: `stretch` (deforma para cubrir),
/// `fit` (entra entero con barras), `fill` (cubre y recorta), `center`
/// (tamaño nativo centrado) o `tile` (repetido). En RON va como cadena
/// kebab-case: `"stretch"`, `"fit"`, `"fill"`, `"center"`, `"tile"`.
#[serde(with = "wallpaper_fit_slug_serde")]
pub wallpaper_fit: WallpaperFit,
/// Entradas del menú raíz (estilo openbox) que aparece al click derecho
/// sobre el fondo. Vacío = sin menú (el click derecho en el fondo no hace
/// nada). Cada entrada lanza su `command` con `sh -c`.
pub menu: Vec<MenuEntry>,
/// Zonas de la pantalla (fracciones `0..=1`): **blancos de arrastre**.
/// Al arrastrar una ventana sobre una zona, el compositor la resalta; al
/// soltarla encima, la ancla a ese rect (flotante). Soltarla fuera de toda
/// zona la deja flotando donde cae (overflow). Vacío = sin zonas. Es el
/// primer preset; `mirada-ctl cycle-zones` cicla a los de [`Self::zone_presets`].
pub zones: Vec<ZoneCfg>,
/// Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindeable a un
/// atajo) cicla `zones → preset 0 → preset 1 → … → zones`. Cada preset es
/// una lista de zonas como [`Self::zones`].
pub zone_presets: Vec<Vec<ZoneCfg>>,
/// Cómo se reparten los monitores en el escritorio global cuando hay más
/// de uno: `"horizontal"` (uno al lado del otro, default) o `"vertical"`
/// (uno encima del otro). El orden lo dicta [`OutputOverride::order`].
/// Mismo vocabulario que [`mirada_layout::Disposicion`].
pub output_direction: String,
/// Overrides por salida (monitor). Cada entrada se identifica por el
/// `name` del conector DRM (`HDMI-A-1`, `DP-1`, …) y puede sobreescribir
/// el wallpaper, su modo de ajuste y el orden de la salida en el
/// escritorio compuesto. Lo que no se indique cae al valor global.
/// Vacío = orden de discovery, wallpaper global para todas.
pub outputs: Vec<OutputOverride>,
}
/// Ajustes específicos de una salida (monitor) — se aplican sólo a la salida
/// cuyo nombre coincide. Hoy alcanzan el fondo del escritorio: imagen y modo
/// de ajuste. Lo que se deja vacío (`""`) cae al valor global.
///
/// El `name` es el nombre del conector como lo reporta el backend DRM en sus
/// logs de arranque: `HDMI-A-1`, `DP-1`, `eDP-1`, … (mayúsculas y guiones).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutputOverride {
/// Nombre del conector DRM al que se aplica este override.
pub name: String,
/// Wallpaper específico de esta salida. Vacío = usa el global.
#[serde(default)]
pub wallpaper_path: String,
/// Ajuste del wallpaper específico para esta salida. Vacío = usa el
/// global. Mismo vocabulario que [`Config::wallpaper_fit`] (`"stretch"`,
/// `"fit"`, `"fill"`, `"center"`, `"tile"`). Se guarda como slug en vez
/// de [`WallpaperFit`] para que en RON quepa una cadena desnuda y
/// `""` sirva como ausente — el `Option<WallpaperFit>` exigiría
/// `Some("fill")` / `None`, ruido innecesario en la config.
#[serde(default)]
pub wallpaper_fit: String,
/// Orden de esta salida en el escritorio compuesto: las salidas se
/// disponen ordenadas crecientemente por `(order, name)`. La de menor
/// `order` queda **primaria** (origen `(0, 0)`). Default `0` — entonces
/// el desempate por `name` da un orden estable, predecible y reproducible
/// (sin override, todas son `0` y mandan los nombres alfabéticamente).
#[serde(default)]
pub order: i32,
/// Escala HiDPI en 120-avos: `120` = 100 %, `180` = 150 %, `240` = 200 %.
/// Misma convención que `wp_fractional_scale` de Wayland y que
/// [`mirada_layout::ESCALA_100`]. Vale `0` (default) = sin override → la
/// salida se anuncia a 100 % nativo. Valores `<= 0` se ignoran.
#[serde(default)]
pub scale_120: u32,
/// Rotación / espejado del scanout. Slugs: `"normal"` (default si vacío),
/// `"90"`, `"180"`, `"270"`, `"flipped"`, `"flipped-90"`, `"flipped-180"`,
/// `"flipped-270"`. Validado al cargar la config (`from_ron`); el
/// compositor lo parsea a su `Transform` al usar.
#[serde(default)]
pub transform: String,
}
/// Parsea el slug de [`Config::output_direction`] a [`Disposicion`].
fn parse_disposition(slug: &str) -> Option<Disposicion> {
match slug {
"horizontal" => Some(Disposicion::Horizontal),
"vertical" => Some(Disposicion::Vertical),
_ => None,
}
}
/// Slugs válidos para [`OutputOverride::transform`]. Mismo orden que la enum
/// `Transform` de smithay (Normal / 90 / 180 / 270 / Flipped / Flipped90 /
/// Flipped180 / Flipped270). El consumidor (drm_backend) hace el match a su
/// tipo; acá sólo validamos.
pub const TRANSFORM_SLUGS: &[&str] = &[
"normal",
"90",
"180",
"270",
"flipped",
"flipped-90",
"flipped-180",
"flipped-270",
];
/// `true` si `slug` es un valor reconocido de [`OutputOverride::transform`].
/// Vacío (`""`) cuenta como ausente y es válido — significa «sin override».
pub fn is_valid_transform_slug(slug: &str) -> bool {
slug.is_empty() || TRANSFORM_SLUGS.contains(&slug)
}
impl OutputOverride {
/// El `wallpaper_fit` parseado, si la cadena no está vacía. `None` =
/// no se setea (el llamante debe caer al global). `Err` si la cadena
/// trae un slug desconocido — se propaga al cargar la config.
fn parsed_wallpaper_fit(&self) -> Result<Option<WallpaperFit>, String> {
if self.wallpaper_fit.is_empty() {
return Ok(None);
}
WallpaperFit::from_slug(&self.wallpaper_fit)
.map(Some)
.ok_or_else(|| {
format!(
"modo de wallpaper desconocido «{}» en outputs[name=\"{}\"] (usa stretch, fit, fill, center o tile)",
self.wallpaper_fit, self.name
)
})
}
}
/// Una zona: `(x, y, w, h)` en fracciones `0..=1` de la pantalla. El `name` es
/// opcional, sólo una etiqueta para tu propia referencia (no se pinta).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ZoneCfg {
#[serde(default)]
pub name: String,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// Una entrada del menú raíz. Es una **hoja** que lanza `command`, o un
/// **submenú** si trae `submenu` no vacío (en ese caso `command` se ignora).
/// La forma plana `(label, command)` sigue siendo válida: `submenu` default
/// vacío. Anidan a cualquier profundidad.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MenuEntry {
pub label: String,
/// Comando a lanzar (`sh -c`) si es hoja. Ignorado si hay `submenu`.
#[serde(default)]
pub command: String,
/// Entradas hijas; no vacío = esta entrada es un submenú.
#[serde(default)]
pub submenu: Vec<MenuEntry>,
}
impl Default for Config {
fn default() -> Self {
let lp = LayoutParams::default();
let dec = Decorations::default();
Self {
dropterm_cmd: DEFAULT_DROPTERM_CMD.to_string(),
dropterm_height_pct: 45,
layout: lp.mode,
gap: lp.gap,
master_ratio: lp.master_ratio,
master_count: lp.master_count,
master_step: 0.05,
float_step: 40,
focus_follows_mouse: true,
border_width: dec.border_width,
border_focus: dec.border_focus,
border_normal: dec.border_normal,
titlebar_height: dec.titlebar_height,
font_path: String::new(),
wallpaper_path: String::new(),
wallpaper_fit: WallpaperFit::default(),
menu: Vec::new(),
zones: Vec::new(),
zone_presets: Vec::new(),
output_direction: "horizontal".to_string(),
outputs: Vec::new(),
}
}
}
impl Config {
/// El paso del área maestra, acotado a un rango útil (`0.01..=0.5`).
pub fn master_step(&self) -> f32 {
self.master_step.clamp(0.01, 0.5)
}
/// El paso en px para mover/redimensionar flotantes, al menos `1`.
pub fn float_step(&self) -> i32 {
self.float_step.max(1)
}
/// El alto del dropdown acotado a `1..=100`, listo para multiplicar.
pub fn dropterm_height_pct(&self) -> i32 {
self.dropterm_height_pct.clamp(1, 100) as i32
}
/// Los parámetros de decoración que derivan de la config (marco, …),
/// con el grosor acotado a `>= 0`.
pub fn decorations(&self) -> Decorations {
Decorations {
border_width: self.border_width.max(0),
border_focus: self.border_focus,
border_normal: self.border_normal,
titlebar_height: self.titlebar_height.max(0),
}
}
/// La dirección de disposición de las salidas en el escritorio compuesto.
/// Default `Horizontal` si el slug no se reconoce — el chequeo duro se
/// hace al cargar la config (ver [`Self::from_ron`]).
pub fn output_disposition(&self) -> Disposicion {
parse_disposition(&self.output_direction).unwrap_or(Disposicion::Horizontal)
}
/// El `order` configurado para la salida `name` — `0` si no hay override.
pub fn output_order_for(&self, name: &str) -> i32 {
self.outputs
.iter()
.find(|o| o.name == name)
.map(|o| o.order)
.unwrap_or(0)
}
/// Escala HiDPI en 120-avos a usar para la salida `name`: el override si
/// existe y es positivo; si no, `120` (100 % nativo, [`mirada_layout::ESCALA_100`]).
pub fn output_scale_120_for(&self, name: &str) -> u32 {
for o in &self.outputs {
if o.name == name && o.scale_120 > 0 {
return o.scale_120;
}
}
mirada_layout::ESCALA_100 as u32
}
/// Slug de transformación a usar para la salida `name`: el override si
/// existe y es no vacío; si no, `"normal"`. Vocabulario en
/// [`TRANSFORM_SLUGS`]. Un slug inválido se ignora silenciosamente —
/// el chequeo duro se hace al cargar la config (ver [`Self::from_ron`]).
pub fn output_transform_for(&self, name: &str) -> &str {
for o in &self.outputs {
if o.name == name && is_valid_transform_slug(&o.transform) && !o.transform.is_empty() {
return &o.transform;
}
}
"normal"
}
/// La ruta del wallpaper a usar para la salida `name`. Si hay un override
/// en [`Self::outputs`] con `wallpaper_path` no vacío para esa salida, se
/// usa esa; si no, cae al global [`Self::wallpaper_path`]. Vacía = fondo
/// de color sólido.
pub fn wallpaper_path_for(&self, name: &str) -> &str {
for o in &self.outputs {
if o.name == name && !o.wallpaper_path.is_empty() {
return &o.wallpaper_path;
}
}
&self.wallpaper_path
}
/// El modo de ajuste del wallpaper para la salida `name`. Si hay un
/// override en [`Self::outputs`] con `wallpaper_fit` no vacío para esa
/// salida, se usa ese; si no, cae al global [`Self::wallpaper_fit`].
/// Un slug inválido en el override se ignora silenciosamente — el chequeo
/// duro se hace al cargar la config (ver [`Self::from_ron`]).
pub fn wallpaper_fit_for(&self, name: &str) -> WallpaperFit {
for o in &self.outputs {
if o.name == name {
if let Ok(Some(f)) = o.parsed_wallpaper_fit() {
return f;
}
}
}
self.wallpaper_fit
}
/// Los parámetros de teselado iniciales que derivan de la config, ya
/// acotados — lo que se le da a cada escritorio al arrancar.
pub fn layout_params(&self) -> LayoutParams {
LayoutParams {
mode: self.layout,
master_ratio: self.master_ratio.clamp(0.05, 0.95),
master_count: self.master_count.max(1),
gap: self.gap.max(0),
}
}
/// Parsea la config desde el texto RON de un archivo. Valida también que
/// los slugs de overrides sean conocidos —`wallpaper_fit` de cada
/// [`OutputOverride`] y el [`Self::output_direction`] global— para que un
/// typo (ej. `"marciano"`) se rechace acá con un mensaje claro, en vez
/// de ignorarse en silencio al pintar.
pub fn from_ron(text: &str) -> Result<Config, String> {
let cfg: Config = ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))?;
for o in &cfg.outputs {
o.parsed_wallpaper_fit()?;
if !is_valid_transform_slug(&o.transform) {
return Err(format!(
"transform desconocido «{}» en outputs[name=\"{}\"] (usa {})",
o.transform,
o.name,
TRANSFORM_SLUGS.join(", ")
));
}
}
if parse_disposition(&cfg.output_direction).is_none() {
return Err(format!(
"output_direction desconocido «{}» (usa horizontal o vertical)",
cfg.output_direction
));
}
Ok(cfg)
}
/// La ruta canónica de la config: `~/.config/mirada/config.ron`.
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "mirada")
.map(|d| d.config_dir().join("config.ron"))
}
/// Carga la config de un archivo RON.
pub fn load(path: &Path) -> Result<Config, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
Config::from_ron(&text)
}
/// Vigila el archivo de config para recargarlo en caliente.
pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
crate::watch::FileWatch::new(path)
}
/// Carga la config del usuario con un fallback amable: si el archivo no
/// existe, escribe una plantilla documentada y devuelve los defaults; si
/// está corrupto, avisa y devuelve los defaults.
pub fn load_or_default(path: &Path) -> Config {
if path.exists() {
match Config::load(path) {
Ok(c) => c,
Err(e) => {
eprintln!(
"mirada · config «{}» inválida ({e}); uso los valores por defecto.",
path.display()
);
Config::default()
}
}
} else {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(path, CONFIG_TEMPLATE) {
Ok(()) => eprintln!("mirada · plantilla de config escrita en {}", path.display()),
Err(e) => eprintln!("mirada · no pude escribir la plantilla de config: {e}"),
}
Config::default()
}
}
}
/// La plantilla que se escribe la primera vez: los defaults explícitos, con
/// comentarios. Editarla cambia el comportamiento al reiniciar mirada.
const CONFIG_TEMPLATE: &str = "\
// Config de mirada — ajustes del escritorio que no son atajos (keymap.ron)
// ni reglas de ventana (rules.ron). Reinicia mirada para aplicar cambios.
(
// Comando de la terminal dropdown (quake), que Super+grave despliega.
// DEBE fijar el app_id `mirada.dropterm` para que mirada la reconozca:
// kitty --class mirada.dropterm · foot --app-id mirada.dropterm
dropterm_cmd: \"kitty --class mirada.dropterm\",
// Alto del cajón dropdown, en % del alto de pantalla (baja desde arriba).
dropterm_height_pct: 45,
// Teselado inicial de cada escritorio.
// master-stack · centered-master · spiral · grid · columns · rows · monocle
layout: \"master-stack\",
gap: 8, // margen en px alrededor de cada ventana
master_ratio: 0.6, // fracción de ancho de la ventana maestra
master_count: 1, // cuántas ventanas en el área maestra
master_step: 0.05, // paso de grow/shrink-master (más chico = más fino)
float_step: 40, // paso en px para mover/redimensionar flotantes por teclado
// El foco del teclado sigue al puntero (sin click). false = foco al clickear.
focus_follows_mouse: true,
// Marco de ventana. Colores RGBA en 0..=255; border_width: 0 = sin marco.
border_width: 2,
border_focus: (92, 143, 235, 255), // azul al foco
border_normal: (56, 56, 69, 255), // gris discreto sin foco
// Barra de título sobre cada ventana (px). 0 = sin barra (sólo el título
// de la ventana enfocada, superpuesto). La franja se reserva arriba.
titlebar_height: 24,
// Fuente para las etiquetas (título, menú). Vacía = se prueba una lista
// de fuentes comunes del sistema (Liberation, DejaVu, Noto, Adwaita…).
font_path: \"\",
// Imagen de fondo del escritorio (PNG/JPEG/WebP). Vacía = color sólido.
// Ej: \"/home/yo/.config/mirada/fondo.png\".
wallpaper_path: \"\",
// Cómo encaja la imagen en la salida:
// stretch — deforma para cubrir exactamente (default).
// fit — la imagen entra entera, con barras negras (letterbox).
// fill — la imagen cubre la salida, los bordes se recortan.
// center — tamaño nativo centrado (padding negro o recorte si es grande).
// tile — repetida en su tamaño nativo desde la esquina superior-izquierda.
wallpaper_fit: \"stretch\",
// Menú raíz (estilo openbox): aparece al click DERECHO sobre el fondo.
// Vacío = sin menú. Una entrada es hoja (lanza command con `sh -c`) o
// submenú (si trae `submenu`, anidable a cualquier profundidad). Ej:
// menu: [
// (label: \"Terminal\", command: \"kitty\"),
// (label: \"Apps\", submenu: [
// (label: \"Navegador\", command: \"firefox\"),
// (label: \"Editores\", submenu: [
// (label: \"nada\", command: \"nada\"),
// ]),
// ]),
// ],
menu: [],
// Zonas: blancos de arrastre (fracciones 0..=1 de la pantalla). Al arrastrar
// una ventana sobre una zona se resalta; al soltarla encima, aterriza en ese
// rect; soltarla fuera la deja flotando donde cae (overflow). El `name` es
// opcional (sólo tu referencia). Vacío = sin zonas. Ej (media/cuartos):
// zones: [
// (x: 0.0, y: 0.0, w: 0.5, h: 1.0),
// (x: 0.5, y: 0.0, w: 0.5, h: 0.5),
// (x: 0.5, y: 0.5, w: 0.5, h: 0.5),
// ],
zones: [],
// Presets adicionales de zonas. `mirada-ctl cycle-zones` (bindealo a un
// atajo) cicla zones → preset 0 → preset 1 → … → zones. Ej:
// zone_presets: [
// [ (x: 0.0, y: 0.0, w: 0.5, h: 1.0), (x: 0.5, y: 0.0, w: 0.5, h: 1.0) ],
// [ (x: 0.0, y: 0.0, w: 1.0, h: 1.0) ],
// ],
zone_presets: [],
// Cómo se reparten los monitores en el escritorio global cuando hay más
// de uno: \"horizontal\" (uno al lado del otro) o \"vertical\" (encima).
output_direction: \"horizontal\",
// Overrides por salida (monitor). Cada entrada identifica el conector
// DRM por su `name` (ej. \"HDMI-A-1\", \"DP-1\", \"eDP-1\"; sale en los
// logs de arranque del compositor). Sobreescribe wallpaper + orden +
// escala HiDPI + transformación de la salida. Lo que se deja vacío
// cae al global. La salida con `order` más chico queda primaria
// (origen 0,0). `scale_120` en 120-avos (120=100%, 180=150%, 240=200%).
// `transform`: normal / 90 / 180 / 270 / flipped / flipped-90 /
// flipped-180 / flipped-270. Vacío = orden alfabético, sin overrides. Ej:
// outputs: [
// (name: \"DP-1\", order: 0, scale_120: 240,
// wallpaper_path: \"/home/yo/fondos/code.png\",
// wallpaper_fit: \"fill\"),
// (name: \"HDMI-A-1\", order: 1, transform: \"90\",
// wallpaper_path: \"/home/yo/fondos/sala.png\"),
// ],
outputs: [],
)
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_parses_to_the_defaults() {
assert_eq!(Config::from_ron(CONFIG_TEMPLATE).unwrap(), Config::default());
}
#[test]
fn omitted_fields_fall_back_to_defaults() {
let c = Config::from_ron("( gap: 20 )").unwrap();
assert_eq!(c.gap, 20);
// El resto queda en su default.
assert_eq!(c.dropterm_cmd, Config::default().dropterm_cmd);
assert!(c.focus_follows_mouse);
}
#[test]
fn layout_params_clamp_out_of_range_values() {
let c = Config::from_ron("( master_ratio: 2.0, master_count: 0, gap: -5 )").unwrap();
let lp = c.layout_params();
assert_eq!(lp.master_ratio, 0.95);
assert_eq!(lp.master_count, 1);
assert_eq!(lp.gap, 0);
}
#[test]
fn dropterm_height_is_clamped() {
let c = Config::from_ron("( dropterm_height_pct: 250 )").unwrap();
assert_eq!(c.dropterm_height_pct(), 100);
let c = Config::from_ron("( dropterm_height_pct: 0 )").unwrap();
assert_eq!(c.dropterm_height_pct(), 1);
}
#[test]
fn decorations_derive_from_the_config_and_clamp_width() {
let c = Config::from_ron(
"( border_width: -3, border_focus: (10, 20, 30, 255), border_normal: (1, 2, 3, 4) )",
)
.unwrap();
let d = c.decorations();
assert_eq!(d.border_width, 0); // acotado a >= 0
assert_eq!(d.border_focus, [10, 20, 30, 255]);
assert_eq!(d.border_normal, [1, 2, 3, 4]);
}
#[test]
fn default_decorations_match_the_protocol_default() {
assert_eq!(Config::default().decorations(), Decorations::default());
}
#[test]
fn the_layout_mode_parses_from_its_slug_string() {
let c = Config::from_ron(r#"( layout: "centered-master" )"#).unwrap();
assert_eq!(c.layout, LayoutMode::CenteredMaster);
}
#[test]
fn an_unknown_layout_slug_is_rejected() {
assert!(Config::from_ron(r#"( layout: "tetris" )"#).is_err());
}
#[test]
fn menu_flat_entries_parse_without_submenu() {
let c = Config::from_ron(
r#"( menu: [(label: "Terminal", command: "kitty")] )"#,
)
.unwrap();
assert_eq!(c.menu.len(), 1);
assert_eq!(c.menu[0].label, "Terminal");
assert_eq!(c.menu[0].command, "kitty");
assert!(c.menu[0].submenu.is_empty());
}
#[test]
fn zones_parsean_con_nombre_opcional() {
let c = Config::from_ron(
r#"( zones: [
(x: 0.0, y: 0.0, w: 0.6, h: 1.0),
(name: "chat", x: 0.6, y: 0.0, w: 0.4, h: 1.0),
] )"#,
)
.unwrap();
assert_eq!(c.zones.len(), 2);
assert_eq!(c.zones[0].name, ""); // name es opcional
assert!((c.zones[0].w - 0.6).abs() < 1e-6);
assert_eq!(c.zones[1].name, "chat");
}
#[test]
fn wallpaper_fit_parsea_de_su_slug() {
for (slug, want) in [
("stretch", WallpaperFit::Stretch),
("fit", WallpaperFit::Fit),
("fill", WallpaperFit::Fill),
("center", WallpaperFit::Center),
("tile", WallpaperFit::Tile),
] {
let c = Config::from_ron(&format!(r#"( wallpaper_fit: "{slug}" )"#)).unwrap();
assert_eq!(c.wallpaper_fit, want);
}
}
#[test]
fn wallpaper_fit_default_es_stretch() {
assert_eq!(Config::default().wallpaper_fit, WallpaperFit::Stretch);
}
#[test]
fn output_override_aplica_su_wallpaper_solo_al_monitor_nombrado() {
let c = Config::from_ron(
r#"( wallpaper_path: "global.png", outputs: [
(name: "HDMI-A-1", wallpaper_path: "sala.png"),
(name: "DP-1", wallpaper_path: "code.png", wallpaper_fit: "fill"),
] )"#,
)
.unwrap();
assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "sala.png");
assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Stretch);
assert_eq!(c.wallpaper_path_for("DP-1"), "code.png");
assert_eq!(c.wallpaper_fit_for("DP-1"), WallpaperFit::Fill);
// Salida sin override cae al global.
assert_eq!(c.wallpaper_path_for("eDP-1"), "global.png");
assert_eq!(c.wallpaper_fit_for("eDP-1"), WallpaperFit::Stretch);
}
#[test]
fn output_override_con_path_vacio_no_pisa_al_global() {
// Un override con sólo `name` (path vacío) deja el wallpaper en el
// global — útil si sólo se quiere cambiar el `fit` del monitor.
let c = Config::from_ron(
r#"( wallpaper_path: "global.png", outputs: [
(name: "HDMI-A-1", wallpaper_fit: "fit"),
] )"#,
)
.unwrap();
assert_eq!(c.wallpaper_path_for("HDMI-A-1"), "global.png");
assert_eq!(c.wallpaper_fit_for("HDMI-A-1"), WallpaperFit::Fit);
}
#[test]
fn output_override_rechaza_fit_desconocido() {
let err = Config::from_ron(
r#"( outputs: [ (name: "DP-1", wallpaper_fit: "marciano") ] )"#,
)
.unwrap_err();
assert!(err.contains("marciano"), "mensaje útil: {err}");
}
#[test]
fn output_direction_default_es_horizontal() {
let c = Config::default();
assert_eq!(c.output_disposition(), Disposicion::Horizontal);
}
#[test]
fn output_direction_parsea_vertical_y_horizontal() {
let c = Config::from_ron(r#"( output_direction: "vertical" )"#).unwrap();
assert_eq!(c.output_disposition(), Disposicion::Vertical);
let c = Config::from_ron(r#"( output_direction: "horizontal" )"#).unwrap();
assert_eq!(c.output_disposition(), Disposicion::Horizontal);
}
#[test]
fn output_direction_desconocido_es_rechazado() {
let err = Config::from_ron(r#"( output_direction: "diagonal" )"#).unwrap_err();
assert!(err.contains("diagonal"), "mensaje útil: {err}");
}
#[test]
fn output_order_for_cae_a_cero_sin_override() {
let c = Config::default();
assert_eq!(c.output_order_for("HDMI-A-1"), 0);
}
#[test]
fn output_scale_120_default_es_100_pct_si_no_hay_override() {
let c = Config::default();
assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
}
#[test]
fn output_scale_120_lee_el_override() {
let c = Config::from_ron(
r#"( outputs: [
(name: "DP-1", scale_120: 240),
(name: "HDMI-A-1", scale_120: 0),
] )"#,
)
.unwrap();
assert_eq!(c.output_scale_120_for("DP-1"), 240);
// `scale_120: 0` cuenta como sin override → 100 %.
assert_eq!(c.output_scale_120_for("HDMI-A-1"), 120);
// Salida sin entrada → 100 %.
assert_eq!(c.output_scale_120_for("eDP-1"), 120);
}
#[test]
fn output_transform_default_es_normal() {
let c = Config::default();
assert_eq!(c.output_transform_for("HDMI-A-1"), "normal");
}
#[test]
fn output_transform_lee_el_override() {
let c = Config::from_ron(
r#"( outputs: [
(name: "HDMI-A-1", transform: "90"),
(name: "DP-1", transform: "flipped-180"),
] )"#,
)
.unwrap();
assert_eq!(c.output_transform_for("HDMI-A-1"), "90");
assert_eq!(c.output_transform_for("DP-1"), "flipped-180");
assert_eq!(c.output_transform_for("eDP-1"), "normal");
}
#[test]
fn output_transform_desconocido_es_rechazado() {
let err = Config::from_ron(
r#"( outputs: [ (name: "DP-1", transform: "diagonal") ] )"#,
)
.unwrap_err();
assert!(err.contains("diagonal"), "mensaje útil: {err}");
}
#[test]
fn output_order_for_lee_el_override() {
let c = Config::from_ron(
r#"( outputs: [
(name: "DP-1", order: 0),
(name: "HDMI-A-1", order: 5),
] )"#,
)
.unwrap();
assert_eq!(c.output_order_for("HDMI-A-1"), 5);
assert_eq!(c.output_order_for("DP-1"), 0);
// Sin entrada cae a 0.
assert_eq!(c.output_order_for("eDP-1"), 0);
}
#[test]
fn menu_nested_submenus_parse() {
let c = Config::from_ron(
r#"( menu: [
(label: "Apps", submenu: [
(label: "Navegador", command: "firefox"),
(label: "Más", submenu: [
(label: "nada", command: "nada"),
]),
]),
] )"#,
)
.unwrap();
assert_eq!(c.menu.len(), 1);
let apps = &c.menu[0];
assert_eq!(apps.label, "Apps");
assert_eq!(apps.submenu.len(), 2);
assert_eq!(apps.submenu[0].command, "firefox");
assert_eq!(apps.submenu[1].submenu[0].label, "nada");
}
}
+229
View File
@@ -0,0 +1,229 @@
//! `ctl` — el API de control externo del Cerebro.
//!
//! Mientras el keymap ([`crate::keymap`]) es la cara *configurable* de las
//! acciones, este módulo es su cara *programable*: deja que otro proceso
//! —un script, una taskbar, el binario `mirada-ctl`— dispare una
//! [`DesktopAction`] o consulte el estado, sin tocar el teclado.
//!
//! Todo converge igualmente en `Desktop::apply`: una petición de control
//! no es más que otro front-end del mismo embudo. El transporte es un
//! socket Unix de petición/respuesta, con el marco `postcard` que ya usa
//! [`mirada_protocol`]; `DesktopAction` viaja como enum serializado (no
//! como cadena), así que el contrato es tipado de punta a punta.
//!
//! - El Cerebro abre un [`CtlServer`] y atiende [`CtlConn`]s en su bucle.
//! - El cliente usa [`send_request`] — una petición, una respuesta, cierra.
use std::io::{self, ErrorKind};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use mirada_layout::WindowId;
use mirada_protocol::{read_frame, write_frame};
use crate::action::DesktopAction;
/// Una orden de un cliente de control al Cerebro.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CtlRequest {
/// Aplica una acción de escritorio — el equivalente a pulsar su atajo.
Do(DesktopAction),
/// Pide la lista de ventanas conocidas, en todos los escritorios.
ListWindows,
/// Cicla al siguiente conjunto de zonas de arrastre (presets de
/// `config.ron`). Lo atiende el Cuerpo (las zonas son suyas), no el Cerebro.
CycleZones,
}
/// La respuesta del Cerebro a un [`CtlRequest`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CtlReply {
/// La orden se aplicó.
Ok,
/// La orden no se pudo aplicar; el motivo, para mostrar al usuario.
Error(String),
/// La lista pedida con [`CtlRequest::ListWindows`].
Windows(Vec<WindowLine>),
}
/// Una ventana en la vista de `mirada-ctl windows`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowLine {
/// Id de la ventana — el que se pasa a `focus-window:N`.
pub id: WindowId,
pub app_id: String,
pub title: String,
/// Escritorio virtual donde está (1-based); `0` = guardada en el
/// scratchpad, en ningún escritorio.
pub workspace: usize,
/// `true` si es la ventana enfocada del escritorio activo.
pub focused: bool,
}
/// La ruta del socket de control: `$XDG_RUNTIME_DIR/mirada-ctl.sock`, o
/// el directorio temporal si esa variable no está.
pub fn default_socket_path() -> PathBuf {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
dir.join("mirada-ctl.sock")
}
/// El extremo servidor del API de control — lo abre el dueño del
/// [`Desktop`](crate::Desktop) (la app `mirada`, o `mirada-compositor`
/// con el Cerebro embebido).
pub struct CtlServer {
listener: UnixListener,
path: PathBuf,
}
impl CtlServer {
/// Abre el socket de control en `path`. Si ya hay un Cerebro vivo
/// escuchando ahí, falla; si encuentra un socket muerto (de un
/// compositor anterior), lo retira y se queda con él.
pub fn bind(path: &Path) -> io::Result<Self> {
if path.exists() {
if UnixStream::connect(path).is_ok() {
return Err(io::Error::new(
ErrorKind::AddrInUse,
"ya hay un Cerebro escuchando en el socket de control",
));
}
let _ = std::fs::remove_file(path);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(path)?;
listener.set_nonblocking(true)?;
Ok(Self { listener, path: path.to_path_buf() })
}
/// Acepta una conexión pendiente sin bloquear. `None` si no hay
/// ninguna — pensado para llamarse cada vuelta del bucle de eventos.
pub fn poll(&self) -> Option<CtlConn> {
match self.listener.accept() {
Ok((stream, _)) => Some(CtlConn { stream }),
Err(_) => None,
}
}
}
impl Drop for CtlServer {
fn drop(&mut self) {
// Dejar el socket limpio para el próximo arranque.
let _ = std::fs::remove_file(&self.path);
}
}
/// Una conexión de control aceptada: una petición y una respuesta.
pub struct CtlConn {
stream: UnixStream,
}
impl CtlConn {
/// Lee la petición del cliente (bloquea hasta el marco completo; es
/// uno solo y llega enseguida).
pub fn read_request(&mut self) -> io::Result<Option<CtlRequest>> {
self.stream.set_nonblocking(false)?;
read_frame(&mut self.stream)
}
/// Envía la respuesta. El cliente cierra al recibirla.
pub fn reply(&mut self, reply: &CtlReply) -> io::Result<()> {
write_frame(&mut self.stream, reply)
}
}
/// Envía una petición al Cerebro y espera su respuesta. Es el camino que
/// usa el binario `mirada-ctl`: conecta, pregunta, cierra.
pub fn send_request(path: &Path, request: &CtlRequest) -> io::Result<CtlReply> {
let mut stream = UnixStream::connect(path)?;
write_frame(&mut stream, request)?;
read_frame(&mut stream)?
.ok_or_else(|| io::Error::new(ErrorKind::UnexpectedEof, "el Cerebro cerró sin responder"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
/// Una ruta de socket única para un test (los sockets no se pueden
/// reabrir; cada test necesita la suya).
fn temp_socket(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("mirada-ctl-test-{tag}-{nanos}.sock"))
}
#[test]
fn default_socket_path_lives_under_a_runtime_dir() {
let p = default_socket_path();
assert_eq!(p.file_name().unwrap(), "mirada-ctl.sock");
}
#[test]
fn a_request_round_trips_over_the_socket() {
let path = temp_socket("roundtrip");
let server = CtlServer::bind(&path).unwrap();
// El "Cerebro": atiende una petición y responde.
let srv = thread::spawn(move || loop {
if let Some(mut conn) = server.poll() {
let req = conn.read_request().unwrap().unwrap();
let reply = match req {
CtlRequest::Do(DesktopAction::FocusNext) => CtlReply::Ok,
other => CtlReply::Error(format!("inesperado: {other:?}")),
};
conn.reply(&reply).unwrap();
return;
}
thread::yield_now();
});
let reply = send_request(&path, &CtlRequest::Do(DesktopAction::FocusNext)).unwrap();
assert_eq!(reply, CtlReply::Ok);
srv.join().unwrap();
}
#[test]
fn list_windows_carries_the_window_lines() {
let path = temp_socket("windows");
let server = CtlServer::bind(&path).unwrap();
let lines = vec![WindowLine {
id: 7,
app_id: "org.brahman.shuma".into(),
title: "shell".into(),
workspace: 2,
focused: true,
}];
let expected = lines.clone();
let srv = thread::spawn(move || loop {
if let Some(mut conn) = server.poll() {
assert_eq!(conn.read_request().unwrap().unwrap(), CtlRequest::ListWindows);
conn.reply(&CtlReply::Windows(lines)).unwrap();
return;
}
thread::yield_now();
});
let reply = send_request(&path, &CtlRequest::ListWindows).unwrap();
assert_eq!(reply, CtlReply::Windows(expected));
srv.join().unwrap();
}
#[test]
fn binding_twice_on_a_live_socket_is_refused() {
let path = temp_socket("dup");
let _first = CtlServer::bind(&path).unwrap();
// El primero sigue vivo: el segundo debe rechazarse.
assert!(CtlServer::bind(&path).is_err());
}
}
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
//! El keymap configurable — atajos del escritorio en RON, recargables en
//! caliente.
//!
//! # Dónde vive el keymap
//!
//! Sólo en el Cerebro. El Cuerpo (`mirada-compositor`) **nunca** ve este
//! mapa: lo único que recibe es la lista de cadenas a interceptar
//! ([`grab_list`](Keymap::grab_list)) dentro de un
//! [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys). El
//! Cuerpo hace un `Vec::contains` ciego y devuelve la combinación pulsada
//! como [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); es el
//! [`Desktop`](crate::Desktop) quien la traduce a una
//! [`DesktopAction`]. Esa separación —*qué* interceptar vs. *qué
//! significa*— es la que hace innecesario cualquier candado o `Arc`:
//! el mapa es monohilo aquí y la lista viaja de golpe en un solo mensaje.
//!
//! # Persistencia
//!
//! En disco es RON de texto (`~/.config/mirada/keymap.ron`), editable a
//! mano y versionable. El cable sólo lleva la lista de cadenas; no hay
//! format binario de configuración. Hay un único ejecutable que hace de
//! "configurador": la app `mirada`, que carga este archivo al arrancar.
//!
//! # Recarga en caliente
//!
//! [`Keymap::watch`] devuelve un [`KeymapWatch`] que vigila el archivo;
//! cuando cambia, el dueño del [`Desktop`](crate::Desktop) recarga el
//! keymap, llama a [`Desktop::set_keymap`](crate::Desktop::set_keymap) y
//! reenvía el `GrabKeys` resultante. Sin reiniciar nada.
use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::action::{default_keymap, DesktopAction};
use crate::watch::FileWatch;
/// Atajos del escritorio: combinación canónica → acción.
///
/// La combinación es la cadena que canoniza el Cuerpo (`"Super+Shift+j"`,
/// `"Super+space"`…). El keymap es lo único que la traduce a una
/// [`DesktopAction`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Keymap {
bindings: BTreeMap<String, DesktopAction>,
}
impl Default for Keymap {
/// El keymap por defecto, estilo *tiling WM* (ver [`default_keymap`]).
fn default() -> Self {
Self {
bindings: default_keymap().into_iter().collect(),
}
}
}
impl Keymap {
/// Construye un keymap a partir de pares `(combinación, acción)`.
pub fn from_pairs(pairs: impl IntoIterator<Item = (String, DesktopAction)>) -> Self {
Self {
bindings: pairs.into_iter().collect(),
}
}
/// La acción asociada a una combinación, si la hay.
pub fn lookup(&self, combo: &str) -> Option<DesktopAction> {
self.bindings.get(combo).cloned()
}
/// Las combinaciones a interceptar — el contenido de un `GrabKeys`.
pub fn grab_list(&self) -> Vec<String> {
self.bindings.keys().cloned().collect()
}
/// Todos los atajos, en orden de combinación.
pub fn bindings(&self) -> &BTreeMap<String, DesktopAction> {
&self.bindings
}
/// Cuántos atajos hay.
pub fn len(&self) -> usize {
self.bindings.len()
}
/// `true` si no hay ningún atajo.
pub fn is_empty(&self) -> bool {
self.bindings.is_empty()
}
// --- RON ----------------------------------------------------------
/// Parsea un keymap desde el texto RON de un archivo de configuración.
pub fn from_ron(text: &str) -> Result<Keymap, KeymapError> {
let file: KeymapFile = ron::from_str(text)
.map_err(|e| KeymapError::Parse(format!("RON inválido: {e}")))?;
let mut bindings = BTreeMap::new();
for (combo, action) in file.bindings {
let parsed = action
.parse::<DesktopAction>()
.map_err(|e| KeymapError::Parse(format!("atajo \"{combo}\": {e}")))?;
bindings.insert(combo, parsed);
}
Ok(Keymap { bindings })
}
/// Serializa el keymap a RON (sin la cabecera de documentación).
pub fn to_ron(&self) -> String {
let file = KeymapFile {
bindings: self
.bindings
.iter()
.map(|(k, v)| (k.clone(), v.to_string()))
.collect(),
};
ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
.expect("un KeymapFile de cadenas siempre serializa")
}
// --- Disco --------------------------------------------------------
/// La ruta canónica del keymap del usuario: `~/.config/mirada/keymap.ron`.
/// `None` si no se puede determinar el directorio de configuración.
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "mirada")
.map(|d| d.config_dir().join("keymap.ron"))
}
/// Carga un keymap desde un archivo RON.
pub fn load(path: &Path) -> Result<Keymap, KeymapError> {
let text = std::fs::read_to_string(path)?;
Keymap::from_ron(&text)
}
/// El keymap como RON con la cabecera de documentación — exactamente
/// lo que [`save`](Keymap::save) escribe en disco.
pub fn documented_ron(&self) -> String {
format!("{KEYMAP_HEADER}\n{}", self.to_ron())
}
/// Escribe el keymap a `path` como RON documentado (con cabecera de
/// comentarios), creando el directorio padre si falta.
pub fn save(&self, path: &Path) -> Result<(), KeymapError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, self.documented_ron())?;
Ok(())
}
/// Carga el keymap del usuario con un fallback amable:
///
/// - si el archivo no existe, escribe uno por defecto documentado y lo
/// devuelve (así el usuario lo descubre y lo puede editar);
/// - si existe pero está corrupto, avisa por `stderr` y devuelve el
/// keymap por defecto **sin tocar el archivo** (no se pierde el
/// trabajo del usuario por un error de sintaxis).
pub fn load_or_init(path: &Path) -> Keymap {
if path.exists() {
match Keymap::load(path) {
Ok(km) => km,
Err(e) => {
eprintln!(
"mirada · keymap «{}» inválido ({e}); uso el de por defecto.",
path.display()
);
Keymap::default()
}
}
} else {
let km = Keymap::default();
match km.save(path) {
Ok(()) => eprintln!("mirada · keymap inicial escrito en {}", path.display()),
Err(e) => eprintln!("mirada · no pude escribir el keymap inicial: {e}"),
}
km
}
}
/// Vigila el archivo del keymap para recargarlo en caliente — un
/// [`FileWatch`] genérico, igual que la config y las reglas.
pub fn watch(path: &Path) -> notify::Result<KeymapWatch> {
FileWatch::new(path)
}
}
/// Vigía del archivo de keymap para la recarga en caliente. Hoy es un
/// alias del [`FileWatch`] genérico — se conserva el nombre por
/// compatibilidad con quien lo nombra (`mirada-compositor`).
pub type KeymapWatch = FileWatch;
/// La forma en disco del keymap — un mapa de cadenas. Las acciones van
/// como texto (`"layout:grid"`) y no como enum, para que el RON sea
/// trivial y los errores se reporten atajo a atajo.
#[derive(Serialize, Deserialize)]
struct KeymapFile {
bindings: BTreeMap<String, String>,
}
/// La cabecera de comentarios del archivo que escribe [`Keymap::save`].
const KEYMAP_HEADER: &str = "\
// keymap de mirada — atajos del escritorio (carmen).
//
// Formato: \"Combinación\": \"acción\"
// La combinación la canoniza el compositor: Super, Ctrl, Shift, Alt y la
// tecla, en ese orden (p. ej. \"Super+Shift+j\", \"Super+space\").
//
// Acciones:
// focus-next / focus-prev mueve el foco (cíclico)
// focus-left/right/up/down mueve el foco espacial (Super+flechas)
// move-forward / move-backward reordena la ventana enfocada (orden)
// move-left/right/up/down mueve la ventana por geometría (Super+Shift+flechas)
// close-focused cierra la enfocada
// toggle-float alterna flotante / teselada (una)
// toggle-tiling alterna todo el escritorio teselado/flotante
// toggle-fullscreen alterna pantalla completa
// send-to-scratchpad guarda la enfocada en el scratchpad
// toggle-scratchpad invoca / oculta la del scratchpad
// toggle-dropterm baja / sube la terminal dropdown (quake)
// cycle-layout siguiente modo de teselado
// layout:<modo> master-stack | centered-master | spiral
// grid | columns | rows | monocle
// grow-master / shrink-master redimensiona el área maestra
// inc-master / dec-master nº de ventanas maestras (nmaster)
// promote-to-master la enfocada al puesto maestro (rota)
// swap-master intercambia la enfocada con la maestra (sólo esas dos)
// resize-float-left/right/up/down redimensiona la flotante enfocada
// focus-output-next pasa el foco al siguiente monitor
// focus-output-left/right/up/down foco al monitor vecino (por geometría)
// send-to-output-left/right/up/down manda la enfocada al monitor vecino
// workspace:N activa el escritorio N (1..9)
// send-to-workspace:N manda la enfocada al escritorio N (sin saltar)
// move-to-workspace:N manda la enfocada al escritorio N y salta allí
// spawn:<comando> lanza un programa (p. ej. spawn:foot)
// quit apaga el compositor
//
// Edita y guarda: mirada recarga el keymap en caliente, sin reiniciar.";
/// Un fallo al cargar o guardar un keymap.
#[derive(Debug)]
pub enum KeymapError {
/// El RON no parsea, o una acción no se reconoce. El mensaje ya está
/// formateado para mostrarse al usuario.
Parse(String),
/// Fallo de E/S al leer o escribir el archivo.
Io(io::Error),
}
impl fmt::Display for KeymapError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeymapError::Parse(msg) => f.write_str(msg),
KeymapError::Io(e) => write!(f, "E/S: {e}"),
}
}
}
impl std::error::Error for KeymapError {}
impl From<io::Error> for KeymapError {
fn from(e: io::Error) -> Self {
KeymapError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mirada_layout::LayoutMode;
#[test]
fn the_default_keymap_round_trips_through_ron() {
let km = Keymap::default();
let back = Keymap::from_ron(&km.to_ron()).unwrap();
assert_eq!(km, back);
}
#[test]
fn the_saved_file_carries_the_documentation_header() {
let km = Keymap::default();
let written = km.documented_ron();
// La cabecera son comentarios — RON los ignora al reparsear.
assert!(written.starts_with("// keymap de mirada"));
assert_eq!(Keymap::from_ron(&written).unwrap(), km);
}
#[test]
fn grab_list_is_exactly_the_set_of_bound_combos() {
let km = Keymap::default();
let grabs = km.grab_list();
assert_eq!(grabs.len(), km.len());
assert!(grabs.contains(&"Super+j".to_string()));
assert!(grabs.contains(&"Super+Shift+e".to_string()));
}
#[test]
fn lookup_resolves_a_default_binding() {
let km = Keymap::default();
assert_eq!(km.lookup("Super+q"), Some(DesktopAction::CloseFocused));
assert_eq!(km.lookup("Super+t"), Some(DesktopAction::SetLayout(LayoutMode::MasterStack)));
assert_eq!(km.lookup("Super+sin-asignar"), None);
}
#[test]
fn a_custom_keymap_parses_from_ron() {
let ron = r#"(
bindings: {
"Alt+Return": "cycle-layout",
"Alt+x": "close-focused",
"Alt+3": "workspace:3",
},
)"#;
let km = Keymap::from_ron(ron).unwrap();
assert_eq!(km.len(), 3);
assert_eq!(km.lookup("Alt+Return"), Some(DesktopAction::CycleLayout));
assert_eq!(km.lookup("Alt+3"), Some(DesktopAction::SwitchWorkspace(2)));
}
#[test]
fn an_unknown_action_names_the_offending_binding() {
let ron = r#"( bindings: { "Super+z": "fly-away" } )"#;
let err = Keymap::from_ron(ron).unwrap_err().to_string();
assert!(err.contains("Super+z"), "el error debe nombrar el atajo: {err}");
}
#[test]
fn malformed_ron_is_rejected() {
assert!(Keymap::from_ron("esto no es ron").is_err());
}
}
+42
View File
@@ -0,0 +1,42 @@
//! `mirada-brain` — el orquestador de escritorio del compositor.
//!
//! Es el "Cerebro" de mirada sin pantalla: mantiene el estado del
//! escritorio (salidas, escritorios virtuales, ventanas, foco), consume
//! los [`BodyEvent`]s que reporta el Cuerpo y produce los
//! [`BrainCommand`]s que el Cuerpo aplica.
//!
//! Es agnóstico de GPUI y de `smithay`: una app GPUI sólo lo *envuelve*
//! para pintar un HUD y para mover los bytes por el cable de
//! [`mirada_protocol`]. Toda la lógica vive aquí y es determinista —
//! la misma secuencia de eventos da siempre el mismo estado.
//!
//! - [`action`] — las acciones de escritorio y el mapa de teclas.
//! - [`config`] — la [`Config`] general del WM (dropterm, teselado, foco).
//! - [`desktop`] — el [`Desktop`]: el estado y el bucle `evento → comandos`.
//! - [`keymap`] — el [`Keymap`] configurable en RON, recargable en caliente.
//! - [`rules`] — las [`Rules`] de ventana (escritorio/flotante por `app_id`).
//! - [`ctl`] — el API de control externo (`mirada-ctl`, taskbars, scripts).
#![forbid(unsafe_code)]
pub mod action;
pub mod config;
pub mod ctl;
pub mod desktop;
pub mod keymap;
pub mod rules;
pub mod watch;
pub use action::{default_keymap, DesktopAction, WORKSPACE_COUNT};
pub use config::{Config, MenuEntry, OutputOverride, ZoneCfg, DROPTERM_APP_ID};
pub use ctl::{CtlConn, CtlReply, CtlRequest, CtlServer, WindowLine};
pub use desktop::{Desktop, Output, WindowInfo};
pub use keymap::{Keymap, KeymapError, KeymapWatch};
pub use rules::{Rule, RuleOutcome, Rules};
pub use watch::FileWatch;
pub use mirada_layout::{
disponer, envolvente, wallpaper_dst_rect, Disposicion, LayoutMode, LayoutParams, Rect,
WallpaperFit, WindowId, Workspace, ZoneFrac,
};
pub use mirada_protocol::{BodyEvent, BrainCommand, Decorations, OutputId, WindowPlacement};
+216
View File
@@ -0,0 +1,216 @@
//! Reglas de ventana — config declarativa que decide, al abrirse una
//! ventana, a qué escritorio va y si flota.
//!
//! Mismo patrón que [`crate::keymap`]: RON de texto en
//! `~/.config/mirada/rules.ron`, que el [`Desktop`](crate::Desktop)
//! consulta en cada `WindowOpened` — el evento ya trae `app_id` y
//! `title`. Una regla casa por subcadena (sin distinguir mayúsculas);
//! gana la primera que case.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Una regla: criterio de coincidencia + qué aplicar.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Rule {
/// Subcadena que debe contener el `app_id`; vacía = casa con cualquiera.
#[serde(default)]
pub app_id: String,
/// Subcadena que debe contener el título; vacía = cualquiera.
#[serde(default)]
pub title: String,
/// Escritorio de destino (1-based); `0` = no moverla.
#[serde(default)]
pub workspace: usize,
/// Abrir la ventana flotando.
#[serde(default)]
pub floating: bool,
}
impl Rule {
/// `true` si la regla casa con una ventana de este `app_id`/`title`.
fn matches(&self, app_id: &str, title: &str) -> bool {
let app_ok = self.app_id.is_empty() || contains_ci(app_id, &self.app_id);
let title_ok = self.title.is_empty() || contains_ci(title, &self.title);
app_ok && title_ok
}
}
/// `true` si `haystack` contiene `needle`, sin distinguir mayúsculas.
fn contains_ci(haystack: &str, needle: &str) -> bool {
haystack.to_lowercase().contains(&needle.to_lowercase())
}
/// Qué hacer con una ventana recién abierta, según las reglas.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RuleOutcome {
/// Escritorio de destino, ya como índice 0-based. `None` = el activo.
pub workspace: Option<usize>,
/// Abrir flotando.
pub floating: bool,
}
/// El conjunto de reglas de ventana del usuario.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rules {
#[serde(default)]
rules: Vec<Rule>,
}
impl Rules {
/// Construye un conjunto de reglas a partir de una lista.
pub fn new(rules: Vec<Rule>) -> Self {
Self { rules }
}
/// Resuelve qué hacer con una ventana — gana la primera regla que case.
pub fn resolve(&self, app_id: &str, title: &str) -> RuleOutcome {
for r in &self.rules {
if r.matches(app_id, title) {
return RuleOutcome {
workspace: (r.workspace >= 1).then(|| r.workspace - 1),
floating: r.floating,
};
}
}
RuleOutcome::default()
}
/// Cuántas reglas hay.
pub fn len(&self) -> usize {
self.rules.len()
}
/// `true` si no hay ninguna regla.
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
/// Parsea las reglas desde el texto RON de un archivo de config.
pub fn from_ron(text: &str) -> Result<Rules, String> {
ron::from_str(text).map_err(|e| format!("RON inválido: {e}"))
}
/// La ruta canónica de las reglas: `~/.config/mirada/rules.ron`.
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "mirada")
.map(|d| d.config_dir().join("rules.ron"))
}
/// Carga las reglas de un archivo RON.
pub fn load(path: &Path) -> Result<Rules, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("E/S: {e}"))?;
Rules::from_ron(&text)
}
/// Vigila el archivo de reglas para recargarlo en caliente.
pub fn watch(path: &Path) -> notify::Result<crate::watch::FileWatch> {
crate::watch::FileWatch::new(path)
}
/// Carga las reglas del usuario con un fallback amable: si el archivo
/// no existe, escribe una plantilla documentada y devuelve un
/// conjunto vacío; si está corrupto, avisa y devuelve vacío.
pub fn load_or_default(path: &Path) -> Rules {
if path.exists() {
match Rules::load(path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"mirada · reglas «{}» inválidas ({e}); las ignoro.",
path.display()
);
Rules::default()
}
}
} else {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(path, RULES_TEMPLATE) {
Ok(()) => eprintln!("mirada · plantilla de reglas escrita en {}", path.display()),
Err(e) => eprintln!("mirada · no pude escribir la plantilla de reglas: {e}"),
}
Rules::default()
}
}
}
/// La plantilla que se escribe la primera vez — sin reglas, con ejemplos
/// comentados para que el usuario los descubra.
const RULES_TEMPLATE: &str = "\
// Reglas de ventana de mirada — qué hacer con una ventana al abrirse.
//
// Cada regla casa por subcadena de `app_id` y/o `title` (sin distinguir
// mayúsculas; cadena vacía = cualquiera) y aplica un destino: `workspace`
// (1..9; 0 = no mover) y/o `floating`. Gana la primera regla que case.
//
// Descomenta y edita los ejemplos:
(
rules: [
// (app_id: \"pavucontrol\", floating: true),
// (app_id: \"firefox\", workspace: 2),
// (title: \"Picture-in-Picture\", floating: true),
],
)
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_parses_to_an_empty_rule_set() {
assert!(Rules::from_ron(RULES_TEMPLATE).unwrap().is_empty());
}
#[test]
fn rules_parse_from_ron_with_omitted_fields() {
let ron = r#"(
rules: [
(app_id: "pavucontrol", floating: true),
(app_id: "firefox", workspace: 2),
],
)"#;
assert_eq!(Rules::from_ron(ron).unwrap().len(), 2);
}
#[test]
fn resolve_sends_a_match_to_its_workspace() {
let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 3) ] )"#).unwrap();
let out = r.resolve("org.mozilla.firefox", "");
assert_eq!(out.workspace, Some(2)); // 3 (1-based) -> índice 2
assert!(!out.floating);
}
#[test]
fn resolve_matches_app_id_case_insensitively_by_substring() {
let r = Rules::from_ron(r#"( rules: [ (app_id: "FIREFOX", floating: true) ] )"#).unwrap();
assert!(r.resolve("org.mozilla.firefox", "").floating);
}
#[test]
fn resolve_matches_by_title() {
let r =
Rules::from_ron(r#"( rules: [ (title: "Picture-in-Picture", floating: true) ] )"#)
.unwrap();
assert!(r.resolve("cualquiera", "YouTube — Picture-in-Picture").floating);
assert!(!r.resolve("cualquiera", "ventana normal").floating);
}
#[test]
fn the_first_matching_rule_wins() {
let r = Rules::from_ron(
r#"( rules: [ (app_id: "term", workspace: 1), (app_id: "term", workspace: 5) ] )"#,
)
.unwrap();
assert_eq!(r.resolve("term", "").workspace, Some(0));
}
#[test]
fn no_match_yields_the_default_outcome() {
let r = Rules::from_ron(r#"( rules: [ (app_id: "firefox", workspace: 2) ] )"#).unwrap();
assert_eq!(r.resolve("xterm", ""), RuleOutcome::default());
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Vigía genérico de un archivo de configuración, para la recarga en
//! caliente. Lo comparten el keymap, la config y las reglas: los tres son
//! RON en `~/.config/mirada/` que el usuario edita a mano y mirada
//! recarga sin reiniciar.
//!
//! El patrón: se vigila el **directorio** (los editores reescriben el
//! archivo por *rename*, no editándolo en sitio) y se filtra al archivo de
//! interés. Una ráfaga de eventos de un solo guardado se *coalesce* en un
//! único [`changed`](FileWatch::changed).
use std::path::Path;
use std::sync::mpsc;
/// Vigía de un archivo para la recarga en caliente.
///
/// Mantenlo vivo mientras quieras recargas; al soltarlo, la vigilancia
/// cesa. Consulta [`changed`](FileWatch::changed) en tu bucle de eventos.
pub struct FileWatch {
_watcher: notify::RecommendedWatcher,
rx: mpsc::Receiver<()>,
}
impl FileWatch {
/// Empieza a vigilar `path`. Vigila su directorio padre (si existe) y
/// filtra los eventos al archivo concreto, así capta los guardados por
/// *rename* de los editores.
pub fn new(path: &Path) -> notify::Result<FileWatch> {
use notify::{RecursiveMode, Watcher};
let target = path.to_path_buf();
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
if event.paths.iter().any(|p| p == &target) {
let _ = tx.send(());
}
}
})?;
let dir = path.parent().filter(|d| d.exists());
watcher.watch(dir.unwrap_or(path), RecursiveMode::NonRecursive)?;
Ok(FileWatch { _watcher: watcher, rx })
}
/// `true` si el archivo cambió desde la última consulta. Coalesce una
/// ráfaga de eventos (un guardado dispara varios) en un solo `true`.
pub fn changed(&self) -> bool {
self.rx.try_iter().count() > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// Un `FileWatch` detecta una escritura del archivo vigilado. Es un test
/// de integración con el SO (inotify): si el entorno no provee un backend
/// de vigilancia (algunos sandboxes), `FileWatch::new` falla y el test se
/// salta — no queremos un test frágil que rompa el smoke del workspace.
#[test]
fn detects_a_write_to_the_watched_file() {
let dir = std::env::temp_dir().join(format!("mirada-watch-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("config.ron");
std::fs::write(&file, b"(\n)\n").unwrap();
let Ok(watch) = FileWatch::new(&file) else {
eprintln!("watch: sin backend de vigilancia en este entorno; salto el test.");
let _ = std::fs::remove_dir_all(&dir);
return;
};
assert!(!watch.changed(), "recién creado: nada que reportar todavía");
// Reescribe el archivo y espera (acotado) a que el evento llegue.
std::fs::write(&file, b"( gap: 12 )\n").unwrap();
let mut seen = false;
for _ in 0..60 {
if watch.changed() {
seen = true;
break;
}
std::thread::sleep(Duration::from_millis(50)); // hasta ~3 s
}
let _ = std::fs::remove_dir_all(&dir);
assert!(seen, "el FileWatch no reportó la escritura en 3 s");
}
}
@@ -0,0 +1,26 @@
[package]
name = "mirada-compositor"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — el Cuerpo del compositor: un compositor Wayland teselante sobre smithay (backend winit, nested). Tesela con un Cerebro embebido o uno externo por mirada-link."
[[bin]]
name = "mirada-compositor"
path = "src/main.rs"
[dependencies]
mirada-brain = { path = "../mirada-brain" }
mirada-body = { path = "../mirada-body" }
mirada-link = { path = "../mirada-link" }
auth-core = { path = "../../../shared/auth/auth-core" }
nix = { workspace = true }
smithay = "0.7"
# Rasterizado de texto sobre CPU (etiquetas: barra de título, menú). Es un
# rasterizador puro-Rust ligero; smithay sube el búfer RGBA como textura.
ab_glyph = "0.2"
# Decodifica y escala el wallpaper (PNG/JPEG/WebP) para componerlo en el fondo.
image = { workspace = true }
+221
View File
@@ -0,0 +1,221 @@
# mirada-compositor — el Cuerpo de carmen
Un compositor Wayland teselante real, sobre [`smithay`]. Es el **Cuerpo**
de la arquitectura Cerebro↔Cuerpo de `mirada` (ver
`crates/modules/mirada/SDD.md`): habla el protocolo Wayland con los
clientes, compone sus superficies y aplica la geometría que decide el
Cerebro.
Tiene **dos backends gráficos**:
- **`winit`** — corre **anidado**, como una ventana dentro de tu sesión
gráfica actual (X11 o Wayland). Para desarrollar y probar sin dejar el
escritorio.
- **`drm`** — corre **nativo** sobre una TTY, sin sesión anfitriona:
toma la GPU (DRM/KMS/GBM/EGL), el teclado (`libinput`) y la pantalla
entera. Es carmen como tu escritorio de verdad.
Sin argumentos elige solo: con `DISPLAY`/`WAYLAND_DISPLAY``winit`;
sin ellos → `drm`. O fuérzalo: `mirada-compositor --winit` / `--drm`.
La bandera `--greeter` (ortogonal al backend) arranca el compositor como
gestor de login — ver **Modo greeter (DM)** más abajo.
## Backends
### winit — anidado
```sh
cargo run -p mirada-compositor -- --winit
```
Necesita una sesión gráfica anfitriona (X11 o Wayland) donde dibujar su
ventana; sin ella aborta con un mensaje que lo explica.
### drm — nativo sobre TTY
```sh
cargo run -p mirada-compositor -- --drm
```
Corre directo sobre el hardware. Requiere una **TTY** (`Ctrl+Alt+F3`),
una GPU con `/dev/dri`, y `seatd` o `logind` para la sesión. Toma la
pantalla completa; sal con `Super+Shift+e` o `Ctrl+C`.
Lleva teclado y ratón por `libinput`: el foco sigue al puntero y los
clics y la rueda llegan a la ventana que tienes debajo. El cursor toma
la forma que pide el cliente (la «I» sobre texto, una mano…) y cae a un
cuadrado por defecto sobre el escritorio. **`Super`+arrastre** con el
botón izquierdo mueve una ventana, con el derecho la redimensiona — al
arrastrarla, la ventana pasa a flotar. Cada ventana lleva un marco
fino: azul la que tiene el foco, gris las demás.
- `MIRADA_STARTUP=<cmd>` — lanza una app al arrancar (`MIRADA_STARTUP=foot`).
- `MIRADA_DRM_TIMEOUT=<s>` — cierra el compositor solo tras N segundos
(0 o sin definir = sin tope).
## Como sesión de escritorio
Para usar carmen como tu escritorio de verdad — entrar a una sesión, no
sólo probarlo:
1. Compila e instala los binarios en el `PATH`:
```sh
cargo build --release -p mirada-compositor -p mirada-ctl -p mirada-launcher
sudo install -m755 target/release/mirada-compositor \
target/release/mirada-ctl target/release/mirada-launcher /usr/local/bin/
sudo install -m755 session/mirada-session /usr/local/bin/
```
2. Arranca desde una TTY:
```sh
mirada-session
```
O regístralo en un gestor de login copiando `session/carmen.desktop`
a `/usr/share/wayland-sessions/` — aparecerá carmen como sesión.
3. **Autoarranque** — los programas que quieras al iniciar van en
`~/.config/mirada/autostart`, uno por línea (`#` comenta). Tienes un
ejemplo en `session/autostart.example`:
```sh
mkdir -p ~/.config/mirada
cp 02_ruway/mirada/mirada-compositor/session/autostart.example \
~/.config/mirada/autostart
```
Dentro de la sesión, `Ctrl+Alt+F1…F12` salta a otra TTY y vuelve sin
romper carmen.
## Modo greeter (DM)
`mirada-compositor --greeter` arranca el compositor como **gestor de
login**: en vez de la sesión, compone el greeter (`mirada-greeter`),
que lanza como proceso hijo. El usuario teclea sus credenciales; cuando
el login es válido el greeter emite un `SessionTicket` por su stdout y
el compositor **muta a modo sesión sin reiniciar el servidor Wayland**
— el mismo proceso, la misma GPU, las mismas ventanas («mutación
atómica»). Desde ahí baja privilegios al usuario autenticado
(`setuid`/`setgid` + grupos) para todo lo que lanza.
La bandera es ortogonal al backend: `--greeter` solo (auto), o
`--greeter --drm` / `--greeter --winit`.
```sh
# DM real, sobre una TTY — el compositor corre como root: PAM lo exige
sudo mirada-compositor --greeter --drm
# iterar el greeter anidado, con credenciales de prueba
MIRADA_GREETER_MOCK=demo:demo \
cargo run -p mirada-compositor -- --greeter --winit
```
En modo greeter no se registran atajos (todas las teclas van al
greeter — que el usuario no pueda lanzar nada ni cerrar el compositor),
se rechaza `spawn:` y no corre el autoarranque; los atajos y la sesión
arrancan sólo tras el traspaso. `MIRADA_GREETER_BIN` apunta a otro
binario de greeter (cómodo para señalar a `target/…` en desarrollo).
## 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`)
embebido. Es un compositor teselante completo en un solo proceso.
```sh
cargo run -p mirada-compositor
```
- **Enlazado** — el Cuerpo escucha en un socket y la app `mirada` (el
Cerebro GPUI) se conecta y decide la geometría.
```sh
# terminal 1 — el Cuerpo
MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada-compositor
# terminal 2 — el Cerebro
MIRADA_SOCKET=/tmp/mirada.sock cargo run -p mirada
```
## Probarlo
Al arrancar imprime el `WAYLAND_DISPLAY` que abrió. Lanza cualquier
cliente Wayland contra él:
```sh
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+…`: 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
Los atajos son configurables en RON: `~/.config/mirada/keymap.ron`. En
modo autónomo, el Cuerpo lo carga al arrancar (si no existe, escribe uno
por defecto documentado) y lo **recarga en caliente** — edita el archivo,
guarda, y los atajos cambian sin reiniciar. En modo enlazado el keymap es
asunto del Cerebro (la app `mirada`).
```sh
cargo run -p mirada-brain --example keymap-default # ver el formato
```
El compositor en sí no interpreta atajos: sólo intercepta las
combinaciones que el Cerebro le pide (`GrabKeys`) y le devuelve la
pulsada. *Qué significa* cada una lo decide `mirada-brain`. Ver el SDD.
## Control externo
En modo autónomo, el compositor abre un socket de control y `mirada-ctl`
lo maneja desde la terminal — al estilo de `swaymsg`/`hyprctl`:
```sh
mirada-ctl focus-next # cambia el foco
mirada-ctl focus-window 5 # enfoca una ventana concreta
mirada-ctl workspace 3 # va al escritorio 3
mirada-ctl windows # lista las ventanas
```
En modo enlazado el socket de control lo abre el Cerebro (la app
`mirada`), no el compositor.
## Qué implementa
`wl_compositor`, `xdg_shell` (toplevels y popups), `wl_shm`, `wl_seat`
(teclado, y ratón en el backend DRM), `wl_output`, `wl_data_device`
(selección), `xdg-decoration` — fuerza decoración del servidor y no
dibuja ninguna, así las ventanas van sin barra de título — y
`zwp_linux_dmabuf`, que deja conectarse a los clientes que pintan por
GPU (apps GPUI, navegadores acelerados). Composición con `GlesRenderer`
— en `winit` sobre la ventana, en `drm` con un `DrmCompositor` por
salida.
Reusa `mirada-body` para la contabilidad de salidas y superficies, y
`mirada-link` para el cable hacia un Cerebro externo. Toda la lógica
espacial es agnóstica de Wayland y vive en los crates de
`crates/modules/mirada/`.
## Pendiente
Del backend DRM: conmutación de VT, hotplug de monitores, multi-GPU.
Puntero en el backend `winit`. Aislamiento de clientes. Ver el SDD.
[`smithay`]: https://github.com/Smithay/smithay
@@ -0,0 +1,15 @@
# mirada-compositor
> Tiling Wayland compositor — the "Body" of [mirada](../README.md).
A real tiling Wayland compositor built on [`smithay`]. It's the **Body** of `mirada`'s Brain↔Body architecture: it speaks the Wayland protocol with apps, owns surfaces and inputs, executes the layout decisions [`mirada-brain`](../mirada-brain/README.md) emits. Without `mirada-brain`, it still runs as a minimal default layout.
## Usage
```sh
cargo run --release -p mirada-compositor
```
## Deps
- `smithay`, [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-body`](../mirada-body/README.md)
@@ -0,0 +1,21 @@
# Autoarranque de carmen — cópialo a ~/.config/mirada/autostart
#
# Un comando por línea; se lanza al arrancar el compositor, con
# WAYLAND_DISPLAY ya puesto. Las líneas en blanco y las que empiezan
# por # se ignoran. Cada línea se pasa a `sh -c`, así que valen las
# variables, las tuberías y el `&` final no hace falta.
# El marco pata — barra/dock acoplado a un borde; carmen lo reconoce por su
# app_id y le reserva la franja. (Si no hay autostart, el compositor lo levanta
# solo; acá lo ponemos explícito para combinarlo con shuma.)
pata-llimphi
# El shell de carmen propiamente dicho (shuma): barra superior con apps y
# launcher. Es el binario que instala install-mirada-dm.sh.
shuma-shell-llimphi
# Una terminal para empezar.
foot
# Ejemplos (descoméntalos si los quieres):
# mirada-ctl layout spiral
# wbg ~/fondo.png
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=carmen
Comment=Compositor Wayland teselante (mirada)
Exec=mirada-session
Type=Application
DesktopNames=carmen
@@ -0,0 +1,6 @@
[Desktop Entry]
Name=mirada · pata
Comment=Compositor Wayland teselante (mirada) con el marco pata
Exec=mirada-session-pata
Type=Application
DesktopNames=mirada
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# mirada-session — arranca carmen (el compositor mirada) como una sesión
# de escritorio. Pensado para lanzarse desde una TTY o desde un gestor de
# login (greetd, ly, …).
#
# Instálalo en el PATH (p. ej. /usr/local/bin/mirada-session) junto al
# binario `mirada-compositor`.
#
# CAMINO CANÓNICO (recomendado): el Ente `mirada-session` declarado en la
# Tarjeta Semilla de arje-zero (`03_ukupacha/arje/init/arje-zero/src/seed.rs::mirada_session_card`)
# levanta el compositor con estas mismas variables de entorno pero bajo
# supervisión del fractal — back-off automático ante crash y trazado por
# el bus de eventos. Este script queda como FALLBACK para hosts con DM
# externo (greetd, ly, sddm) o setups manuales que aún no migraron a
# arje-zero como PID 1.
# Carmen es un compositor Wayland.
export XDG_SESSION_TYPE=wayland
export XDG_CURRENT_DESKTOP=carmen
export XDG_SESSION_DESKTOP=carmen
# Que las apps GUI prefieran sus backends Wayland.
export MOZ_ENABLE_WAYLAND=1
export QT_QPA_PLATFORM="wayland;xcb"
export SDL_VIDEODRIVER=wayland
export _JAVA_AWT_WM_NONREPARENTING=1
# El backend DRM toma la TTY entera. Los programas de arranque van en
# ~/.config/mirada/autostart (uno por línea).
exec mirada-compositor --drm
@@ -0,0 +1,26 @@
#!/bin/sh
# mirada-session-pata — sesión «mirada · pata» para gestores de login
# EXTERNOS (sddm, greetd, ly…): arranca el compositor mirada y deja a pata
# como marco del escritorio. Instálalo en /usr/local/bin junto a
# `mirada-compositor` y `pata-llimphi`.
#
# (Cuando el DM es el propio mirada —`mirada-dm`—, no hace falta este
# script: elegís «mirada · pata» en el greeter y el marco arranca como
# cliente sin reiniciar el servidor.)
set -eu
export XDG_SESSION_TYPE=wayland
export XDG_CURRENT_DESKTOP=mirada
export XDG_SESSION_DESKTOP=mirada
export MOZ_ENABLE_WAYLAND=1
export QT_QPA_PLATFORM="wayland;xcb"
export SDL_VIDEODRIVER=wayland
# Asegura pata en el autoarranque del usuario (idempotente). pata ancla por
# wlr-layer-shell (su backend nativo), que mirada ahora soporta.
mkdir -p "${HOME}/.config/mirada"
AUTO="${HOME}/.config/mirada/autostart"
LINE='pata-llimphi'
grep -qxF "$LINE" "$AUTO" 2>/dev/null || echo "$LINE" >> "$AUTO"
exec mirada-compositor --drm
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,373 @@
//! Menú raíz estilo openbox: un pop-up que aparece al click derecho sobre el
//! fondo del escritorio (no sobre una ventana) y lista comandos del usuario,
//! con **submenús anidados** a cualquier profundidad.
//!
//! Sólo geometría, árbol e hit-testing puros — el render y el input viven en
//! [`crate::drm_backend`]. El árbol de entradas ([`MenuNode`]) se arma desde la
//! config ([`mirada_brain::Config::menu`]) en `main.rs`.
//!
//! El estado abierto es una **cascada de columnas**: la columna 0 muestra la
//! raíz; [`RootMenu::path`] lista, por nivel, qué fila-submenú está abierta, y
//! cada entrada de `path` agrega una columna a la derecha (o a la izquierda si
//! no hay lugar). Mover el puntero sobre una fila-submenú abre su columna hija
//! ([`RootMenu::update_hover`]); el click lanza una hoja o no hace nada sobre un
//! submenú ([`RootMenu::click`]).
/// Alto de cada fila del menú, en píxeles.
pub const ITEM_H: i32 = 26;
/// Ancho de cada columna del menú, en píxeles.
pub const MENU_W: i32 = 210;
/// Relleno vertical arriba y abajo de cada lista, en píxeles.
pub const PAD: i32 = 5;
/// Un nodo del árbol del menú: una **hoja** que lanza `command`, o un
/// **submenú** (cuando `command` es `None`) con `children`.
#[derive(Clone, Debug, PartialEq)]
pub struct MenuNode {
pub label: String,
/// `Some(cmd)` = hoja que lanza `cmd`; `None` = submenú.
pub command: Option<String>,
/// Hijas del submenú (vacío en una hoja).
pub children: Vec<MenuNode>,
}
impl MenuNode {
/// Una hoja con etiqueta y comando.
pub fn leaf(label: impl Into<String>, command: impl Into<String>) -> Self {
Self { label: label.into(), command: Some(command.into()), children: vec![] }
}
/// Un submenú con etiqueta e hijas.
pub fn submenu(label: impl Into<String>, children: Vec<MenuNode>) -> Self {
Self { label: label.into(), command: None, children }
}
/// `true` si es un submenú (sin comando, abre una columna hija).
pub fn is_submenu(&self) -> bool {
self.command.is_none()
}
}
/// Lo que devuelve [`RootMenu::click`]: qué hacer con el click.
#[derive(Debug, PartialEq)]
pub enum ClickResult {
/// Click sobre una hoja: lanzar este comando y cerrar el menú.
Launch(String),
/// Click sobre una fila-submenú: el menú sigue abierto.
Stay,
/// Click fuera de toda columna: cerrar el menú.
Close,
}
/// Geometría de una columna ya colocada en pantalla.
struct Col {
x: i32,
y: i32,
len: usize,
}
/// Una fila lista para pintar.
pub struct MenuRowView {
pub x: i32,
pub y: i32,
pub label: String,
/// Resaltada (bajo el puntero o submenú abierto en esta columna).
pub highlighted: bool,
/// Es un submenú (se le pinta un indicador ``).
pub submenu: bool,
}
/// Una columna lista para pintar.
pub struct MenuColView {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
pub rows: Vec<MenuRowView>,
}
/// Un menú raíz abierto: el árbol, el origen de la primera columna y la ruta de
/// submenús abiertos.
pub struct RootMenu {
root: Vec<MenuNode>,
ox: i32,
oy: i32,
/// `path[k]` = índice de la fila-submenú abierta en la columna `k`.
path: Vec<usize>,
out_w: i32,
out_h: i32,
}
impl RootMenu {
/// Abre el menú con su primera columna anclada en `(px, py)`, dentro de una
/// salida de `(out_w, out_h)`.
pub fn open(px: i32, py: i32, root: Vec<MenuNode>, out_w: i32, out_h: i32) -> Self {
Self { root, ox: px, oy: py, path: Vec::new(), out_w, out_h }
}
/// Alto total de una columna con `n` filas.
pub fn height_for(n: usize) -> i32 {
n as i32 * ITEM_H + 2 * PAD
}
/// Las entradas que muestra la columna `c` (0 = raíz), siguiendo `path`.
/// `None` si `c` excede la cascada abierta o la ruta es inconsistente.
fn column_entries(&self, c: usize) -> Option<&[MenuNode]> {
let mut nodes = self.root.as_slice();
for k in 0..c {
let i = *self.path.get(k)?;
let node = nodes.get(i)?;
if node.children.is_empty() {
return None;
}
nodes = node.children.as_slice();
}
Some(nodes)
}
/// Coloca cada columna abierta: la 0 anclada (acotada) al origen; cada hija
/// a la derecha de su padre, o a la izquierda si no hay lugar, alineada a la
/// fila que la abrió. Acotadas a la salida.
fn columns(&self) -> Vec<Col> {
let mut cols = Vec::new();
let (mut prev_x, mut prev_y) = (0, 0);
for c in 0..=self.path.len() {
let Some(entries) = self.column_entries(c) else { break };
let len = entries.len();
let h = Self::height_for(len);
let (x, y) = if c == 0 {
let x = self.ox.min((self.out_w - MENU_W).max(0)).max(0);
let y = self.oy.min((self.out_h - h).max(0)).max(0);
(x, y)
} else {
let prow = self.path[c - 1];
let right = prev_x + MENU_W;
let x = if right + MENU_W <= self.out_w {
right
} else if prev_x - MENU_W >= 0 {
prev_x - MENU_W
} else {
(self.out_w - MENU_W).max(0)
};
let row_y = prev_y + PAD + prow as i32 * ITEM_H;
let y = row_y.min((self.out_h - h).max(0)).max(0);
(x, y)
};
cols.push(Col { x, y, len });
prev_x = x;
prev_y = y;
}
cols
}
/// La `(columna, fila)` bajo `(px, py)`, o `None`. Itera de la columna más
/// profunda a la raíz: si una hija solapa a su padre (colocada a la
/// izquierda), gana la hija (está encima).
fn hit(&self, px: i32, py: i32) -> Option<(usize, usize)> {
let cols = self.columns();
for (c, col) in cols.iter().enumerate().rev() {
if px >= col.x && px < col.x + MENU_W {
let rel = py - (col.y + PAD);
if rel >= 0 && rel < col.len as i32 * ITEM_H {
return Some((c, (rel / ITEM_H) as usize));
}
}
}
None
}
/// Actualiza la cascada según el puntero: sobre una fila-submenú abre su
/// columna hija (cerrando las más profundas); sobre una hoja cierra las
/// columnas posteriores a la suya; fuera de todo, no toca nada (no colapsa
/// al cruzar el hueco entre columnas).
pub fn update_hover(&mut self, px: i32, py: i32) {
let action = self.hit(px, py).and_then(|(c, r)| {
self.column_entries(c)
.and_then(|e| e.get(r))
.map(|n| (c, r, n.is_submenu()))
});
if let Some((c, r, is_sub)) = action {
self.path.truncate(c);
if is_sub {
self.path.push(r);
}
}
}
/// Resuelve un click: hoja → lanzar; fila-submenú → abrirla y seguir; fuera
/// de toda columna → cerrar.
pub fn click(&mut self, px: i32, py: i32) -> ClickResult {
let Some((c, r)) = self.hit(px, py) else {
return ClickResult::Close;
};
let cmd = self
.column_entries(c)
.and_then(|e| e.get(r))
.and_then(|n| n.command.clone());
match cmd {
Some(cmd) => ClickResult::Launch(cmd),
None => {
// Fila-submenú: asegurar que su columna hija esté abierta.
self.path.truncate(c);
self.path.push(r);
ClickResult::Stay
}
}
}
/// Las columnas listas para pintar, con la fila bajo `(px, py)` y las
/// fila-submenú abiertas resaltadas.
pub fn render(&self, px: i32, py: i32) -> Vec<MenuColView> {
let hover = self.hit(px, py);
let cols = self.columns();
let mut out = Vec::new();
for (c, col) in cols.iter().enumerate() {
let Some(entries) = self.column_entries(c) else { continue };
let rows = entries
.iter()
.enumerate()
.map(|(r, node)| {
let ry = col.y + PAD + r as i32 * ITEM_H;
let on_path = self.path.get(c).copied() == Some(r);
let hovered = hover == Some((c, r));
MenuRowView {
x: col.x,
y: ry,
label: node.label.clone(),
highlighted: on_path || hovered,
submenu: node.is_submenu(),
}
})
.collect();
out.push(MenuColView {
x: col.x,
y: col.y,
w: MENU_W,
h: Self::height_for(col.len),
rows,
});
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree() -> Vec<MenuNode> {
vec![
MenuNode::leaf("Terminal", "kitty"),
MenuNode::submenu(
"Apps",
vec![
MenuNode::leaf("Navegador", "firefox"),
MenuNode::submenu("Más", vec![MenuNode::leaf("nada", "nada")]),
],
),
]
}
fn menu() -> RootMenu {
RootMenu::open(100, 100, tree(), 1920, 1080)
}
fn row_y(col_y: i32, r: i32) -> i32 {
col_y + PAD + r * ITEM_H + 1
}
#[test]
fn arranca_con_una_sola_columna() {
let m = menu();
assert_eq!(m.columns().len(), 1);
}
#[test]
fn hover_sobre_submenu_abre_columna_hija() {
let mut m = menu();
// Fila 1 (Apps) de la columna 0 — es submenú.
m.update_hover(150, row_y(100, 1));
assert_eq!(m.path, vec![1]);
assert_eq!(m.columns().len(), 2);
}
#[test]
fn hover_sobre_hoja_cierra_las_columnas_profundas() {
let mut m = menu();
m.update_hover(150, row_y(100, 1)); // abre Apps
assert_eq!(m.path, vec![1]);
// Ahora hover sobre la hoja "Terminal" (fila 0 de la columna 0).
m.update_hover(150, row_y(100, 0));
assert!(m.path.is_empty(), "la hoja debe colapsar la cascada");
}
#[test]
fn cascada_de_dos_niveles() {
let mut m = menu();
m.update_hover(150, row_y(100, 1)); // Apps → col 1
let cols = m.columns();
assert_eq!(cols.len(), 2);
// En la columna 1, la fila 1 ("Más") es submenú: hover la abre → col 2.
let c1 = &cols[1];
m.update_hover(c1.x + 10, row_y(c1.y, 1));
assert_eq!(m.path, vec![1, 1]);
assert_eq!(m.columns().len(), 3);
}
#[test]
fn click_en_hoja_lanza_su_comando() {
let mut m = menu();
assert_eq!(
m.click(150, row_y(100, 0)),
ClickResult::Launch("kitty".into())
);
}
#[test]
fn click_en_submenu_lo_abre_y_sigue() {
let mut m = menu();
assert_eq!(m.click(150, row_y(100, 1)), ClickResult::Stay);
assert_eq!(m.path, vec![1]);
}
#[test]
fn click_fuera_cierra() {
let mut m = menu();
assert_eq!(m.click(5, 5), ClickResult::Close);
}
#[test]
fn click_en_hoja_anidada_lanza() {
let mut m = menu();
m.update_hover(150, row_y(100, 1)); // Apps
let cols = m.columns();
let c1 = &cols[1];
// Fila 0 de la columna 1 = "Navegador" (hoja).
assert_eq!(
m.click(c1.x + 10, row_y(c1.y, 0)),
ClickResult::Launch("firefox".into())
);
}
#[test]
fn render_marca_submenus_y_resaltado() {
let mut m = menu();
m.update_hover(150, row_y(100, 1)); // abre Apps
let view = m.render(150, row_y(100, 1));
assert_eq!(view.len(), 2);
// Columna 0: fila "Apps" es submenú y está resaltada (en path).
let apps = &view[0].rows[1];
assert!(apps.submenu);
assert!(apps.highlighted);
// "Terminal" es hoja, no resaltada.
assert!(!view[0].rows[0].submenu);
}
#[test]
fn open_acota_la_primera_columna_a_la_salida() {
let m = RootMenu::open(790, 595, tree(), 800, 600);
let c0 = &m.columns()[0];
assert!(c0.x + MENU_W <= 800);
assert!(c0.y + RootMenu::height_for(c0.len) <= 600);
}
}
@@ -0,0 +1,173 @@
//! Rasterizado de texto del compositor.
//!
//! El compositor sólo sabía pintar rectángulos sólidos y superficies de
//! clientes — no tenía fuentes. Este módulo rasteriza una cadena a un búfer
//! RGBA sobre CPU (con `ab_glyph`, un rasterizador puro-Rust ligero) que
//! luego smithay sube como textura (`MemoryRenderBuffer`). Es la base de la
//! barra de título y del menú: ambos necesitan dibujar etiquetas.
//!
//! El búfer sale en **ARGB8888** premultiplicado, que es lo que
//! `MemoryRenderBuffer::from_slice` con `Fourcc::Argb8888` espera; en memoria
//! little-endian eso son bytes en orden `[B, G, R, A]`.
use std::path::{Path, PathBuf};
use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
/// Rutas de fuentes del sistema que se prueban en orden si la config no fija
/// una. Cubre las familias habituales en Arch/Artix y derivados.
const FONT_CANDIDATES: &[&str] = &[
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
"/usr/share/fonts/gnu-free/FreeSans.otf",
"/usr/share/fonts/TTF/Hack-Regular.ttf",
];
/// Una cadena ya rasterizada: bytes ARGB8888 premultiplicados y su tamaño.
pub struct Rasterized {
pub rgba: Vec<u8>,
pub width: i32,
pub height: i32,
}
/// Una fuente cargada para rasterizar etiquetas del compositor.
pub struct TextRenderer {
font: FontVec,
}
impl TextRenderer {
/// Carga una fuente desde un archivo concreto (el override de la config).
pub fn from_path(path: &Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
let font = FontVec::try_from_vec(bytes).ok()?;
Some(Self { font })
}
/// Carga la primera fuente disponible: la de `preferred` (config) si
/// existe, si no la primera de [`FONT_CANDIDATES`]. `None` si no hay
/// ninguna — entonces el compositor simplemente no pinta etiquetas.
pub fn system(preferred: Option<&str>) -> Option<Self> {
let mut paths: Vec<PathBuf> = Vec::new();
if let Some(p) = preferred {
paths.push(PathBuf::from(p));
}
paths.extend(FONT_CANDIDATES.iter().map(PathBuf::from));
paths.into_iter().find_map(|p| Self::from_path(&p))
}
/// Rasteriza `text` a `px` de alto con `color` (RGBA `0..=255`).
/// Devuelve los píxeles ARGB8888 premultiplicados y el tamaño, o `None`
/// si el texto queda vacío / sin glyphs visibles.
pub fn rasterize(&self, text: &str, px: f32, color: [u8; 4]) -> Option<Rasterized> {
let px = px.max(1.0);
let scale = PxScale::from(px);
let scaled = self.font.as_scaled(scale);
let ascent = scaled.ascent();
let height = (scaled.ascent() - scaled.descent()).ceil().max(1.0) as i32;
// Layout: posiciona cada glyph en la línea base y junta sus contornos.
let mut pen_x = 0.0f32;
let mut max_x = 0.0f32;
let mut outlines = Vec::new();
for c in text.chars() {
let gid = self.font.glyph_id(c);
let glyph = gid.with_scale_and_position(scale, ab_glyph::point(pen_x, ascent));
if let Some(o) = self.font.outline_glyph(glyph) {
max_x = max_x.max(o.px_bounds().max.x);
outlines.push(o);
}
pen_x += scaled.h_advance(gid);
}
let width = pen_x.ceil().max(max_x.ceil()).max(1.0) as i32;
if outlines.is_empty() {
return None;
}
let mut rgba = vec![0u8; (width * height * 4) as usize];
let (cr, cg, cb, ca) = (
color[0] as f32,
color[1] as f32,
color[2] as f32,
color[3] as f32 / 255.0,
);
for o in &outlines {
let b = o.px_bounds();
let (ox, oy) = (b.min.x as i32, b.min.y as i32);
o.draw(|gx, gy, cov| {
let x = ox + gx as i32;
let y = oy + gy as i32;
if x < 0 || y < 0 || x >= width || y >= height {
return;
}
let a = (cov * ca).clamp(0.0, 1.0);
if a <= 0.0 {
return;
}
let i = ((y * width + x) * 4) as usize;
// Compuesto source-over premultiplicado sobre lo que haya
// (los glyphs casi no se solapan, pero así es correcto).
let inv = 1.0 - a;
let sb = cb * a;
let sg = cg * a;
let sr = cr * a;
let sa = a * 255.0;
rgba[i] = (sb + rgba[i] as f32 * inv) as u8; // B
rgba[i + 1] = (sg + rgba[i + 1] as f32 * inv) as u8; // G
rgba[i + 2] = (sr + rgba[i + 2] as f32 * inv) as u8; // R
rgba[i + 3] = (sa + rgba[i + 3] as f32 * inv) as u8; // A
});
}
Some(Rasterized { rgba, width, height })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Carga una fuente del sistema o salta el test si el entorno no tiene
/// ninguna (no queremos fragilizar el smoke del workspace por las fuentes).
fn font_or_skip() -> Option<TextRenderer> {
let r = TextRenderer::system(None);
if r.is_none() {
eprintln!("text: sin fuentes del sistema; salto el test.");
}
r
}
#[test]
fn rasterizes_text_to_a_nonempty_opaque_buffer() {
let Some(tr) = font_or_skip() else { return };
let r = tr.rasterize("Hi", 16.0, [255, 255, 255, 255]).unwrap();
assert!(r.width > 0 && r.height > 0);
assert_eq!(r.rgba.len(), (r.width * r.height * 4) as usize);
// Algún píxel tiene cobertura (canal alfa > 0).
assert!(r.rgba.chunks_exact(4).any(|p| p[3] > 0), "ningún glyph se dibujó");
}
#[test]
fn empty_text_rasterizes_to_nothing() {
let Some(tr) = font_or_skip() else { return };
assert!(tr.rasterize("", 16.0, [255, 255, 255, 255]).is_none());
// El espacio no tiene contorno visible.
assert!(tr.rasterize(" ", 16.0, [255, 255, 255, 255]).is_none());
}
#[test]
fn color_lands_in_bgra_order_premultiplied() {
let Some(tr) = font_or_skip() else { return };
// Rojo puro opaco: el píxel más cubierto debe tener R alto, G/B bajos.
let r = tr.rasterize("M", 32.0, [255, 0, 0, 255]).unwrap();
let reddest = r
.rgba
.chunks_exact(4)
.max_by_key(|p| p[3])
.expect("hay píxeles");
// ARGB8888 LE = [B, G, R, A].
assert!(reddest[2] > reddest[0], "R debería superar a B en texto rojo");
assert!(reddest[2] > reddest[1], "R debería superar a G en texto rojo");
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "mirada-ctl"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada-ctl — control del compositor carmen por línea de comandos (estilo swaymsg/hyprctl): aplica acciones de escritorio y consulta ventanas vía el socket de control de mirada-brain."
[[bin]]
name = "mirada-ctl"
path = "src/main.rs"
[dependencies]
mirada-brain = { path = "../mirada-brain" }
+16
View File
@@ -0,0 +1,16 @@
# mirada-ctl
> CLI de control de [mirada](../README.md).
Comandos: `mirada-ctl workspace next`, `mirada-ctl window close`, `mirada-ctl layout tiled`, etc. Habla con el compositor via [`mirada-link`](../mirada-link/README.md).
## Uso
```sh
cargo run --release -p mirada-ctl -- workspace next
```
## Deps
- [`mirada-link`](../mirada-link/README.md)
- `clap`
+16
View File
@@ -0,0 +1,16 @@
# mirada-ctl
> Control CLI of [mirada](../README.md).
Commands: `mirada-ctl workspace next`, `mirada-ctl window close`, `mirada-ctl layout tiled`, etc. Talks to the compositor via [`mirada-link`](../mirada-link/README.md).
## Usage
```sh
cargo run --release -p mirada-ctl -- workspace next
```
## Deps
- [`mirada-link`](../mirada-link/README.md)
- `clap`
+151
View File
@@ -0,0 +1,151 @@
//! `mirada-ctl` — el control del compositor carmen por línea de comandos.
//!
//! Al estilo de `swaymsg` / `hyprctl`: dispara una acción de escritorio o
//! consulta el estado, hablando con el Cerebro por su socket de control
//! ([`mirada_brain::ctl`]). El Cerebro es la app `mirada`, o
//! `mirada-compositor` cuando lleva el Cerebro embebido.
//!
//! ```sh
//! mirada-ctl focus-next # cambia el foco
//! mirada-ctl focus-window 5 # enfoca una ventana concreta
//! mirada-ctl workspace 3 # va al escritorio 3
//! mirada-ctl layout grid # fija el modo de teselado
//! mirada-ctl windows # lista las ventanas
//! mirada-ctl actions # lista las acciones
//! ```
use std::process::ExitCode;
use mirada_brain::ctl::{self, CtlReply, CtlRequest, WindowLine};
use mirada_brain::DesktopAction;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(msg) => {
eprintln!("mirada-ctl: {msg}");
ExitCode::FAILURE
}
}
}
fn run(args: &[String]) -> Result<(), String> {
match args.first().map(String::as_str) {
None | Some("-h" | "--help" | "help") => {
print_help();
Ok(())
}
Some("actions") => {
print_actions();
Ok(())
}
Some("windows") => match request(CtlRequest::ListWindows)? {
CtlReply::Windows(ws) => {
print_windows(&ws);
Ok(())
}
CtlReply::Error(e) => Err(e),
CtlReply::Ok => Err("respuesta inesperada del Cerebro".into()),
},
// Cicla al siguiente preset de zonas de arrastre (config.ron). Bindealo
// a un atajo lanzando `mirada-ctl cycle-zones`.
Some("cycle-zones") => match request(CtlRequest::CycleZones)? {
CtlReply::Ok => Ok(()),
CtlReply::Error(e) => Err(e),
CtlReply::Windows(_) => Err("respuesta inesperada del Cerebro".into()),
},
// Todo lo demás es una acción. `focus-window 5` y `workspace 3`
// se unen con `:` a la forma canónica (`focus-window:5`).
Some(_) => {
let spec = args.join(":");
let action: DesktopAction = spec
.parse()
.map_err(|e| format!("{e}\n lista de acciones: mirada-ctl actions"))?;
match request(CtlRequest::Do(action))? {
CtlReply::Ok => Ok(()),
CtlReply::Error(e) => Err(e),
CtlReply::Windows(_) => Err("respuesta inesperada del Cerebro".into()),
}
}
}
}
/// Manda una petición al Cerebro y devuelve su respuesta.
fn request(req: CtlRequest) -> Result<CtlReply, String> {
let path = ctl::default_socket_path();
ctl::send_request(&path, &req).map_err(|e| {
format!(
"no pude hablar con el Cerebro en {} ({e})\n \
¿está corriendo `mirada` o `mirada-compositor`?",
path.display()
)
})
}
/// Imprime la lista de ventanas, marcando la enfocada con `*`.
fn print_windows(windows: &[WindowLine]) {
if windows.is_empty() {
println!("(no hay ventanas)");
return;
}
for w in windows {
let mark = if w.focused { '*' } else { ' ' };
// El escritorio 0 es el scratchpad (ventana guardada).
let ws = if w.workspace == 0 {
"scratch".to_string()
} else {
w.workspace.to_string()
};
println!("{mark} id {:<4} esc {:<7} {:<24} {}", w.id, ws, w.app_id, w.title);
}
}
fn print_help() {
println!(
"mirada-ctl — control del compositor carmen\n\
\n\
USO:\n \
mirada-ctl <acción> aplica una acción de escritorio\n \
mirada-ctl windows lista las ventanas\n \
mirada-ctl cycle-zones cicla el preset de zonas de arrastre\n \
mirada-ctl actions lista las acciones disponibles\n\
\n\
EJEMPLOS:\n \
mirada-ctl focus-next\n \
mirada-ctl focus-window 5\n \
mirada-ctl workspace 3\n \
mirada-ctl layout grid"
);
}
fn print_actions() {
// Cadena multilínea literal: la indentación de cada línea es la que
// se imprime (el `\` tras la comilla se come sólo el primer salto).
print!(
"\
Acciones de mirada-ctl:
focus-next mueve el foco a la siguiente ventana
focus-prev mueve el foco a la anterior
focus-window <id> enfoca la ventana <id> (ver: mirada-ctl windows)
move-forward adelanta la ventana enfocada en el teselado
move-backward la atrasa
close-focused cierra la ventana enfocada
toggle-float alterna flotante / teselada la enfocada
toggle-fullscreen alterna pantalla completa en la enfocada
send-to-scratchpad guarda la ventana enfocada en el scratchpad
toggle-scratchpad invoca u oculta la ventana del scratchpad
cycle-layout pasa al siguiente modo de teselado
layout <modo> master-stack · centered-master · spiral
grid · columns · rows · monocle
grow-master agranda el área de la ventana maestra
shrink-master la encoge
inc-master / dec-master de ventanas en el área maestra (nmaster)
promote-to-master la ventana enfocada al puesto maestro
workspace <n> activa el escritorio n (1..9)
send-to-workspace <n> manda la enfocada al escritorio n
focus-output-next pasa el foco al siguiente monitor
quit apaga el compositor
"
);
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "mirada-greeter"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada-greeter — el greeter de carmen: ventana Llimphi de login que autentica con auth-core y emite un SessionTicket al compositor por stdout."
[[bin]]
name = "mirada-greeter"
path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
llimphi-clipboard = { workspace = true }
app-bus = { workspace = true }
auth-core = { path = "../../../shared/auth/auth-core" }
rimay-localize = { path = "../../../shared/rimay-localize" }
wawa-config = { path = "../../../shared/wawa-config" }
+44
View File
@@ -0,0 +1,44 @@
# mirada-greeter
El greeter (pantalla de login) del escritorio carmen.
Una ventana GPUI: el compositor `mirada-compositor`, cuando bootea en
modo greeter, la arranca como proceso hijo, la compone a pantalla
completa (la reconoce por `app_id = "carmen.greeter"`) y le lee el
stdout.
## Flujo
1. El usuario teclea usuario + contraseña. `Enter` en «usuario» pasa el
foco a «contraseña»; `Enter` en «contraseña» autentica.
2. La autenticación corre con [`auth-core`] en un hilo de fondo (PAM
puede demorar ~2 s ante un fallo, no se congela la UI).
3. En éxito, el greeter **imprime un `SessionTicket` a stdout** y
termina. El compositor parsea esa línea y hace el traspaso a modo
sesión sin reiniciar el servidor gráfico.
La línea de tiquet lleva el prefijo `MIRADA-SESSION-TICKET-v1`; el resto
del stdout (logs) se ignora.
## Backend de autenticación
| Entorno | Backend |
|---|---|
| (por defecto) | PAM, servicio `carmen` (`/etc/pam.d/carmen`) |
| `MIRADA_GREETER_PAM=<servicio>` | PAM con otro servicio |
| `MIRADA_GREETER_MOCK=usuario:secreto` | Mock — credenciales fijas |
El modo mock sirve para iterar la UI en cajas sin PAM o con el greeter
anidado dentro de otro escritorio:
```sh
MIRADA_GREETER_MOCK=demo:demo cargo run -p mirada-greeter
```
## Integración con el compositor
El consumo del tiquet ya está cableado. `mirada-compositor --greeter`
lanza este greeter, lee su stdout y, al recibir el `SessionTicket`,
muta de `BodyMode::Greeter` a `BodyMode::Session` y arranca la sesión
del usuario con `setuid`/`setgid` — sin reiniciar el servidor Wayland.
Ver el README de `mirada-compositor`, sección **Modo greeter (DM)**.
+16
View File
@@ -0,0 +1,16 @@
# mirada-greeter
> Greeter (login screen) of [mirada](../README.md)'s desktop.
Runs from TTY, asks for credentials, validates against the system user database, then launches the user's session. Uses Llimphi for the visual; no PAM dependency — uses the same auth model as the rest of the monorepo.
## Usage
```sh
cargo run --release -p mirada-greeter
```
## Deps
- [`llimphi-ui`](../../llimphi/) + widgets `text-input`, `button`
- [`shared/auth/auth-core`](../../../shared/auth/auth-core/LEEME.md)
+896
View File
@@ -0,0 +1,896 @@
//! `mirada-greeter` — el greeter del escritorio carmen.
//!
//! Ventana Llimphi de login. El compositor (`mirada-compositor`) la arranca
//! como proceso hijo cuando bootea en modo greeter, la compone a pantalla
//! completa (la reconoce por `app_id = "carmen.greeter"`) y le lee el stdout.
//!
//! Flujo: el usuario teclea usuario + contraseña, el greeter autentica con
//! [`auth_core`], y en éxito **imprime un [`SessionTicket`] a stdout** y
//! termina. El compositor parsea esa línea, hace el traspaso a modo sesión
//! (setuid al usuario + arranque) sin reiniciar el servidor gráfico — la
//! «mutación atómica» del DM.
//!
//! Backend de autenticación (ver [`pick_authenticator`]):
//! - por defecto, PAM contra el servicio `carmen`;
//! - `MIRADA_GREETER_MOCK="usuario:secreto"` usa el mock, para iterar la UI
//! en cajas sin PAM o con el greeter anidado en otro escritorio.
mod rain;
mod sessions;
mod state;
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use auth_core::{
AuthError, Authenticator, MockAuthenticator, PamAuthenticator, SessionTicket, UserInfo,
DEFAULT_SERVICE,
};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_theme::Theme;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
use llimphi_widget_menubar::{
menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
DEFAULT_HEIGHT as MENU_H,
};
use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
use llimphi_motion::{animate, motion, Tween};
use llimphi_clipboard::SystemClipboard;
/// `app_id` con el que el compositor reconoce y compone el greeter.
const GREETER_APP_ID: &str = "carmen.greeter";
/// Autenticador compartible entre el hilo de UI y el de fondo.
type DynAuth = Arc<dyn Authenticator + Send + Sync>;
fn main() {
rimay_localize::init();
// Carga el idioma persistido en wawa-config (sobrescribe el default "es-PE").
let _ = rimay_localize::set_locale(&wawa_config::WawaConfig::load().lang);
llimphi_ui::run::<Greeter>();
}
/// Elige el backend de autenticación según el entorno.
fn pick_authenticator() -> DynAuth {
// Modo dev: credenciales fijas, sin tocar PAM.
if let Ok(spec) = std::env::var("MIRADA_GREETER_MOCK") {
if let Some((user, secret)) = spec.split_once(':') {
eprintln!("mirada-greeter · backend mock (usuario «{user}»)");
return Arc::new(MockAuthenticator::new().with_user(user, secret));
}
eprintln!("mirada-greeter · MIRADA_GREETER_MOCK mal formado (falta «:»), ignorado");
}
// Camino real: PAM. Servicio sobreescribible con `MIRADA_GREETER_PAM`.
let service =
std::env::var("MIRADA_GREETER_PAM").unwrap_or_else(|_| DEFAULT_SERVICE.to_string());
eprintln!("mirada-greeter · backend PAM (servicio «{service}»)");
Arc::new(PamAuthenticator::new(service))
}
/// Imprime el tiquet a stdout y fuerza el flush antes de terminar.
fn emit_ticket(ticket: &SessionTicket) {
println!("{}", ticket.to_line());
let _ = std::io::stdout().flush();
}
// ---------------------------------------------------------------------
// Modelo + mensajes
// ---------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
User,
Pass,
}
enum Status {
Idle,
Authenticating,
Failed(String),
}
struct Model {
auth: DynAuth,
user: TextInputState,
pass: TextInputState,
focus: Field,
status: Status,
/// Sesiones de escritorio descubiertas en el sistema (la 0 es mirada).
sessions: Vec<sessions::Session>,
/// Índice de la sesión elegida dentro de `sessions`.
session_idx: usize,
/// Clipboard del sistema, compartido por el menú de edición.
clipboard: SystemClipboard,
/// Menú principal: índice del menú raíz abierto (`None` cerrado).
menu_open: Option<usize>,
/// Menú de edición contextual: ancla `(x, y)` en ventana (`None` cerrado).
edit_menu: Option<(f32, f32)>,
/// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
menu_active: usize,
/// Animación de aparición/swap del dropdown principal.
menu_anim: Tween<f32>,
/// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
edit_active: usize,
/// Animación de aparición del menú de edición.
edit_anim: Tween<f32>,
/// ¿Pintar el fondo de lluvia de glifos (rusty rain)?
rain_enabled: bool,
/// Paleta del fondo de lluvia.
rain_color: state::RainColor,
/// Reloj del fondo (segundos), avanzado por `Msg::RainTick`.
rain_t: f32,
}
#[derive(Clone)]
enum Msg {
Focus(Field),
/// Tecla a aplicar al campo focado (`TextInputState::apply_key`).
EditKey(KeyEvent),
Submit,
AuthDone(Result<UserInfo, AuthError>),
/// Avanza la sesión elegida (con wrap) — clic en el selector de la
/// tarjeta.
CycleSession(i32),
/// Fija la sesión elegida por índice — elección desde el menú.
PickSession(usize),
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` = cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Right-click sobre la ventana → abre el menú de edición en `(x, y)`
/// operando sobre el campo focuseado.
EditMenuOpen(f32, f32),
/// Acción elegida en el menú de edición.
EditMenuAction(EditAction),
/// Navegación ↑/↓ por la fila activa del menú principal.
MenuNav(i32),
/// Enter sobre la fila activa del menú principal.
MenuActivate,
/// Tick de animación de aparición/swap (re-render).
MenuTick,
/// Navegación ↑/↓ por la fila activa del menú de edición.
EditNav(i32),
/// Enter sobre la fila activa del menú de edición.
EditActivate,
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Tick del fondo de lluvia — avanza el reloj y repinta.
RainTick,
}
// ---------------------------------------------------------------------
// Bucle Elm
// ---------------------------------------------------------------------
struct Greeter;
impl App for Greeter {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"carmen · greeter"
}
fn app_id() -> Option<&'static str> {
Some(GREETER_APP_ID)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let saved = state::GreeterState::load();
let sessions = sessions::discover();
// Prerellena el último usuario y arranca el foco directo en la
// contraseña si ya hay un nombre recordado.
let mut user = TextInputState::new();
if !saved.last_user.is_empty() {
user.set_text(saved.last_user.clone());
}
let focus = if saved.last_user.is_empty() {
Field::User
} else {
Field::Pass
};
// Restaura el último escritorio elegido buscándolo por nombre (los
// índices no son estables entre arranques: las sesiones del sistema
// pueden aparecer/desaparecer).
let session_idx = sessions
.iter()
.position(|s| s.name == saved.last_session)
.unwrap_or(0);
// Si el fondo está encendido, arranca el reloj de animación (~30 fps).
if saved.rain_enabled {
handle.spawn_periodic(Duration::from_millis(33), || Msg::RainTick);
}
Model {
auth: pick_authenticator(),
user,
pass: TextInputState::masked(),
focus,
status: Status::Idle,
sessions,
session_idx,
clipboard: SystemClipboard::new(),
menu_open: None,
edit_menu: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
edit_active: usize::MAX,
edit_anim: Tween::idle(1.0),
rain_enabled: saved.rain_enabled,
rain_color: saved.rain_color,
rain_t: 0.0,
}
}
fn on_key(model: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
if e.state != KeyState::Pressed {
return None;
}
// Mientras esperamos a PAM, no aceptamos input.
if matches!(model.status, Status::Authenticating) {
return None;
}
// Menú principal abierto: las flechas navegan. ←/→ cambian de menú
// raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc
// cierra.
if let Some(mi) = model.menu_open {
let n = app_menu(model).menus.len().max(1);
match &e.key {
Key::Named(NamedKey::Escape) => return Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowLeft) => {
return Some(Msg::MenuOpen(Some((mi + n - 1) % n)));
}
Key::Named(NamedKey::ArrowRight) => {
return Some(Msg::MenuOpen(Some((mi + 1) % n)));
}
Key::Named(NamedKey::ArrowDown) => return Some(Msg::MenuNav(1)),
Key::Named(NamedKey::ArrowUp) => return Some(Msg::MenuNav(-1)),
Key::Named(NamedKey::Enter) => return Some(Msg::MenuActivate),
_ => return None,
}
}
// Menú de edición abierto: ↑/↓ navegan, Enter ejecuta, Esc cierra.
if model.edit_menu.is_some() {
match &e.key {
Key::Named(NamedKey::Escape) => return Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowDown) => return Some(Msg::EditNav(1)),
Key::Named(NamedKey::ArrowUp) => return Some(Msg::EditNav(-1)),
Key::Named(NamedKey::Enter) => return Some(Msg::EditActivate),
_ => return None,
}
}
match &e.key {
Key::Named(NamedKey::Tab) => Some(Msg::Focus(toggle(model.focus))),
// ↑/↓ cambian de escritorio sin tocar el ratón (los campos de una
// línea no usan las flechas verticales, así que quedan libres).
Key::Named(NamedKey::ArrowUp) => Some(Msg::CycleSession(-1)),
Key::Named(NamedKey::ArrowDown) => Some(Msg::CycleSession(1)),
Key::Named(NamedKey::Enter) => {
if model.focus == Field::User {
Some(Msg::Focus(Field::Pass))
} else {
Some(Msg::Submit)
}
}
_ => {
// Todo lo demás se delega al widget — `apply_key` decide
// si la consume (printable, Backspace) o no.
Some(Msg::EditKey(e.clone()))
}
}
}
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model {
let mut m = model;
match msg {
Msg::Focus(f) => m.focus = f,
Msg::EditKey(ev) => {
let dst = match m.focus {
Field::User => &mut m.user,
Field::Pass => &mut m.pass,
};
if dst.apply_key(&ev) {
// Tipear limpia el error previo — el usuario está
// corrigiendo.
if matches!(m.status, Status::Failed(_)) {
m.status = Status::Idle;
}
}
}
Msg::Submit => {
if matches!(m.status, Status::Authenticating) {
return m;
}
let user = m.user.text().trim().to_string();
if user.is_empty() {
m.status = Status::Failed(rimay_localize::t("greeter-error-empty-user"));
m.focus = Field::User;
return m;
}
let secret = m.pass.text().to_string();
let auth = Arc::clone(&m.auth);
m.status = Status::Authenticating;
handle.spawn(move || Msg::AuthDone(auth.authenticate(&user, &secret)));
}
Msg::AuthDone(Ok(user)) => {
// El comando de la sesión elegida viaja en el tiquet. Vacío
// (sesión nativa mirada) ⇒ el compositor usa su autostart.
let chosen = m.sessions.get(m.session_idx);
let exec = chosen.map(|s| s.exec.clone()).unwrap_or_default();
let foreign = chosen.map(|s| s.foreign).unwrap_or(false);
// Recuerda usuario + escritorio (y la config del fondo) para
// el próximo login.
state::GreeterState {
last_user: m.user.text().trim().to_string(),
last_session: chosen.map(|s| s.name.clone()).unwrap_or_default(),
rain_enabled: m.rain_enabled,
rain_color: m.rain_color,
}
.save();
let ticket = SessionTicket::new(user);
let ticket = if exec.is_empty() {
ticket
} else {
ticket.with_session(exec).foreign(foreign)
};
emit_ticket(&ticket);
handle.quit();
}
Msg::CycleSession(dir) => {
let n = m.sessions.len().max(1) as i32;
let cur = m.session_idx as i32;
m.session_idx = (((cur + dir) % n + n) % n) as usize;
}
Msg::PickSession(i) => {
if i < m.sessions.len() {
m.session_idx = i;
}
m.menu_open = None;
}
Msg::AuthDone(Err(e)) => {
m.status = Status::Failed(e.to_string());
m.pass.clear();
m.focus = Field::Pass;
}
Msg::MenuOpen(idx) => {
m.menu_open = idx;
m.edit_menu = None;
m.menu_active = usize::MAX;
if idx.is_some() {
m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
animate(handle, motion::FAST, || Msg::MenuTick);
}
}
Msg::MenuNav(dir) => {
if let Some(mi) = m.menu_open {
let menu = app_menu(&m);
m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
}
}
Msg::MenuActivate => {
if let Some(mi) = m.menu_open {
let menu = app_menu(&m);
if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
return handle_menu_command(m, cmd, handle);
}
}
}
Msg::MenuTick => {}
Msg::EditNav(dir) => {
let (input, masked) = focused_input(&m);
let flags = EditFlags::from_editor(input.editor(), masked);
m.edit_active = editmenu::edit_menu_step(flags, m.edit_active, dir);
}
Msg::EditActivate => {
let (input, masked) = focused_input(&m);
let flags = EditFlags::from_editor(input.editor(), masked);
if let Some(a) = editmenu::edit_menu_action_at(flags, m.edit_active) {
return apply_edit_menu_action(m, a);
}
}
Msg::CloseMenus => {
m.menu_open = None;
m.edit_menu = None;
m.menu_active = usize::MAX;
m.edit_active = usize::MAX;
}
Msg::MenuCommand(cmd) => return handle_menu_command(m, cmd, handle),
Msg::EditMenuOpen(x, y) => {
// Mientras autenticamos no abrimos el menú de edición.
if !matches!(m.status, Status::Authenticating) {
m.edit_menu = Some((x, y));
m.edit_active = usize::MAX;
m.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
animate(handle, motion::FAST, || Msg::MenuTick);
}
}
Msg::EditMenuAction(action) => return apply_edit_menu_action(m, action),
Msg::RainTick => {
// Avanza el reloj del fondo. Se envuelve para no perder
// precisión `f32` en sesiones largas del greeter.
m.rain_t = (m.rain_t + 0.033) % 100_000.0;
}
}
m
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let theme = Theme::dark();
let menu = app_menu(model);
let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
let input_palette = TextInputPalette::from_theme(&theme);
// Barrita de acento sobre el título — el toque de color del DM.
let accent_bar = View::new(Style {
size: Size {
width: length(46.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.fill(theme.accent)
.radius(2.0);
let title = row(30.0, "carmen", 23.0, theme.fg_text);
let subtitle = row(
16.0,
&rimay_localize::t("greeter-subtitle"),
12.0,
theme.fg_muted,
);
let user_cap = row(
14.0,
&rimay_localize::t("greeter-label-user"),
10.0,
theme.fg_muted,
);
let user_box = text_input_view(
&model.user,
&rimay_localize::t("greeter-placeholder-user"),
model.focus == Field::User,
&input_palette,
Msg::Focus(Field::User),
);
let pass_cap = row(
14.0,
&rimay_localize::t("greeter-label-password"),
10.0,
theme.fg_muted,
);
let pass_box = text_input_view(
&model.pass,
"·······",
model.focus == Field::Pass,
&input_palette,
Msg::Focus(Field::Pass),
);
let (status_msg, status_color) = match &model.status {
Status::Idle => (String::new(), theme.fg_muted),
Status::Authenticating => (
rimay_localize::t("greeter-status-authenticating"),
theme.fg_muted,
),
Status::Failed(m) => (m.clone(), theme.fg_destructive),
};
let status_line = row(16.0, &status_msg, 11.0, status_color);
// Selector de sesión: una pastilla «‹ nombre · tipo ›». Siempre hay
// al menos «mirada» y «mirada · pata», así que las flechas sirven.
let sess = model.sessions.get(model.session_idx);
let sess_name = sess.map(|s| s.name.clone()).unwrap_or_else(|| "mirada".into());
let sess_kind = sess.map(|s| s.kind.tag()).unwrap_or("wayland");
let sess_cap = row(14.0, &rimay_localize::t("mirada-greeter-label-desktop"), 10.0, theme.fg_muted);
let arrow = |glyph: &str, msg: Msg| {
View::new(Style {
size: Size {
width: length(30.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_button)
.radius(7.0)
.text_aligned(glyph.to_string(), 14.0, theme.fg_text, Alignment::Center)
.on_click(msg)
};
let sess_center = View::new(Style {
flex_grow: 1.0,
size: Size {
width: Dimension::auto(),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_input)
.radius(7.0)
.text_aligned(
format!("{sess_name} · {sess_kind}"),
11.0,
theme.fg_text,
Alignment::Center,
);
let session_selector = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![
arrow("", Msg::CycleSession(-1)),
sess_center,
arrow("", Msg::CycleSession(1)),
]);
// Botón de entrar: la acción primaria, en color de acento. Mientras
// autentica se atenúa y cambia de rótulo.
let busy = matches!(model.status, Status::Authenticating);
let (btn_label, btn_fill) = if busy {
(rimay_localize::t("mirada-greeter-btn-submitting"), theme.bg_button)
} else {
(rimay_localize::t("mirada-greeter-btn-submit"), theme.accent)
};
let enter_btn = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(38.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(btn_fill)
.radius(9.0)
.text_aligned(
btn_label.to_string(),
13.0,
Color::from_rgba8(245, 246, 250, 255),
Alignment::Center,
)
.on_click(Msg::Submit);
let card = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(360.0_f32),
height: Dimension::auto(),
},
gap: Size {
width: length(0.0_f32),
height: length(11.0_f32),
},
padding: Rect {
left: length(32.0_f32),
right: length(32.0_f32),
top: length(30.0_f32),
bottom: length(26.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.radius(14.0)
.children(vec![
accent_bar,
title,
subtitle,
spacer(6.0),
user_cap,
user_box,
pass_cap,
pass_box,
status_line,
spacer(2.0),
sess_cap,
session_selector,
spacer(6.0),
enter_btn,
spacer(2.0),
row(13.0, &rimay_localize::t("mirada-greeter-hint-nav"), 9.0, theme.fg_muted),
row(13.0, &rimay_localize::t("mirada-greeter-hint-console"), 9.0, theme.fg_muted),
]);
// Zona central que aloja la tarjeta de login. Ocupa todo el
// espacio sobrante bajo la barra de menú. Si el fondo de lluvia está
// activo, su `paint_with` pinta detrás de la tarjeta (el painter de un
// nodo corre antes que sus hijos).
let mut body = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_app);
if model.rain_enabled {
let t = model.rain_t;
let bright = rain_bright(model.rain_color, &theme);
body = body.paint_with(move |scene, ts, rect| {
rain::paint(scene, ts, rect, t, bright);
});
}
let body = body.children(vec![card]);
// Raíz en columna: barra de menú arriba + cuerpo centrado. El
// right-click se engancha en la raíz (origen 0,0 ⇒ las coords
// locales ya son de ventana) y abre el menú de edición sobre el
// campo focuseado.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
.children(vec![menubar, body])
}
fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
let theme = Theme::dark();
let (w, h) = Self::initial_size();
let viewport = (w as f32, h as f32);
// El menú de edición tiene prioridad si está abierto.
if let Some((x, y)) = model.edit_menu {
let (input, masked) = focused_input(model);
let flags = EditFlags::from_editor(input.editor(), masked);
let mut spec = editmenu::edit_context_menu(
(x, y),
viewport,
&theme,
flags,
Msg::EditMenuAction,
Msg::CloseMenus,
);
spec.active = model.edit_active;
return Some(context_menu_view_ex(
spec,
ContextMenuExtras { appear: model.edit_anim.value(), ..Default::default() },
));
}
// Si no, el dropdown del menú principal.
let menu = app_menu(model);
menubar_overlay_animated(
&menubar_spec(&menu, model, &theme),
model.menu_active,
model.menu_anim.value(),
)
}
}
/// El campo de texto focuseado + si está enmascarado.
fn focused_input(model: &Model) -> (&TextInputState, bool) {
match model.focus {
Field::User => (&model.user, model.user.is_masked()),
Field::Pass => (&model.pass, model.pass.is_masked()),
}
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(
menu: &'a app_bus::AppMenu,
model: &Model,
theme: &'a Theme,
) -> MenuBarSpec<'a, Msg> {
let (w, h) = Greeter::initial_size();
MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport: (w as f32, h as f32),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// Construye el menú principal del greeter reflejando el estado real del
/// campo focuseado (Cortar/Copiar grises sin selección o si enmascarado).
fn app_menu(model: &Model) -> app_bus::AppMenu {
use app_bus::{AppMenu, Menu, MenuItem};
let t = rimay_localize::t;
let (input, masked) = focused_input(model);
let editor = input.editor();
let has_sel = editor.has_selection();
let can_undo = editor.can_undo();
let can_redo = editor.can_redo();
let has_text = !editor.is_empty();
let busy = matches!(model.status, Status::Authenticating);
let mut undo = MenuItem::new(t("undo"), "edit.undo").shortcut("Ctrl+Z");
if !can_undo { undo = undo.disabled(); }
let mut redo = MenuItem::new(t("redo"), "edit.redo").shortcut("Ctrl+Y");
if !can_redo { redo = redo.disabled(); }
let mut cut = MenuItem::new(t("cut"), "edit.cut").shortcut("Ctrl+X").separated();
let mut copy = MenuItem::new(t("copy"), "edit.copy").shortcut("Ctrl+C");
// Enmascarado o sin selección ⇒ no se puede cortar/copiar.
if !has_sel || masked { cut = cut.disabled(); copy = copy.disabled(); }
let paste = MenuItem::new(t("paste"), "edit.paste").shortcut("Ctrl+V");
let mut sel_all = MenuItem::new(t("select-all"), "edit.selectall").shortcut("Ctrl+A").separated();
if !has_text { sel_all = sel_all.disabled(); }
let mut iniciar = MenuItem::new(t("mirada-greeter-session-submit"), "session.submit").shortcut("Enter");
if busy { iniciar = iniciar.disabled(); }
// Menú "Sesión": acciones de login + la lista de sesiones descubiertas.
// La elegida lleva «●»; el resto « ».
let mut sesion = Menu::new(t("mirada-greeter-menu-session"))
.item(iniciar)
.item(MenuItem::new(t("mirada-greeter-session-goto-user"), "session.user"))
.item(MenuItem::new(t("mirada-greeter-session-goto-pass"), "session.pass"));
for (i, s) in model.sessions.iter().enumerate() {
let mark = if i == model.session_idx { "" } else { " " };
let label = format!("{mark}{} · {}", s.name, s.kind.tag());
let mut item = MenuItem::new(label, format!("session.pick.{i}"));
if i == 0 {
item = item.separated();
}
sesion = sesion.item(item);
}
// Menú de idioma: autónimos sin traducir. El item activo lleva ✔.
// El comando `lang.<code>` lo resuelve `handle_menu_command`.
let cur = rimay_localize::current_locale();
let lang_item = |label: &str, code: &str| {
let mut it = MenuItem::new(label, format!("lang.{code}"));
if cur == code {
it = it.icon("\u{2714}");
}
it
};
AppMenu::new()
.menu(sesion)
.menu(
Menu::new(t("edit"))
.item(undo)
.item(redo)
.item(cut)
.item(copy)
.item(paste)
.item(sel_all),
)
.menu(
Menu::new(t("language"))
.item(lang_item("Español", "es-PE"))
.item(lang_item("English", "en-US"))
.item(lang_item("Runasimi", "qu-PE")),
)
}
/// Traduce el `command` del menú principal al `Msg` real y lo despacha.
fn handle_menu_command(mut model: Model, command: String, handle: &Handle<Msg>) -> Model {
model.menu_open = None;
// Cambio de idioma desde el menú "Idioma": aplica el locale en caliente
// y lo persiste en la capa de usuario de wawa-config.
if let Some(code) = command.strip_prefix("lang.") {
let _ = rimay_localize::set_locale(code);
let mut cfg = wawa_config::WawaConfig::load();
cfg.lang = code.to_string();
let _ = cfg.save();
return model;
}
// Elección de sesión: «session.pick.<idx>».
if let Some(rest) = command.strip_prefix("session.pick.") {
if let Ok(i) = rest.parse::<usize>() {
return Greeter::update(model, Msg::PickSession(i), handle);
}
return model;
}
let target = match command.as_str() {
"session.submit" => Some(Msg::Submit),
"session.user" => Some(Msg::Focus(Field::User)),
"session.pass" => Some(Msg::Focus(Field::Pass)),
"edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
"edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
"edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
"edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
"edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
"edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
_ => None,
};
match target {
Some(Msg::Submit) => Greeter::update(model, Msg::Submit, handle),
Some(msg) => Greeter::update(model, msg, handle),
None => model,
}
}
/// Aplica una acción del menú de edición al campo focuseado. Limpia el
/// error previo si el contenido cambió (el usuario está corrigiendo).
fn apply_edit_menu_action(mut model: Model, action: EditAction) -> Model {
model.edit_menu = None;
let r = {
let mut clip = std::mem::replace(&mut model.clipboard, SystemClipboard::new());
let editor = match model.focus {
Field::User => model.user.editor_mut(),
Field::Pass => model.pass.editor_mut(),
};
let r = editmenu::apply(editor, action, &mut clip);
model.clipboard = clip;
r
};
if r.changed() && matches!(model.status, Status::Failed(_)) {
model.status = Status::Idle;
}
model
}
/// Resuelve el color base (RGB brillante) del fondo de lluvia. `Accent` toma
/// el acento del tema; el resto son paletas fijas.
fn rain_bright(color: state::RainColor, theme: &Theme) -> (u8, u8, u8) {
match color {
state::RainColor::Green => (120, 255, 140),
state::RainColor::Red => (255, 90, 80),
state::RainColor::Amber => (255, 200, 90),
state::RainColor::Cyan => (110, 235, 255),
state::RainColor::Accent => {
let c = theme.accent.to_rgba8();
(c.r, c.g, c.b)
}
}
}
fn toggle(f: Field) -> Field {
match f {
Field::User => Field::Pass,
Field::Pass => Field::User,
}
}
// ---------------------------------------------------------------------
// Helpers de vista
// ---------------------------------------------------------------------
/// Un hueco vertical de `h` px — separa grupos dentro de la tarjeta.
fn spacer(h: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(h),
},
..Default::default()
})
}
/// Fila de ancho completo con un texto a la izquierda.
fn row(height: f32, text: &str, size: f32, color: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(height),
},
..Default::default()
})
.text_aligned(text.to_string(), size, color, Alignment::Start)
}
+163
View File
@@ -0,0 +1,163 @@
//! «rusty rain» — el fondo de lluvia de glifos estilo *Matrix* del greeter.
//!
//! Inspirado en la animación del DM `ly` y en la rutina `rusty-rain` de Rust,
//! pero reescrito como **render puro y determinista**: dado el tiempo `t` (en
//! segundos) y el rect del lienzo, cada columna deriva su estado por hashing de
//! su índice. No hay `Vec<Columna>` mutable que persistir entre frames, así que
//! el efecto sobrevive a resizes y al modelo Elm sin estado extra — sólo se
//! avanza un `f32` de reloj.
//!
//! Se dibuja dentro de `paint_with` del cuerpo del greeter, por debajo de la
//! tarjeta de login (el painter de un nodo pinta antes que sus hijos).
use std::sync::OnceLock;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::vello;
use llimphi_ui::llimphi_text::{self, Alignment, Typesetter};
use llimphi_ui::PaintRect;
/// Semilla global del campo. Fija ⇒ el patrón es estable entre arranques; toda
/// la variedad sale del hashing por columna/celda.
const SEED: u64 = 0x6361726d_656e0001; // "carmen" + 1
/// Geometría de la grilla, en px.
const FONT_PX: f32 = 16.0;
const CELL_W: f32 = 14.0;
const CELL_H: f32 = 18.0;
/// splitmix64 — hash entero rápido y de buena dispersión.
fn hash(x: u64) -> u64 {
let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Hash → `f32` en `[0, 1)`.
fn hf(x: u64) -> f32 {
(hash(x) >> 40) as f32 / (1u64 << 24) as f32
}
/// El repertorio de glifos: katakana de ancho medio (el sello *Matrix*) más
/// dígitos, latinas y símbolos. Si la fuente del sistema no trae katakana,
/// fontique cae a un fallback CJK; lo peor es algún `.notdef`, que en este
/// contexto pasa por «glifo cifrado» igual.
fn glyphs() -> &'static [char] {
static G: OnceLock<Vec<char>> = OnceLock::new();
G.get_or_init(|| {
let mut v: Vec<char> = Vec::new();
for c in 0xFF66u32..=0xFF9D {
if let Some(ch) = char::from_u32(c) {
v.push(ch);
}
}
v.extend('0'..='9');
v.extend('A'..='Z');
v.extend([
'+', '-', '*', '/', '=', '<', '>', ':', ';', '#', '@', '%', '&', '$', '?',
]);
v
})
}
/// El glifo de la celda `(col, row)` en el instante `t`. Cada celda parpadea a
/// su propio ritmo (14 Hz) con fase propia, así la lluvia «muta» de forma
/// orgánica en vez de cambiar toda a la vez.
fn glyph_at(col: usize, row: i32, t: f32) -> char {
let g = glyphs();
let cell = hash(SEED ^ ((col as u64) << 20) ^ (row as u64 & 0xFFFFF));
let rate = 1.0 + hf(cell ^ 0xAB) * 3.0;
let flip = (t * rate) as u64;
g[(hash(cell ^ flip.wrapping_mul(0x9E37)) as usize) % g.len()]
}
/// Mezcla lineal de dos canales de 8 bits.
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8
}
/// Color de un glifo según su distancia a la cabeza del chorro: la cabeza
/// (`dist == 0`) casi blanca, el resto el color base atenuándose y volviéndose
/// translúcido hacia la cola.
fn glyph_color(dist: i32, tail: i32, bright: (u8, u8, u8)) -> Color {
let (br, bg, bb) = bright;
if dist <= 0 {
// Cabeza: tinte casi blanco para que «encienda» la columna.
return Color::from_rgba8(
lerp_u8(br, 255, 0.75),
lerp_u8(bg, 255, 0.85),
lerp_u8(bb, 255, 0.75),
255,
);
}
let f = (1.0 - dist as f32 / tail.max(1) as f32).clamp(0.0, 1.0);
let scale = 0.22 + 0.78 * f;
let a = (30.0 + 220.0 * f.powf(1.15)).clamp(0.0, 255.0) as u8;
Color::from_rgba8(
(br as f32 * scale) as u8,
(bg as f32 * scale) as u8,
(bb as f32 * scale) as u8,
a,
)
}
/// Pinta un frame de la lluvia sobre `rect`. `t` es el reloj en segundos;
/// `bright` el color base ya resuelto (RGB del tema o de la paleta elegida).
pub fn paint(scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect, t: f32, bright: (u8, u8, u8)) {
if rect.w < CELL_W || rect.h < CELL_H {
return;
}
let cols = (rect.w / CELL_W).ceil() as usize;
let rows = (rect.h / CELL_H).ceil() as i32 + 1;
let line_height = CELL_H / FONT_PX;
let dark = Color::from_rgba8(
(bright.0 as f32 * 0.2) as u8,
(bright.1 as f32 * 0.2) as u8,
(bright.2 as f32 * 0.2) as u8,
255,
);
for col in 0..cols {
// Parámetros estables de la columna, por hashing de su índice.
let base = hash(SEED ^ (col as u64).wrapping_mul(0x1_0000_01B3));
let speed = 5.0 + hf(base ^ 1) * 17.0; // filas por segundo
let tail = 6 + (hf(base ^ 2) * 22.0) as i32; // largo del chorro
let gap = 4.0 + hf(base ^ 3) * 28.0; // filas vacías entre pasadas
let phase = hf(base ^ 4);
let period = rows as f32 + tail as f32 + gap;
let head = (phase * period + t * speed).rem_euclid(period);
let head_i = head.floor() as i32;
let first_r = (head_i - tail + 1).max(0);
let last_r = head_i.min(rows - 1);
if last_r < first_r {
continue; // chorro completamente fuera de pantalla
}
// Una sola pasada de shaping por columna: el chorro es un string
// con los glifos separados por '\n' y un color por glifo.
let mut s = String::new();
let mut runs: Vec<(usize, usize, Color)> = Vec::new();
let mut byte = 0usize;
for r in first_r..=last_r {
let ch = glyph_at(col, r, t);
let color = glyph_color(head_i - r, tail, bright);
let mut buf = [0u8; 4];
let enc = ch.encode_utf8(&mut buf);
let start = byte;
s.push_str(enc);
byte += enc.len();
runs.push((start, byte, color));
if r != last_r {
s.push('\n');
byte += 1;
}
}
let x = rect.x + col as f32 * CELL_W;
let y = rect.y + first_r as f32 * CELL_H;
let layout = ts.layout_runs(&s, FONT_PX, dark, &runs, Alignment::Start, line_height);
llimphi_text::draw_layout_runs(scene, &layout, (x as f64, y as f64));
}
}
@@ -0,0 +1,268 @@
//! Enumeración de sesiones de escritorio instaladas.
//!
//! Un display manager no inventa qué sesiones ofrecer: las lee de los
//! directorios estándar de XDG. Cada sesión es un archivo `.desktop` con
//! un `Name` legible y un `Exec` que la arranca:
//!
//! - `…/wayland-sessions/*.desktop` → sesiones Wayland (sway, Hyprland,
//! Plasma Wayland, GNOME…).
//! - `…/xsessions/*.desktop` → sesiones X11.
//!
//! El greeter lista lo que **ya existe** en el sistema (sin instalar
//! nada), el usuario elige una, y su `Exec` viaja en el [`SessionTicket`]
//! para que el compositor la ejecute como el usuario autenticado. Si la
//! lista no trae nada de afuera, queda al menos la entrada nativa de
//! mirada (su autostart), que es `Exec` vacío.
use std::path::Path;
/// Servidor gráfico de una sesión (sólo informativo, para etiquetar).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
Wayland,
X11,
}
impl Kind {
pub fn tag(self) -> &'static str {
match self {
Kind::Wayland => "wayland",
Kind::X11 => "x11",
}
}
}
/// Una sesión ofrecible en el login.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session {
/// Nombre legible (campo `Name` del `.desktop`).
pub name: String,
/// Comando que la arranca (campo `Exec`, ya sin field-codes `%U`/`%i`).
/// Vacío ⇒ sesión nativa de mirada: el compositor cae a su autostart
/// en vez de ejecutar un comando ajeno.
pub exec: String,
pub kind: Kind,
/// `true` si es un compositor **ajeno** (descubierto en `wayland-sessions`):
/// el handoff lo lanza por `exec` soltando el DRM. `false` para las
/// nativas de mirada (pata, autostart), que corren como clientes.
pub foreign: bool,
}
/// Raíces XDG donde buscar sesiones, según `XDG_DATA_HOME` y
/// `XDG_DATA_DIRS` (con los defaults del spec si faltan). De cada raíz
/// `R` se miran `R/wayland-sessions` y `R/xsessions`. Honrar XDG es lo que
/// hace un DM de verdad —y permite dropear un `.desktop` en
/// `~/.local/share/wayland-sessions` para probar sin tocar `/usr`.
fn xdg_data_roots() -> Vec<String> {
let mut roots = Vec::new();
// XDG_DATA_HOME (default ~/.local/share) primero: gana sobre el sistema.
match std::env::var("XDG_DATA_HOME") {
Ok(h) if !h.is_empty() => roots.push(h),
_ => {
if let Ok(home) = std::env::var("HOME") {
roots.push(format!("{home}/.local/share"));
}
}
}
let 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 dirs.split(':').filter(|s| !s.is_empty()) {
roots.push(d.to_string());
}
roots
}
/// Descubre todas las sesiones del sistema. La primera entrada es siempre
/// la nativa de mirada (`Exec` vacío) para que la lista nunca esté vacía y
/// haya siempre un camino al autostart del compositor. Deduplica por
/// `(name, exec)` —la misma sesión puede aparecer en varias raíces XDG.
pub fn discover() -> Vec<Session> {
// Built-ins nativos: corren como clientes del propio compositor (no
// son `foreign`). «mirada» a secas ⇒ Exec vacío ⇒ autostart del
// usuario; «mirada · pata» ⇒ arranca el marco pata (forzado a su
// backend de ventana, que es como mirada lo acopla por app-id).
let mut out = vec![
Session {
name: "mirada".to_string(),
exec: String::new(),
kind: Kind::Wayland,
foreign: false,
},
Session {
// pata ancla por wlr-layer-shell (su backend nativo, que mirada
// ahora soporta): barra con zona exclusiva, sin winit ni app-id.
name: "mirada · pata".to_string(),
exec: "pata-llimphi".to_string(),
kind: Kind::Wayland,
foreign: false,
},
];
for root in xdg_data_roots() {
collect_dir(&Path::new(&root).join("wayland-sessions"), Kind::Wayland, &mut out);
collect_dir(&Path::new(&root).join("xsessions"), Kind::X11, &mut out);
}
// Las sesiones del propio mirada (carmen.desktop, mirada-pata.desktop)
// existen para DMs externos; aquí ya están cubiertas por los built-ins,
// así que las filtramos para no duplicar.
out.retain(|s| !is_mirada_session(&s.exec));
// Dedup global por (name, exec): la misma sesión puede repetirse en
// varias raíces XDG y no quedar contigua. `dedup_by` no basta.
let mut seen = std::collections::HashSet::new();
out.retain(|s| seen.insert((s.name.clone(), s.exec.clone())));
out
}
/// ¿El `Exec` arranca el propio mirada? (`mirada-session*`,
/// `mirada-compositor`). Esas sesiones las cubren los built-ins.
fn is_mirada_session(exec: &str) -> bool {
let first = exec.split_whitespace().next().unwrap_or("");
let base = first.rsplit('/').next().unwrap_or(first);
base.starts_with("mirada-session") || base == "mirada-compositor"
}
/// Lee un directorio de sesiones, parsea cada `.desktop` y agrega los
/// válidos a `out` ordenados por nombre. Un directorio inexistente o
/// ilegible se ignora en silencio (no todos los sistemas tienen ambos).
fn collect_dir(dir: &Path, kind: Kind, out: &mut Vec<Session>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut found: Vec<Session> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("desktop") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(session) = parse_entry(&text, kind) {
found.push(session);
}
}
found.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
out.extend(found);
}
/// Parsea un `.desktop`: toma `Name` y `Exec` de la sección
/// `[Desktop Entry]`. Devuelve `None` si está oculta (`Hidden`/`NoDisplay`)
/// o no trae un `Exec` ejecutable.
fn parse_entry(text: &str, kind: Kind) -> Option<Session> {
let mut in_main = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut hidden = false;
for raw in text.lines() {
let line = raw.trim();
if line.starts_with('[') && line.ends_with(']') {
// Sólo nos interesa la sección principal; las
// `[Desktop Action …]` se ignoran.
in_main = line == "[Desktop Entry]";
continue;
}
if !in_main || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
match key.trim() {
// Sólo la clave sin locale (`Name`, no `Name[es]`): el primero
// que aparezca gana.
"Name" if name.is_none() => name = Some(value.to_string()),
"Exec" if exec.is_none() => exec = Some(strip_field_codes(value)),
"Hidden" | "NoDisplay" if value == "true" => hidden = true,
_ => {}
}
}
if hidden {
return None;
}
let exec = exec?;
if exec.is_empty() {
return None;
}
Some(Session {
name: name.unwrap_or_else(|| exec.clone()),
exec,
kind,
// Toda sesión declarada en el sistema es un compositor ajeno: el
// handoff la lanza por `exec`, no como cliente.
foreign: true,
})
}
/// Quita los field-codes de un `Exec` (`%U`, `%f`, `%i`, `%k`, …). El
/// spec los reserva como tokens `%x` de dos chars; en sesiones casi nunca
/// aparecen, pero los limpiamos por las dudas para no pasarle basura a
/// `sh -c`.
fn strip_field_codes(exec: &str) -> String {
exec.split_whitespace()
.filter(|tok| !(tok.len() == 2 && tok.starts_with('%')))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsea_entrada_minima() {
let s = parse_entry("[Desktop Entry]\nName=Sway\nExec=sway\n", Kind::Wayland).unwrap();
assert_eq!(s.name, "Sway");
assert_eq!(s.exec, "sway");
assert_eq!(s.kind, Kind::Wayland);
}
#[test]
fn limpia_field_codes() {
let s = parse_entry(
"[Desktop Entry]\nName=Plasma\nExec=startplasma-wayland %U\n",
Kind::Wayland,
)
.unwrap();
assert_eq!(s.exec, "startplasma-wayland");
}
#[test]
fn ignora_ocultas() {
assert!(parse_entry(
"[Desktop Entry]\nName=X\nExec=x\nNoDisplay=true\n",
Kind::Wayland
)
.is_none());
assert!(
parse_entry("[Desktop Entry]\nName=X\nExec=x\nHidden=true\n", Kind::X11).is_none()
);
}
#[test]
fn ignora_otras_secciones() {
// El `Exec` de una `[Desktop Action]` no debe colarse como el de
// la sesión.
let s = parse_entry(
"[Desktop Entry]\nName=Foo\nExec=foo\n[Desktop Action New]\nExec=foo --new\n",
Kind::Wayland,
)
.unwrap();
assert_eq!(s.exec, "foo");
}
#[test]
fn sin_exec_no_es_sesion() {
assert!(parse_entry("[Desktop Entry]\nName=Solo nombre\n", Kind::Wayland).is_none());
}
#[test]
fn discover_siempre_trae_mirada() {
let v = discover();
assert_eq!(v[0].name, "mirada");
assert!(v[0].exec.is_empty());
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Estado persistente del greeter: recuerda el último usuario y el último
//! escritorio elegidos, y lleva la config del fondo (rusty rain).
//!
//! Formato: un archivo de texto con líneas `clave = valor` (igual de simple
//! que el parser de `.desktop` de [`crate::sessions`]; no metemos `toml` por
//! un puñado de claves). Se lee en `init` y se reescribe tras un login válido.
//!
//! Ruta: se prueban candidatas en orden (override por entorno, XDG, `$HOME`,
//! y `/var/lib` para arranques de sistema sin home de usuario). La lectura usa
//! la primera que exista; la escritura, la primera donde se pueda crear el
//! directorio y escribir.
use std::path::PathBuf;
/// Paleta del fondo de lluvia.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RainColor {
Green,
Red,
Amber,
Cyan,
Accent,
}
impl RainColor {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"green" | "verde" => Some(Self::Green),
"red" | "rojo" => Some(Self::Red),
"amber" | "ambar" | "ámbar" => Some(Self::Amber),
"cyan" | "cian" => Some(Self::Cyan),
"accent" | "acento" => Some(Self::Accent),
_ => None,
}
}
fn tag(self) -> &'static str {
match self {
Self::Green => "green",
Self::Red => "red",
Self::Amber => "amber",
Self::Cyan => "cyan",
Self::Accent => "accent",
}
}
}
/// Estado persistido del greeter.
#[derive(Clone, Debug)]
pub struct GreeterState {
/// Último usuario que entró (para prerellenar el campo).
pub last_user: String,
/// Nombre del último escritorio elegido (se matchea contra `sessions`).
pub last_session: String,
/// ¿Pintar el fondo de lluvia?
pub rain_enabled: bool,
/// Paleta del fondo.
pub rain_color: RainColor,
}
impl Default for GreeterState {
fn default() -> Self {
Self {
last_user: String::new(),
last_session: String::new(),
// El fondo viene encendido por defecto; se apaga por archivo o
// por `MIRADA_GREETER_RAIN=0`.
rain_enabled: true,
rain_color: RainColor::Green,
}
}
}
/// `true` si el valor representa un booleano afirmativo.
fn truthy(v: &str) -> bool {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes" | "si" | ""
)
}
impl GreeterState {
/// Carga el estado del primer archivo candidato que exista, y aplica los
/// overrides de entorno por encima. Nunca falla: cae al default.
pub fn load() -> Self {
let mut st = Self::default();
for p in candidate_paths() {
if let Ok(text) = std::fs::read_to_string(&p) {
st.merge_text(&text);
break;
}
}
st.apply_env();
st
}
/// Mezcla las claves de un archivo `clave = valor` sobre `self`.
fn merge_text(&mut self, text: &str) {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
let (k, v) = (k.trim(), v.trim());
match k {
"last_user" => self.last_user = v.to_string(),
"last_session" => self.last_session = v.to_string(),
"rain" => self.rain_enabled = truthy(v),
"rain_color" => {
if let Some(c) = RainColor::parse(v) {
self.rain_color = c;
}
}
_ => {}
}
}
}
/// Overrides de entorno: `MIRADA_GREETER_RAIN` (bool) y
/// `MIRADA_GREETER_RAIN_COLOR` (paleta).
fn apply_env(&mut self) {
if let Ok(v) = std::env::var("MIRADA_GREETER_RAIN") {
self.rain_enabled = truthy(&v);
}
if let Ok(v) = std::env::var("MIRADA_GREETER_RAIN_COLOR") {
if let Some(c) = RainColor::parse(&v) {
self.rain_color = c;
}
}
}
/// Serializa a `clave = valor`.
fn to_text(&self) -> String {
format!(
"# mirada-greeter — estado recordado\n\
last_user = {}\n\
last_session = {}\n\
rain = {}\n\
rain_color = {}\n",
self.last_user,
self.last_session,
self.rain_enabled,
self.rain_color.tag(),
)
}
/// Persiste el estado en el primer candidato escribible. Silencioso: si
/// ningún destino acepta la escritura (greeter sin home, FS de sólo
/// lectura) no es fatal — sólo se pierde la memoria entre logins.
pub fn save(&self) {
let body = self.to_text();
for p in candidate_paths() {
if let Some(dir) = p.parent() {
if !dir.as_os_str().is_empty() && std::fs::create_dir_all(dir).is_err() {
continue;
}
}
if std::fs::write(&p, &body).is_ok() {
return;
}
}
}
}
/// Rutas candidatas para el archivo de estado, en orden de preferencia.
fn candidate_paths() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(p) = std::env::var("MIRADA_GREETER_STATE") {
if !p.is_empty() {
out.push(PathBuf::from(p));
}
}
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").filter(|x| !x.is_empty()) {
out.push(PathBuf::from(x).join("mirada/greeter.conf"));
}
if let Some(h) = std::env::var_os("HOME").filter(|h| !h.is_empty()) {
out.push(PathBuf::from(h).join(".config/mirada/greeter.conf"));
}
out.push(PathBuf::from("/var/lib/mirada/greeter.conf"));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsea_claves() {
let mut st = GreeterState::default();
st.merge_text(
"# comentario\nlast_user = ana\nlast_session = Sway\nrain = off\nrain_color = cyan\n",
);
assert_eq!(st.last_user, "ana");
assert_eq!(st.last_session, "Sway");
assert!(!st.rain_enabled);
assert_eq!(st.rain_color, RainColor::Cyan);
}
#[test]
fn round_trip() {
let st = GreeterState {
last_user: "bob".into(),
last_session: "mirada · pata".into(),
rain_enabled: true,
rain_color: RainColor::Amber,
};
let mut back = GreeterState::default();
back.merge_text(&st.to_text());
assert_eq!(back.last_user, "bob");
assert_eq!(back.last_session, "mirada · pata");
assert!(back.rain_enabled);
assert_eq!(back.rain_color, RainColor::Amber);
}
}
@@ -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]
+15
View File
@@ -0,0 +1,15 @@
# mirada-launcher
> App launcher de [mirada](../README.md).
Fuzzy-finder de aplicaciones instaladas (lee `.desktop` files en Linux). Atajo configurable (`Super+Space` por default).
## Uso
```sh
cargo run --release -p mirada-launcher
```
## Deps
- [`llimphi-ui`](../../llimphi/) + widget `text-input`, `list`
+15
View File
@@ -0,0 +1,15 @@
# mirada-launcher
> App launcher of [mirada](../README.md).
Fuzzy-finder of installed applications (reads `.desktop` files on Linux). Configurable shortcut (`Super+Space` by default).
## Usage
```sh
cargo run --release -p mirada-launcher
```
## Deps
- [`llimphi-ui`](../../llimphi/) + widget `text-input`, `list`
+274
View File
@@ -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);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "mirada-layout"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — motor de teselado del compositor Wayland: reparte la pantalla entre ventanas según el modo de layout. Agnóstico de Wayland y de smithay."
[dependencies]
# Deps declaradas directas (no `workspace = true`): mirada-layout es un
# crate-núcleo compartido entre dos workspaces de Cargo —brahman y
# renaser— así que mantiene sus dependencias autocontenidas.
#
# `libm`: `sqrt`/`ceil`/`round` viven en `std`, no en `core`; este crate
# es `no_std`, así que la matemática de punto flotante pasa por `libm`.
libm = "0.2"
# `serde` es opcional: el motor de teselado no necesita (de)serializar
# para funcionar. Los consumidores Linux (mirada-protocol/brain) activan
# la feature; el kernel bare-metal de renaser no. `default-features =
# false` evita arrastrar `std` aun con la feature activa.
serde = { version = "1", optional = true, default-features = false, features = ["derive", "alloc"] }
[features]
serde = ["dep:serde"]
+9
View File
@@ -0,0 +1,9 @@
# mirada-layout
> Reglas de layout de ventanas de [mirada](../README.md).
Algoritmos: floating, tiling (i3-style), tabbed, monocle. Cambiables por workspace. Los rules-engine de [`mirada-brain`](../mirada-brain/README.md) lo invocan.
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md)
+9
View File
@@ -0,0 +1,9 @@
# mirada-layout
> Window layout rules of [mirada](../README.md).
Algorithms: floating, tiling (i3-style), tabbed, monocle. Per-workspace switchable. The [`mirada-brain`](../mirada-brain/README.md) rules engine invokes it.
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md)
@@ -0,0 +1,107 @@
//! Geometría — el rectángulo en coordenadas de pantalla.
use alloc::vec::Vec;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Un rectángulo en píxeles de pantalla. El origen `(0,0)` es la
/// esquina superior-izquierda; `x` crece a la derecha, `y` hacia abajo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
Self { x, y, w, h }
}
/// Área en píxeles cuadrados.
pub fn area(&self) -> i64 {
self.w.max(0) as i64 * self.h.max(0) as i64
}
/// `true` si el rectángulo tiene ancho y alto positivos.
pub fn is_visible(&self) -> bool {
self.w > 0 && self.h > 0
}
/// Encoge el rectángulo `g` píxeles por cada lado. Si el margen se
/// come toda la dimensión, ésta queda en `0` (no negativa).
pub fn inset(&self, g: i32) -> Rect {
Rect {
x: self.x + g,
y: self.y + g,
w: (self.w - 2 * g).max(0),
h: (self.h - 2 * g).max(0),
}
}
/// `true` si `(px, py)` cae dentro del rectángulo.
pub fn contains(&self, px: i32, py: i32) -> bool {
px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
}
}
/// Reparte `total` píxeles en `n` tramos contiguos sin perder ni un
/// píxel: las fronteras caen en `total · k / n`, así que la suma de los
/// tamaños es exactamente `total`. Devuelve `(offset, tamaño)` por tramo.
pub fn split(total: i32, n: usize) -> Vec<(i32, i32)> {
if n == 0 {
return Vec::new();
}
let total = total.max(0) as i64;
let n64 = n as i64;
(0..n)
.map(|k| {
let start = total * k as i64 / n64;
let end = total * (k as i64 + 1) / n64;
(start as i32, (end - start) as i32)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inset_shrinks_by_gap_on_every_side() {
let r = Rect::new(0, 0, 100, 80).inset(5);
assert_eq!(r, Rect::new(5, 5, 90, 70));
}
#[test]
fn inset_clamps_to_zero() {
let r = Rect::new(0, 0, 8, 8).inset(10);
assert_eq!((r.w, r.h), (0, 0));
assert!(!r.is_visible());
}
#[test]
fn split_loses_no_pixels() {
for n in 1..=13 {
let parts = split(1000, n);
assert_eq!(parts.len(), n);
assert_eq!(parts.iter().map(|(_, s)| *s).sum::<i32>(), 1000);
// Los tramos son contiguos.
for w in parts.windows(2) {
assert_eq!(w[0].0 + w[0].1, w[1].0);
}
}
}
#[test]
fn contains_checks_bounds() {
let r = Rect::new(10, 10, 20, 20);
assert!(r.contains(10, 10));
assert!(r.contains(29, 29));
assert!(!r.contains(30, 30));
assert!(!r.contains(9, 15));
}
}
+628
View File
@@ -0,0 +1,628 @@
//! Modos de teselado — cómo se reparte la pantalla entre ventanas.
use alloc::{vec, vec::Vec};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::geometry::{split, Rect};
/// Estrategia de teselado.
///
/// Las variantes nuevas se añaden **al final** para no mover los índices
/// con que `postcard` las serializa en el API de control.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub enum LayoutMode {
/// Una ventana maestra a la izquierda; el resto apiladas a la derecha.
MasterStack,
/// Todas a pantalla completa, superpuestas — sólo se ve la enfocada.
Monocle,
/// Rejilla uniforme.
Grid,
/// Columnas verticales de igual ancho.
Columns,
/// Filas horizontales de igual alto.
Rows,
/// Ventana maestra centrada; el resto en columnas a ambos lados.
/// Pensado para monitores anchos.
CenteredMaster,
/// Espiral de Fibonacci: cada ventana parte por la mitad el espacio
/// que queda, alternando el sentido del corte.
Spiral,
}
/// Una zona como fracciones `0..=1` de una pantalla: esquina `(x, y)` y tamaño
/// `(w, h)`. Es un **blanco de arrastre** (drag-to-zone): el compositor la pinta
/// mientras se arrastra una ventana y, al soltar encima, la ancla a este rect.
/// El nombre vive en la config; el motor sólo da la geometría ([`Self::to_rect`]).
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ZoneFrac {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl ZoneFrac {
/// Escala la zona (fracciones `0..=1`) a un rect absoluto dentro de
/// `screen`, acotándola para que no se salga. Pura (`no_std`).
pub fn to_rect(self, screen: Rect) -> Rect {
let fx = self.x.clamp(0.0, 1.0);
let fy = self.y.clamp(0.0, 1.0);
// `clamp` (núcleo) en vez de `min` (que vive en `std`): este crate es no_std.
let fw = self.w.clamp(0.0, 1.0 - fx);
let fh = self.h.clamp(0.0, 1.0 - fy);
// `libm` (no `f32::round`, que vive en `std`): este crate es `no_std`.
let x = screen.x + libm::roundf(fx * screen.w as f32) as i32;
let y = screen.y + libm::roundf(fy * screen.h as f32) as i32;
let w = (libm::roundf(fw * screen.w as f32) as i32).max(1);
let h = (libm::roundf(fh * screen.h as f32) as i32).max(1);
Rect::new(x, y, w, h)
}
}
/// Cómo se coloca la imagen del wallpaper dentro de la salida. Es **geometría
/// pura**: el compositor decide qué hacer con el rect que devuelve [`wallpaper_dst_rect`]
/// y los píxeles que quedan fuera de él (típicamente negro).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub enum WallpaperFit {
/// Deformar la imagen para que cubra exactamente la salida. Sin barras
/// negras, puede distorsionar la relación de aspecto. Es el default.
Stretch,
/// Encajar la imagen entera dentro de la salida (contain): se respeta el
/// aspecto y la dimensión más restrictiva toca el borde; el resto queda
/// negro (letterbox/pillarbox).
Fit,
/// Cubrir la salida (cover): se respeta el aspecto y la dimensión más
/// laxa toca el borde; el resto de la imagen sobresale y se recorta.
Fill,
/// Pegar la imagen en su tamaño nativo, centrada. Si es más chica queda
/// rodeada de negro; si es más grande se recorta.
Center,
/// Repetir la imagen en su tamaño nativo, tilada desde la esquina
/// superior-izquierda hasta cubrir la salida.
Tile,
}
impl Default for WallpaperFit {
fn default() -> Self {
WallpaperFit::Stretch
}
}
impl WallpaperFit {
/// Identificador kebab-case del modo (`"stretch"`, `"fit"`, `"fill"`,
/// `"center"`, `"tile"`) para serializarlo en config de texto.
pub fn slug(self) -> &'static str {
match self {
WallpaperFit::Stretch => "stretch",
WallpaperFit::Fit => "fit",
WallpaperFit::Fill => "fill",
WallpaperFit::Center => "center",
WallpaperFit::Tile => "tile",
}
}
/// Parsea un slug; `None` si no coincide con ninguno conocido.
pub fn from_slug(slug: &str) -> Option<Self> {
Some(match slug {
"stretch" => WallpaperFit::Stretch,
"fit" => WallpaperFit::Fit,
"fill" => WallpaperFit::Fill,
"center" => WallpaperFit::Center,
"tile" => WallpaperFit::Tile,
_ => return None,
})
}
}
/// Para `Stretch`/`Fit`/`Fill`/`Center`, devuelve el rect `(x, y, w, h)` —en
/// coordenadas de la salida— donde se pinta la imagen escalada. El consumidor
/// rellena el resto con un color de fondo (típicamente negro). Para `Center`
/// la imagen va a su tamaño nativo (puede salirse del destino, se clipea).
/// Para [`WallpaperFit::Tile`] devuelve `(0, 0, sw, sh)` — el consumidor lo
/// tila a mano.
///
/// Pura (`no_std`): aritmética entera salvo el escalado proporcional, que usa
/// `libm` para no depender de `std`.
pub fn wallpaper_dst_rect(
fit: WallpaperFit,
src_w: i32,
src_h: i32,
dst_w: i32,
dst_h: i32,
) -> (i32, i32, i32, i32) {
let sw = src_w.max(1);
let sh = src_h.max(1);
let dw = dst_w.max(0);
let dh = dst_h.max(0);
match fit {
WallpaperFit::Stretch => (0, 0, dw, dh),
WallpaperFit::Tile => (0, 0, sw, sh),
WallpaperFit::Center => {
let x = (dw - sw) / 2;
let y = (dh - sh) / 2;
(x, y, sw, sh)
}
WallpaperFit::Fit | WallpaperFit::Fill => {
// `src_wider`: la imagen es más ancha que el destino (mismo signo
// que `sw/sh > dw/dh`, sin floats: `sw*dh > sh*dw`).
let src_wider = (sw as i64) * (dh as i64) > (sh as i64) * (dw as i64);
// Fit (contain): la dimensión más restrictiva toca el borde, la
// imagen entra entera → si la imagen es más ancha, igualar ancho.
// Fill (cover): la dimensión más laxa toca el borde, la imagen
// sobresale por la otra → si la imagen es más ancha, igualar alto.
let match_width = match fit {
WallpaperFit::Fit => src_wider,
WallpaperFit::Fill => !src_wider,
_ => unreachable!(),
};
let (scaled_w, scaled_h) = if match_width {
let h = libm::roundf(dw as f32 * sh as f32 / sw as f32) as i32;
(dw, h.max(1))
} else {
let w = libm::roundf(dh as f32 * sw as f32 / sh as f32) as i32;
(w.max(1), dh)
};
let x = (dw - scaled_w) / 2;
let y = (dh - scaled_h) / 2;
(x, y, scaled_w, scaled_h)
}
}
}
impl LayoutMode {
/// Todos los modos, en el orden del ciclo de `CycleLayout`.
pub const ALL: [LayoutMode; 7] = [
LayoutMode::MasterStack,
LayoutMode::CenteredMaster,
LayoutMode::Spiral,
LayoutMode::Grid,
LayoutMode::Columns,
LayoutMode::Rows,
LayoutMode::Monocle,
];
/// El siguiente modo en el ciclo (envuelve al llegar al final).
pub fn next(self) -> LayoutMode {
let i = Self::ALL.iter().position(|&m| m == self).unwrap_or(0);
Self::ALL[(i + 1) % Self::ALL.len()]
}
}
/// Parámetros del teselado.
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LayoutParams {
pub mode: LayoutMode,
/// Fracción del ancho para la ventana maestra en `MasterStack` y
/// `CenteredMaster` (se acota a `0.05..=0.95`).
pub master_ratio: f32,
/// Cuántas ventanas van en el área maestra (`nmaster`); al menos 1.
pub master_count: usize,
/// Margen en píxeles alrededor de cada ventana.
pub gap: i32,
}
impl Default for LayoutParams {
fn default() -> Self {
Self {
mode: LayoutMode::MasterStack,
master_ratio: 0.6,
master_count: 1,
gap: 8,
}
}
}
/// Calcula el rectángulo de cada una de las `count` ventanas dentro de
/// `screen`. El vector resultante tiene exactamente `count` elementos,
/// en el mismo orden que las ventanas.
pub fn tile(screen: Rect, count: usize, params: &LayoutParams) -> Vec<Rect> {
if count == 0 {
return Vec::new();
}
let cells = match params.mode {
LayoutMode::Monocle => vec![screen; count],
LayoutMode::Columns => columns(screen, count),
LayoutMode::Rows => rows(screen, count),
LayoutMode::Grid => grid(screen, count),
LayoutMode::MasterStack => {
master_stack(screen, count, params.master_ratio, params.master_count)
}
LayoutMode::CenteredMaster => {
centered_master(screen, count, params.master_ratio, params.master_count)
}
LayoutMode::Spiral => spiral(screen, count),
};
// El margen se aplica al final, uniforme para todos los modos. *Smart
// gaps*: una sola ventana va a sangre, sin margen desperdiciado.
let gap = if count == 1 { 0 } else { params.gap };
cells.into_iter().map(|c| c.inset(gap)).collect()
}
/// Columnas verticales de igual ancho.
fn columns(screen: Rect, count: usize) -> Vec<Rect> {
split(screen.w, count)
.into_iter()
.map(|(off, w)| Rect::new(screen.x + off, screen.y, w, screen.h))
.collect()
}
/// Rejilla `cols × rows` lo más cuadrada posible.
fn grid(screen: Rect, count: usize) -> Vec<Rect> {
// `libm` en vez de los métodos de `f64`: `sqrt`/`ceil` viven en
// `std`, no en `core` — y este crate es `no_std`.
let cols = libm::ceil(libm::sqrt(count as f64)) as usize;
let rows = count.div_ceil(cols);
let col_parts = split(screen.w, cols);
let row_parts = split(screen.h, rows);
(0..count)
.map(|i| {
let (cx, cw) = col_parts[i % cols];
let (ry, rh) = row_parts[i / cols];
Rect::new(screen.x + cx, screen.y + ry, cw, rh)
})
.collect()
}
/// Filas horizontales de igual alto.
fn rows(screen: Rect, count: usize) -> Vec<Rect> {
split(screen.h, count)
.into_iter()
.map(|(off, h)| Rect::new(screen.x, screen.y + off, screen.w, h))
.collect()
}
/// Espiral de Fibonacci: cada ventana se queda con la mitad del espacio
/// libre y la siguiente recurre en la otra mitad, alternando el corte.
/// La última ventana llena todo lo que sobra.
fn spiral(screen: Rect, count: usize) -> Vec<Rect> {
let mut out = Vec::with_capacity(count);
let mut area = screen;
let mut horizontal = true;
for _ in 1..count {
if horizontal {
let p = split(area.w, 2);
out.push(Rect::new(area.x, area.y, p[0].1, area.h));
area = Rect::new(area.x + p[1].0, area.y, p[1].1, area.h);
} else {
let p = split(area.h, 2);
out.push(Rect::new(area.x, area.y, area.w, p[0].1));
area = Rect::new(area.x, area.y + p[1].0, area.w, p[1].1);
}
horizontal = !horizontal;
}
out.push(area);
out
}
/// `master_count` ventanas maestras centradas + el resto repartido en
/// columnas a ambos lados.
fn centered_master(screen: Rect, count: usize, ratio: f32, master_count: usize) -> Vec<Rect> {
let m = master_count.clamp(1, count);
let stack = count - m;
// Centrar sólo tiene sentido con al menos una ventana por lado.
if stack < 2 {
return master_stack(screen, count, ratio, master_count);
}
let ratio = ratio.clamp(0.05, 0.95);
let master_w = libm::roundf(screen.w as f32 * ratio) as i32;
let sides = split(screen.w - master_w, 2);
let (left_w, right_w) = (sides[0].1, sides[1].1);
let left_n = stack / 2;
let right_n = stack - left_n;
let mut out = Vec::with_capacity(count);
// Las maestras, apiladas en la columna central — orden de teselado.
for (off, h) in split(screen.h, m) {
out.push(Rect::new(screen.x + left_w, screen.y + off, master_w, h));
}
for (off, h) in split(screen.h, left_n) {
out.push(Rect::new(screen.x, screen.y + off, left_w, h));
}
for (off, h) in split(screen.h, right_n) {
out.push(Rect::new(screen.x + left_w + master_w, screen.y + off, right_w, h));
}
out
}
/// `master_count` ventanas maestras a la izquierda + el resto en pila a
/// la derecha. Sin pila, las maestras llenan toda la pantalla.
fn master_stack(screen: Rect, count: usize, ratio: f32, master_count: usize) -> Vec<Rect> {
let m = master_count.clamp(1, count);
let stack = count - m;
if stack == 0 {
return split(screen.h, m)
.into_iter()
.map(|(off, h)| Rect::new(screen.x, screen.y + off, screen.w, h))
.collect();
}
let ratio = ratio.clamp(0.05, 0.95);
let master_w = libm::roundf(screen.w as f32 * ratio) as i32;
let stack_x = screen.x + master_w;
let stack_w = screen.w - master_w;
let mut out = Vec::with_capacity(count);
for (off, h) in split(screen.h, m) {
out.push(Rect::new(screen.x, screen.y + off, master_w, h));
}
for (off, h) in split(screen.h, stack) {
out.push(Rect::new(stack_x, screen.y + off, stack_w, h));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
fn params(mode: LayoutMode) -> LayoutParams {
LayoutParams { mode, gap: 0, ..LayoutParams::default() }
}
#[test]
fn empty_count_yields_no_rects() {
assert!(tile(SCREEN, 0, &params(LayoutMode::Grid)).is_empty());
}
#[test]
fn zone_frac_escala_a_pixeles() {
let screen = Rect::new(0, 0, 1000, 800);
let z = ZoneFrac { x: 0.5, y: 0.0, w: 0.5, h: 0.5 };
assert_eq!(z.to_rect(screen), Rect::new(500, 0, 500, 400));
}
#[test]
fn zone_frac_fuera_de_rango_se_acota_a_la_pantalla() {
let screen = Rect::new(0, 0, 1000, 800);
// x=0.8 + w=0.5 se pasa: w se recorta a 0.2.
let z = ZoneFrac { x: 0.8, y: 0.0, w: 0.5, h: 1.0 };
assert_eq!(z.to_rect(screen), Rect::new(800, 0, 200, 800));
}
#[test]
fn tile_count_matches_window_count() {
for mode in LayoutMode::ALL {
for n in 1..=9 {
assert_eq!(tile(SCREEN, n, &params(mode)).len(), n, "modo {mode:?}");
}
}
}
#[test]
fn rows_partition_the_height_exactly() {
let rects = tile(SCREEN, 3, &params(LayoutMode::Rows));
assert_eq!(rects.iter().map(|r| r.h).sum::<i32>(), 1080);
assert!(rects.iter().all(|r| r.w == 1920));
}
#[test]
fn spiral_tiles_cover_the_screen_without_overlap() {
for n in 1..=9 {
let total: i64 = tile(SCREEN, n, &params(LayoutMode::Spiral))
.iter()
.map(|r| r.area())
.sum();
assert_eq!(total, SCREEN.area(), "espiral con {n} ventanas");
}
}
#[test]
fn centered_master_centers_the_master_and_covers_the_screen() {
let rects = tile(SCREEN, 5, &params(LayoutMode::CenteredMaster));
let master = rects[0];
// Hueco a la izquierda y a la derecha de la maestra: iguales ±1px.
let left = master.x - SCREEN.x;
let right = (SCREEN.x + SCREEN.w) - (master.x + master.w);
assert!((left - right).abs() <= 1, "maestra no centrada: {left} vs {right}");
let total: i64 = rects.iter().map(|r| r.area()).sum();
assert_eq!(total, SCREEN.area());
}
#[test]
fn layout_mode_next_cycles_through_every_mode() {
let mut visited: Vec<LayoutMode> = Vec::new();
let mut m = LayoutMode::MasterStack;
for _ in 0..LayoutMode::ALL.len() {
assert!(!visited.contains(&m), "modo repetido en el ciclo: {m:?}");
visited.push(m);
m = m.next();
}
// Tras una vuelta completa, de vuelta al inicio.
assert_eq!(m, LayoutMode::MasterStack);
for mode in LayoutMode::ALL {
assert!(visited.contains(&mode), "el ciclo no pasa por {mode:?}");
}
}
#[test]
fn monocle_gives_every_window_the_full_screen() {
for r in tile(SCREEN, 4, &params(LayoutMode::Monocle)) {
assert_eq!(r, SCREEN);
}
}
#[test]
fn columns_partition_the_width_exactly() {
let rects = tile(SCREEN, 3, &params(LayoutMode::Columns));
assert_eq!(rects.iter().map(|r| r.w).sum::<i32>(), 1920);
// Todas ocupan el alto completo.
assert!(rects.iter().all(|r| r.h == 1080));
}
#[test]
fn master_stack_master_takes_its_ratio() {
let rects = tile(SCREEN, 3, &params(LayoutMode::MasterStack));
// 60% de 1920 = 1152.
assert_eq!(rects[0].w, 1152);
// Las dos de la pila comparten el resto del ancho y el alto.
assert_eq!(rects[1].w, 1920 - 1152);
assert_eq!(rects[1].h + rects[2].h, 1080);
}
#[test]
fn master_stack_single_window_fills_screen() {
let rects = tile(SCREEN, 1, &params(LayoutMode::MasterStack));
assert_eq!(rects[0], SCREEN);
}
#[test]
fn grid_tiles_cover_the_screen_without_overlap() {
// 4 ventanas → rejilla 2×2, cada una un cuarto.
let rects = tile(SCREEN, 4, &params(LayoutMode::Grid));
let total: i64 = rects.iter().map(|r| r.area()).sum();
assert_eq!(total, SCREEN.area());
}
#[test]
fn gap_shrinks_every_window() {
let p = LayoutParams { mode: LayoutMode::Columns, gap: 10, ..LayoutParams::default() };
for r in tile(SCREEN, 2, &p) {
// Cada celda de 960 de ancho se encoge 20 (10 por lado).
assert_eq!(r.w, 960 - 20);
assert_eq!(r.h, 1080 - 20);
}
}
#[test]
fn nmaster_keeps_n_windows_in_the_master_column() {
let p = LayoutParams {
mode: LayoutMode::MasterStack,
master_count: 2,
gap: 0,
..LayoutParams::default()
};
let rects = tile(SCREEN, 4, &p);
// Dos maestras comparten el ancho maestro (60% de 1920 = 1152).
assert_eq!(rects[0].w, 1152);
assert_eq!(rects[1].w, 1152);
// Dos de pila comparten el resto.
assert_eq!(rects[2].w, 1920 - 1152);
assert_eq!(rects[3].w, 1920 - 1152);
// Las dos maestras parten la altura entre ellas.
assert_eq!(rects[0].h + rects[1].h, 1080);
}
#[test]
fn nmaster_above_window_count_makes_every_window_a_master() {
let p = LayoutParams {
mode: LayoutMode::MasterStack,
master_count: 9,
gap: 0,
..LayoutParams::default()
};
let rects = tile(SCREEN, 3, &p);
// Sin pila: las tres ocupan el ancho completo.
assert!(rects.iter().all(|r| r.w == 1920));
assert_eq!(rects.iter().map(|r| r.h).sum::<i32>(), 1080);
}
#[test]
fn smart_gaps_drop_the_margin_for_a_single_window() {
let p = LayoutParams { mode: LayoutMode::MasterStack, gap: 20, ..LayoutParams::default() };
// Una sola ventana: a sangre, sin margen.
assert_eq!(tile(SCREEN, 1, &p)[0], SCREEN);
// Con dos, el margen vuelve.
assert!(tile(SCREEN, 2, &p)[0].w < SCREEN.w);
}
#[test]
fn layout_is_deterministic() {
let p = params(LayoutMode::Grid);
assert_eq!(tile(SCREEN, 7, &p), tile(SCREEN, 7, &p));
}
// --- wallpaper_dst_rect ---------------------------------------------
#[test]
fn wallpaper_stretch_cubre_toda_la_salida() {
assert_eq!(
wallpaper_dst_rect(WallpaperFit::Stretch, 800, 600, 1920, 1080),
(0, 0, 1920, 1080),
);
}
#[test]
fn wallpaper_center_pega_la_imagen_a_su_tamano() {
// Imagen más chica → queda centrada con padding.
let r = wallpaper_dst_rect(WallpaperFit::Center, 800, 600, 1920, 1080);
assert_eq!(r, ((1920 - 800) / 2, (1080 - 600) / 2, 800, 600));
// Imagen más grande → offset negativo (se clipea).
let r = wallpaper_dst_rect(WallpaperFit::Center, 4000, 3000, 1920, 1080);
assert_eq!(r, ((1920 - 4000) / 2, (1080 - 3000) / 2, 4000, 3000));
}
#[test]
fn wallpaper_fit_respeta_aspecto_y_no_sobresale() {
// Imagen 4:3 (más cuadrada) en pantalla 16:9 (más ancha) → fit toca el
// alto (la imagen es más alta-proporcional que la pantalla, así que la
// dimensión más restrictiva es el ancho-virtual; el alto llena 1080 y
// el ancho queda con pillarbox).
let (x, y, w, h) = wallpaper_dst_rect(WallpaperFit::Fit, 800, 600, 1920, 1080);
assert!(w <= 1920 && h <= 1080);
assert_eq!(h, 1080);
assert_eq!(w, 1440); // 1080 * 800 / 600
assert_eq!(y, 0);
assert_eq!(x, (1920 - 1440) / 2);
// Imagen 16:9 panorámica en pantalla 4:3 → letterbox arriba/abajo.
let (x, y, w, h) = wallpaper_dst_rect(WallpaperFit::Fit, 1600, 900, 1024, 768);
assert_eq!(w, 1024);
assert_eq!(h, 576); // 1024 * 9 / 16
assert_eq!(x, 0);
assert_eq!(y, (768 - 576) / 2);
}
#[test]
fn wallpaper_fill_cubre_y_recorta_los_bordes() {
// 4:3 imagen en 16:9 pantalla → fill llena el ancho, sobra arriba/abajo
// (offset y negativo).
let (x, y, w, h) = wallpaper_dst_rect(WallpaperFit::Fill, 800, 600, 1920, 1080);
assert_eq!(w, 1920);
assert_eq!(h, 1440); // 1920 * 600 / 800
assert_eq!(x, 0);
assert!(y < 0, "fill debe sobresalir en Y, no quedar dentro");
// 16:9 imagen en 4:3 pantalla → fill llena el alto, sobra a los lados.
let (x, y, w, h) = wallpaper_dst_rect(WallpaperFit::Fill, 1600, 900, 1024, 768);
assert_eq!(h, 768);
// 768 * 1600 / 900 = 1365.33 → 1365.
assert!(w >= 1024);
assert!(x < 0);
assert_eq!(y, 0);
}
#[test]
fn wallpaper_tile_devuelve_el_tamano_nativo() {
assert_eq!(
wallpaper_dst_rect(WallpaperFit::Tile, 128, 128, 1920, 1080),
(0, 0, 128, 128),
);
}
#[test]
fn wallpaper_aspecto_igual_no_distorsiona_ni_recorta() {
// Si la imagen ya tiene el mismo aspecto, fit y fill coinciden con
// stretch (cubre todo, sin offset).
let r_fit = wallpaper_dst_rect(WallpaperFit::Fit, 1600, 900, 1920, 1080);
let r_fill = wallpaper_dst_rect(WallpaperFit::Fill, 1600, 900, 1920, 1080);
assert_eq!(r_fit, (0, 0, 1920, 1080));
assert_eq!(r_fill, (0, 0, 1920, 1080));
}
#[test]
fn wallpaper_dimensiones_degeneradas_no_panican() {
// No se exige nada de los valores devueltos: que no panique.
let _ = wallpaper_dst_rect(WallpaperFit::Fit, 0, 0, 1920, 1080);
let _ = wallpaper_dst_rect(WallpaperFit::Fill, 100, 100, 0, 0);
let _ = wallpaper_dst_rect(WallpaperFit::Center, 100, 100, 0, 0);
}
}
+31
View File
@@ -0,0 +1,31 @@
//! `mirada-layout` — el motor de teselado del compositor Wayland.
//!
//! mirada es un compositor Wayland; este crate es su cerebro espacial,
//! aislado de Wayland y de `smithay`. Decide *dónde* va cada ventana —
//! un cálculo puro sobre rectángulos— para que el compositor sólo tenga
//! que aplicar la geometría a las superficies reales.
//!
//! - [`geometry`] — el [`Rect`] y el reparto exacto de píxeles.
//! - [`layout`] — los modos de teselado y la función [`tile`].
//! - [`workspace`] — el [`Workspace`]: ventanas, foco y modo.
//!
//! Todo es determinista y testeable sin un servidor gráfico: la misma
//! pantalla y las mismas ventanas dan siempre la misma distribución.
#![cfg_attr(not(test), no_std)]
#![forbid(unsafe_code)]
// Lógica pura sobre `core` + `alloc`: sin `std`. Así el mismo motor de
// teselado compila para Linux y para el kernel bare-metal de renaser
// (`x86_64-unknown-none`); el allocator lo aporta el consumidor.
extern crate alloc;
pub mod geometry;
pub mod layout;
pub mod outputs;
pub mod workspace;
pub use geometry::Rect;
pub use layout::{tile, wallpaper_dst_rect, LayoutMode, LayoutParams, WallpaperFit, ZoneFrac};
pub use outputs::{disponer, disponer_logico, envolvente, Disposicion, Salida, ESCALA_100};
pub use workspace::{Workspace, WindowId};
@@ -0,0 +1,230 @@
//! Disposición de outputs físicos en el espacio compuesto.
//!
//! Mientras [`layout`](crate::layout) decide *dónde va cada ventana dentro de
//! un output*, este módulo decide *dónde va cada output dentro del escritorio
//! global*. Es el cálculo que un compositor multi-monitor necesita en cuanto
//! tiene más de un scanout: a cada monitor, de dimensiones propias, hay que
//! asignarle un origen `(x, y)` en coordenadas globales para que las ventanas
//! que viajan de uno a otro lo hagan por un plano continuo.
//!
//! Es pura geometría — sin `std`, sin dependencia del kernel ni del driver de
//! GPU—. El consumidor (en wawa: `kernel/src/pantallas.rs` alimentado por el
//! driver virtio-gpu cuando enumere scanouts) traduce cada [`Rect`] devuelto a
//! su tipo nativo de región y lo registra. El día que la enumeración de
//! scanouts esté disponible, esta función es la única pieza de matemática que
//! hace falta — y ya está probada.
use alloc::vec::Vec;
use crate::geometry::Rect;
/// Cómo se reparten los outputs en el espacio global.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposicion {
/// En fila, de izquierda a derecha, alineados arriba (`y = 0`). El ancho
/// global es la suma de anchos; el alto, el del más alto. Es el arreglo
/// por defecto de la mayoría de escritorios de doble monitor.
Horizontal,
/// En columna, de arriba a abajo, alineados a la izquierda (`x = 0`). El
/// alto global es la suma de altos; el ancho, el del más ancho.
Vertical,
}
/// Dispone `tamanos` (cada uno `(ancho, alto)` en píxeles) según `modo` y
/// devuelve un [`Rect`] por output con su origen global ya calculado, en el
/// mismo orden de entrada. El primero queda anclado en `(0, 0)` — es el
/// primario—. Tamaños no positivos se respetan tal cual (el llamante decide si
/// filtrar outputs apagados antes de llamar).
///
/// Ejemplo (dos monitores 1920×1080 + 1280×1024 en fila):
/// `[(1920,1080),(1280,1024)]` → `[Rect{0,0,1920,1080}, Rect{1920,0,1280,1024}]`.
pub fn disponer(tamanos: &[(i32, i32)], modo: Disposicion) -> Vec<Rect> {
let mut rects = Vec::with_capacity(tamanos.len());
let mut avance = 0;
for &(w, h) in tamanos {
let rect = match modo {
Disposicion::Horizontal => Rect::new(avance, 0, w, h),
Disposicion::Vertical => Rect::new(0, avance, w, h),
};
rects.push(rect);
avance += match modo {
Disposicion::Horizontal => w.max(0),
Disposicion::Vertical => h.max(0),
};
}
rects
}
/// Factor de escala HiDPI expresado en 120-avos, la convención de
/// `wp_fractional_scale` de Wayland: `120` = 100 %, `180` = 150 %, `240` = 200 %.
/// Mantener la escala como entero sobre 120 deja toda la matemática en `i32`
/// —sin `f32`, apto para el kernel de wawa— y casa exacto con el protocolo.
pub const ESCALA_100: i32 = 120;
/// Un output físico con su factor de escala HiDPI. `ancho`/`alto` son los
/// píxeles reales del scanout; `escala_120`, cuántos 120-avos de aumento aplica
/// el cliente (ver [`ESCALA_100`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Salida {
pub ancho: i32,
pub alto: i32,
pub escala_120: i32,
}
impl Salida {
/// Una salida a 100 % (`escala_120 = ESCALA_100`).
pub fn new(ancho: i32, alto: i32) -> Self {
Self {
ancho,
alto,
escala_120: ESCALA_100,
}
}
/// La misma salida con otra escala.
pub fn con_escala(self, escala_120: i32) -> Self {
Self { escala_120, ..self }
}
/// Tamaño lógico `(ancho, alto)` del output: los píxeles físicos divididos
/// por la escala. Un 4K (3840×2160) a 200 % mide 1920×1080 lógicos. Una
/// escala no positiva se trata como 100 % (sin escalar). La división trunca
/// —el píxel lógico parcial no existe—.
pub fn logico(&self) -> (i32, i32) {
let escala = if self.escala_120 > 0 {
self.escala_120
} else {
ESCALA_100
};
let w = self.ancho.max(0) as i64 * ESCALA_100 as i64 / escala as i64;
let h = self.alto.max(0) as i64 * ESCALA_100 as i64 / escala as i64;
(w as i32, h as i32)
}
}
/// Como [`disponer`], pero en **coordenadas lógicas**: cada output aporta su
/// tamaño *lógico* (físico ÷ escala) al encadenado. Es lo que un compositor
/// multi-DPI necesita: las ventanas viajan por un plano lógico continuo, y cada
/// output traduce de vuelta a físico con su propia escala al componer, así un
/// monitor 1× junto a uno 2× no abre un salto en el escritorio. Mismo orden de
/// entrada; el primero queda anclado en `(0, 0)`.
///
/// Ejemplo (un 1080p a 100 % junto a un 4K a 200 %): ambos miden 1920×1080
/// lógicos, así que quedan `[Rect{0,0,1920,1080}, Rect{1920,0,1920,1080}]` —el
/// 4K, con el doble de píxeles físicos, ocupa el mismo ancho lógico—.
pub fn disponer_logico(salidas: &[Salida], modo: Disposicion) -> Vec<Rect> {
let tamanos: Vec<(i32, i32)> = salidas.iter().map(Salida::logico).collect();
disponer(&tamanos, modo)
}
/// El rectángulo que envuelve a todos los outputs dispuestos: el tamaño del
/// escritorio compuesto. Útil para dimensionar un framebuffer global o validar
/// que el espacio cabe. Vacío (`0×0` en el origen) si no hay outputs.
pub fn envolvente(rects: &[Rect]) -> Rect {
if rects.is_empty() {
return Rect::new(0, 0, 0, 0);
}
let mut max_x = 0;
let mut max_y = 0;
for r in rects {
max_x = max_x.max(r.x + r.w.max(0));
max_y = max_y.max(r.y + r.h.max(0));
}
Rect::new(0, 0, max_x, max_y)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn horizontal_encadena_origenes_por_ancho() {
let r = disponer(&[(1920, 1080), (1280, 1024)], Disposicion::Horizontal);
assert_eq!(r[0], Rect::new(0, 0, 1920, 1080));
assert_eq!(r[1], Rect::new(1920, 0, 1280, 1024));
}
#[test]
fn vertical_apila_por_alto() {
let r = disponer(&[(800, 600), (800, 480)], Disposicion::Vertical);
assert_eq!(r[0], Rect::new(0, 0, 800, 600));
assert_eq!(r[1], Rect::new(0, 600, 800, 480));
}
#[test]
fn primario_unico_queda_en_origen() {
let r = disponer(&[(1024, 768)], Disposicion::Horizontal);
assert_eq!(r, [Rect::new(0, 0, 1024, 768)]);
}
#[test]
fn sin_outputs_da_vec_vacio() {
assert!(disponer(&[], Disposicion::Horizontal).is_empty());
assert_eq!(envolvente(&[]), Rect::new(0, 0, 0, 0));
}
#[test]
fn envolvente_horizontal_suma_ancho_y_toma_alto_maximo() {
let r = disponer(&[(1920, 1080), (1280, 1024)], Disposicion::Horizontal);
// ancho = 1920 + 1280; alto = max(1080, 1024).
assert_eq!(envolvente(&r), Rect::new(0, 0, 3200, 1080));
}
#[test]
fn envolvente_vertical_suma_alto_y_toma_ancho_maximo() {
let r = disponer(&[(800, 600), (1024, 480)], Disposicion::Vertical);
assert_eq!(envolvente(&r), Rect::new(0, 0, 1024, 1080));
}
#[test]
fn tamano_no_positivo_no_avanza_el_cursor() {
// Un output "apagado" (0 de ancho) no desplaza al siguiente.
let r = disponer(&[(0, 0), (640, 480)], Disposicion::Horizontal);
assert_eq!(r[1], Rect::new(0, 0, 640, 480));
}
#[test]
fn logico_divide_los_fisicos_por_la_escala() {
// 4K a 200 % mide 1920×1080 lógicos.
assert_eq!(Salida::new(3840, 2160).con_escala(240).logico(), (1920, 1080));
// 150 %: 2560×1440 → 1706×960 (trunca el píxel parcial).
assert_eq!(Salida::new(2560, 1440).con_escala(180).logico(), (1706, 960));
}
#[test]
fn escala_100_equivale_a_los_fisicos() {
let s = Salida::new(1920, 1080);
assert_eq!(s.escala_120, ESCALA_100);
assert_eq!(s.logico(), (1920, 1080));
}
#[test]
fn escala_no_positiva_se_trata_como_100() {
// Un output que aún no reportó escala (0) no se encoge a infinito.
assert_eq!(Salida::new(1280, 720).con_escala(0).logico(), (1280, 720));
assert_eq!(Salida::new(1280, 720).con_escala(-5).logico(), (1280, 720));
}
#[test]
fn disponer_logico_continua_el_plano_entre_dpis_distintas() {
// Un 1080p@100 % junto a un 4K@200 %: ambos 1920×1080 lógicos, plano
// continuo (el segundo arranca justo donde acaba el primero).
let salidas = [
Salida::new(1920, 1080),
Salida::new(3840, 2160).con_escala(240),
];
let r = disponer_logico(&salidas, Disposicion::Horizontal);
assert_eq!(r[0], Rect::new(0, 0, 1920, 1080));
assert_eq!(r[1], Rect::new(1920, 0, 1920, 1080));
// El escritorio lógico mide 3840×1080 pese a los 7680 px físicos.
assert_eq!(envolvente(&r), Rect::new(0, 0, 3840, 1080));
}
#[test]
fn disponer_logico_sin_escala_coincide_con_disponer() {
let salidas = [Salida::new(1920, 1080), Salida::new(1280, 1024)];
let logico = disponer_logico(&salidas, Disposicion::Horizontal);
let fisico = disponer(&[(1920, 1080), (1280, 1024)], Disposicion::Horizontal);
assert_eq!(logico, fisico);
}
}
@@ -0,0 +1,414 @@
//! `Workspace` — un conjunto de ventanas, su foco y su modo de teselado.
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
// El macro `vec!` sólo lo usan los tests de este módulo.
#[cfg(test)]
use alloc::vec;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::geometry::Rect;
use crate::layout::{tile, LayoutMode, LayoutParams};
/// Identificador de una ventana (una superficie Wayland).
pub type WindowId = u64;
/// Un escritorio: ventanas en orden de teselado + la enfocada + el modo.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Workspace {
/// Ventanas en orden de teselado (la 0 es la maestra en `MasterStack`).
windows: Vec<WindowId>,
/// Índice de la ventana enfocada en `windows`.
focus: usize,
params: LayoutParams,
/// Ventanas flotantes y su rectángulo: salen del teselado y se pintan
/// encima. Las que no están aquí se teselan normalmente.
floating: BTreeMap<WindowId, Rect>,
/// La ventana en pantalla completa, si hay alguna: cubre toda la
/// salida y oculta al resto.
fullscreen: Option<WindowId>,
}
impl Workspace {
/// Escritorio vacío con los parámetros dados.
pub fn new(params: LayoutParams) -> Self {
Self {
windows: Vec::new(),
focus: 0,
params,
floating: BTreeMap::new(),
fullscreen: None,
}
}
pub fn len(&self) -> usize {
self.windows.len()
}
pub fn is_empty(&self) -> bool {
self.windows.is_empty()
}
/// Ventanas en orden de teselado.
pub fn windows(&self) -> &[WindowId] {
&self.windows
}
pub fn params(&self) -> &LayoutParams {
&self.params
}
/// Reemplaza todos los parámetros del teselado de una vez — lo usa la
/// config del usuario al fijar gap/ratio/nmaster/modo iniciales.
pub fn set_params(&mut self, params: LayoutParams) {
self.params = params;
}
/// Cambia el modo de teselado.
pub fn set_mode(&mut self, mode: LayoutMode) {
self.params.mode = mode;
}
/// Ajusta la fracción de la ventana maestra.
pub fn set_master_ratio(&mut self, ratio: f32) {
self.params.master_ratio = ratio;
}
/// Ajusta cuántas ventanas van en el área maestra (`nmaster`).
pub fn set_master_count(&mut self, count: usize) {
self.params.master_count = count;
}
/// Añade una ventana y la enfoca. Si ya estaba, sólo la enfoca.
pub fn add(&mut self, window: WindowId) {
if let Some(i) = self.windows.iter().position(|&w| w == window) {
self.focus = i;
} else {
self.windows.push(window);
self.focus = self.windows.len() - 1;
}
}
/// Quita una ventana. `false` si no estaba. El foco se reajusta para
/// seguir apuntando a una ventana válida.
pub fn remove(&mut self, window: WindowId) -> bool {
let Some(i) = self.windows.iter().position(|&w| w == window) else {
return false;
};
self.windows.remove(i);
self.floating.remove(&window);
if self.fullscreen == Some(window) {
self.fullscreen = None;
}
if i < self.focus {
self.focus -= 1;
}
if self.focus >= self.windows.len() {
self.focus = self.windows.len().saturating_sub(1);
}
true
}
/// Marca una ventana como flotante en `rect`, o la devuelve al
/// teselado con `None`. La ventana sigue en el orden de foco.
pub fn set_floating(&mut self, window: WindowId, rect: Option<Rect>) {
match rect {
Some(r) => {
self.floating.insert(window, r);
}
None => {
self.floating.remove(&window);
}
}
}
/// `true` si la ventana está flotando.
pub fn is_floating(&self, window: WindowId) -> bool {
self.floating.contains_key(&window)
}
/// El rectángulo flotante de una ventana, si flota — para moverla o
/// redimensionarla por teclado.
pub fn floating_rect(&self, window: WindowId) -> Option<Rect> {
self.floating.get(&window).copied()
}
/// La ventana en pantalla completa de este escritorio, si hay alguna.
pub fn fullscreen(&self) -> Option<WindowId> {
self.fullscreen
}
/// Pone (o quita, con `None`) la ventana en pantalla completa.
pub fn set_fullscreen(&mut self, window: Option<WindowId>) {
self.fullscreen = window;
}
/// Ventana enfocada, o `None` si el escritorio está vacío.
pub fn focused(&self) -> Option<WindowId> {
self.windows.get(self.focus).copied()
}
/// Mueve el foco a la ventana siguiente (cíclico).
pub fn focus_next(&mut self) {
if !self.windows.is_empty() {
self.focus = (self.focus + 1) % self.windows.len();
}
}
/// Mueve el foco a la ventana anterior (cíclico).
pub fn focus_prev(&mut self) {
if !self.windows.is_empty() {
self.focus = (self.focus + self.windows.len() - 1) % self.windows.len();
}
}
/// Enfoca una ventana por id. `false` si no está en el escritorio.
pub fn focus_window(&mut self, window: WindowId) -> bool {
match self.windows.iter().position(|&w| w == window) {
Some(i) => {
self.focus = i;
true
}
None => false,
}
}
/// Intercambia la ventana enfocada con la siguiente en el orden de
/// teselado; el foco la acompaña. No hace nada si ya es la última.
pub fn move_focused_forward(&mut self) {
if self.focus + 1 < self.windows.len() {
self.windows.swap(self.focus, self.focus + 1);
self.focus += 1;
}
}
/// Intercambia la ventana enfocada con la anterior. No hace nada si
/// ya es la primera.
pub fn move_focused_backward(&mut self) {
if self.focus > 0 && !self.windows.is_empty() {
self.windows.swap(self.focus, self.focus - 1);
self.focus -= 1;
}
}
/// Intercambia dos ventanas en el orden de teselado, dejando el foco en
/// `a`. No hace nada si alguna no está en el escritorio o son la misma.
/// Lo usa el arrastre interactivo de ventanas teseladas (swap-on-drag).
pub fn swap(&mut self, a: WindowId, b: WindowId) -> bool {
if a == b {
return false;
}
let (Some(ia), Some(ib)) = (
self.windows.iter().position(|&w| w == a),
self.windows.iter().position(|&w| w == b),
) else {
return false;
};
self.windows.swap(ia, ib);
self.focus = ib;
true
}
/// Lleva la ventana enfocada al primer puesto del orden de teselado
/// (la posición maestra); el foco la acompaña. No hace nada si ya es
/// la primera o el escritorio está vacío.
pub fn promote_focused(&mut self) {
if self.focus > 0 && self.focus < self.windows.len() {
let w = self.windows.remove(self.focus);
self.windows.insert(0, w);
self.focus = 0;
}
}
/// Resuelve la geometría: el rectángulo de cada ventana dentro de
/// `screen`. Primero las teseladas en orden de teselado, luego las
/// flotantes con su propio rectángulo — éstas van al final para que
/// el Cuerpo las pinte encima.
pub fn layout(&self, screen: Rect) -> Vec<(WindowId, Rect)> {
let tiled: Vec<WindowId> = self
.windows
.iter()
.copied()
.filter(|id| !self.floating.contains_key(id))
.collect();
let rects = tile(screen, tiled.len(), &self.params);
let mut out: Vec<(WindowId, Rect)> = tiled.into_iter().zip(rects).collect();
for &id in &self.windows {
if let Some(&rect) = self.floating.get(&id) {
out.push((id, rect));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ws() -> Workspace {
Workspace::new(LayoutParams::default())
}
#[test]
fn add_focuses_the_new_window() {
let mut w = ws();
w.add(10);
w.add(20);
assert_eq!(w.focused(), Some(20));
assert_eq!(w.len(), 2);
}
#[test]
fn adding_an_existing_window_just_focuses_it() {
let mut w = ws();
w.add(10);
w.add(20);
w.add(10);
assert_eq!(w.focused(), Some(10));
assert_eq!(w.len(), 2);
}
#[test]
fn focus_cycles_both_ways() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
assert_eq!(w.focused(), Some(3));
w.focus_next();
assert_eq!(w.focused(), Some(1)); // dio la vuelta
w.focus_prev();
assert_eq!(w.focused(), Some(3));
}
#[test]
fn remove_keeps_focus_valid() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(2);
w.remove(2);
// El foco se mantiene dentro de rango.
assert!(w.focused().is_some());
assert_eq!(w.len(), 2);
}
#[test]
fn remove_before_focus_shifts_it() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(3); // focus = 2
w.remove(1); // quita una anterior
assert_eq!(w.focused(), Some(3)); // sigue enfocada la 3
}
#[test]
fn remove_last_window_empties_workspace() {
let mut w = ws();
w.add(7);
assert!(w.remove(7));
assert!(w.is_empty());
assert_eq!(w.focused(), None);
}
#[test]
fn move_focused_reorders_tiling() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(1); // primera
w.move_focused_forward();
assert_eq!(w.windows(), &[2, 1, 3]);
assert_eq!(w.focused(), Some(1)); // el foco la acompañó
w.move_focused_backward();
assert_eq!(w.windows(), &[1, 2, 3]);
}
#[test]
fn swap_exchanges_two_windows_and_focuses_the_first() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
assert!(w.swap(1, 3));
assert_eq!(w.windows(), &[3, 2, 1]);
// El foco queda en la primera del par (la arrastrada).
assert_eq!(w.focused(), Some(1));
// Swap con la misma, o con una ausente, no hace nada.
assert!(!w.swap(2, 2));
assert!(!w.swap(2, 99));
assert_eq!(w.windows(), &[3, 2, 1]);
}
#[test]
fn promote_brings_the_focused_window_to_the_front() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
w.focus_window(3);
w.promote_focused();
assert_eq!(w.windows(), &[3, 1, 2]);
assert_eq!(w.focused(), Some(3));
// Promover la que ya es maestra no hace nada.
w.promote_focused();
assert_eq!(w.windows(), &[3, 1, 2]);
}
#[test]
fn layout_pairs_each_window_with_a_rect() {
let mut w = ws();
for id in [100, 200, 300] {
w.add(id);
}
let screen = Rect::new(0, 0, 1920, 1080);
let placed = w.layout(screen);
assert_eq!(placed.len(), 3);
let ids: Vec<_> = placed.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![100, 200, 300]);
}
#[test]
fn empty_workspace_lays_out_nothing() {
assert!(ws().layout(Rect::new(0, 0, 800, 600)).is_empty());
}
#[test]
fn a_floating_window_keeps_its_rect_and_goes_last() {
let mut w = ws();
for id in [1, 2, 3] {
w.add(id);
}
let float_rect = Rect::new(50, 50, 400, 300);
w.set_floating(2, Some(float_rect));
assert!(w.is_floating(2));
let placed = w.layout(Rect::new(0, 0, 1920, 1080));
assert_eq!(placed.len(), 3);
// La flotante va al final, con su rectángulo intacto.
assert_eq!(placed[2], (2, float_rect));
let ids: Vec<_> = placed.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![1, 3, 2]);
// Devolverla al teselado.
w.set_floating(2, None);
assert!(!w.is_floating(2));
assert_eq!(w.layout(Rect::new(0, 0, 1920, 1080)).len(), 3);
}
#[test]
fn removing_a_window_clears_its_floating_state() {
let mut w = ws();
w.add(1);
w.set_floating(1, Some(Rect::new(0, 0, 100, 100)));
w.remove(1);
w.add(1); // mismo id, ventana nueva: ya no flota
assert!(!w.is_floating(1));
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "mirada-link"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — transporte Cerebro↔Cuerpo del compositor: el canal de socket Unix que mueve BrainCommand y BodyEvent sobre el marco postcard de mirada-protocol."
[dependencies]
mirada-protocol = { path = "../mirada-protocol" }
serde = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
# mirada-link
> IPC entre componentes mirada de [mirada](../README.md).
Bus interno: compositor ↔ portal ↔ greeter ↔ launcher ↔ ctl. Usa [`chasqui-nous-real`](../../chasqui/chasqui-nous-real/README.md) sobre socket Unix.
## Deps
- [`chasqui-core`](../../chasqui/chasqui-core/README.md), [`chasqui-nous-real`](../../chasqui/chasqui-nous-real/README.md)
+9
View File
@@ -0,0 +1,9 @@
# mirada-link
> IPC between [mirada](../README.md) components.
Internal bus: compositor ↔ portal ↔ greeter ↔ launcher ↔ ctl. Uses [`chasqui-nous-real`](../../chasqui/chasqui-nous-real/README.md) over a Unix socket.
## Deps
- [`chasqui-core`](../../chasqui/chasqui-core/README.md), [`chasqui-nous-real`](../../chasqui/chasqui-nous-real/README.md)
+252
View File
@@ -0,0 +1,252 @@
//! `mirada-link` — el transporte Cerebro↔Cuerpo del compositor.
//!
//! [`mirada_protocol`] define *qué* se dice (los enums y el marco de
//! cable); este crate define *cómo viaja*: un socket Unix con un hilo
//! lector de fondo que entrega los mensajes recibidos por un canal, para
//! que el dueño del [`Link`] sólo tenga que sondear sin bloquearse.
//!
//! Los dos procesos usan el mismo tipo, parametrizado al revés:
//!
//! - El Cerebro tiene un [`BrainLink`]: envía [`BrainCommand`], recibe
//! [`BodyEvent`].
//! - El Cuerpo tiene un [`BodyLink`]: envía [`BodyEvent`], recibe
//! [`BrainCommand`].
//!
//! Para arrancar el par hay tres caminos: [`connected_pair`] (un
//! `socketpair`, ideal para heredar un fd al lanzar al hijo o para
//! tests), [`Link::connect`] (conectar a una ruta) y [`Link::listen`]
//! (escuchar en una ruta y aceptar una conexión).
#![forbid(unsafe_code)]
use std::io::{self, BufReader};
use std::marker::PhantomData;
use std::net::Shutdown;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use serde::de::DeserializeOwned;
use serde::Serialize;
use mirada_protocol::{read_frame, write_frame, BodyEvent, BrainCommand};
/// El extremo del Cerebro: envía [`BrainCommand`], recibe [`BodyEvent`].
pub type BrainLink = Link<BrainCommand, BodyEvent>;
/// El extremo del Cuerpo: envía [`BodyEvent`], recibe [`BrainCommand`].
pub type BodyLink = Link<BodyEvent, BrainCommand>;
/// Un extremo del canal: envía mensajes de tipo `Out` y recibe `In`.
///
/// La escritura es síncrona sobre el socket; la lectura la hace un hilo
/// de fondo que deposita lo recibido en un canal interno. Al soltar el
/// `Link` se cierra el socket, lo que termina el hilo lector propio y le
/// señala EOF al otro extremo.
pub struct Link<Out, In> {
writer: UnixStream,
incoming: Receiver<In>,
_out: PhantomData<fn(Out)>,
}
impl<Out, In> Link<Out, In>
where
Out: Serialize,
In: DeserializeOwned + Send + 'static,
{
/// Construye un `Link` sobre un socket ya conectado.
pub fn from_stream(stream: UnixStream) -> io::Result<Self> {
let reader = stream.try_clone()?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut r = BufReader::new(reader);
// Lee marcos hasta EOF limpio o error de socket.
while let Ok(Some(msg)) = read_frame::<_, In>(&mut r) {
if tx.send(msg).is_err() {
break; // el dueño soltó el Link
}
}
});
Ok(Self { writer: stream, incoming: rx, _out: PhantomData })
}
/// Conecta a un socket Unix en `path` (lado cliente).
pub fn connect<P: AsRef<Path>>(path: P) -> io::Result<Self> {
Self::from_stream(UnixStream::connect(path)?)
}
/// Escucha en `path` y bloquea hasta aceptar una conexión (lado
/// servidor). El socket de escucha se cierra tras el primer cliente.
pub fn listen<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let listener = UnixListener::bind(path)?;
let (stream, _) = listener.accept()?;
Self::from_stream(stream)
}
/// Envía un mensaje. Falla si el otro extremo cerró el canal.
pub fn send(&mut self, msg: &Out) -> io::Result<()> {
write_frame(&mut self.writer, msg)
}
/// Recoge un mensaje si hay alguno pendiente, sin bloquear.
pub fn try_recv(&self) -> Option<In> {
self.incoming.try_recv().ok()
}
/// Vacía todos los mensajes pendientes — un tick del bucle de eventos.
pub fn drain(&self) -> Vec<In> {
self.incoming.try_iter().collect()
}
/// Bloquea hasta recibir un mensaje. Devuelve `None` si el otro
/// extremo cerró el canal.
pub fn recv(&self) -> Option<In> {
self.incoming.recv().ok()
}
}
impl<Out, In> Drop for Link<Out, In> {
fn drop(&mut self) {
// Cierra la conexión: el hilo lector propio recibe EOF y termina,
// y el otro extremo ve EOF en su próxima lectura.
let _ = self.writer.shutdown(Shutdown::Both);
}
}
/// Crea un par Cerebro↔Cuerpo conectado en memoria, con un `socketpair`.
///
/// Es el camino de los tests y también el del despliegue real cuando el
/// Cerebro lanza al Cuerpo como proceso hijo y le hereda un extremo.
pub fn connected_pair() -> io::Result<(BrainLink, BodyLink)> {
let (a, b) = UnixStream::pair()?;
Ok((Link::from_stream(a)?, Link::from_stream(b)?))
}
#[cfg(test)]
mod tests {
use super::*;
use mirada_protocol::{Rect, WindowPlacement};
use std::time::Duration;
fn place(id: u64) -> BrainCommand {
BrainCommand::Place(vec![WindowPlacement {
id,
rect: Rect::new(0, 0, 800, 600),
visible: true,
focused: true,
floating: false,
fullscreen: false,
}])
}
#[test]
fn brain_command_reaches_the_body() {
let (mut brain, body) = connected_pair().unwrap();
brain.send(&place(1)).unwrap();
// Da un instante al hilo lector.
for _ in 0..100 {
if let Some(cmd) = body.try_recv() {
assert_eq!(cmd, place(1));
return;
}
thread::sleep(Duration::from_millis(2));
}
panic!("el comando no llegó al Cuerpo");
}
#[test]
fn body_event_reaches_the_brain() {
let (brain, mut body) = connected_pair().unwrap();
let ev = BodyEvent::Keybind("Super+Return".into());
body.send(&ev).unwrap();
assert_eq!(brain.recv(), Some(ev));
}
#[test]
fn many_messages_keep_their_order() {
let (brain, mut body) = connected_pair().unwrap();
for id in 0..20 {
body.send(&BodyEvent::WindowClosed { id }).unwrap();
}
for id in 0..20 {
assert_eq!(brain.recv(), Some(BodyEvent::WindowClosed { id }));
}
}
#[test]
fn drain_collects_everything_pending() {
let (mut brain, body) = connected_pair().unwrap();
for id in 1..=5 {
brain.send(&place(id)).unwrap();
}
// Espera a que el hilo lector encole los cinco.
let mut got = Vec::new();
for _ in 0..100 {
got.extend(body.drain());
if got.len() == 5 {
break;
}
thread::sleep(Duration::from_millis(2));
}
assert_eq!(got.len(), 5);
}
#[test]
fn dropping_one_end_closes_the_other() {
let (brain, body) = connected_pair().unwrap();
drop(body);
// Sin nadie al otro lado, recv termina con None en vez de colgarse.
assert_eq!(brain.recv(), None);
}
#[test]
fn sending_into_a_closed_link_errors() {
let (mut brain, body) = connected_pair().unwrap();
drop(body);
// La primera escritura puede pasar al búfer del socket; alguna
// de ellas acaba fallando con tubería rota.
let mut errored = false;
for id in 0..1000 {
if brain.send(&place(id)).is_err() {
errored = true;
break;
}
}
assert!(errored, "se esperaba un error de tubería rota");
}
#[test]
fn connect_and_listen_round_trip_over_a_path() {
let dir = std::env::temp_dir();
let path = dir.join(format!("mirada-link-test-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&path);
let server_path = path.clone();
let server = thread::spawn(move || {
let mut link: BodyLink = Link::listen(&server_path).unwrap();
link.send(&BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 })
.unwrap();
// Mantén vivo el extremo hasta que el cliente lea.
link.recv()
});
// Espera a que el servidor publique el socket.
let mut brain: Option<BrainLink> = None;
for _ in 0..200 {
if let Ok(l) = Link::connect(&path) {
brain = Some(l);
break;
}
thread::sleep(Duration::from_millis(2));
}
let mut brain = brain.expect("no se pudo conectar al servidor");
assert_eq!(
brain.recv(),
Some(BodyEvent::OutputAdded { id: 0, width: 1920, height: 1080 })
);
brain.send(&BrainCommand::Shutdown).unwrap();
assert_eq!(server.join().unwrap(), Some(BrainCommand::Shutdown));
let _ = std::fs::remove_file(&path);
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "mirada-portal"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada-portal — backend xdg-desktop-portal de carmen: implementa org.freedesktop.impl.portal.Settings y publica el tema activo de nahual (claro/oscuro + acento + contraste) a GTK, Qt, Firefox y Chromium por protocolo."
[[bin]]
name = "mirada-portal"
path = "src/main.rs"
[dependencies]
zbus = { workspace = true }
notify = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
+76
View File
@@ -0,0 +1,76 @@
# mirada-portal
Backend de `xdg-desktop-portal` para el escritorio **carmen**. Implementa
`org.freedesktop.impl.portal.Settings` y publica un único namespace:
`org.freedesktop.appearance`.
## Qué resuelve
GTK y Qt leen su configuración de sitios incompatibles entre sí. Pero
**ambos** —además de Firefox y Chromium— consultan el portal de
FreeDesktop para saber:
- `color-scheme` — claro (`2`) u oscuro (`1`),
- `accent-color` — el color de acento como `(ddd)` RGB,
- `contrast` — contraste alto (`1`) o normal (`0`).
`mirada-portal` responde esas tres claves a partir del tema activo de
`nahual` y, cuando el tema cambia, emite `SettingChanged`: todo el
ecosistema voltea en vivo, **sin tocar un solo archivo de config de las
apps**.
## Fuente del tema
El daemon lee `$XDG_CONFIG_HOME/nahual/theme` (el archivo que persiste
`nahual-theme`, con el nombre del preset activo) y lo vigila con
`notify`. La traducción nombre → hechos del portal está en
[`src/theme_facts.rs`], que espeja `nahual_theme::Theme::all()` sin
enlazar GPUI.
## Arquitectura
Esto es el **backend** del portal. El frontend genérico
`xdg-desktop-portal` (paquete agnóstico, liviano) enruta las llamadas de
las apps hacia este backend según el archivo `mirada.portal`. No hay que
implementar el frontend.
## Instalación de los archivos de `data/`
```sh
install -Dm644 data/mirada.portal \
/usr/share/xdg-desktop-portal/portals/mirada.portal
install -Dm644 data/mirada-portals.conf \
/usr/share/xdg-desktop-portal/mirada-portals.conf
install -Dm644 data/org.freedesktop.impl.portal.desktop.mirada.service \
/usr/share/dbus-1/services/org.freedesktop.impl.portal.desktop.mirada.service
install -Dm755 target/release/mirada-portal /usr/bin/mirada-portal
```
El frontend casa `UseIn=mirada` contra `XDG_CURRENT_DESKTOP`, así que
carmen debe exportar `XDG_CURRENT_DESKTOP=mirada`. Alternativamente, el
`mirada-portals.conf` lo fuerza con `default=mirada`.
`mirada-portal` se puede arrancar desde `~/.config/mirada/autostart` o
dejar que el frontend lo active por D-Bus (de ahí el `.service`).
## Smoke test (sin frontend ni apps GTK)
Con un bus de sesión vivo, el backend se puede interrogar directo:
```sh
busctl --user introspect org.freedesktop.impl.portal.desktop.mirada \
/org/freedesktop/portal/desktop
busctl --user call org.freedesktop.impl.portal.desktop.mirada \
/org/freedesktop/portal/desktop \
org.freedesktop.impl.portal.Settings ReadAll as 0
```
Cambiar `~/.config/nahual/theme` debe disparar una señal `SettingChanged`
(observable con `busctl --user monitor`).
## Límite conocido (v1)
El portal `org.freedesktop.appearance` sólo lleva claro/oscuro + acento +
contraste. **No** lleva la paleta completa de `nahual`. Para recolorear
GTK/Qt a los colores exactos del tema hace falta, además, inyección de
entorno + CSS generado en el `spawn` de carmen — siguiente paso del plan.
+10
View File
@@ -0,0 +1,10 @@
# mirada-portal
> `xdg-desktop-portal` backend for [mirada](../README.md).
Implements the freedesktop portal protocol on top of [`mirada-compositor`](../mirada-compositor/README.md): file pickers, screenshare, open-uri, screenshot. Any app using portal APIs (Firefox, Chromium, GTK apps) works on the carmen desktop without modification.
## Deps
- [`mirada-protocol`](../mirada-protocol/README.md), [`mirada-link`](../mirada-link/README.md)
- `zbus` (D-Bus)
@@ -0,0 +1,3 @@
[preferred]
default=mirada
org.freedesktop.impl.portal.Settings=mirada
@@ -0,0 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.mirada
Interfaces=org.freedesktop.impl.portal.Settings
UseIn=mirada
@@ -0,0 +1,3 @@
[D-BUS Service]
Name=org.freedesktop.impl.portal.desktop.mirada
Exec=/usr/bin/mirada-portal
+430
View File
@@ -0,0 +1,430 @@
//! `mirada-portal` — backend de `xdg-desktop-portal` para el escritorio
//! carmen.
//!
//! Implementa la interfaz `org.freedesktop.impl.portal.Settings` y
//! publica un único namespace: `org.freedesktop.appearance`. Con eso,
//! GTK4/libadwaita, Qt6, Firefox y Chromium leen del sistema —
//! **por protocolo, sin tocar sus archivos de config** — si el
//! escritorio está en modo claro u oscuro, su color de acento y si pide
//! contraste alto. Cuando el tema de `nahual` cambia, el portal emite
//! `SettingChanged` y todas esas apps voltean en vivo.
//!
//! Fuente del tema: el archivo que persiste `nahual-theme`
//! (`$XDG_CONFIG_HOME/nahual/theme`, contiene el nombre del preset
//! activo). El portal lo vigila con `notify` y reexpone sus hechos —
//! ver [`theme_facts`].
//!
//! Este crate es el **backend** del portal: el frontend genérico
//! `xdg-desktop-portal` lo enruta vía el archivo `mirada.portal`. Ver
//! el README para la instalación de los archivos de `data/`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::signal::unix::{signal, SignalKind};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use zbus::zvariant::{OwnedValue, Value};
use zbus::{fdo, interface, SignalContext};
mod theme_facts;
use theme_facts::ThemeFacts;
/// Nombre de bus del backend. El patrón `org.freedesktop.impl.portal.
/// desktop.<id>` es el que espera el frontend `xdg-desktop-portal`; el
/// `<id>` (`mirada`) tiene que coincidir con el `DBusName` del archivo
/// `mirada.portal`.
const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.mirada";
/// Ruta de objeto canónica de los portales del escritorio.
const OBJ_PATH: &str = "/org/freedesktop/portal/desktop";
/// Único namespace que servimos. El estándar moderno que leen GTK, Qt,
/// Firefox y Chromium para claro/oscuro + acento.
const APPEARANCE_NS: &str = "org.freedesktop.appearance";
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
init_tracing();
info!("mirada-portal: arrancando backend org.freedesktop.impl.portal.Settings");
let theme_path = theme_config_path();
let initial = read_facts(theme_path.as_deref());
info!(
?theme_path,
color_scheme = initial.color_scheme(),
contrast = initial.contrast(),
"tema inicial resuelto"
);
let facts = Arc::new(Mutex::new(initial));
let portal = SettingsPortal {
facts: Arc::clone(&facts),
};
// El portal vive en el bus de **sesión** (no el de sistema): es un
// servicio del escritorio del usuario, no del sistema.
let conn_result = zbus::connection::Builder::session()
.and_then(|b| b.name(BUS_NAME))
.and_then(|b| b.serve_at(OBJ_PATH, portal));
match conn_result {
Ok(builder) => match builder.build().await {
Ok(conn) => {
info!(name = BUS_NAME, "name adquirido en el bus de sesión");
run(conn, facts, theme_path).await
}
Err(e) => {
warn!(?e, "no se pudo construir la conexión D-Bus — modo idle");
wait_for_term().await
}
},
Err(e) => {
warn!(?e, "builder D-Bus falló (¿hay bus de sesión?) — modo idle");
wait_for_term().await
}
}
}
/// Conectado al bus: monta el watcher del tema y espera la señal de
/// término. El watcher se guarda en `_watcher` para que no se dropee
/// (al dropearse dejaría de vigilar).
async fn run(
conn: zbus::Connection,
facts: Arc<Mutex<ThemeFacts>>,
theme_path: Option<PathBuf>,
) -> anyhow::Result<()> {
let _watcher = match &theme_path {
Some(path) => match spawn_theme_watcher(conn.clone(), Arc::clone(&facts), path.clone()) {
Ok(w) => Some(w),
Err(e) => {
warn!(
?e,
"watcher del tema no disponible — el portal no actualizará en vivo"
);
None
}
},
None => {
warn!("sin ruta de config de tema — el portal sirve un valor fijo");
None
}
};
wait_for_term().await
}
// ============================================================================
// Interfaz D-Bus: org.freedesktop.impl.portal.Settings
// ============================================================================
struct SettingsPortal {
/// Hechos del tema activo. El watcher los reescribe cuando cambia.
facts: Arc<Mutex<ThemeFacts>>,
}
#[interface(name = "org.freedesktop.impl.portal.Settings")]
impl SettingsPortal {
/// Versión de la interfaz impl. `ReadOne` se agregó en la 2.
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
2
}
/// `ReadAll(namespaces) -> a{sa{sv}}`. Los `namespaces` son patrones
/// (sufijo `*` = prefijo); lista vacía = todos. Sólo respondemos
/// `org.freedesktop.appearance`.
async fn read_all(
&self,
namespaces: Vec<String>,
) -> fdo::Result<HashMap<String, HashMap<String, OwnedValue>>> {
let mut out = HashMap::new();
if namespace_requested(&namespaces, APPEARANCE_NS) {
let facts = *self.facts.lock().unwrap();
out.insert(APPEARANCE_NS.to_string(), appearance_map(&facts)?);
}
Ok(out)
}
/// `ReadOne(namespace, key) -> v`. Lee un único valor.
async fn read_one(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
let facts = *self.facts.lock().unwrap();
lookup(&facts, &namespace, &key)
}
/// `Read(namespace, key) -> v`. Deprecado a favor de `ReadOne` desde
/// la versión 2 del portal, pero apps viejas lo siguen llamando.
async fn read(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
let facts = *self.facts.lock().unwrap();
lookup(&facts, &namespace, &key)
}
/// `SettingChanged(namespace, key, value)`. Lo emite el watcher
/// cuando el tema persistido cambia.
#[zbus(signal)]
async fn setting_changed(
ctxt: &SignalContext<'_>,
namespace: &str,
key: &str,
value: Value<'_>,
) -> zbus::Result<()>;
}
// ============================================================================
// Mapeo tema → valores del portal
// ============================================================================
/// Construye el mapa `a{sv}` del namespace `org.freedesktop.appearance`.
fn appearance_map(facts: &ThemeFacts) -> fdo::Result<HashMap<String, OwnedValue>> {
Ok(HashMap::from([
(
"color-scheme".to_string(),
into_owned(Value::U32(facts.color_scheme()))?,
),
(
"contrast".to_string(),
into_owned(Value::U32(facts.contrast()))?,
),
("accent-color".to_string(), into_owned(accent_value(facts))?),
]))
}
/// Resuelve una clave concreta dentro de `org.freedesktop.appearance`.
fn lookup(facts: &ThemeFacts, namespace: &str, key: &str) -> fdo::Result<OwnedValue> {
if namespace != APPEARANCE_NS {
return Err(fdo::Error::Failed(format!(
"namespace no servido por mirada-portal: {namespace}"
)));
}
let value = match key {
"color-scheme" => Value::U32(facts.color_scheme()),
"contrast" => Value::U32(facts.contrast()),
"accent-color" => accent_value(facts),
other => {
return Err(fdo::Error::Failed(format!(
"clave desconocida en {APPEARANCE_NS}: {other}"
)));
}
};
into_owned(value)
}
/// El acento como structure `(ddd)` — tres dobles RGB en 0..1.
fn accent_value(facts: &ThemeFacts) -> Value<'static> {
let (r, g, b) = facts.accent_rgb();
Value::from((r, g, b))
}
/// `Value` → `OwnedValue`. Sólo falla con valores que llevan fds; los
/// nuestros (enteros y dobles) nunca lo hacen.
fn into_owned(value: Value<'_>) -> fdo::Result<OwnedValue> {
OwnedValue::try_from(value).map_err(|e| fdo::Error::Failed(format!("zvariant: {e}")))
}
/// ¿El patrón de namespaces de un `ReadAll` pide `ns`? Lista vacía =
/// todos. Un patrón con sufijo `*` matchea por prefijo; sino, exacto.
fn namespace_requested(patterns: &[String], ns: &str) -> bool {
if patterns.is_empty() {
return true;
}
patterns.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => ns.starts_with(prefix),
None => p == ns,
})
}
// ============================================================================
// Watcher del tema persistido
// ============================================================================
/// Vigila el archivo de tema de `nahual`; cuando cambia, recomputa los
/// hechos y emite `SettingChanged`. Devuelve el watcher, que el caller
/// debe mantener vivo.
fn spawn_theme_watcher(
conn: zbus::Connection,
facts: Arc<Mutex<ThemeFacts>>,
path: PathBuf,
) -> notify::Result<notify::RecommendedWatcher> {
use notify::{RecursiveMode, Watcher};
// Canal acotado: el callback de notify (en su propio hilo) sólo
// hace `try_send`; si el buffer está lleno ya hay un evento
// pendiente y da igual perder éste — coalescencia natural.
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8);
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = tx.try_send(());
}
})?;
// Vigilamos el **directorio padre**: así captamos también la
// creación del archivo si aún no existe.
let watch_target = path.parent().unwrap_or(&path).to_path_buf();
watcher.watch(&watch_target, RecursiveMode::NonRecursive)?;
info!(dir = ?watch_target, "vigilando el directorio del tema");
tokio::spawn(async move {
while rx.recv().await.is_some() {
let fresh = read_facts(Some(&path));
let changed = {
let mut guard = facts.lock().unwrap();
let differs = *guard != fresh;
*guard = fresh;
differs
};
if changed {
info!(
color_scheme = fresh.color_scheme(),
contrast = fresh.contrast(),
"el tema cambió — emitiendo SettingChanged"
);
if let Err(e) = emit_appearance_changed(&conn, &fresh).await {
warn!(?e, "no se pudo emitir SettingChanged");
}
}
}
});
Ok(watcher)
}
/// Emite `SettingChanged` para las tres claves de `appearance`.
async fn emit_appearance_changed(conn: &zbus::Connection, facts: &ThemeFacts) -> zbus::Result<()> {
let ctxt = SignalContext::new(conn, OBJ_PATH)?;
SettingsPortal::setting_changed(
&ctxt,
APPEARANCE_NS,
"color-scheme",
Value::U32(facts.color_scheme()),
)
.await?;
SettingsPortal::setting_changed(
&ctxt,
APPEARANCE_NS,
"contrast",
Value::U32(facts.contrast()),
)
.await?;
SettingsPortal::setting_changed(&ctxt, APPEARANCE_NS, "accent-color", accent_value(facts))
.await?;
Ok(())
}
// ============================================================================
// Lectura del tema persistido
// ============================================================================
/// Lee el nombre de tema del archivo y resuelve sus hechos. Si el
/// archivo falta o está vacío, asume `Nebula` — el default de
/// `nahual_theme::install_default`.
fn read_facts(path: Option<&Path>) -> ThemeFacts {
let name = path
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Nebula".to_string());
theme_facts::facts_for(&name)
}
/// Ruta del archivo donde `nahual-theme` persiste el nombre del tema
/// activo. Réplica de `nahual_theme::config_path()` — `mirada-portal`
/// no enlaza GPUI, así que no puede llamarla directamente. Convención
/// XDG: `$XDG_CONFIG_HOME/nahual/theme`, sino `$HOME/.config/...`.
fn theme_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join("nahual").join("theme"))
}
// ============================================================================
// Plomería
// ============================================================================
async fn wait_for_term() -> anyhow::Result<()> {
let mut term = signal(SignalKind::terminate())?;
let mut int_ = signal(SignalKind::interrupt())?;
tokio::select! {
_ = term.recv() => info!("SIGTERM"),
_ = int_.recv() => info!("SIGINT"),
}
Ok(())
}
fn init_tracing() {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("mirada_portal=info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.init();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn namespace_empty_matches_all() {
assert!(namespace_requested(&[], APPEARANCE_NS));
}
#[test]
fn namespace_exact_match() {
assert!(namespace_requested(
&[APPEARANCE_NS.to_string()],
APPEARANCE_NS
));
assert!(!namespace_requested(
&["org.example".to_string()],
APPEARANCE_NS
));
}
#[test]
fn namespace_wildcard_prefix() {
assert!(namespace_requested(
&["org.freedesktop.*".to_string()],
APPEARANCE_NS
));
assert!(!namespace_requested(
&["org.gnome.*".to_string()],
APPEARANCE_NS
));
}
#[test]
fn appearance_map_has_three_keys() {
let facts = theme_facts::facts_for("Nebula");
let m = appearance_map(&facts).unwrap();
assert_eq!(m.len(), 3);
assert!(m.contains_key("color-scheme"));
assert!(m.contains_key("accent-color"));
assert!(m.contains_key("contrast"));
}
#[test]
fn lookup_unknown_namespace_errs() {
let facts = theme_facts::facts_for("Nebula");
assert!(lookup(&facts, "org.gnome.desktop.interface", "color-scheme").is_err());
}
#[test]
fn lookup_unknown_key_errs() {
let facts = theme_facts::facts_for("Nebula");
assert!(lookup(&facts, APPEARANCE_NS, "no-such-key").is_err());
}
#[test]
fn lookup_color_scheme_ok() {
let facts = theme_facts::facts_for("Solarized Light");
assert!(lookup(&facts, APPEARANCE_NS, "color-scheme").is_ok());
}
}
@@ -0,0 +1,199 @@
//! Tabla de hechos del tema relevantes para el portal.
//!
//! El portal sólo necesita tres hechos de cada tema: si es oscuro, su
//! color de acento, y si es de alto contraste. La fuente de verdad de
//! la paleta completa es `nahual_theme::Theme` (crate `nahual-theme`);
//! esta tabla la **espeja deliberadamente** para que el daemon del
//! portal no tenga que enlazar GPUI (que `nahual-theme` arrastra por
//! sus tipos `Hsla`/`Background`).
//!
//! Si se agrega un preset nuevo a `nahual_theme::Theme::all()`, hay que
//! reflejarlo aquí. Un nombre desconocido cae a [`FALLBACK`] — el
//! portal degrada a "oscuro, sin acento marcado" en vez de romperse.
/// Hechos de un tema que el portal expone por `org.freedesktop.appearance`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeFacts {
/// `true` → el tema es oscuro (`color-scheme` = 1).
pub is_dark: bool,
/// `true` → alto contraste (`contrast` = 1).
pub high_contrast: bool,
/// Color de acento en HSL: `(matiz 0..360, saturación 0..1, luz 0..1)`.
/// Se guarda en HSL porque así está escrito en `nahual-theme` — la
/// conversión a RGB se hace al servir el valor.
pub accent_hsl: (f64, f64, f64),
}
impl ThemeFacts {
/// `color-scheme` de `org.freedesktop.appearance`: 0 = sin
/// preferencia, 1 = oscuro, 2 = claro. El escritorio siempre tiene
/// un tema activo, así que nunca devolvemos 0.
pub fn color_scheme(&self) -> u32 {
if self.is_dark {
1
} else {
2
}
}
/// `contrast` de `org.freedesktop.appearance`: 0 = normal,
/// 1 = contraste alto.
pub fn contrast(&self) -> u32 {
u32::from(self.high_contrast)
}
/// Acento como RGB en 0..1, el format `(ddd)` que pide el portal.
pub fn accent_rgb(&self) -> (f64, f64, f64) {
let (h, s, l) = self.accent_hsl;
hsl_to_rgb(h, s, l)
}
}
/// Tema por defecto si el nombre persistido no se reconoce: oscuro, sin
/// acento marcado (gris neutro). Degradación segura ante un preset
/// futuro que esta tabla aún no conozca.
pub const FALLBACK: ThemeFacts = ThemeFacts {
is_dark: true,
high_contrast: false,
accent_hsl: (0.0, 0.0, 0.5),
};
/// Mapea el nombre persistido de un tema a sus hechos. Espeja
/// `nahual_theme::Theme::all()` (8 presets al 2026-05-21). Los números
/// de acento están copiados literalmente de `nahual-theme/src/lib.rs`.
pub fn facts_for(name: &str) -> ThemeFacts {
match name.trim() {
"Nebula" => ThemeFacts {
is_dark: true,
high_contrast: false,
accent_hsl: (280.0, 0.65, 0.65),
},
"Aurora" => ThemeFacts {
is_dark: true,
high_contrast: false,
accent_hsl: (150.0, 0.70, 0.55),
},
"Sunset" => ThemeFacts {
is_dark: true,
high_contrast: false,
accent_hsl: (15.0, 0.78, 0.62),
},
"Flat Dark" => ThemeFacts {
is_dark: true,
high_contrast: false,
accent_hsl: (210.0, 0.70, 0.55),
},
"Solarized Light" => ThemeFacts {
is_dark: false,
high_contrast: false,
accent_hsl: (205.0, 0.69, 0.42),
},
"High Contrast" => ThemeFacts {
is_dark: true,
high_contrast: true,
accent_hsl: (60.0, 1.00, 0.60),
},
"Print Color" => ThemeFacts {
is_dark: false,
high_contrast: false,
accent_hsl: (15.0, 0.70, 0.40),
},
"Print B&W" => ThemeFacts {
is_dark: false,
high_contrast: false,
accent_hsl: (0.0, 0.00, 0.20),
},
_ => FALLBACK,
}
}
/// HSL → RGB. `h` en grados 0..360, `s` y `l` en 0..1. Devuelve RGB en
/// 0..1. Algoritmo estándar (croma + segmento del matiz).
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let h_prime = h.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
let (r1, g1, b1) = match h_prime as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
(r1 + m, g1 + m, b1 + m)
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn rgb_eq(got: (f64, f64, f64), want: (f64, f64, f64)) -> bool {
approx(got.0, want.0) && approx(got.1, want.1) && approx(got.2, want.2)
}
#[test]
fn hsl_primaries() {
assert!(rgb_eq(hsl_to_rgb(0.0, 1.0, 0.5), (1.0, 0.0, 0.0)));
assert!(rgb_eq(hsl_to_rgb(120.0, 1.0, 0.5), (0.0, 1.0, 0.0)));
assert!(rgb_eq(hsl_to_rgb(240.0, 1.0, 0.5), (0.0, 0.0, 1.0)));
}
#[test]
fn hsl_grays() {
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)));
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 1.0), (1.0, 1.0, 1.0)));
// Acento de "Print B&W": gris medio-oscuro.
assert!(rgb_eq(hsl_to_rgb(0.0, 0.0, 0.2), (0.2, 0.2, 0.2)));
}
#[test]
fn known_themes_map_color_scheme() {
assert_eq!(facts_for("Nebula").color_scheme(), 1);
assert_eq!(facts_for("Aurora").color_scheme(), 1);
assert_eq!(facts_for("Solarized Light").color_scheme(), 2);
assert_eq!(facts_for("Print Color").color_scheme(), 2);
}
#[test]
fn high_contrast_only_for_high_contrast_theme() {
assert!(facts_for("High Contrast").high_contrast);
assert_eq!(facts_for("High Contrast").contrast(), 1);
assert!(!facts_for("Nebula").high_contrast);
assert_eq!(facts_for("Nebula").contrast(), 0);
}
#[test]
fn unknown_theme_falls_back() {
let f = facts_for("NoSuchTheme");
assert_eq!(f, FALLBACK);
assert_eq!(f.color_scheme(), 1, "FALLBACK es oscuro");
}
#[test]
fn accent_rgb_in_range() {
for name in [
"Nebula",
"Aurora",
"Sunset",
"Flat Dark",
"Solarized Light",
"High Contrast",
"Print Color",
"Print B&W",
] {
let (r, g, b) = facts_for(name).accent_rgb();
for ch in [r, g, b] {
assert!(
(0.0..=1.0).contains(&ch),
"{name}: canal fuera de rango: {ch}"
);
}
}
}
}
@@ -0,0 +1,15 @@
[package]
name = "mirada-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "mirada — contrato Cerebro↔Cuerpo del compositor: comandos de geometría que el Cerebro (GPUI) envía y eventos de hardware/superficies que el Cuerpo (smithay) reporta. Marco postcard con prefijo de longitud."
[dependencies]
# `serde` activa los `derive` de los tipos de layout — este crate los
# (de)serializa con postcard.
mirada-layout = { path = "../mirada-layout", features = ["serde"] }
serde = { workspace = true }
postcard = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
# mirada-protocol
> Schema Wayland + extensiones propias de [mirada](../README.md).
Tipos de protocolo: `Surface`, `Output`, `Input`, eventos `pointer/keyboard/touch`. Extensiones específicas de gioser (sesión multi-cuadrante, drag-and-drop con metadata semántica).
## Deps
- `wayland-{protocols,server,client}`
@@ -0,0 +1,9 @@
# mirada-protocol
> Wayland schema + own extensions of [mirada](../README.md).
Protocol types: `Surface`, `Output`, `Input`, `pointer/keyboard/touch` events. Gioser-specific extensions (multi-quadrant session, drag-and-drop with semantic metadata).
## Deps
- `wayland-{protocols,server,client}`
+425
View File
@@ -0,0 +1,425 @@
//! `mirada-protocol` — el contrato Cerebro↔Cuerpo del compositor.
//!
//! mirada se parte en dos procesos:
//!
//! - **El Cuerpo** (`mirada-compositor`, sobre `smithay`): habla Wayland
//! con los clientes, posee el hardware (DRM/GPU/libinput) y compone las
//! superficies reales. Los píxeles nunca salen de él.
//! - **El Cerebro** (una app GPUI sobre [`mirada-layout`]): decide *dónde*
//! va cada ventana — pura aritmética de rectángulos— y orquesta el
//! escritorio (layouts, atajos, focos).
//!
//! Este crate es el único lenguaje que comparten: un par de enums y un
//! marco de cable. No depende de Wayland, ni de `smithay`, ni de GPUI —
//! sólo de [`mirada-layout`] para reusar [`Rect`] y [`WindowId`].
//!
//! - El Cerebro emite [`BrainCommand`]; el Cuerpo los aplica.
//! - El Cuerpo emite [`BodyEvent`]; el Cerebro reacciona y recalcula.
//!
//! El cable es [`postcard`] con prefijo de longitud `u32` little-endian
//! (ver [`write_frame`] / [`read_frame`]).
#![forbid(unsafe_code)]
use std::io::{self, Read, Write};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub use mirada_layout::geometry::Rect;
pub use mirada_layout::workspace::WindowId;
use mirada_layout::{LayoutMode, Workspace};
/// Identificador de una salida física (un monitor).
pub type OutputId = u32;
/// Dónde y cómo debe colocarse una ventana en pantalla.
///
/// Es la unidad de geometría que el Cerebro calcula y el Cuerpo aplica a
/// la superficie Wayland correspondiente.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowPlacement {
pub id: WindowId,
/// Rectángulo en píxeles de pantalla.
pub rect: Rect,
/// `false` la oculta sin destruirla (p. ej. en modo `Monocle`).
pub visible: bool,
/// `true` si esta ventana tiene el foco del teclado.
pub focused: bool,
/// `true` si flota (fuera del teselado): el Cuerpo la pinta encima.
pub floating: bool,
/// `true` si está en pantalla completa: cubre toda la salida.
pub fullscreen: bool,
}
/// Parámetros de decoración de ventana que el Cerebro fija en el Cuerpo.
/// Hoy cubre el marco (grosor + colores). Los colores son RGBA en
/// `0..=255` — enteros para conservar `Eq` en [`BrainCommand`] y por ser
/// más naturales de escribir en la config que floats en `0..1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decorations {
/// Grosor del marco en píxeles; `0` = ventanas sin marco.
pub border_width: i32,
/// Color RGBA del marco de la ventana enfocada.
pub border_focus: [u8; 4],
/// Color RGBA del marco de las ventanas sin foco.
pub border_normal: [u8; 4],
/// Alto de la barra de título en píxeles; `0` = sin barra de título (sólo
/// se muestra el título de la ventana enfocada superpuesto, como antes).
/// La franja se reserva arriba de cada ventana (no-shell): la superficie
/// del cliente se achica y la barra se pinta encima.
pub titlebar_height: i32,
}
impl Default for Decorations {
/// Los valores históricos del Cuerpo: marco de 2 px, azul al foco,
/// gris discreto sin él, barra de título de 24 px.
fn default() -> Self {
Self {
border_width: 2,
border_focus: [92, 143, 235, 255],
border_normal: [56, 56, 69, 255],
titlebar_height: 24,
}
}
}
/// Una orden del Cerebro al Cuerpo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BrainCommand {
/// Geometría completa del escritorio: el Cuerpo mueve/redimensiona
/// cada superficie y oculta las que falten en la lista.
Place(Vec<WindowPlacement>),
/// Pide el cierre ordenado de una ventana (`xdg_toplevel.close`).
Close(WindowId),
/// Mata al cliente de una ventana que no responde.
Kill(WindowId),
/// Registra los atajos globales que el Cuerpo debe interceptar y
/// devolver como [`BodyEvent::Keybind`] en vez de pasarlos al cliente.
GrabKeys(Vec<String>),
/// Cambia el cursor del puntero al nombre dado (tema XCursor).
SetCursor(String),
/// Fija los parámetros de decoración de las ventanas (marco, …). El
/// Cerebro lo envía al arrancar y tras recargar la config.
SetDecorations(Decorations),
/// Lanza un programa como proceso hijo del Cuerpo — hereda su
/// entorno, `WAYLAND_DISPLAY` incluido, así el cliente se conecta
/// aquí. La cadena se pasa a `sh -c`.
Spawn(String),
/// Apaga el Cuerpo y libera el hardware.
Shutdown,
}
/// Un hecho del Cuerpo que el Cerebro debe conocer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BodyEvent {
/// Apareció un monitor (al arrancar o en caliente).
OutputAdded { id: OutputId, width: i32, height: i32 },
/// Desapareció un monitor.
OutputRemoved { id: OutputId },
/// Cambió el tamaño físico de un monitor — se redimensionó la ventana
/// anfitriona o el backend reportó otra resolución. El escritorio que
/// muestra **no** cambia (a diferencia de quitar y volver a añadir).
OutputResized { id: OutputId, width: i32, height: i32 },
/// El marco (`pata`/shell) reservó —o liberó— franjas en los bordes de un
/// monitor: las **zonas exclusivas** que el teselado debe esquivar, en
/// píxeles desde cada borde. Cero en los cuatro = nada reservado (el área
/// útil vuelve a ser el monitor entero). A diferencia de `OutputResized`,
/// no cambia el tamaño físico: sólo el área teselada dentro de él, así que
/// soporta barras en cualquier borde (top/bottom/left/right) a la vez.
OutputReserved {
id: OutputId,
top: i32,
bottom: i32,
left: i32,
right: i32,
},
/// Un cliente creó una ventana de nivel superior.
WindowOpened { id: WindowId, app_id: String, title: String },
/// Una ventana se cerró (por el cliente o tras un [`BrainCommand::Close`]).
WindowClosed { id: WindowId },
/// Una ventana cambió su título.
WindowRetitled { id: WindowId, title: String },
/// El usuario pulsó un atajo registrado con [`BrainCommand::GrabKeys`].
Keybind(String),
/// El puntero entró en una ventana — el Cerebro puede enfocar al pasar
/// (foco-sigue-ratón, si la config lo habilita).
PointerEntered { id: WindowId },
/// El usuario hizo click (botón primario) sobre una ventana — el
/// Cerebro la enfoca, esté donde esté, **sin** depender del
/// foco-sigue-ratón. Es el camino del foco-al-click.
Clicked { id: WindowId },
/// Arrastre interactivo de una ventana **teselada** sobre el punto
/// `(x, y)` de pantalla: el Cerebro la intercambia con la ventana
/// teselada que haya ahí (reordena el stack), conservándola teselada.
/// El arrastre de una flotante usa [`BodyEvent::WindowFloatTo`] en su
/// lugar — moverla, no intercambiarla.
WindowDragged { id: WindowId, x: i32, y: i32 },
/// Un cliente pidió pantalla completa para su ventana (`true`), o la
/// soltó (`false`) — `xdg_toplevel.set_fullscreen`.
FullscreenRequest { id: WindowId, fullscreen: bool },
/// El usuario arrastró una ventana con el ratón a un rectángulo nuevo
/// (mover o redimensionar interactivos). El Cerebro la hace flotar
/// ahí; si estaba teselada, deja de estarlo.
WindowFloatTo { id: WindowId, rect: Rect },
}
/// Tamaño máximo de un marco, en bytes. Acota el búfer de [`read_frame`]
/// para que un prefijo de longitud corrupto no reserve gigabytes.
pub const MAX_FRAME: usize = 16 * 1024 * 1024;
/// Escribe `value` como un marco: prefijo `u32` LE con la longitud + el
/// cuerpo serializado con `postcard`.
pub fn write_frame<W: Write, T: Serialize>(w: &mut W, value: &T) -> io::Result<()> {
let body = postcard::to_stdvec(value)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if body.len() > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"marco mayor que MAX_FRAME",
));
}
w.write_all(&(body.len() as u32).to_le_bytes())?;
w.write_all(&body)?;
w.flush()
}
/// Lee un marco escrito por [`write_frame`]. Devuelve `Ok(None)` en un
/// EOF limpio (el otro extremo cerró sin datos a medias).
pub fn read_frame<R: Read, T: DeserializeOwned>(r: &mut R) -> io::Result<Option<T>> {
let mut len = [0u8; 4];
match r.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(len) as usize;
if len > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"prefijo de longitud mayor que MAX_FRAME",
));
}
let mut body = vec![0u8; len];
r.read_exact(&mut body)?;
let value = postcard::from_bytes(&body)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(value))
}
/// Traduce un [`Workspace`] de mirada-layout a la geometría de cable.
///
/// Es el puente del Cerebro: toma el estado abstracto (ventanas, foco,
/// modo) y la pantalla física, y produce el [`Vec<WindowPlacement>`] que
/// va dentro de un [`BrainCommand::Place`].
///
/// En modo [`LayoutMode::Monocle`] sólo la ventana enfocada queda
/// `visible`; en el resto de modos todas lo están.
pub fn placements(ws: &Workspace, screen: Rect) -> Vec<WindowPlacement> {
let fullscreen = ws.fullscreen();
let monocle = ws.params().mode == LayoutMode::Monocle;
let focused = ws.focused();
ws.layout(screen)
.into_iter()
.map(|(id, rect)| {
let floating = ws.is_floating(id);
let is_fs = fullscreen == Some(id);
// Con una ventana en pantalla completa manda ella: ocupa toda
// la salida, es la única visible y se lleva el foco.
let (rect, visible, is_focused) = match fullscreen {
Some(_) => (if is_fs { screen } else { rect }, is_fs, is_fs),
None => {
let f = focused == Some(id);
// Una flotante siempre se ve; en `Monocle`, sólo la enfocada.
(rect, floating || !monocle || f, f)
}
};
WindowPlacement {
id,
rect,
visible,
focused: is_focused,
floating,
fullscreen: is_fs,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use mirada_layout::LayoutParams;
use std::io::Cursor;
fn ws(mode: LayoutMode) -> Workspace {
let mut w = Workspace::new(LayoutParams { mode, ..LayoutParams::default() });
for id in [10, 20, 30] {
w.add(id);
}
w
}
const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 };
#[test]
fn frame_round_trips_a_brain_command() {
let cmd = BrainCommand::Place(vec![WindowPlacement {
id: 7,
rect: Rect::new(0, 0, 800, 600),
visible: true,
focused: true,
floating: false,
fullscreen: false,
}]);
let mut buf = Vec::new();
write_frame(&mut buf, &cmd).unwrap();
let mut cur = Cursor::new(buf);
let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
assert_eq!(back, cmd);
}
#[test]
fn frame_round_trips_a_body_event() {
let ev = BodyEvent::WindowOpened {
id: 42,
app_id: "org.brahman.shuma".into(),
title: "shell".into(),
};
let mut buf = Vec::new();
write_frame(&mut buf, &ev).unwrap();
let mut cur = Cursor::new(buf);
let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
assert_eq!(back, ev);
}
#[test]
fn frame_round_trips_the_new_body_events() {
for ev in [
BodyEvent::Clicked { id: 7 },
BodyEvent::WindowDragged { id: 3, x: 640, y: -12 },
] {
let mut buf = Vec::new();
write_frame(&mut buf, &ev).unwrap();
let back: BodyEvent = read_frame(&mut Cursor::new(buf)).unwrap().unwrap();
assert_eq!(back, ev);
}
}
#[test]
fn frame_round_trips_a_set_decorations_command() {
let cmd = BrainCommand::SetDecorations(Decorations {
border_width: 3,
border_focus: [10, 20, 30, 255],
border_normal: [1, 2, 3, 4],
titlebar_height: 24,
});
let mut buf = Vec::new();
write_frame(&mut buf, &cmd).unwrap();
let back: BrainCommand = read_frame(&mut Cursor::new(buf)).unwrap().unwrap();
assert_eq!(back, cmd);
}
#[test]
fn default_decorations_are_the_historic_values() {
let d = Decorations::default();
assert_eq!(d.border_width, 2);
assert_eq!(d.border_focus, [92, 143, 235, 255]);
assert_eq!(d.border_normal, [56, 56, 69, 255]);
}
#[test]
fn several_frames_stream_in_order() {
let evs = vec![
BodyEvent::OutputAdded { id: 0, width: 2560, height: 1440 },
BodyEvent::WindowOpened { id: 1, app_id: "a".into(), title: "t".into() },
BodyEvent::Keybind("Super+Return".into()),
];
let mut buf = Vec::new();
for ev in &evs {
write_frame(&mut buf, ev).unwrap();
}
let mut cur = Cursor::new(buf);
for ev in &evs {
let back: BodyEvent = read_frame(&mut cur).unwrap().unwrap();
assert_eq!(&back, ev);
}
// Agotado el stream, un EOF limpio.
assert!(read_frame::<_, BodyEvent>(&mut cur).unwrap().is_none());
}
#[test]
fn empty_reader_is_a_clean_eof() {
let mut cur = Cursor::new(Vec::new());
assert!(read_frame::<_, BrainCommand>(&mut cur).unwrap().is_none());
}
#[test]
fn an_oversized_length_prefix_is_rejected() {
let mut buf = Vec::new();
buf.extend_from_slice(&(u32::MAX).to_le_bytes());
let mut cur = Cursor::new(buf);
assert!(read_frame::<_, BrainCommand>(&mut cur).is_err());
}
#[test]
fn placements_cover_every_window() {
let p = placements(&ws(LayoutMode::Columns), SCREEN);
assert_eq!(p.len(), 3);
assert!(p.iter().all(|w| w.visible));
// Sólo una enfocada — la última añadida.
assert_eq!(p.iter().filter(|w| w.focused).count(), 1);
assert!(p.iter().find(|w| w.id == 30).unwrap().focused);
}
#[test]
fn monocle_keeps_only_the_focused_window_visible() {
let p = placements(&ws(LayoutMode::Monocle), SCREEN);
assert_eq!(p.len(), 3);
assert_eq!(p.iter().filter(|w| w.visible).count(), 1);
let shown = p.iter().find(|w| w.visible).unwrap();
assert!(shown.focused);
assert_eq!(shown.id, 30);
}
#[test]
fn an_empty_workspace_places_nothing() {
let empty = Workspace::new(LayoutParams::default());
assert!(placements(&empty, SCREEN).is_empty());
}
#[test]
fn a_floating_window_is_marked_and_stays_visible_in_monocle() {
let mut w = ws(LayoutMode::Monocle); // Monocle oculta las no enfocadas
w.set_floating(10, Some(Rect::new(0, 0, 200, 200)));
let p = placements(&w, SCREEN);
let f = p.iter().find(|x| x.id == 10).unwrap();
assert!(f.floating);
assert!(f.visible, "una flotante se ve aunque el modo sea Monocle");
// Y conserva su rectángulo flotante.
assert_eq!(f.rect, Rect::new(0, 0, 200, 200));
}
#[test]
fn a_fullscreen_window_covers_the_screen_and_hides_the_rest() {
let mut w = ws(LayoutMode::Columns);
w.set_fullscreen(Some(20));
let p = placements(&w, SCREEN);
let fs = p.iter().find(|x| x.id == 20).unwrap();
assert!(fs.fullscreen);
assert!(fs.focused, "la ventana en pantalla completa se lleva el foco");
assert_eq!(fs.rect, SCREEN);
// El resto queda oculto.
assert!(p.iter().filter(|x| x.id != 20).all(|x| !x.visible));
}
#[test]
fn placements_fill_a_place_command_round_trip() {
let cmd = BrainCommand::Place(placements(&ws(LayoutMode::Grid), SCREEN));
let mut buf = Vec::new();
write_frame(&mut buf, &cmd).unwrap();
let mut cur = Cursor::new(buf);
let back: BrainCommand = read_frame(&mut cur).unwrap().unwrap();
assert_eq!(back, cmd);
}
}
Generated
+5516
View File
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
# Cargo.toml raíz STANDALONE de mirada — compositor Wayland + WM sobre Llimphi.
# Versión magra: sin el asistente IA (excluye mirada-asistente-llimphi,
# asistente-puente y su cola pluma-llm) ni la barra web wasm (mirada-bar-web).
# Llimphi se consume por git desde su repo publicado; las hojas compartidas
# (format/auth-core/rimay-localize/wawa-config/app-bus) y el widget menubar
# se vendorizan en su ruta original para no romper paths relativos.
[workspace]
resolver = "2"
members = [
"02_ruway/mirada/mirada-app-llimphi",
"02_ruway/mirada/mirada-bar-core",
"02_ruway/mirada/mirada-body",
"02_ruway/mirada/mirada-brain",
"02_ruway/mirada/mirada-compositor",
"02_ruway/mirada/mirada-ctl",
"02_ruway/mirada/mirada-greeter",
"02_ruway/mirada/mirada-launcher",
"02_ruway/mirada/mirada-layout",
"02_ruway/mirada/mirada-link",
"02_ruway/mirada/mirada-portal",
"02_ruway/mirada/mirada-protocol",
"02_ruway/llimphi/widgets/menubar",
"shared/format", "shared/auth/auth-core",
"shared/rimay-localize", "shared/wawa-config", "shared/app-bus",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/mirada"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { path = "shared/app-bus" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-menubar = { path = "02_ruway/llimphi/widgets/menubar" }
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi" }
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi" }
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core" }
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" }
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi" }
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi" }
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi" }
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi" }
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi" }
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi" }
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi" }
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi" }
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi" }
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi" }
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core" }
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" }
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { path = "00_unanchay/pineal/pineal-core" }
pineal-render = { path = "00_unanchay/pineal/pineal-render" }
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" }
pineal-stream = { path = "00_unanchay/pineal/pineal-stream" }
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" }
pineal-financial = { path = "00_unanchay/pineal/pineal-financial" }
pineal-polar = { path = "00_unanchay/pineal/pineal-polar" }
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" }
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" }
pineal-flow = { path = "00_unanchay/pineal/pineal-flow" }
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" }
pineal-export = { path = "00_unanchay/pineal/pineal-export" }
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" }
pineal-contour = { path = "00_unanchay/pineal/pineal-contour" }
pineal-bars = { path = "00_unanchay/pineal/pineal-bars" }
pineal = { path = "00_unanchay/pineal/pineal-umbrella" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { path = "01_yachay/iniy/iniy-core" }
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest" }
iniy-extract = { path = "01_yachay/iniy/iniy-extract" }
iniy-nli = { path = "01_yachay/iniy/iniy-nli" }
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm" }
iniy-graph = { path = "01_yachay/iniy/iniy-graph" }
iniy-store = { path = "01_yachay/iniy/iniy-store" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords" }
cosmos-core = { path = "01_yachay/cosmos/cosmos-core" }
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris" }
cosmos-time = { path = "01_yachay/cosmos/cosmos-time" }
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# mirada
> A sovereign Wayland compositor + tiling window manager, in Rust, on [Llimphi](https://gitea.gioser.net/sergio/llimphi).
`mirada` (Spanish *look, gaze*) is the display stack: a tiling Wayland compositor, an XDG desktop portal (generic file pickers / screenshare), a PAM login greeter, and the control CLI. The architecture splits cleanly in two:
- **Cuerpo** (`mirada-body` + `mirada-compositor`) — the Wayland surface over [`smithay`](https://github.com/Smithay/smithay). Manages outputs and surfaces, translates commands into backend operations. DRM/KMS native, or nested inside a host Wayland for development.
- **Cerebro** (`mirada-brain`) — the desktop orchestrator: virtual desktops, windows, focus, tiling. Consumes `BodyEvent`, emits `BrainCommand`. **Agnostic of smithay and of any toolkit** — it can drive a remote Cuerpo over `mirada-link`.
All UI (greeter, portal dialogs, menus) renders on Llimphi-HAL, so the same scene tree runs on Wayland and on Wawa bare-metal.
## Run
```sh
cargo run --release -p mirada-compositor # the compositor (nested in a host Wayland for dev)
cargo run --release -p mirada-greeter # PAM login greeter (real backend + mock)
cargo run --release -p mirada-ctl -- ... # control the running compositor (swaymsg/hyprctl style)
```
DRM/KMS needs seat permissions — launch from a greeter/seat, not a plain user terminal. Nested mode (inside an existing Wayland session) is the friendly dev path.
## Features
- **Tiling WM** (Hyprland-like): directional navigation, multi-monitor, workspace move/swap-master, configurable borders, hot-reload of `config.ron`.
- **Multi-monitor, multi-DPI**: per-output wallpaper / order / scale / fit; logical layout across mixed 1×/2× outputs; hotplug.
- **Openbox-style root menu** with nested submenus; autohide bottom bar.
- **Greeter MVP**: remembers last user/desktop, `↑`/`↓` switch desktops, configurable *Matrix*-rain background, real PAM backend + mock.
- **Robust VT switching** (`Ctrl+Alt+F1…F12`) with libseat pause/resume, keymap-independent.
- **Complete XDG portal**: any app gets file pickers via the portal with no app-specific code.
## Scope of this repository
This is the **lean** build of mirada — the compositor, WM, greeter, portal and CLI. The AI desktop assistant (`mirada-asistente`, which pulls an LLM stack) and the experimental WebAssembly status bar (`mirada-bar-web`) are **not** included here; the compositor does not depend on them.
## Caveats (honest state)
- **Not a `sway`/`weston` replacement in stability** — yet. It replaces them in *Llimphi-HAL compatibility*.
- DRM/KMS path validated on **Intel** only. NVIDIA proprietary and multi-GPU/Optimus are untested.
- Production DRM/KMS hardening beyond the MVP is ongoing.
## License
MIT. Builds on [Llimphi](https://gitea.gioser.net/sergio/llimphi) (pulled as a git dependency).
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "app-bus"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "app-bus — registro único de aplicaciones de gioser + protocolo de menú global (Archivo/Editar/Ayuda) + bus de eventos foco/lanzamiento + trait Launcher. Lo consultan los launchers (mirada, shuma, wawa) en vez de reimplementar el despacho cada uno. Los datos + el trait son no_std; el descubrimiento (fs/TOML), el spawn de procesos y el Bus van detrás del feature `std`."
[features]
default = ["std"]
# `std` enciende el descubrimiento por filesystem/TOML, el spawn de
# procesos del host (ProcessLauncher) y el Bus pub/sub (std::sync::mpsc).
# Sin `std`, el crate queda en datos + trait Launcher + AppMenu, listo para
# espejar en el kernel de wawa.
std = ["serde/std", "toml", "directories"]
[dependencies]
# serde directo (no workspace) para poder apagar default-features en el
# build no_std — la versión workspace fuerza std.
serde = { version = "1", default-features = false, features = ["derive", "alloc"] }
toml = { workspace = true, optional = true }
directories = { workspace = true, optional = true }
[dev-dependencies]
serde_json = { workspace = true }
+38
View File
@@ -0,0 +1,38 @@
# app-bus — bus de eventos in-proc para apps Llimphi
Bus de **publish/subscribe** tipado, síncrono y en memoria, para coordinar apps
dentro del mismo proceso. Cubre tres familias de eventos transversales: **foco**
(qué app/ventana lo tiene), **navegación** (abrir/cerrar/enfocar una app o ruta)
y **notificaciones** efímeras.
## Qué expone
- `AppBus` — handle compartible (`Clone`, internamente `Arc<RwLock<…>>`).
- `Event` — enum transversal: `FocusChanged` / `Navigate` / `CloseApp` / `Notify`.
- `NotifyLevel``Info` / `Warn` / `Error`.
- `Subscription` — guard RAII: al soltarlo se cancela la suscripción.
- `publish(Event)` entrega de forma síncrona a todos los suscriptores; entrega
anidada si un callback publica desde su propio handler.
## No-objetivos
- No es un bus interproceso ni de red (eso es Akasha / app-channel).
- No persiste eventos ni garantiza entrega tras reinicio.
- No ordena por prioridad.
## Estado (2026-05-31)
### Hecho
- Bus pub/sub completo: `publish`, `subscribe`, cancelación por guard RAII.
- Enum `Event` con foco/navegación/cierre/notificaciones.
- Consumido por `launcher-llimphi` (dispara navegación/lanzamiento).
### Pendiente
- Adaptador multiproceso (hoy estrictamente in-proc).
- Entrega diferida / cola (hoy reentrancia anidada síncrona).
- Filtrado por tipo de evento en `subscribe` (hoy el callback filtra).
## Lugar en el repo
`shared/app-bus` — canal in-proc de grano fino. El plano de control a más alto
nivel es `shared/sandokan` (ver su SDD.md).
+32
View File
@@ -0,0 +1,32 @@
# Reproductor multimedia de la suite (02_ruway/media).
#
# Copiá este archivo a ~/.config/gioser/apps/media.toml para que los launchers
# (el menú de inicio de pata, el spotlight) y el "abrir con" del navegador lo
# descubran. El binario `media-app` debe estar en el PATH (cargo install) o poné
# su ruta absoluta en `exec`.
id = "media"
label = "Media"
icon = "▶"
category = "ruway"
# Los mimes que sabe abrir (open-with). Deben coincidir con los que pata deriva
# de la extensión (02_ruway/pata/pata-llimphi/src/open.rs::mime_for_path).
handles = [
"video/mp4",
"video/x-matroska",
"video/webm",
"video/quicktime",
"video/x-msvideo",
"audio/mpeg",
"audio/flac",
"audio/wav",
"audio/ogg",
"audio/opus",
]
[launch]
exec = "media-app"
# `%f` = la ruta del archivo a abrir (convención freedesktop). Sin placeholder,
# la ruta se agrega igual como último argumento.
args = ["%f"]
+40
View File
@@ -0,0 +1,40 @@
# Editor de texto/código de la suite (02_ruway/nada): file tree + text-editor
# Llimphi sobre archivos reales. Acepta una ruta (archivo o directorio) como
# argumento.
#
# Copiá este archivo a ~/.config/gioser/apps/nada.toml. El binario `nada` debe
# estar en el PATH (cargo install -p nada) o poné su ruta absoluta en `exec`.
id = "nada"
label = "Nada"
icon = "✎"
category = "ruway"
# Texto y código: lo que el text-editor de Llimphi sabe editar. Coincide con la
# tabla de mimes de pata (open.rs::mime_for_path).
handles = [
"text/plain",
"text/markdown",
"text/x-rst",
"text/csv",
"text/x-rust",
"text/x-python",
"text/javascript",
"text/typescript",
"text/x-c",
"text/x-c++",
"text/x-go",
"text/x-java",
"text/x-ruby",
"application/x-shellscript",
"application/toml",
"application/json",
"application/yaml",
"application/xml",
"text/html",
"text/css",
]
[launch]
exec = "nada"
args = ["%f"]
+795
View File
@@ -0,0 +1,795 @@
//! `app-bus` — el cimiento del menú de aplicaciones de gioser.
//!
//! Hoy hay tres lanzadores que no comparten nada: `mirada-launcher`
//! (TOML propio, `std::process`), `shuma-module-launcher` (otro TOML,
//! `process_group`) y el launcher in-kernel de wawa (Manifiesto, WASM).
//! Cada uno reimplementa "qué apps existen y cómo se lanzan". Este crate
//! es la tabla única que todos consultan.
//!
//! Cuatro piezas, en capas:
//!
//! 1. **Registro** ([`AppRegistry`] + [`AppEntry`]): qué apps hay, cómo se
//! lanzan ([`Launch`]) y qué mimes/lentes saben abrir (open-with).
//! Se descubre de `~/.config/gioser/apps/*.toml` (feature `std`).
//! 2. **Menú global** ([`AppMenu`]/[`Menu`]/[`MenuItem`]): el clásico
//! Archivo/Editar/Ayuda que la app *declara*. Cuando hay una barra de
//! launcher presente, ésta lo *adopta* y la app deja de pintarlo en su
//! ventana — el comportamiento "menú global" de macOS.
//! 3. **Launcher** ([`Launcher`] trait + [`LaunchError`]): la *instrucción
//! de ejecución* abstracta. El host implementa con `std::process`
//! ([`ProcessLauncher`]), wawa con instanciación WASM, shuma
//! despachando `action`. El motor de launcher llama al trait y no se
//! entera de en qué entorno corre.
//! 4. **Bus** ([`Bus`] + [`BusEvent`]): pub/sub in-process de foco /
//! cambio de menú / pedido de lanzamiento / comando. La versión
//! cross-proceso montará sobre el broker de brahman más adelante.
//!
//! Los **datos** ([`AppEntry`], [`Launch`], [`AppMenu`]…) y el **trait
//! [`Launcher`]** son `no_std + alloc`. El descubrimiento por filesystem,
//! el spawn de procesos y el [`Bus`] viven detrás del feature `std`.
#![cfg_attr(not(feature = "std"), no_std)]
#![forbid(unsafe_code)]
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
// =====================================================================
// Registro de apps
// =====================================================================
/// Cómo se enciende una app. Los tres mundos de gioser:
/// `Exec` (binario del host), `Action` (acción interna del chasis que la
/// hospeda — p.ej. `focus:shell`) y `Wasm` (módulo en el almacén de wawa,
/// direccionado por hash de bytecode).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Launch {
/// Spawnear un comando/binario del host.
Exec { program: String, args: Vec<String> },
/// Acción interna a despachar por el host (no spawnea proceso).
Action(String),
/// App WASM de wawa, por hash de bytecode (hex) en el almacén.
Wasm { bytecode_hex: String },
}
/// Una app registrada — la fila de la tabla que ven los launchers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppEntry {
pub id: String,
pub label: String,
/// Glyph/emoji o ruta de ícono. Sin imponer formato — el launcher
/// decide cómo pintarlo (texto en el dock MVP).
pub icon: Option<String>,
/// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante).
pub category: Option<String>,
pub launch: Launch,
/// Mimes/lentes que esta app sabe abrir (open-with). Vacío = no es
/// visor; el registro de visores de nahual-shell se alimenta de acá.
pub handles: Vec<String>,
}
impl AppEntry {
/// `true` si la app declara saber abrir `mime`.
pub fn handles_mime(&self, mime: &str) -> bool {
self.handles.iter().any(|m| m == mime)
}
}
#[cfg(feature = "std")]
impl AppEntry {
/// Enciende la app vía `std::process`. Sólo `Exec` spawnea; `Action`/
/// `Wasm` devuelven `Ok(None)` — los despacha el host (chasis/kernel).
pub fn spawn(&self) -> std::io::Result<Option<std::process::Child>> {
match &self.launch {
Launch::Exec { program, args } => std::process::Command::new(program)
.args(args)
.spawn()
.map(Some),
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
}
}
/// **Open-with out-of-process**: abre `target` con esta app. Para `Exec`,
/// spawnea el binario sustituyendo el placeholder `%f`/`%u` en los args
/// por `target`; si ningún arg lo trae, agrega `target` como último
/// argumento (semántica estilo freedesktop `Exec=app %f`). `Action`/`Wasm`
/// devuelven `Ok(None)`: el target lo despacha el host (chasis a una vista
/// in-process, o kernel de wawa a una app WASM), no un proceso del SO.
pub fn open(&self, target: &str) -> std::io::Result<Option<std::process::Child>> {
match &self.launch {
Launch::Exec { program, args } => std::process::Command::new(program)
.args(expand_target(args, target))
.spawn()
.map(Some),
Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
}
}
}
/// Sustituye los placeholders `%f`/`%u` por `target` en `args`. Si ninguno
/// aparece, agrega `target` como argumento final — la convención de
/// freedesktop (`Exec=app %f`) que entiende cualquier "abrir con".
#[cfg(feature = "std")]
pub fn expand_target(args: &[String], target: &str) -> Vec<String> {
let mut sustituido = false;
let mut out: Vec<String> = args
.iter()
.map(|a| {
if a.contains("%f") || a.contains("%u") {
sustituido = true;
a.replace("%f", target).replace("%u", target)
} else {
a.clone()
}
})
.collect();
if !sustituido {
out.push(target.to_string());
}
out
}
// ----- forma en disco (TOML) -----
/// Espejo serde del archivo `<id>.toml`. La `[launch]` es una tabla con
/// campos opcionales en vez de un enum etiquetado — toml 0.8 trata los
/// enums internamente etiquetados de forma quisquillosa, así que
/// resolvemos a mano a [`Launch`]. Sólo se usa al parsear TOML (`std`).
#[cfg(feature = "std")]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppFile {
id: String,
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
handles: Vec<String>,
launch: LaunchFile,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LaunchFile {
#[serde(default)]
exec: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
action: Option<String>,
#[serde(default)]
wasm: Option<String>,
}
#[cfg(feature = "std")]
impl LaunchFile {
fn resolve(self) -> Option<Launch> {
if let Some(program) = self.exec {
Some(Launch::Exec {
program,
args: self.args,
})
} else if let Some(action) = self.action {
Some(Launch::Action(action))
} else {
self.wasm.map(|bytecode_hex| Launch::Wasm { bytecode_hex })
}
}
}
#[cfg(feature = "std")]
impl AppFile {
fn into_entry(self) -> Option<AppEntry> {
Some(AppEntry {
id: self.id,
label: self.label,
icon: self.icon,
category: self.category,
handles: self.handles,
launch: self.launch.resolve()?,
})
}
}
/// Parsea una entrada de app desde texto TOML. Devuelve `None` si no
/// parsea o si la `[launch]` no nombra ningún modo (`exec`/`action`/`wasm`).
#[cfg(feature = "std")]
pub fn parse_entry(toml_src: &str) -> Option<AppEntry> {
toml::from_str::<AppFile>(toml_src)
.ok()
.and_then(AppFile::into_entry)
}
/// Directorio canónico del registro: `~/.config/gioser/apps/`.
#[cfg(feature = "std")]
pub fn apps_dir() -> Option<std::path::PathBuf> {
directories::BaseDirs::new().map(|b| b.config_dir().join("gioser").join("apps"))
}
/// La tabla de apps. Inmutable tras descubrir — recargar = volver a
/// `discover`. Barato: son pocos archivos y no es hot-path.
#[derive(Debug, Clone, Default)]
pub struct AppRegistry {
entries: Vec<AppEntry>,
}
impl AppRegistry {
pub fn new(mut entries: Vec<AppEntry>) -> Self {
// sort_unstable_by para no exigir alloc extra (vive en core).
entries.sort_unstable_by(|a, b| a.label.cmp(&b.label));
Self { entries }
}
pub fn all(&self) -> &[AppEntry] {
&self.entries
}
pub fn get(&self, id: &str) -> Option<&AppEntry> {
self.entries.iter().find(|e| e.id == id)
}
/// Apps que declaran abrir `mime` — para el open-with universal.
pub fn handlers_for(&self, mime: &str) -> Vec<&AppEntry> {
self.entries.iter().filter(|e| e.handles_mime(mime)).collect()
}
/// Apps de una categoría, en orden de label (para grilla/spotlight).
pub fn in_category(&self, category: &str) -> Vec<&AppEntry> {
self.entries
.iter()
.filter(|e| e.category.as_deref() == Some(category))
.collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(feature = "std")]
impl AppRegistry {
/// Descubre del dir canónico. Vacío si no hay config dir o el dir no
/// existe — la app sigue, sólo sin entradas.
pub fn discover() -> Self {
apps_dir().map(Self::from_dir).unwrap_or_default()
}
/// **Open-with universal**: elige el primer handler de `mime` (orden de
/// label) y le abre `target` out-of-process vía [`AppEntry::open`].
/// Devuelve el `AppEntry` elegido y su `Child` (o `None` en el child si
/// la app es `Action`/`Wasm`, que despacha el host). `Ok(None)` si ninguna
/// app registrada declara abrir ese mime — el caller cae a su visor
/// in-process por defecto (p.ej. el `viewer_registry` de nahual-shell).
pub fn open_with(
&self,
mime: &str,
target: &str,
) -> std::io::Result<Option<(&AppEntry, Option<std::process::Child>)>> {
match self.handlers_for(mime).into_iter().next() {
Some(entry) => Ok(Some((entry, entry.open(target)?))),
None => Ok(None),
}
}
/// Escanea `<dir>/*.toml`. Ignora en silencio los que no parsean
/// (con una nota a stderr), igual que el resto de los loaders del repo.
pub fn from_dir(dir: impl AsRef<std::path::Path>) -> Self {
let dir = dir.as_ref();
let mut entries = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
match std::fs::read_to_string(&p) {
Ok(src) => match parse_entry(&src) {
Some(entry) => entries.push(entry),
None => eprintln!("app-bus: {p:?} no declara una app válida"),
},
Err(err) => eprintln!("app-bus: no se pudo leer {p:?}: {err}"),
}
}
}
Self::new(entries)
}
}
/// Siembra manifests por defecto en [`apps_dir`] si todavía no hay
/// ninguno, para que [`AppRegistry::discover`] devuelva las apps del repo
/// en una máquina recién instalada. No pisa nada si ya existe algún
/// `*.toml`. Devuelve cuántos manifests escribió.
#[cfg(feature = "std")]
pub fn seed_default_apps() -> std::io::Result<usize> {
let Some(dir) = apps_dir() else {
return Ok(0);
};
// Si ya hay manifests, respetar la config del usuario y no tocar nada.
if let Ok(rd) = std::fs::read_dir(&dir) {
let ya_hay = rd.flatten().any(|e| {
e.path().extension().and_then(|s| s.to_str()) == Some("toml")
});
if ya_hay {
return Ok(0);
}
}
std::fs::create_dir_all(&dir)?;
// (id, label, icono, binario, cuadrante). Los binarios son los nombres
// de crate ejecutables del workspace; el cuadrante alimenta la grilla.
const DEFAULTS: &[(&str, &str, &str, &str, &str)] = &[
("cosmos", "Cosmos", "", "cosmos-app-llimphi", "yachay"),
("nada", "Nada", "", "nada", "ruway"),
("pluma", "Pluma", "", "pluma-editor-llimphi", "unanchay"),
("nahual", "Nahual", "", "nahual-shell-llimphi", "ruway"),
("dominium", "Dominium", "", "dominium-app-llimphi", "yachay"),
("tinkuy", "Tinkuy", "", "tinkuy-llimphi", "yachay"),
("takiy", "Takiy", "", "takiy-app-llimphi", "ruway"),
("media", "Media", "", "media-app", "ruway"),
("tullpu", "Tullpu", "", "tullpu-app-llimphi", "ruway"),
("supay", "Supay", "", "supay-app-llimphi", "ruway"),
("sandokan-monitor", "Monitor", "", "sandokan-monitor", "ukupacha"),
];
let mut escritos = 0;
for (id, label, icon, exec, cat) in DEFAULTS {
let toml = alloc::format!(
"id = \"{id}\"\nlabel = \"{label}\"\nicon = \"{icon}\"\ncategory = \"{cat}\"\n\n[launch]\nexec = \"{exec}\"\n"
);
std::fs::write(dir.join(alloc::format!("{id}.toml")), toml)?;
escritos += 1;
}
Ok(escritos)
}
// =====================================================================
// Menú global (Archivo / Editar / Ayuda …)
// =====================================================================
/// Un ítem de menú. `command` es el id que la app entiende: la barra lo
/// re-emite por el [`Bus`] como [`BusEvent::Command`] y la app focuseada
/// lo ejecuta. `shortcut` es sólo para pintar (la app sigue dueña del
/// binding real).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MenuItem {
pub label: String,
pub command: String,
#[serde(default)]
pub shortcut: Option<String>,
/// Glifo (unicode) opcional para el gutter de íconos del dropdown.
#[serde(default)]
pub icon: Option<String>,
#[serde(default = "yes")]
pub enabled: bool,
/// Dibujar un separador *antes* de este ítem.
#[serde(default)]
pub separator_before: bool,
}
fn yes() -> bool {
true
}
impl MenuItem {
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
Self {
label: label.into(),
command: command.into(),
shortcut: None,
icon: None,
enabled: true,
separator_before: false,
}
}
pub fn shortcut(mut self, s: impl Into<String>) -> Self {
self.shortcut = Some(s.into());
self
}
/// Glifo del gutter izquierdo (unicode).
pub fn icon(mut self, glyph: impl Into<String>) -> Self {
self.icon = Some(glyph.into());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn separated(mut self) -> Self {
self.separator_before = true;
self
}
}
/// Un menú raíz (Archivo, Editar, Ayuda…) con sus ítems.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Menu {
pub label: String,
pub items: Vec<MenuItem>,
}
impl Menu {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
items: Vec::new(),
}
}
pub fn item(mut self, it: MenuItem) -> Self {
self.items.push(it);
self
}
}
/// El menú global completo de una app. La app lo declara; la barra de
/// launcher lo adopta (y entonces la app no lo pinta en su ventana).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppMenu {
pub menus: Vec<Menu>,
}
impl AppMenu {
pub fn new() -> Self {
Self::default()
}
pub fn menu(mut self, m: Menu) -> Self {
self.menus.push(m);
self
}
pub fn is_empty(&self) -> bool {
self.menus.is_empty()
}
/// Esqueleto estándar Archivo/Editar/Ayuda — punto de partida para que
/// toda app tenga un menú base coherente sin reinventarlo. Los
/// `command` siguen la convención `menu.<verbo>`; la app mapea los que
/// le sirven y deja `disabled` los que no.
pub fn standard() -> Self {
Self::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Nuevo", "file.new").shortcut("Ctrl+N"))
.item(MenuItem::new("Abrir…", "file.open").shortcut("Ctrl+O"))
.item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
.item(MenuItem::new("Cerrar", "file.close").shortcut("Ctrl+W").separated()),
)
.menu(
Menu::new("Editar")
.item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
.item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"))
.item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated())
.item(MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"))
.item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V")),
)
.menu(
Menu::new("Ayuda")
.item(MenuItem::new("Atajos", "help.shortcuts").shortcut("F1"))
.item(MenuItem::new("Acerca de", "help.about")),
)
}
}
// =====================================================================
// Launcher — la instrucción de ejecución abstracta
// =====================================================================
/// Por qué no se pudo lanzar una app.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchError {
/// Este `Launcher` no maneja el modo de la app (p.ej. un host que no
/// instancia WASM, o wawa que no spawnea procesos del host).
Unsupported,
/// El lanzamiento falló; mensaje libre.
Failed(String),
}
/// La *instrucción de ejecución* abstracta. El motor de launcher
/// (`launcher-core`/`launcher-llimphi`) llama a `launch` y no sabe en qué
/// entorno corre — host, shuma o wawa cada uno trae su impl.
pub trait Launcher {
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError>;
}
/// Launcher del host: spawnea binarios vía `std::process`. No maneja
/// `Action`/`Wasm` (devuelve `Unsupported` — esos los resuelve el chasis).
#[cfg(feature = "std")]
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessLauncher;
#[cfg(feature = "std")]
impl Launcher for ProcessLauncher {
fn launch(&self, app: &AppEntry) -> Result<(), LaunchError> {
match app.spawn() {
Ok(Some(_child)) => Ok(()),
Ok(None) => Err(LaunchError::Unsupported),
Err(e) => Err(LaunchError::Failed(alloc::string::ToString::to_string(&e))),
}
}
}
// =====================================================================
// Bus de eventos (pub/sub in-process) — sólo `std`
// =====================================================================
/// Lo que viaja por el bus. El flujo del menú global: una app toma foco
/// → `AppFocused` + `MenuChanged` → la barra adopta el menú → el usuario
/// clickea un ítem → la barra emite `Command` → la app focuseada lo
/// ejecuta. El dock/spotlight emiten `LaunchRequested` y el shell lo
/// resuelve contra el [`AppRegistry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BusEvent {
/// Una app tomó foco — la barra debería adoptar su menú.
AppFocused { app_id: String },
/// El menú de una app cambió (ítems habilitados/labels dinámicos).
MenuChanged { app_id: String, menu: AppMenu },
/// Dock/spotlight pidieron lanzar una app por id.
LaunchRequested { app_id: String },
/// La barra global disparó un comando del menú hacia la app focuseada.
Command { app_id: String, command: String },
}
/// Bus pub/sub mínimo y `Send + Sync`: fan-out a todos los suscriptores.
/// Un suscriptor caído (receiver dropeado) se poda en el próximo publish.
/// Clonar el `Bus` comparte el mismo conjunto de suscriptores.
#[cfg(feature = "std")]
#[derive(Clone, Default)]
pub struct Bus {
subs: std::sync::Arc<std::sync::Mutex<Vec<std::sync::mpsc::Sender<BusEvent>>>>,
}
#[cfg(feature = "std")]
impl Bus {
pub fn new() -> Self {
Self::default()
}
/// Crea un canal y devuelve su extremo de recepción. El emisor queda
/// registrado para recibir cada `publish` futuro.
pub fn subscribe(&self) -> std::sync::mpsc::Receiver<BusEvent> {
let (tx, rx) = std::sync::mpsc::channel();
self.subs.lock().unwrap().push(tx);
rx
}
/// Emite a todos los suscriptores vivos. Devuelve cuántos lo recibieron.
pub fn publish(&self, ev: BusEvent) -> usize {
let mut subs = self.subs.lock().unwrap();
subs.retain(|tx| tx.send(ev.clone()).is_ok());
subs.len()
}
}
// =====================================================================
// Tests (corren con default features = std)
// =====================================================================
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
#[test]
fn parse_exec_entry() {
let src = r#"
id = "cosmos"
label = "Cosmos"
icon = ""
category = "yachay"
handles = ["application/x-cosmos-chart"]
[launch]
exec = "cosmos-app-llimphi"
args = ["--release"]
"#;
let e = parse_entry(src).expect("parsea");
assert_eq!(e.id, "cosmos");
assert_eq!(e.icon.as_deref(), Some(""));
assert!(e.handles_mime("application/x-cosmos-chart"));
assert_eq!(
e.launch,
Launch::Exec {
program: "cosmos-app-llimphi".into(),
args: vec!["--release".into()],
}
);
}
#[test]
fn parse_action_and_wasm() {
let a = parse_entry("id='s'\nlabel='Shell'\n[launch]\naction='focus:shell'").unwrap();
assert_eq!(a.launch, Launch::Action("focus:shell".into()));
let w = parse_entry("id='h'\nlabel='Hola'\n[launch]\nwasm='deadbeef'").unwrap();
assert_eq!(w.launch, Launch::Wasm { bytecode_hex: "deadbeef".into() });
}
#[test]
fn launch_sin_modo_es_none() {
assert!(parse_entry("id='x'\nlabel='X'\n[launch]").is_none());
}
#[test]
fn registry_from_dir_y_consultas() {
let dir =
std::env::temp_dir().join(format!("app-bus-test-{}-{}", std::process::id(), "reg"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("cosmos.toml"),
"id='cosmos'\nlabel='Cosmos'\ncategory='yachay'\nhandles=['x/chart']\n[launch]\nexec='cosmos-app-llimphi'",
)
.unwrap();
std::fs::write(
dir.join("nada.toml"),
"id='nada'\nlabel='Nada'\ncategory='ruway'\n[launch]\nexec='nada'",
)
.unwrap();
std::fs::write(dir.join("roto.toml"), "no es toml válido = =").unwrap();
let reg = AppRegistry::from_dir(&dir);
assert_eq!(reg.len(), 2);
assert_eq!(reg.all()[0].id, "cosmos");
assert_eq!(reg.get("nada").unwrap().label, "Nada");
assert_eq!(reg.handlers_for("x/chart").len(), 1);
assert_eq!(reg.in_category("yachay").len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers() {
// Los manifiestos de `assets/apps/` (las apps reales de la suite que se
// copian a ~/.config/gioser/apps/) deben parsear y declarar sus mimes.
// Canario del formato: si cambia el esquema, esto avisa.
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/apps");
let reg = AppRegistry::from_dir(&dir);
assert_eq!(reg.len(), 2, "media + nada");
// media abre video/audio; nada, texto/código.
assert_eq!(reg.handlers_for("video/mp4")[0].id, "media");
assert_eq!(reg.handlers_for("text/x-rust")[0].id, "nada");
// El exec lleva el placeholder freedesktop.
let media = reg.get("media").unwrap();
assert_eq!(
media.launch,
Launch::Exec {
program: "media-app".into(),
args: vec!["%f".into()],
}
);
}
#[test]
fn menu_estandar_y_builder() {
let m = AppMenu::standard();
assert_eq!(m.menus.len(), 3);
assert_eq!(m.menus[0].label, "Archivo");
let custom = AppMenu::new().menu(
Menu::new("Carta").item(MenuItem::new("Duplicar", "carta.dup").shortcut("Ctrl+D")),
);
assert_eq!(custom.menus[0].items[0].command, "carta.dup");
}
#[test]
fn menu_roundtrip_serde() {
let m = AppMenu::standard();
let json = serde_json::to_string(&m).unwrap();
let back: AppMenu = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn process_launcher_unsupported_para_action() {
// Action no es del host → Unsupported (no intenta spawnear).
let app = AppEntry {
id: "s".into(),
label: "Shell".into(),
icon: None,
category: None,
launch: Launch::Action("focus:shell".into()),
handles: Vec::new(),
};
assert_eq!(ProcessLauncher.launch(&app), Err(LaunchError::Unsupported));
}
#[test]
fn bus_fanout_y_poda() {
let bus = Bus::new();
let a = bus.subscribe();
let b = bus.subscribe();
let n = bus.publish(BusEvent::LaunchRequested {
app_id: "cosmos".into(),
});
assert_eq!(n, 2);
assert!(matches!(a.recv().unwrap(), BusEvent::LaunchRequested { .. }));
assert!(matches!(b.recv().unwrap(), BusEvent::LaunchRequested { .. }));
drop(a);
let n = bus.publish(BusEvent::AppFocused {
app_id: "nada".into(),
});
assert_eq!(n, 1);
}
// ===== open-with out-of-process =====
#[test]
fn expand_target_sustituye_placeholder() {
let args = vec!["--open".to_string(), "%f".to_string()];
assert_eq!(
expand_target(&args, "/tmp/x.png"),
vec!["--open".to_string(), "/tmp/x.png".to_string()]
);
// `%u` también; y substitución embebida en un arg compuesto.
let args = vec!["url=%u".to_string()];
assert_eq!(expand_target(&args, "http://a"), vec!["url=http://a".to_string()]);
}
#[test]
fn expand_target_agrega_si_no_hay_placeholder() {
let args = vec!["--flag".to_string()];
assert_eq!(
expand_target(&args, "/tmp/x.png"),
vec!["--flag".to_string(), "/tmp/x.png".to_string()]
);
}
#[test]
fn open_with_sin_handler_devuelve_none() {
let reg = AppRegistry::new(vec![]);
assert!(reg.open_with("image/png", "/tmp/x.png").unwrap().is_none());
}
#[test]
fn open_with_spawnea_handler_y_le_pasa_el_target() {
use std::io::Read;
// Archivo donde el "handler" escribirá el target que recibió.
let out =
std::env::temp_dir().join(format!("app-bus-openwith-{}.txt", std::process::id()));
let _ = std::fs::remove_file(&out);
// Handler = sh que escribe $1 (el target expandido en %f) al archivo.
let entry = AppEntry {
id: "writer".into(),
label: "Writer".into(),
icon: None,
category: None,
launch: Launch::Exec {
program: "sh".into(),
args: vec![
"-c".into(),
format!("printf '%s' \"$1\" > {}", out.display()),
"_".into(),
"%f".into(),
],
},
handles: vec!["image/png".into()],
};
let reg = AppRegistry::new(vec![entry]);
let (chosen, child) = reg
.open_with("image/png", "TARGET-123")
.unwrap()
.expect("debe haber handler para image/png");
assert_eq!(chosen.id, "writer");
child.expect("Exec debe spawnear un Child").wait().unwrap();
let mut s = String::new();
std::fs::File::open(&out)
.unwrap()
.read_to_string(&mut s)
.unwrap();
assert_eq!(s, "TARGET-123", "el handler recibió el target en %f");
let _ = std::fs::remove_file(&out);
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "auth-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "auth-core — autenticación del escritorio: contrato Authenticator agnóstico + backend PAM + mock. Lo consume el greeter de carmen (mirada)."
[dependencies]
nix = { workspace = true }
thiserror = { workspace = true }
pam = { workspace = true }
[dev-dependencies]
rpassword = { workspace = true }
+52
View File
@@ -0,0 +1,52 @@
# auth-core
Autenticación del escritorio. Contrato `Authenticator` agnóstico del
backend, con dos implementaciones.
## Para qué
El greeter de carmen (mirada) necesita verificar la contraseña del
usuario y, en éxito, saber su `uid/gid/home/shell` para arrancar la
sesión. Eso es exactamente lo que entrega `Authenticator::authenticate`:
```rust
use brahman_auth::{Authenticator, PamAuthenticator};
let auth = PamAuthenticator::carmen();
match auth.authenticate("sergio", &password) {
Ok(info) => arrancar_sesion(info), // info: UserInfo
Err(e) => mostrar_error_en_greeter(e),
}
```
## Backends
- **`PamAuthenticator`** — verifica contra PAM (`/etc/pam.d/<servicio>`),
el mismo subsistema de `login` y `sudo`. Hereda lo que el
administrador configure ahí (2FA, FIDO2, `pam_faillock`…) sin que el
crate lo sepa.
- **`MockAuthenticator`** — credenciales fijas en memoria. Para tests y
para iterar el greeter en cajas sin PAM configurado.
`AuthError` es deliberadamente grueso: el greeter sólo distingue
"reintentá" (`BadCredentials`) de "cuenta vetada" (`AccountUnavailable`),
y nunca puede saber si un usuario existe.
## Servicio PAM
`data/carmen` es el archivo de servicio. Instalarlo:
```sh
install -Dm644 data/carmen /etc/pam.d/carmen
```
Ajustar el `include` a la pila de login de la distribución (ver los
comentarios del archivo).
## Probar contra PAM en una máquina real
```sh
cargo run -p auth-core --example auth-probe -- "$USER" login
```
Pide la contraseña sin eco e informa el `UserInfo` resuelto.
+15
View File
@@ -0,0 +1,15 @@
#%PAM-1.0
#
# Servicio PAM del greeter de carmen (mirada). Instalar como
# /etc/pam.d/carmen.
#
# El `include` apunta a la pila de login de la distribución; ajustar
# según corresponda:
# Arch system-login
# Debian/Ubuntu common-auth / common-account / ... (una por línea)
# Fedora/RHEL system-auth + postlogin
#
auth include system-login
account include system-login
password include system-login
session include system-login
@@ -0,0 +1,41 @@
//! Prueba interactiva de `brahman-auth` contra PAM. Sirve para verificar
//! la configuración de `/etc/pam.d/<servicio>` en una máquina real.
//!
//! `cargo run -p brahman-auth --example auth-probe -- [usuario] [servicio]`
//!
//! Pide la contraseña sin eco. El servicio por defecto es `carmen`; si
//! `/etc/pam.d/carmen` aún no está instalado, probar con `login`.
use auth_core::{Authenticator, PamAuthenticator};
fn main() {
let mut args = std::env::args().skip(1);
let user = args
.next()
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "root".into());
let service = args.next().unwrap_or_else(|| "carmen".into());
let password = match rpassword::prompt_password(format!("Contraseña de {user}: ")) {
Ok(p) => p,
Err(e) => {
eprintln!("no se pudo leer la contraseña: {e}");
std::process::exit(2);
}
};
let auth = PamAuthenticator::new(&service);
println!("autenticando «{user}» contra el servicio PAM «{service}»…");
match auth.authenticate(&user, &password) {
Ok(info) => {
println!("✓ autenticado");
println!(" uid={} gid={}", info.uid, info.gid);
println!(" home={}", info.home.display());
println!(" shell={}", info.shell.display());
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `brahman-auth` — autenticación del escritorio.
//!
//! Contrato [`Authenticator`] agnóstico del backend, con dos
//! implementaciones:
//!
//! - [`PamAuthenticator`] — el camino real: verifica contra PAM
//! (`/etc/pam.d/<servicio>`), el mismo subsistema que usan `login`,
//! `sudo` y los gestores de login clásicos. Hereda lo que el
//! administrador configure ahí (2FA, llaves FIDO2, `pam_faillock`…)
//! sin que `brahman-auth` tenga que saberlo.
//! - [`MockAuthenticator`] — credenciales fijas en memoria, para tests
//! y para iterar el greeter en cajas sin PAM configurado.
//!
//! Lo consume el greeter de carmen (mirada): el usuario teclea su
//! contraseña, el greeter llama a [`Authenticator::authenticate`], y en
//! éxito recibe un [`UserInfo`] con uid/gid/home/shell — lo que el
//! compositor necesita para arrancar la sesión.
mod pam_backend;
mod ticket;
mod user;
pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE};
pub use ticket::{SessionTicket, TICKET_TAG};
pub use user::{resolve_user, UserInfo};
use std::collections::HashMap;
/// Por qué falló una autenticación. Variantes deliberadamente gruesas:
/// el greeter sólo necesita saber si conviene reintentar (problema de
/// credenciales) o si la cuenta está vetada — y nunca debe poder
/// distinguir "usuario inexistente" de "contraseña errada".
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
/// Usuario o contraseña incorrectos. El greeter deja reintentar sin
/// revelar cuál de los dos falló.
#[error("usuario o contraseña incorrectos")]
BadCredentials,
/// Las credenciales son válidas pero la cuenta está deshabilitada,
/// expirada o requiere una acción (cambio de contraseña).
#[error("la cuenta no está disponible: {0}")]
AccountUnavailable(String),
/// Fallo del subsistema PAM no atribuible a las credenciales
/// (servicio mal configurado, módulo roto, etc.).
#[error("fallo de PAM: {0}")]
Pam(String),
/// No se pudo resolver la identidad del usuario en el sistema tras
/// una autenticación válida (caso raro: `/etc/passwd` inconsistente).
#[error("no se pudo resolver el usuario «{0}» en el sistema")]
UnresolvedUser(String),
}
/// Verifica credenciales y, en éxito, entrega la identidad del sistema.
///
/// `&self`: cada llamada es un intento de login independiente. Las
/// implementaciones crean su propio estado por intento — PAM exige un
/// handle nuevo por transacción, reusarlo entre intentos es un bug.
pub trait Authenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError>;
}
/// Autenticador de credenciales fijas en memoria. No toca PAM: sirve
/// para tests y para iterar el greeter en cajas headless sin
/// `/etc/pam.d` configurado.
#[derive(Debug, Default, Clone)]
pub struct MockAuthenticator {
creds: HashMap<String, String>,
}
impl MockAuthenticator {
/// Crea un autenticador sin usuarios: todo intento falla con
/// [`AuthError::BadCredentials`] hasta registrar alguno.
pub fn new() -> Self {
Self::default()
}
/// Registra un par usuario/secreto aceptado. Encadenable.
pub fn with_user(mut self, username: &str, secret: &str) -> Self {
self.creds.insert(username.to_string(), secret.to_string());
self
}
}
impl Authenticator for MockAuthenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
// Mismo error para usuario inexistente y para contraseña errada:
// no filtra la existencia de cuentas.
match self.creds.get(username) {
Some(expected) if expected == secret => {
// Si el usuario existe en el SO, info real; sino,
// sintética (suficiente para tests y dev headless).
Ok(resolve_user(username).unwrap_or_else(|_| UserInfo::synthetic(username)))
}
_ => Err(AuthError::BadCredentials),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_accepts_registered_user() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
let info = auth.authenticate("sergio", "clave").expect("debe pasar");
assert_eq!(info.name, "sergio");
}
#[test]
fn mock_rejects_wrong_password() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
assert_eq!(
auth.authenticate("sergio", "mala"),
Err(AuthError::BadCredentials)
);
}
#[test]
fn mock_unknown_user_indistinguishable_from_wrong_password() {
let auth = MockAuthenticator::new().with_user("sergio", "clave");
assert_eq!(
auth.authenticate("nadie", "x"),
Err(AuthError::BadCredentials)
);
}
#[test]
fn empty_mock_rejects_everything() {
assert!(MockAuthenticator::new().authenticate("root", "").is_err());
}
#[test]
fn auth_error_is_displayable() {
assert!(!AuthError::BadCredentials.to_string().is_empty());
assert!(AuthError::Pam("x".into()).to_string().contains("PAM"));
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Backend PAM del contrato [`Authenticator`](crate::Authenticator).
//!
//! El módulo se llama `pam_backend` (no `pam`) para no chocar con el
//! crate externo `pam`, del que depende.
use pam::{Client, PamError, PamReturnCode};
use crate::{resolve_user, AuthError, Authenticator, UserInfo};
/// Servicio PAM por defecto del escritorio carmen. Resuelve a
/// `/etc/pam.d/carmen` — ver el archivo `data/carmen` de este crate.
pub const DEFAULT_SERVICE: &str = "carmen";
/// Autentica contra PAM: el mismo subsistema de `login`/`sudo`. Honra
/// `/etc/pam.d/<service>` — módulos, 2FA, llaves FIDO2, `pam_faillock`,
/// lo que el administrador configure ahí, sin que `brahman-auth` lo sepa.
#[derive(Debug, Clone)]
pub struct PamAuthenticator {
service: String,
}
impl PamAuthenticator {
/// Autenticador para un servicio PAM concreto (`/etc/pam.d/<service>`).
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
/// Autenticador para el servicio por defecto del escritorio,
/// [`DEFAULT_SERVICE`].
pub fn carmen() -> Self {
Self::new(DEFAULT_SERVICE)
}
/// Nombre del servicio PAM que usa este autenticador.
pub fn service(&self) -> &str {
&self.service
}
}
impl Default for PamAuthenticator {
fn default() -> Self {
Self::carmen()
}
}
impl Authenticator for PamAuthenticator {
fn authenticate(&self, username: &str, secret: &str) -> Result<UserInfo, AuthError> {
// Un handle PAM nuevo por intento: PAM es stateful por
// transacción y reusar el handle entre intentos es un bug. El
// `Client` cierra la transacción (`pam_end`) en su `Drop`.
let mut client = Client::with_password(&self.service)
.map_err(|e| AuthError::Pam(format!("pam_start({}): {e}", self.service)))?;
client.conversation_mut().set_credentials(username, secret);
// `authenticate()` del crate hace pam_authenticate + pam_acct_mgmt:
// cubre credenciales Y estado de la cuenta en un solo paso.
client.authenticate().map_err(map_pam_error)?;
// Credenciales válidas: resolvemos la identidad del sistema.
resolve_user(username)
}
}
/// Traduce un error de PAM a la taxonomía gruesa de [`AuthError`].
fn map_pam_error(err: PamError) -> AuthError {
match err.0 {
// Credenciales: el greeter debe dejar reintentar.
PamReturnCode::Auth_Err
| PamReturnCode::User_Unknown
| PamReturnCode::Cred_Insufficient
| PamReturnCode::MaxTries => AuthError::BadCredentials,
// Cuenta válida pero vetada o que requiere una acción.
PamReturnCode::Acct_Expired => AuthError::AccountUnavailable("la cuenta expiró".into()),
PamReturnCode::Cred_Expired => {
AuthError::AccountUnavailable("las credenciales expiraron".into())
}
PamReturnCode::AuthTok_Expired => {
AuthError::AccountUnavailable("la contraseña expiró".into())
}
PamReturnCode::New_Authtok_Reqd => {
AuthError::AccountUnavailable("requiere cambiar la contraseña".into())
}
PamReturnCode::Perm_Denied => {
AuthError::AccountUnavailable("acceso denegado por política".into())
}
// Todo lo demás: fallo de infraestructura PAM.
other => AuthError::Pam(format!("{other:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn carmen_uses_default_service() {
assert_eq!(PamAuthenticator::carmen().service(), DEFAULT_SERVICE);
assert_eq!(PamAuthenticator::default().service(), "carmen");
}
#[test]
fn custom_service_name() {
assert_eq!(PamAuthenticator::new("login").service(), "login");
}
#[test]
fn unknown_service_fails_gracefully() {
// Sin `/etc/pam.d/<servicio>` PAM cae a `other` (deny). Debe
// devolver un `AuthError`, nunca paniquear.
let auth = PamAuthenticator::new("brahman-auth-servicio-inexistente-xyz");
assert!(
auth.authenticate("root", "contraseña-cualquiera").is_err(),
"un servicio inexistente debe fallar limpio"
);
}
}
+175
View File
@@ -0,0 +1,175 @@
//! El tiquet de sesión: lo que el greeter le entrega al compositor tras
//! una autenticación exitosa.
//!
//! El greeter de carmen corre como proceso hijo del compositor. Cuando
//! el login tiene éxito, imprime **una línea** de tiquet a su stdout; el
//! compositor escanea las líneas del hijo buscando el prefijo
//! [`TICKET_TAG`] y, al encontrarlo, hace el traspaso a modo sesión.
use std::path::PathBuf;
use crate::UserInfo;
/// Etiqueta + versión de la línea de tiquet. El compositor sólo trata
/// como tiquet las líneas que empiezan con esto — el resto del stdout
/// del greeter (logs, ruido) se ignora.
pub const TICKET_TAG: &str = "MIRADA-SESSION-TICKET-v1";
/// Resultado de un login: la identidad autenticada más, opcionalmente,
/// el comando de sesión elegido. El greeter lo produce; el compositor lo
/// consume para arrancar la sesión (setuid al usuario + spawn).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionTicket {
/// Identidad del usuario autenticado.
pub user: UserInfo,
/// Comando de sesión a ejecutar como el usuario. Vacío = que el
/// compositor decida (su autostart por defecto).
pub session: String,
/// `true` si la sesión es un compositor **ajeno** (sway, Plasma…): el
/// servidor actual debe soltar el DRM y hacer `exec`, no correrla como
/// cliente. `false` para sesiones nativas de mirada (pata, autostart),
/// que sí corren como clientes del mismo compositor.
pub foreign: bool,
}
impl SessionTicket {
/// Crea un tiquet sin comando de sesión explícito.
pub fn new(user: UserInfo) -> Self {
Self {
user,
session: String::new(),
foreign: false,
}
}
/// Fija el comando de sesión. Encadenable.
pub fn with_session(mut self, session: impl Into<String>) -> Self {
self.session = session.into();
self
}
/// Marca la sesión como compositor ajeno (handoff por `exec`).
/// Encadenable.
pub fn foreign(mut self, foreign: bool) -> Self {
self.foreign = foreign;
self
}
/// Serializa el tiquet a una línea única, apta para stdout. Campos
/// separados por tabulador: ni los nombres de usuario, ni los paths,
/// ni los comandos de sesión suelen contener tabuladores.
pub fn to_line(&self) -> String {
format!(
"{TICKET_TAG}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
self.user.name,
self.user.uid,
self.user.gid,
self.user.home.display(),
self.user.shell.display(),
self.session,
if self.foreign { "1" } else { "0" },
)
}
/// Parsea una línea producida por [`to_line`]. `None` si la línea no
/// es un tiquet (otra salida del greeter) o está malformada.
pub fn from_line(line: &str) -> Option<SessionTicket> {
let mut f = line.trim_end_matches(['\r', '\n']).split('\t');
if f.next()? != TICKET_TAG {
return None;
}
let name = f.next()?.to_string();
let uid = f.next()?.parse().ok()?;
let gid = f.next()?.parse().ok()?;
let home = PathBuf::from(f.next()?);
let shell = PathBuf::from(f.next()?);
// El comando de sesión puede venir vacío.
let session = f.next().unwrap_or("").to_string();
// El flag `foreign` es opcional (tiquets viejos no lo traen).
let foreign = matches!(f.next(), Some("1"));
Some(SessionTicket {
user: UserInfo {
name,
uid,
gid,
home,
shell,
},
session,
foreign,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> UserInfo {
UserInfo {
name: "sergio".into(),
uid: 1000,
gid: 1000,
home: PathBuf::from("/home/sergio"),
shell: PathBuf::from("/usr/bin/bash"),
}
}
#[test]
fn round_trip_without_session() {
let t = SessionTicket::new(sample());
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert!(back.session.is_empty());
}
#[test]
fn round_trip_with_session() {
let t = SessionTicket::new(sample()).with_session("shuma-shell --launcher");
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert_eq!(back.session, "shuma-shell --launcher");
}
#[test]
fn round_trip_with_foreign() {
let t = SessionTicket::new(sample())
.with_session("sway")
.foreign(true);
let back = SessionTicket::from_line(&t.to_line()).expect("parsea");
assert_eq!(back, t);
assert!(back.foreign);
}
#[test]
fn foreign_defaults_false_sin_campo() {
// Una línea estilo v1 (sin el campo foreign) parsea con foreign=false.
let line = format!("{TICKET_TAG}\tsergio\t1000\t1000\t/home/sergio\t/usr/bin/bash\tsway");
let back = SessionTicket::from_line(&line).expect("parsea");
assert!(!back.foreign);
assert_eq!(back.session, "sway");
}
#[test]
fn from_line_ignores_non_ticket() {
assert!(SessionTicket::from_line("[INFO] arrancando greeter").is_none());
assert!(SessionTicket::from_line("").is_none());
}
#[test]
fn from_line_rejects_malformed() {
// Prefijo correcto pero faltan campos.
assert!(SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio")).is_none());
// uid no numérico.
assert!(
SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio\tXX\t1000\t/h\t/sh\t"))
.is_none()
);
}
#[test]
fn tolerates_trailing_newline() {
let line = format!("{}\n", SessionTicket::new(sample()).to_line());
assert!(SessionTicket::from_line(&line).is_some());
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Resolución de la identidad de un usuario del sistema.
use std::path::PathBuf;
use crate::AuthError;
/// Identidad de un usuario en el sistema: lo que el compositor necesita
/// para arrancar una sesión — fijar uid/gid, `cd` al home, ejecutar el
/// shell o la sesión de escritorio.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserInfo {
/// Nombre de login.
pub name: String,
/// User ID.
pub uid: u32,
/// Group ID primario.
pub gid: u32,
/// Directorio personal.
pub home: PathBuf,
/// Shell de login.
pub shell: PathBuf,
}
impl UserInfo {
/// Identidad sintética para tests y para cajas donde el usuario no
/// está en `/etc/passwd`. **No** representa a un usuario real del SO
/// — no usar para fijar privilegios de un proceso real.
pub fn synthetic(name: &str) -> Self {
Self {
name: name.to_string(),
uid: 1000,
gid: 1000,
home: PathBuf::from(format!("/home/{name}")),
shell: PathBuf::from("/bin/sh"),
}
}
}
/// Resuelve un usuario por nombre vía `getpwnam`. `Err` si no existe o
/// si la consulta a `/etc/passwd` (o NSS) falla.
pub fn resolve_user(name: &str) -> Result<UserInfo, AuthError> {
match nix::unistd::User::from_name(name) {
Ok(Some(u)) => Ok(UserInfo {
name: u.name,
uid: u.uid.as_raw(),
gid: u.gid.as_raw(),
home: u.dir,
shell: u.shell,
}),
Ok(None) => Err(AuthError::UnresolvedUser(name.to_string())),
Err(e) => Err(AuthError::Pam(format!("getpwnam({name}): {e}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_root() {
// root (uid 0) existe en todo sistema Unix.
let info = resolve_user("root").expect("root debe existir");
assert_eq!(info.uid, 0);
assert_eq!(info.name, "root");
}
#[test]
fn unknown_user_errs() {
let r = resolve_user("usuario-que-no-existe-xyzzy");
assert!(matches!(r, Err(AuthError::UnresolvedUser(_))));
}
#[test]
fn synthetic_has_home_under_slash_home() {
let info = UserInfo::synthetic("prueba");
assert_eq!(info.home, PathBuf::from("/home/prueba"));
assert_eq!(info.uid, 1000);
}
}
+41
View File
@@ -0,0 +1,41 @@
# =============================================================================
# renaser :: format — el format del grafo de objetos en disco
# -----------------------------------------------------------------------------
# Nucleo `#![no_std]` COMPARTIDO: lo enlaza el kernel bare-metal (target
# `x86_64-unknown-none`) y, por ser no_std, tambien lo compila sin friccion el
# anfitrion `boot`. Es la unica verdad del format del grafo —tipos,
# (de)serializacion postcard, hash BLAKE3, trazado de registros—, de modo que
# kernel y constructor de imagen hablen exactamente el mismo idioma de disco.
#
# Queda EXCLUIDO del espacio de trabajo (ver el Cargo.toml raiz), como el
# kernel: lo consume un paquete bare-metal, asi que fija sus versiones de
# forma explicita, sin herencia del workspace.
# =============================================================================
[package]
name = "format"
version = "0.1.0"
edition = "2021"
license = "MPL-2.0"
authors = ["JL Soltech <gerencia@jlsoltech.com>"]
description = "renaser :: format del grafo de objetos en disco — compartido kernel ↔ boot"
[lib]
bench = false
doctest = false
[dependencies]
# `serde` da el rasgo de (de)serializacion; `postcard` lo materializa en un
# format binario compacto — el que viaja al disco. Ambos `no_std`, sobre `alloc`.
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
# `serde-big-array` cubre el hueco de serde con arrays mayores a 32 bytes: las
# firmas Ed25519 (`Firma = [u8; 64]`) lo requieren. Compatible `no_std`.
serde-big-array = { version = "0.5", default-features = false }
# `blake3`: la funcion hash que da identidad a cada objeto. Se fuerza la
# implementacion ESCALAR pura (`pure` + los cuatro `no_*`): el target del kernel
# corre sin SSE, y un camino SIMD por deteccion en tiempo de ejecucion
# ejecutaria instrucciones que la CPU, sin `CR4.OSFXSR`, rechazaria con un #UD.
blake3 = { version = "1", default-features = false, features = [
"pure", "no_sse2", "no_sse41", "no_avx2", "no_avx512",
] }
+36
View File
@@ -0,0 +1,36 @@
# format — el formato nativo de gioser
Tipos canónicos del **DAG direccionado por contenido** (BLAKE3 + postcard),
compartidos entre host y kernel `wawa`. `#![no_std]` — cruza la frontera al
kernel bare-metal por `path`. Es el formato en el que TODO el suite trabaja en
nativo (los formatos ajenos entran por `shared/foreign-*` y se convierten a
esto).
## Módulos
- `tipos` — objetos, hashes, identidades de contenido.
- `cable` — referencias entre objetos (aristas del DAG).
- `firma` — firmas Ed25519 y verificación.
- `pruebas` — pruebas de revocación de capacidades (WAWA.md §14.1.3).
- `grafo` — construcción/recorrido del DAG.
- `constantes` — parámetros del formato (tamaños, versiones).
## Estado (2026-05-31)
### Hecho
- Tipos canónicos del DAG (objetos, cables, hashes) en `no_std`, validados en
`wasm32-unknown-unknown` por `scripts/check-shared-cores.sh`.
- Firma/verificación Ed25519 (`firma`) y pruebas de revocación (`pruebas`),
canónicos compartidos kernel↔host para el enforcement §14.1.3.
- `lib.rs` (2327 LOC) **dividido en módulos temáticos** (cable/firma/grafo/…).
- Suite amplia (~52 tests).
### Pendiente
- Versionado/migración del formato en disco (campo de versión existe; políticas
de upgrade aún por definir).
- Más cobertura de los caminos de revocación end-to-end.
## Lugar en el repo
`shared/format` — núcleo `no_std` compartido. Lo consumen apps, `agora` y el
kernel `wawa`.
+290
View File
@@ -0,0 +1,290 @@
use super::*;
// =============================================================================
// El hash y el trazado de un registro en el log
// =============================================================================
/// La identidad de un objeto: el hash BLAKE3 de su forma serializada. Kernel y
/// `boot` la calculan por aqui — una sola definicion del hash, jamas dos.
pub fn hash(bytes: &[u8]) -> Hash {
*blake3::hash(bytes).as_bytes()
}
/// Numero de sectores que ocupa un registro cuyo payload mide `longitud`
/// bytes. Cada registro es `[longitud: u32 LE][payload postcard][relleno 0]`.
pub fn sectores_registro(longitud: usize) -> u64 {
(4 + longitud).div_ceil(TAM_SECTOR) as u64
}
/// Compone el registro en disco de un payload: `[longitud u32 LE][payload]
/// [relleno a cero]`, alineado a un numero entero de sectores. Es el trazado
/// exacto que el kernel lee al reconstruir su indice — lo escriben tanto
/// `kernel::almacen` (al anexar un objeto) como `boot` (al sembrar la imagen).
pub fn componer_registro(payload: &[u8]) -> Vec<u8> {
let n = sectores_registro(payload.len()) as usize;
let mut registro = vec![0u8; n * TAM_SECTOR];
registro[0..4].copy_from_slice(&(payload.len() as u32).to_le_bytes());
registro[4..4 + payload.len()].copy_from_slice(payload);
registro
}
/// Lee la cabecera de longitud de un registro (sus 4 primeros bytes). Devuelve
/// `None` si la longitud es cero —fin del log— o supera [`MAX_OBJETO`]
/// —corrupcion—. Gemela de [`componer_registro`].
pub fn longitud_registro(cabecera: &[u8]) -> Option<usize> {
if cabecera.len() < 4 {
return None;
}
let longitud =
u32::from_le_bytes([cabecera[0], cabecera[1], cabecera[2], cabecera[3]]) as usize;
if longitud == 0 || longitud > MAX_OBJETO {
None
} else {
Some(longitud)
}
}
// =============================================================================
// Fase 60 — Asistente Akasha: tipos de mensaje del canal del asistente
// -----------------------------------------------------------------------------
// La app `asistente.wasm` (kernel-side) y el `asistente-puente` (host-side)
// conversan por un canal Akasha bien conocido. Estos tipos definen el
// protocolo. Diseñado para serializarse con `postcard` (el mismo encoder
// que usa todo el resto del kernel) y vivir en `#![no_std] + alloc` para
// cruzar la frontera kernel-wasm sin friction.
//
// ESTADO (Fase 60 v1): tipos definidos, sin código que los consuma todavía.
// Ver `docs/ASISTENTE_WAWA.md` §2.2 para el contexto del diseño.
// =============================================================================
/// Canal Akasha bien conocido para el asistente. ASCII `"AS"` = 0x4153. El
/// kernel filtra paquetes con este canal hacia los suscriptores del oficio
/// asistente; el puente Linux abre un socket raw que suscribe al mismo
/// número para recibir consultas y enviar propuestas.
///
/// NOTA: 0x4153 está dentro del rango histórico de "longitud" de Ethernet
/// (< 0x0600), así que NO sirve como EtherType. Para los frames del
/// asistente sobre el cable se usa [`ETHERTYPE_ASISTENTE`]; este valor
/// queda como discriminante interno (postcard tag, identificador del
/// oficio en logs y trazas).
pub const CANAL_ASISTENTE: u16 = 0x4153;
/// EtherType de los frames del asistente sobre el cable. Vecino del
/// 0x88B5 que ya usa Akasha — los dos viven en el rango "experimental"
/// que la IEEE deja libre. El demuxer Akasha del kernel (`akasha.rs`)
/// trata frames con EtherType ajeno como "para el usuario": los encola
/// tal cual sin procesar. La app `asistente.wasm` los recoge con
/// `sys_net_recibir`, filtra por este EtherType y decodifica el payload
/// como [`MensajeAsistente`] postcard.
///
/// Mantenerlo distinto de 0x88B5 evita que el demuxer intente
/// deserializar el payload como `MensajeAkasha` y lo descarte como
/// `PayloadInvalido` antes de pasarlo al usuario.
pub const ETHERTYPE_ASISTENTE: u16 = 0x88B6;
/// Acción que el LLM (vía el puente) propone al asistente. La app pinta
/// la propuesta, el humano decide. Acciones potentes (re-anclar manifiesto,
/// cambiar configuración) referencian objetos del grafo por `Hash` — el
/// puente los preparó y los ingestó vía Akasha; el kernel los verifica al
/// aplicar.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub enum AccionPropuesta {
/// Lanzar la app `plantilla`-ésima del manifiesto. Equivalente al
/// `Mando::LanzarFila` del launcher, pero dirigido por LLM.
LanzarApp { plantilla: u32 },
/// Re-anclar el manifiesto al hash propuesto. Requiere firma humana
/// vía `daemon-firma` antes de invocar `sys_manifiesto_proponer`.
InstalarApp { manifiesto_propuesto: Hash },
/// Cambiar la `Configuracion` activa al hash propuesto. Mismo flujo
/// de firma humana que `InstalarApp`.
CambiarConfiguracion { config_propuesta: Hash },
/// Sin efecto sobre el sistema — el LLM nada más anota algo para que
/// el humano lo lea. Útil para responder preguntas tipo "¿cuántas
/// apps tengo?" sin disparar acciones.
Notar { texto: String },
}
/// Contexto del estado actual del nodo wawa que la app envía al puente
/// junto con la consulta. Permite que el LLM responda con info concreta
/// (nombres de apps reales, configuración activa) en lugar de a ciegas.
/// Lo que se incluye está acotado deliberadamente — más campos = más
/// tokens en el system prompt = más coste.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)]
pub struct Contexto {
/// Nombres de las apps del manifiesto vivo, en el orden del catálogo
/// del launcher. El LLM puede usar `LanzarApp { plantilla: i }` con
/// el índice de la fila correspondiente.
pub apps: Vec<String>,
/// Hash del manifiesto vigente. Permite que el puente detecte si su
/// caché local quedó stale (otro nodo re-ancló en paralelo) y
/// rerequiera contexto fresco.
pub manifiesto_actual: Option<Hash>,
/// Hash de la `Configuracion` activa, si la hay. `None` si el
/// manifiesto no enlaza ninguna.
pub configuracion_activa: Option<Hash>,
}
/// Un mensaje sobre el canal `CANAL_ASISTENTE`. La app y el puente
/// hablan exclusivamente este enum — un atacante que envíe payload ajeno
/// al canal se queda sin decodificar (postcard rechaza el frame).
#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
pub enum MensajeAsistente {
/// La app pregunta. El puente lo retransmite al LLM. `id` correlaciona
/// request/response — un puente sirviendo varios nodos los distingue
/// por id ANTES de cualquier RTT extra.
Consulta {
id: u64,
prompt: String,
contexto: Contexto,
},
/// El puente responde con una propuesta interpretada del LLM.
/// `confianza` es la decisión del puente — `1.0` si el LLM produjo
/// JSON limpio y la acción está en la lista blanca; valores menores
/// si tuvo que adivinar o si el parseo fue parcial.
Propuesta {
id: u64,
accion: AccionPropuesta,
explicacion: String,
confianza: f32,
},
/// El puente reporta un fallo de transporte o parseo. El `id`
/// correlaciona contra la consulta original; el `motivo` es un string
/// libre que la app pinta al humano.
Error { id: u64, motivo: String },
}
impl MensajeAsistente {
/// Serializa con postcard. El kernel lo manda por Akasha; el puente
/// lo recibe y deserializa.
pub fn serializar(&self) -> Result<Vec<u8>, postcard::Error> {
postcard::to_allocvec(self)
}
/// Deserializa desde bytes. Si el frame está truncado o el canal
/// trajo basura ajena, devuelve error sin tocar `self`.
pub fn deserializar(bytes: &[u8]) -> Result<Self, postcard::Error> {
postcard::from_bytes(bytes)
}
}
// =============================================================================
// Protocolo "cable" del asistente — alfabeto minimo sin alloc
// -----------------------------------------------------------------------------
// `MensajeAsistente` (arriba) usa `String` y `Vec` para empaquetar prompts
// y explicaciones de longitud arbitraria. La app `asistente.wasm` corre en
// no_std SIN alloc — no puede construir esos tipos. Para el cable definimos
// un alfabeto minimo que cabe en arrays fijos: cabecera de 12 bytes
// (canal + tipo + id) + payload de longitud inferida del frame.
//
// El puente Linux traduce entre el rico `MensajeAsistente` (que usa para
// hablar con pluma-llm) y este protocolo cable (que viaja por Akasha).
// =============================================================================
/// Tamaño en bytes de la cabecera del protocolo cable.
/// `canal (2) + tipo (2) + id (8) = 12`.
pub const TAM_CABECERA_CABLE: usize = 12;
/// Tipos de mensaje sobre el cable del asistente. Discriminante u16 big
/// endian estable — los lectores binarios pueden grep por estos valores.
#[repr(u16)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TipoCable {
/// Consulta de la app al puente. Payload = bytes ASCII del prompt
/// (sin nul terminator — la longitud se infiere del frame).
Consulta = 1,
/// Propuesta del puente del tipo `Notar` (la IA contestó algo
/// informativo). Payload = bytes ASCII del texto.
PropuestaNotar = 2,
/// Propuesta del puente del tipo `LanzarApp`. Payload = u32 BE con
/// el índice de plantilla a lanzar (4 bytes).
PropuestaLanzarApp = 3,
/// Propuesta de re-anclar el manifiesto. Payload = 32 bytes del hash.
PropuestaInstalarApp = 4,
/// Propuesta de cambiar la configuración activa. Payload = 32 bytes
/// del hash de la nueva configuración.
PropuestaCambiarConfig = 5,
/// Error reportado por el puente (transporte, rechazo del LLM,
/// parseo). Payload = bytes ASCII del motivo.
Error = 6,
/// Fase 60 v4 :: la app `asistente.wasm` pide la firma humana de un
/// objeto (manifiesto/configuración). El puente lo relaya al
/// `wawactl daemon-firma` por su transporte normal (PTY/virtio-console)
/// y devuelve la firma en un [`TipoCable::Firma`]. Payload:
/// `[tipo_obj: u8, hash: [u8; 32]]` = 33 bytes.
/// - `tipo_obj` = [`TIPO_OBJETO_CUADERNO`] (1) si el hash es de
/// manifiesto/cuaderno (legacy `wawa::sign_request::`).
/// - `tipo_obj` = [`TIPO_OBJETO_CONFIGURACION`] (2) si es de
/// configuración (`wawa::sign_config::`).
/// Otros valores son rechazados por el puente con un `TipoCable::Error`.
RequestFirma = 7,
/// Fase 60 v4 :: respuesta del puente con la firma humana ya
/// autorizada por el operador (via `daemon-firma`). Payload:
/// `[slot: u8, firma: [u8; 64]]` = 65 bytes. `slot` es 0/1/2 — el
/// índice dentro de `AGORA_AUTH_RING` que el operador eligió al
/// arrancar el demonio. El asistente.wasm construye el sobre
/// firmado y, cuando tenga PERMISO_RAIZ (hito 6), invoca
/// `sys_manifiesto_proponer`.
Firma = 8,
}
/// FASE 60 v4 :: discriminantes del primer byte del payload de
/// `TipoCable::RequestFirma`. El puente los mapea al prefijo correcto
/// para `daemon-firma` (`wawa::sign_request::` vs `wawa::sign_config::`).
/// El mismo discriminante puede aparecer en logs del operador.
pub const TIPO_OBJETO_CUADERNO: u8 = 1;
/// Como [`TIPO_OBJETO_CUADERNO`] pero para configuraciones. Ver Fase 60 v2
/// del `wawactl daemon-firma` — el prefijo correspondiente es
/// `wawa::sign_config::`.
pub const TIPO_OBJETO_CONFIGURACION: u8 = 2;
impl TipoCable {
/// Traduce un u16 al variant correspondiente o `None` si es
/// desconocido (el cable trajo un tipo no registrado).
pub fn de_u16(v: u16) -> Option<Self> {
match v {
1 => Some(Self::Consulta),
2 => Some(Self::PropuestaNotar),
3 => Some(Self::PropuestaLanzarApp),
4 => Some(Self::PropuestaInstalarApp),
5 => Some(Self::PropuestaCambiarConfig),
6 => Some(Self::Error),
7 => Some(Self::RequestFirma),
8 => Some(Self::Firma),
_ => None,
}
}
}
/// Escribe la cabecera del cable en `out`. Devuelve la longitud escrita
/// (siempre `TAM_CABECERA_CABLE`) o `None` si `out` no cabe — el caller
/// reserva el buffer apropiado.
pub fn escribir_cabecera_cable(out: &mut [u8], tipo: TipoCable, id: u64) -> Option<usize> {
if out.len() < TAM_CABECERA_CABLE {
return None;
}
out[0..2].copy_from_slice(&CANAL_ASISTENTE.to_be_bytes());
out[2..4].copy_from_slice(&(tipo as u16).to_be_bytes());
out[4..12].copy_from_slice(&id.to_be_bytes());
Some(TAM_CABECERA_CABLE)
}
/// Lee la cabecera del cable y verifica que el canal sea el del
/// asistente. Devuelve `(tipo, id)` o `None` si los bytes son
/// insuficientes, el canal no coincide o el tipo es desconocido. El
/// llamante interpreta `&bytes[TAM_CABECERA_CABLE..]` según `tipo`.
pub fn leer_cabecera_cable(bytes: &[u8]) -> Option<(TipoCable, u64)> {
if bytes.len() < TAM_CABECERA_CABLE {
return None;
}
let canal = u16::from_be_bytes([bytes[0], bytes[1]]);
if canal != CANAL_ASISTENTE {
return None;
}
let tipo_raw = u16::from_be_bytes([bytes[2], bytes[3]]);
let tipo = TipoCable::de_u16(tipo_raw)?;
let id = u64::from_be_bytes([
bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11],
]);
Some((tipo, id))
}

Some files were not shown because too many files have changed in this diff Show More