gioser

tawasuyu/brahman

Rust 260TOML 84JSON 14HTML 13339 Definiciones

⎇ be61ddb6eb441193c4374a243fbb00aed47b42de crates/modules/mirada/mirada-brain/src/action.rs Rust · 295 líneas · 11.7 KB Historia Quién tocó qué crudo

feat(mirada): pantalla completa real — toggle-fullscreen be61ddb6e · 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#[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}