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:
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
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>"), "<script>");
|
||||
assert_eq!(escape_text("a & b"), "a & 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<script>y"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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 nº 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
|
||||
"
|
||||
);
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)**.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 (1–4 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());
|
||||
}
|
||||
}
|
||||
@@ -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" | "sí"
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms(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, ¶ms(mode)).len(), n, "modo {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_partition_the_height_exactly() {
|
||||
let rects = tile(SCREEN, 3, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(LayoutMode::Monocle)) {
|
||||
assert_eq!(r, SCREEN);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_partition_the_width_exactly() {
|
||||
let rects = tile(SCREEN, 3, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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}`
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+447
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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`.
|
||||
@@ -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
Reference in New Issue
Block a user