gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

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

feat(mirada): API de acciones — mirada-ctl + HUD interactivo b31f98883 · 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    /// 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}