⎇ b31f98883379a172efd309cd2cec98d16eb591db
crates/modules/mirada/mirada-brain/src/action.rs
Historia
Quién tocó qué
crudo
feat(mirada): API de acciones — mirada-ctl + HUD interactivo
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 /// Pasa al siguiente modo de teselado.
42 CycleLayout,
43 /// Fija un modo de teselado concreto.
44 SetLayout(LayoutMode),
45 /// Activa el escritorio virtual `n` (índice 0-based).
46 SwitchWorkspace(usize),
47 /// Manda la ventana enfocada al escritorio virtual `n`.
48 SendToWorkspace(usize),
49 /// Apaga el compositor.
50 Quit,
51}
52
53/// El nombre RON-seguro de un modo de teselado (sin guiones problemáticos
54/// para identificadores: aquí van como valor de cadena, no de enum).
55fn layout_slug(mode: LayoutMode) -> &'static str {
56 match mode {
57 LayoutMode::MasterStack => "master-stack",
58 LayoutMode::Monocle => "monocle",
59 LayoutMode::Grid => "grid",
60 LayoutMode::Columns => "columns",
61 }
62}
63
64/// Modo de teselado desde su `slug`.
65fn layout_from_slug(slug: &str) -> Option<LayoutMode> {
66 Some(match slug {
67 "master-stack" => LayoutMode::MasterStack,
68 "monocle" => LayoutMode::Monocle,
69 "grid" => LayoutMode::Grid,
70 "columns" => LayoutMode::Columns,
71 _ => return None,
72 })
73}
74
75impl fmt::Display for DesktopAction {
76 /// La forma textual estable de la acción — el vocabulario del keymap.
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 DesktopAction::FocusNext => f.write_str("focus-next"),
80 DesktopAction::FocusPrev => f.write_str("focus-prev"),
81 DesktopAction::FocusWindow(id) => write!(f, "focus-window:{id}"),
82 DesktopAction::MoveForward => f.write_str("move-forward"),
83 DesktopAction::MoveBackward => f.write_str("move-backward"),
84 DesktopAction::CloseFocused => f.write_str("close-focused"),
85 DesktopAction::CycleLayout => f.write_str("cycle-layout"),
86 DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
87 // Los escritorios se numeran 1-based de cara al usuario.
88 DesktopAction::SwitchWorkspace(n) => write!(f, "workspace:{}", n + 1),
89 DesktopAction::SendToWorkspace(n) => write!(f, "send-to-workspace:{}", n + 1),
90 DesktopAction::Quit => f.write_str("quit"),
91 }
92 }
93}
94
95impl FromStr for DesktopAction {
96 /// Mensaje de error ya formateado, listo para mostrar al usuario.
97 type Err = String;
98
99 fn from_str(s: &str) -> Result<Self, String> {
100 let s = s.trim();
101 Ok(match s {
102 "focus-next" => Self::FocusNext,
103 "focus-prev" => Self::FocusPrev,
104 "move-forward" => Self::MoveForward,
105 "move-backward" => Self::MoveBackward,
106 "close-focused" => Self::CloseFocused,
107 "cycle-layout" => Self::CycleLayout,
108 "quit" => Self::Quit,
109 _ => {
110 if let Some(slug) = s.strip_prefix("layout:") {
111 Self::SetLayout(
112 layout_from_slug(slug)
113 .ok_or_else(|| format!("modo de teselado desconocido: '{slug}'"))?,
114 )
115 } else if let Some(id) = s.strip_prefix("focus-window:") {
116 Self::FocusWindow(
117 id.trim()
118 .parse()
119 .map_err(|_| format!("id de ventana inválido: '{id}'"))?,
120 )
121 } else if let Some(n) = s.strip_prefix("send-to-workspace:") {
122 Self::SendToWorkspace(parse_workspace(n)?)
123 } else if let Some(n) = s.strip_prefix("workspace:") {
124 Self::SwitchWorkspace(parse_workspace(n)?)
125 } else {
126 return Err(format!("acción desconocida: '{s}'"));
127 }
128 }
129 })
130 }
131}
132
133/// Parsea el número de escritorio del keymap (1-based) a índice (0-based),
134/// acotado a [`WORKSPACE_COUNT`].
135fn parse_workspace(s: &str) -> Result<usize, String> {
136 let n: usize = s
137 .trim()
138 .parse()
139 .map_err(|_| format!("número de escritorio inválido: '{s}'"))?;
140 if (1..=WORKSPACE_COUNT).contains(&n) {
141 Ok(n - 1)
142 } else {
143 Err(format!("escritorio fuera de rango (1..={WORKSPACE_COUNT}): {n}"))
144 }
145}
146
147/// Mapa de teclas por defecto, estilo *tiling WM* (modificador `Super`).
148///
149/// Las cadenas deben coincidir literalmente con las que el Cuerpo emite
150/// en [`BodyEvent::Keybind`](mirada_protocol::BodyEvent::Keybind); son
151/// también las que se registran con
152/// [`BrainCommand::GrabKeys`](mirada_protocol::BrainCommand::GrabKeys).
153pub fn default_keymap() -> Vec<(String, DesktopAction)> {
154 let mut map = vec![
155 ("Super+j".into(), DesktopAction::FocusNext),
156 ("Super+k".into(), DesktopAction::FocusPrev),
157 ("Super+Shift+j".into(), DesktopAction::MoveForward),
158 ("Super+Shift+k".into(), DesktopAction::MoveBackward),
159 ("Super+q".into(), DesktopAction::CloseFocused),
160 ("Super+space".into(), DesktopAction::CycleLayout),
161 ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
162 ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
163 ("Super+g".into(), DesktopAction::SetLayout(LayoutMode::Grid)),
164 ("Super+c".into(), DesktopAction::SetLayout(LayoutMode::Columns)),
165 ("Super+Shift+e".into(), DesktopAction::Quit),
166 ];
167 // Un escritorio por dígito: `Super+1`..`Super+9` lo activan,
168 // `Super+Shift+1`.. mandan la ventana enfocada allí.
169 for n in 0..WORKSPACE_COUNT {
170 map.push((format!("Super+{}", n + 1), DesktopAction::SwitchWorkspace(n)));
171 map.push((
172 format!("Super+Shift+{}", n + 1),
173 DesktopAction::SendToWorkspace(n),
174 ));
175 }
176 map
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn keymap_has_no_duplicate_bindings() {
185 let map = default_keymap();
186 let mut keys: Vec<_> = map.iter().map(|(k, _)| k.clone()).collect();
187 keys.sort();
188 let unique = keys.len();
189 keys.dedup();
190 assert_eq!(keys.len(), unique, "hay un atajo repetido");
191 }
192
193 #[test]
194 fn keymap_covers_every_virtual_workspace() {
195 let map = default_keymap();
196 for n in 0..WORKSPACE_COUNT {
197 assert!(map
198 .iter()
199 .any(|(_, a)| *a == DesktopAction::SwitchWorkspace(n)));
200 assert!(map
201 .iter()
202 .any(|(_, a)| *a == DesktopAction::SendToWorkspace(n)));
203 }
204 }
205
206 #[test]
207 fn every_default_action_round_trips_through_its_text_form() {
208 for (_, action) in default_keymap() {
209 let text = action.to_string();
210 let back: DesktopAction = text.parse().unwrap();
211 assert_eq!(action, back, "no redondea: {text}");
212 }
213 }
214
215 #[test]
216 fn every_layout_mode_round_trips() {
217 for mode in [
218 LayoutMode::MasterStack,
219 LayoutMode::Monocle,
220 LayoutMode::Grid,
221 LayoutMode::Columns,
222 ] {
223 let a = DesktopAction::SetLayout(mode);
224 assert_eq!(a, a.to_string().parse().unwrap());
225 }
226 }
227
228 #[test]
229 fn workspace_actions_are_one_based_in_text() {
230 assert_eq!(DesktopAction::SwitchWorkspace(0).to_string(), "workspace:1");
231 assert_eq!(
232 "workspace:1".parse::<DesktopAction>().unwrap(),
233 DesktopAction::SwitchWorkspace(0)
234 );
235 assert_eq!(
236 "send-to-workspace:9".parse::<DesktopAction>().unwrap(),
237 DesktopAction::SendToWorkspace(8)
238 );
239 }
240
241 #[test]
242 fn out_of_range_or_unknown_actions_are_rejected() {
243 assert!("workspace:0".parse::<DesktopAction>().is_err());
244 assert!("workspace:99".parse::<DesktopAction>().is_err());
245 assert!("layout:fractal".parse::<DesktopAction>().is_err());
246 assert!("focus-window:abc".parse::<DesktopAction>().is_err());
247 assert!("teleport".parse::<DesktopAction>().is_err());
248 }
249
250 #[test]
251 fn focus_window_round_trips_with_its_id() {
252 let a = DesktopAction::FocusWindow(42);
253 assert_eq!(a.to_string(), "focus-window:42");
254 assert_eq!("focus-window:42".parse::<DesktopAction>().unwrap(), a);
255 }
256}