diff --git a/crates/apps/mirada-compositor/README.md b/crates/apps/mirada-compositor/README.md index 3169dc6..11575ad 100644 --- a/crates/apps/mirada-compositor/README.md +++ b/crates/apps/mirada-compositor/README.md @@ -62,8 +62,9 @@ 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+…`: 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`, escritorios `Super+1..9`, cerrar `Super+q`. Cierra -la ventana del compositor para salir. +maestra `Super+h/l`, `nmaster` `Super+,/.`, promover a maestra +`Super+Return`, escritorios `Super+1..9`, cerrar `Super+q`. Cierra la +ventana del compositor para salir. ## Atajos de teclado diff --git a/crates/apps/mirada-compositor/src/main.rs b/crates/apps/mirada-compositor/src/main.rs index 599d048..de69296 100644 --- a/crates/apps/mirada-compositor/src/main.rs +++ b/crates/apps/mirada-compositor/src/main.rs @@ -403,12 +403,13 @@ fn combo_string(mods: &ModifiersState, sym: Keysym) -> Option { let name = if key == " " { "space".to_string() } else { + // ¿Es un único carácter imprimible? Entonces la tecla es ese carácter. let mut chars = key.chars(); - let c = chars.next()?; - if chars.next().is_some() || !c.is_ascii_graphic() { - return None; + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_graphic() => c.to_ascii_lowercase().to_string(), + // Si no, una tecla con nombre: Return, Tab, Up, F5… + _ => named_key(sym)?, } - c.to_ascii_lowercase().to_string() }; let mut combo = String::new(); if mods.logo { @@ -427,6 +428,17 @@ fn combo_string(mods: &ModifiersState, sym: Keysym) -> Option { Some(combo) } +/// El nombre canónico de una tecla especial — `Return`, `Tab`, `Up`, +/// `F5`… `None` si xkb no le da un nombre razonable. +fn named_key(sym: Keysym) -> Option { + let name = xkb::keysym_get_name(sym); + if name.is_empty() || name == "NoSymbol" || name.starts_with("0x") { + None + } else { + Some(name) + } +} + /// Despacha los callbacks de frame de un árbol de superficies: avisa a /// cada cliente de que puede dibujar el siguiente cuadro. fn send_frames_surface_tree(surface: &WlSurface, time: u32) { diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs index 9ebc18e..83c68b0 100644 --- a/crates/apps/mirada-ctl/src/main.rs +++ b/crates/apps/mirada-ctl/src/main.rs @@ -125,6 +125,8 @@ Acciones de mirada-ctl: 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 activa el escritorio n (1..9) send-to-workspace manda la enfocada al escritorio n quit apaga el compositor diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs index 7fc3e7c..7ac08c0 100644 --- a/crates/apps/mirada/src/main.rs +++ b/crates/apps/mirada/src/main.rs @@ -22,7 +22,8 @@ //! n abre una ventana tab / espacio cicla layout //! w cierra la enfocada t m g c r d s layout directo //! j / k foco siguiente/anterior h / l área maestra −/+ -//! Shift+j / k mueve la enfocada 1..9 ir a escritorio +//! Shift+j / k mueve la enfocada , / . nmaster −/+ +//! Enter promueve a maestra 1..9 ir a escritorio //! Ctrl+1..9 enviar a escritorio //! ``` //! @@ -291,6 +292,9 @@ impl Mirada { "s" => self.act(DesktopAction::SetLayout(LayoutMode::Spiral)), "h" => self.act(DesktopAction::ShrinkMaster), "l" => self.act(DesktopAction::GrowMaster), + "enter" => self.act(DesktopAction::PromoteToMaster), + "," => self.act(DesktopAction::IncMaster), + "." => self.act(DesktopAction::DecMaster), d if d.len() == 1 && d.as_bytes()[0].is_ascii_digit() && d != "0" => { let n = (d.as_bytes()[0] - b'1') as usize; if ctrl { diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md index 10760f1..ccec167 100644 --- a/crates/modules/mirada/SDD.md +++ b/crates/modules/mirada/SDD.md @@ -51,8 +51,10 @@ ejecuta operaciones de geometría". - **`mirada-layout`** — `Rect` + `split` (reparto exacto de píxeles), `LayoutMode` con 7 modos (`MasterStack`, `CenteredMaster`, `Spiral` —espiral de Fibonacci—, `Grid`, `Columns`, `Rows`, `Monocle`) y - `LayoutMode::next()` para el ciclo, `Workspace` con foco cíclico y - reordenado. Determinista. + `LayoutMode::next()` para el ciclo, `Workspace` con foco cíclico, + reordenado y `promote_focused`. `LayoutParams` lleva `master_ratio` y + `master_count` (`nmaster`); *smart gaps* (una sola ventana va a + sangre). Determinista. - **`mirada-protocol`** — `WindowPlacement`, los enums `BrainCommand` y `BodyEvent`, el marco `postcard` con prefijo `u32` LE (`write_frame`/`read_frame`, guard `MAX_FRAME`) y el puente @@ -111,10 +113,12 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres: - **`DesktopAction::FocusWindow(WindowId)`** — direccionamiento directo de una ventana (no sólo ciclar con `FocusNext`/`Prev`); si está en otro escritorio, salta a él. Lo usan la taskbar y `mirada-ctl`. -- **`SetLayout`/`CycleLayout`/`GrowMaster`/`ShrinkMaster`** — los 7 modos - de teselado se intercambian por el API (`mirada-ctl layout spiral`), y - el área maestra se redimensiona en caliente (`grow`/`shrink-master`, - atajos `Super+l`/`Super+h`). +- **Layout y área maestra por el API** — los 7 modos se intercambian + (`SetLayout`/`CycleLayout`, `mirada-ctl layout spiral`); el área + maestra se redimensiona (`grow`/`shrink-master`, `Super+l`/`Super+h`); + `inc`/`dec-master` cambian `nmaster` (`Super+,`/`Super+.`); y + `promote-to-master` lleva la enfocada al puesto maestro (`Super+Return` + — `combo_string` ya canoniza teclas con nombre: `Return`, `Tab`, `F5`…). - **HUD interactivo** (app `mirada`) — los pips de escritorio y las ventanas del lienzo son clicables: clic = `apply` de la acción. - **`mirada-ctl`** — control externo por línea de comandos @@ -136,8 +140,8 @@ gráficos para ejercitar `mirada-ctl` en modo desatendido. ## Estado -Implementado y verde: `mirada-layout` (26 tests), `mirada-protocol` -(9), `mirada-brain` (39), `mirada-link` (7), `mirada-body` (13), las +Implementado y verde: `mirada-layout` (30 tests), `mirada-protocol` +(9), `mirada-brain` (41), `mirada-link` (7), `mirada-body` (13), las apps `mirada` y `mirada-compositor` (compilan; verificación visual manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`). diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs index fe4e3c6..39df510 100644 --- a/crates/modules/mirada/mirada-brain/src/action.rs +++ b/crates/modules/mirada/mirada-brain/src/action.rs @@ -46,6 +46,12 @@ pub enum DesktopAction { 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 (orden de teselado). + PromoteToMaster, /// Activa el escritorio virtual `n` (índice 0-based). SwitchWorkspace(usize), /// Manda la ventana enfocada al escritorio virtual `n`. @@ -96,6 +102,9 @@ impl fmt::Display for DesktopAction { 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"), // 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), @@ -119,6 +128,9 @@ impl FromStr for DesktopAction { "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, "quit" => Self::Quit, _ => { if let Some(slug) = s.strip_prefix("layout:") { @@ -181,6 +193,9 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> { ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)), ("Super+h".into(), DesktopAction::ShrinkMaster), ("Super+l".into(), DesktopAction::GrowMaster), + ("Super+Return".into(), DesktopAction::PromoteToMaster), + ("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, diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs index 48a13e1..36629a0 100644 --- a/crates/modules/mirada/mirada-brain/src/desktop.rs +++ b/crates/modules/mirada/mirada-brain/src/desktop.rs @@ -190,6 +190,12 @@ impl Desktop { } DesktopAction::GrowMaster => self.nudge_master(0.05), DesktopAction::ShrinkMaster => self.nudge_master(-0.05), + DesktopAction::IncMaster => self.nudge_master_count(1), + DesktopAction::DecMaster => self.nudge_master_count(-1), + DesktopAction::PromoteToMaster => { + self.workspaces[self.active].promote_focused(); + self.relayout() + } DesktopAction::SwitchWorkspace(n) => { if n < self.workspaces.len() && n != self.active { self.active = n; @@ -224,6 +230,14 @@ impl Desktop { self.relayout() } + /// Ajusta `nmaster` del escritorio activo, acotado a `1..=9`. + fn nudge_master_count(&mut self, delta: i32) -> Vec { + let ws = &mut self.workspaces[self.active]; + let n = (ws.params().master_count as i32 + delta).clamp(1, 9) as usize; + ws.set_master_count(n); + self.relayout() + } + /// Recalcula la geometría del escritorio activo y la empaqueta en un /// [`BrainCommand::Place`]. Sin salida conectada, no hay nada que /// colocar. @@ -468,6 +482,32 @@ mod tests { assert!((d.active_workspace().params().master_ratio - r0).abs() < 1e-6); } + #[test] + fn inc_and_dec_master_adjust_nmaster() { + let mut d = desktop_with_screen(); + for id in [1, 2, 3] { + open(&mut d, id); + } + assert_eq!(d.active_workspace().params().master_count, 1); + d.apply(DesktopAction::IncMaster); + assert_eq!(d.active_workspace().params().master_count, 2); + d.apply(DesktopAction::DecMaster); + d.apply(DesktopAction::DecMaster); // no baja de 1 + assert_eq!(d.active_workspace().params().master_count, 1); + } + + #[test] + fn promote_to_master_brings_the_focused_window_to_the_front() { + let mut d = desktop_with_screen(); + for id in [1, 2, 3] { + open(&mut d, id); + } + d.apply(DesktopAction::FocusWindow(3)); + d.apply(DesktopAction::PromoteToMaster); + assert_eq!(d.active_workspace().windows()[0], 3); + assert_eq!(d.focused_window(), Some(3)); + } + #[test] fn master_ratio_stays_within_bounds() { let mut d = desktop_with_screen(); diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs index dedd37e..2de0ea4 100644 --- a/crates/modules/mirada/mirada-brain/src/keymap.rs +++ b/crates/modules/mirada/mirada-brain/src/keymap.rs @@ -241,6 +241,8 @@ const KEYMAP_HEADER: &str = "\ // layout: 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 // workspace:N activa el escritorio N (1..9) // send-to-workspace:N manda la enfocada al escritorio N // quit apaga el compositor diff --git a/crates/modules/mirada/mirada-layout/src/layout.rs b/crates/modules/mirada/mirada-layout/src/layout.rs index 95b5a37..629631e 100644 --- a/crates/modules/mirada/mirada-layout/src/layout.rs +++ b/crates/modules/mirada/mirada-layout/src/layout.rs @@ -52,16 +52,23 @@ impl LayoutMode { #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct LayoutParams { pub mode: LayoutMode, - /// Fracción del ancho para la ventana maestra en `MasterStack` - /// (se acota a `0.05..=0.95`). + /// 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, gap: 8 } + Self { + mode: LayoutMode::MasterStack, + master_ratio: 0.6, + master_count: 1, + gap: 8, + } } } @@ -77,12 +84,18 @@ pub fn tile(screen: Rect, count: usize, params: &LayoutParams) -> Vec { LayoutMode::Columns => columns(screen, count), LayoutMode::Rows => rows(screen, count), LayoutMode::Grid => grid(screen, count), - LayoutMode::MasterStack => master_stack(screen, count, params.master_ratio), - LayoutMode::CenteredMaster => centered_master(screen, count, params.master_ratio), + 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. - cells.into_iter().map(|c| c.inset(params.gap)).collect() + // 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. @@ -139,25 +152,27 @@ fn spiral(screen: Rect, count: usize) -> Vec { out } -/// Ventana maestra centrada + pila repartida en columnas a ambos lados. -fn centered_master(screen: Rect, count: usize, ratio: f32) -> Vec { - // Con una o dos ventanas no hay nada que centrar: cae a maestro+pila. - if count <= 2 { - return master_stack(screen, count, ratio); +/// `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 { + 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 = (screen.w as f32 * ratio).round() as i32; let sides = split(screen.w - master_w, 2); let (left_w, right_w) = (sides[0].1, sides[1].1); - - let stack = count - 1; let left_n = stack / 2; let right_n = stack - left_n; let mut out = Vec::with_capacity(count); - // 0 = la maestra, centrada. - out.push(Rect::new(screen.x + left_w, screen.y, master_w, screen.h)); - // Columna izquierda, luego la derecha — el orden de teselado. + // 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)); } @@ -167,19 +182,27 @@ fn centered_master(screen: Rect, count: usize, ratio: f32) -> Vec { out } -/// Ventana maestra a la izquierda + pila a la derecha. -fn master_stack(screen: Rect, count: usize, ratio: f32) -> Vec { - if count == 1 { - return vec![screen]; +/// `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 { + 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 = (screen.w as f32 * ratio).round() as i32; - let master = Rect::new(screen.x, screen.y, master_w, screen.h); - let stack_x = screen.x + master_w; let stack_w = screen.w - master_w; - let mut out = vec![master]; - for (off, h) in split(screen.h, count - 1) { + + 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 @@ -192,7 +215,7 @@ mod tests { const SCREEN: Rect = Rect { x: 0, y: 0, w: 1920, h: 1080 }; fn params(mode: LayoutMode) -> LayoutParams { - LayoutParams { mode, master_ratio: 0.6, gap: 0 } + LayoutParams { mode, gap: 0, ..LayoutParams::default() } } #[test] @@ -296,7 +319,7 @@ mod tests { #[test] fn gap_shrinks_every_window() { - let p = LayoutParams { mode: LayoutMode::Columns, master_ratio: 0.6, gap: 10 }; + 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); @@ -304,6 +327,48 @@ mod tests { } } + #[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::(), 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); diff --git a/crates/modules/mirada/mirada-layout/src/workspace.rs b/crates/modules/mirada/mirada-layout/src/workspace.rs index cedfe79..4d68d84 100644 --- a/crates/modules/mirada/mirada-layout/src/workspace.rs +++ b/crates/modules/mirada/mirada-layout/src/workspace.rs @@ -51,6 +51,11 @@ impl Workspace { 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) { @@ -125,6 +130,17 @@ impl Workspace { } } + /// 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`, en orden de teselado. pub fn layout(&self, screen: Rect) -> Vec<(WindowId, Rect)> { @@ -220,6 +236,21 @@ mod tests { assert_eq!(w.windows(), &[1, 2, 3]); } + #[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(); diff --git a/vamos.txt b/vamos.txt index 4fb9e2c..531d115 100644 --- a/vamos.txt +++ b/vamos.txt @@ -1037,5 +1037,15 @@ + nmaster + promover a maestra + smart gaps (al estilo dwm): + LayoutParams.master_count = nº de ventanas en el área maestra (MasterStack y CenteredMaster). + mirada-ctl inc-master / dec-master # Super+, / Super+. — nmaster ±1 (acotado 1..9) + mirada-ctl promote-to-master # Super+Return — la enfocada salta al puesto maestro + Smart gaps: una sola ventana va a sangre, sin margen desperdiciado. + combo_string del compositor ahora canoniza teclas con nombre (Return, Tab, F5, flechas…), + no sólo caracteres imprimibles — así Super+Return es un atajo válido del keymap. + + +