gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Sutichaykuna

⎇ b3b44e2c72c1c3a5ab166bae7cdbf5175ead382e crates/modules/mirada/mirada-brain/src/action.rs Rust · 336 siq'ikuna · 13.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(mirada): mirada-launcher — lanzador de aplicaciones b3b44e2c7 · sergio ·

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}