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