⎇ b3b44e2c72c1c3a5ab166bae7cdbf5175ead382e
crates/modules/mirada/mirada-brain/src/action.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(mirada): mirada-launcher — lanzador de aplicaciones
1//! Acciones de escritorio y su mapa de teclas por defecto.
2//!
3//! Una [`DesktopAction`] es una orden de alto nivel del usuario, ya
4//! desligada de la tecla concreta: el [`Desktop`](crate::Desktop) las
5//! aplica sin saber qué combinación las disparó.
6//!
7//! Cada acción tiene una **forma textual** estable ([`Display`] /
8//! [`FromStr`]) — `"focus-next"`, `"layout:grid"`, `"workspace:3"` — que
9//! es el vocabulario del keymap configurable en RON (ver [`crate::keymap`]).
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16use mirada_layout::{LayoutMode, WindowId};
17
18/// Número de escritorios virtuales que mantiene el `Desktop`.
19pub const WORKSPACE_COUNT: usize = 9;
20
21/// Una orden de escritorio de alto nivel.
22///
23/// Es serializable (`postcard`) para viajar por el API de control
24/// ([`crate::ctl`]) y tiene una forma textual estable ([`Display`] /
25/// [`FromStr`]) para el keymap y `mirada-ctl`.
26///
27/// No es `Copy`: [`Spawn`](DesktopAction::Spawn) lleva su comando.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub enum DesktopAction {
30 /// Mueve el foco a la ventana siguiente del escritorio activo.
31 FocusNext,
32 /// Mueve el foco a la ventana anterior.
33 FocusPrev,
34 /// Enfoca una ventana concreta por su id; si está en otro escritorio,
35 /// salta a él. Para clics de taskbar o `mirada-ctl focus-window`.
36 FocusWindow(WindowId),
37 /// Adelanta la ventana enfocada en el orden de teselado.
38 MoveForward,
39 /// Atrasa la ventana enfocada en el orden de teselado.
40 MoveBackward,
41 /// Cierra la ventana enfocada (cierre ordenado).
42 CloseFocused,
43 /// Alterna entre flotante y teselada la ventana enfocada.
44 ToggleFloat,
45 /// Alterna pantalla completa en la ventana enfocada.
46 ToggleFullscreen,
47 /// Guarda la ventana enfocada en el scratchpad (la oculta).
48 SendToScratchpad,
49 /// Invoca u oculta la ventana del scratchpad — aparece flotando.
50 ToggleScratchpad,
51 /// Pasa al siguiente modo de teselado.
52 CycleLayout,
53 /// Fija un modo de teselado concreto.
54 SetLayout(LayoutMode),
55 /// Agranda el área de la ventana maestra (`MasterStack`/`CenteredMaster`).
56 GrowMaster,
57 /// Encoge el área de la ventana maestra.
58 ShrinkMaster,
59 /// Mete una ventana más en el área maestra (`nmaster`).
60 IncMaster,
61 /// Saca una ventana del área maestra.
62 DecMaster,
63 /// Lleva la ventana enfocada al puesto maestro (orden de teselado).
64 PromoteToMaster,
65 /// Activa el escritorio virtual `n` (índice 0-based).
66 SwitchWorkspace(usize),
67 /// Manda la ventana enfocada al escritorio virtual `n`.
68 SendToWorkspace(usize),
69 /// Mueve el foco a la siguiente salida (monitor).
70 FocusOutputNext,
71 /// Lanza un programa — abre una terminal, un navegador, lo que sea.
72 /// El comando se pasa a `sh -c` en el Cuerpo.
73 Spawn(String),
74 /// Apaga el compositor.
75 Quit,
76}
77
78/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
79/// para identificadores: aquí van como valor de cadena, no de enum).
80fn layout_slug(mode: LayoutMode) -> &'static str {
81 match mode {
82 LayoutMode::MasterStack => "master-stack",
83 LayoutMode::Monocle => "monocle",
84 LayoutMode::Grid => "grid",
85 LayoutMode::Columns => "columns",
86 LayoutMode::Rows => "rows",
87 LayoutMode::CenteredMaster => "centered-master",
88 LayoutMode::Spiral => "spiral",
89 }
90}
91
92/// Modo de teselado desde su `slug`.
93fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
94 Some(match slug {
95 "master-stack" => LayoutMode::MasterStack,
96 "monocle" => LayoutMode::Monocle,
97 "grid" => LayoutMode::Grid,
98 "columns" => LayoutMode::Columns,
99 "rows" => LayoutMode::Rows,
100 "centered-master" => LayoutMode::CenteredMaster,
101 "spiral" => LayoutMode::Spiral,
102 _ => return None,
103 })
104}
105
106impl fmt::Display for DesktopAction {
107 /// La forma textual estable de la acción — el vocabulario del keymap.
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 DesktopAction::FocusNext => f.write_str("focus-next"),
111 DesktopAction::FocusPrev => f.write_str("focus-prev"),
112 DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
113 DesktopAction::MoveForward => f.write_str("move-forward"),
114 DesktopAction::MoveBackward => f.write_str("move-backward"),
115 DesktopAction::CloseFocused => f.write_str("close-focused"),
116 DesktopAction::ToggleFloat => f.write_str("toggle-float"),
117 DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
118 DesktopAction::SendToScratchpad => f.write_str("send-to-scratchpad"),
119 DesktopAction::ToggleScratchpad => f.write_str("toggle-scratchpad"),
120 DesktopAction::CycleLayout => f.write_str("cycle-layout"),
121 DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
122 DesktopAction::GrowMaster => f.write_str("grow-master"),
123 DesktopAction::ShrinkMaster => f.write_str("shrink-master"),
124 DesktopAction::IncMaster => f.write_str("inc-master"),
125 DesktopAction::DecMaster => f.write_str("dec-master"),
126 DesktopAction::PromoteToMaster => f.write_str("promote-to-master"),
127 // Los escritorios se numeran 1-based de cara al usuario.
128 DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
129 DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
130 DesktopAction::FocusOutputNext => f.write_str("focus-output-next"),
131 DesktopAction::Spawn(cmd) => write!(f, "spawn:{cmd}"),
132 DesktopAction::Quit => f.write_str("quit"),
133 }
134 }
135}
136
137impl FromStr for DesktopAction {
138 /// Mensaje de error ya formateado, listo para mostrar al usuario.
139 type Err = String;
140
141 fn from_str(s: &str) -> Result<Self, String> {
142 let s = s.trim();
143 Ok(match s {
144 "focus-next" => Self::FocusNext,
145 "focus-prev" => Self::FocusPrev,
146 "move-forward" => Self::MoveForward,
147 "move-backward" => Self::MoveBackward,
148 "close-focused" => Self::CloseFocused,
149 "toggle-float" => Self::ToggleFloat,
150 "toggle-fullscreen" => Self::ToggleFullscreen,
151 "send-to-scratchpad" => Self::SendToScratchpad,
152 "toggle-scratchpad" => Self::ToggleScratchpad,
153 "cycle-layout" => Self::CycleLayout,
154 "grow-master" => Self::GrowMaster,
155 "shrink-master" => Self::ShrinkMaster,
156 "inc-master" => Self::IncMaster,
157 "dec-master" => Self::DecMaster,
158 "promote-to-master" => Self::PromoteToMaster,
159 "focus-output-next" => Self::FocusOutputNext,
160 "quit" => Self::Quit,
161 _ => {
162 if let Some(slug) = s.strip_prefix("layout:") {
163 Self::SetLayout(
164 layout_from_slug(slug)
165 .ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
166 )
167 } else if let Some(id) = s.strip_prefix("focus-window:") {
168 Self::FocusWindow(
169 id.trim()
170 .parse()
171 .map_err(|_| format!("id de ventana inválido: '{id}'"))?,
172 )
173 } else if let Some(n) = s.strip_prefix("send-to-workspace:") {
174 Self::SendToWorkspace(parse_workspace(n)?)
175 } else if let Some(n) = s.strip_prefix("workspace:") {
176 Self::SwitchWorkspace(parse_workspace(n)?)
177 } else if let Some(cmd) = s.strip_prefix("spawn:") {
178 let cmd = cmd.trim();
179 if cmd.is_empty() {
180 return Err("spawn: necesita un comando".into());
181 }
182 Self::Spawn(cmd.to_string())
183 } else {
184 return Err(format!("acción desconocida: '{s}'"));
185 }
186 }
187 })
188 }
189}
190
191/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
192/// acotado a [`WORKSPACE_COUNT`].
193fn parse_workspace(s: &str) -> Result<usize, String> {
194 let n: usize = s
195 .trim()
196 .parse()
197 .map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
198 if (1..=WORKSPACE_COUNT).contains(&n) {
199 Ok(n - 1)
200 } else {
201 Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
202 }
203}
204
205/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
206///
207/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
208/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
209/// también las que se registran con
210/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
211pub fn default_keymap() -> Vec<(String, DesktopAction)> {
212 let mut map = vec![
213 ("Super+j".into(), DesktopAction::FocusNext),
214 ("Super+k".into(), DesktopAction::FocusPrev),
215 ("Super+Shift+j".into(), DesktopAction::MoveForward),
216 ("Super+Shift+k".into(), DesktopAction::MoveBackward),
217 ("Super+q".into(), DesktopAction::CloseFocused),
218 ("Super+f".into(), DesktopAction::ToggleFloat),
219 ("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
220 ("Super+`".into(), DesktopAction::ToggleScratchpad),
221 ("Super+space".into(), DesktopAction::CycleLayout),
222 ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
223 ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
224 ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
225 ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
226 ("Super+r".into(), DesktopAction::SetLayout(LayoutMode::Rows)),
227 ("Super+d".into(), DesktopAction::SetLayout(LayoutMode::CenteredMaster)),
228 ("Super+s".into(), DesktopAction::SetLayout(LayoutMode::Spiral)),
229 ("Super+h".into(), DesktopAction::ShrinkMaster),
230 ("Super+l".into(), DesktopAction::GrowMaster),
231 ("Super+o".into(), DesktopAction::FocusOutputNext),
232 ("Super+Return".into(), DesktopAction::PromoteToMaster),
233 ("Super+Shift+Return".into(), DesktopAction::Spawn("foot".into())),
234 ("Super+p".into(), DesktopAction::Spawn("foot -e mirada-launcher".into())),
235 ("Super+,".into(), DesktopAction::IncMaster),
236 ("Super+.".into(), DesktopAction::DecMaster),
237 ("Super+Shift+e".into(), DesktopAction::Quit),
238 ];
239 // Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
240 // `Super+Shift+1`.. mandan la ventana enfocada allí.
241 for n in 0..WORKSPACE_COUNT {
242 map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
243 map.push((
244 format!("Super+Shift+{}", n + 1),
245 DesktopAction::SendToWorkspace(n),
246 ));
247 }
248 map
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn keymap_has_no_duplicate_bindings() {
257 let map = default_keymap();
258 let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
259 keys.sort();
260 let unique = keys.len();
261 keys.dedup();
262 assert_eq!(keys.len(), unique, "hay un atajo repetido");
263 }
264
265 #[test]
266 fn keymap_covers_every_virtual_workspace() {
267 let map = default_keymap();
268 for n in 0..WORKSPACE_COUNT {
269 assert!(map
270 .iter()
271 .any(|(_, a)| a == &DesktopAction::SwitchWorkspace(n)));
272 assert!(map
273 .iter()
274 .any(|(_, a)| a == &DesktopAction::SendToWorkspace(n)));
275 }
276 }
277
278 #[test]
279 fn every_default_action_round_trips_through_its_text_form() {
280 for (_, action) in default_keymap() {
281 let text = action.to_string();
282 let back: DesktopAction = text.parse().unwrap();
283 assert_eq!(action, back, "no redondea: {text}");
284 }
285 }
286
287 #[test]
288 fn every_layout_mode_round_trips() {
289 for mode in LayoutMode::ALL {
290 let a = DesktopAction::SetLayout(mode);
291 assert_eq!(a, a.to_string().parse().unwrap());
292 }
293 }
294
295 #[test]
296 fn workspace_actions_are_one_based_in_text() {
297 assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
298 assert_eq!(
299 "workspace:1".parse::<DesktopAction>().unwrap(),
300 DesktopAction::SwitchWorkspace(0)
301 );
302 assert_eq!(
303 "send-to-workspace:9".parse::<DesktopAction>().unwrap(),
304 DesktopAction::SendToWorkspace(8)
305 );
306 }
307
308 #[test]
309 fn out_of_range_or_unknown_actions_are_rejected() {
310 assert!("workspace:0".parse::<DesktopAction>().is_err());
311 assert!("workspace:99".parse::<DesktopAction>().is_err());
312 assert!("layout:fractal".parse::<DesktopAction>().is_err());
313 assert!("focus-window:abc".parse::<DesktopAction>().is_err());
314 assert!("teleport".parse::<DesktopAction>().is_err());
315 }
316
317 #[test]
318 fn focus_window_round_trips_with_its_id() {
319 let a = DesktopAction::FocusWindow(42);
320 assert_eq!(a.to_string(), "focus-window:42");
321 assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
322 }
323
324 #[test]
325 fn spawn_round_trips_keeping_the_whole_command() {
326 let a = DesktopAction::Spawn("foot --title diario".into());
327 assert_eq!(a.to_string(), "spawn:foot --title diario");
328 assert_eq!(a.to_string().parse::<DesktopAction>().unwrap(), a);
329 }
330
331 #[test]
332 fn spawn_without_a_command_is_rejected() {
333 assert!("spawn:".parse::<DesktopAction>().is_err());
334 assert!("spawn: ".parse::<DesktopAction>().is_err());
335 }
336}