gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

feat(mirada): scratchpad — ventana desplegable estilo terminal quake

Una ventana se puede guardar en el scratchpad (oculta, en ningún
escritorio) e invocar a voluntad como overlay flotante — el patrón de
la terminal desplegable.

- Desktop.scratchpad: Vec<WindowId>. SendToScratchpad saca la ventana
  enfocada del teselado y la guarda; ToggleScratchpad (Super+`) la
  invoca flotando y centrada en el escritorio activo, o la oculta.
- Invocarla desde otro escritorio la trae consigo (sale de donde
  estuviera). WindowClosed la quita del scratchpad.
- window_lines marca las guardadas como workspace 0; mirada-ctl windows
  las lista como «esc scratch».

Sin cambios de protocolo — una ventana del scratchpad invocada no es
más que una flotante. Verificado end-to-end con headless-ctl.
mirada-brain 58->63 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

13d2ae71fb8d56689e3906ab11012dbdb6f4b0df · ruwaq sergio · (5 killa ñawpaq) · tayta 799dcef22 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 18

crates/apps/mirada-ctl/src/main.rs

crates/apps/mirada/src/main.rs

crates/modules/mirada/mirada-brain/src/action.rs

crates/modules/mirada/mirada-brain/src/desktop.rs

crates/modules/mirada/mirada-brain/src/keymap.rs

8 qillqakuna · +159 −7

diff --git a/crates/apps/mirada-ctl/src/main.rs b/crates/apps/mirada-ctl/src/main.rs
index 005b442..b4b404a 100644
--- a/crates/apps/mirada-ctl/src/main.rs
+++ b/crates/apps/mirada-ctl/src/main.rs
@@ -84,10 +84,13 @@ fn print_windows(windows: &[WindowLine]) {
     }
     for w in windows {
         let mark = if w.focused { '*' } else { ' ' };
-        println!(
-            "{mark} id {:<4} esc {}  {:<24} {}",
-            w.id, w.workspace, w.app_id, w.title
-        );
+        // El escritorio 0 es el scratchpad (ventana guardada).
+        let ws = if w.workspace == 0 {
+            "scratch".to_string()
+        } else {
+            w.workspace.to_string()
+        };
+        println!("{mark} id {:<4} esc {:<7} {:<24} {}", w.id, ws, w.app_id, w.title);
     }
 }
 
@@ -122,6 +125,8 @@ Acciones de mirada-ctl:
   close-focused              cierra la ventana enfocada
   toggle-float               alterna flotante / teselada la enfocada
   toggle-fullscreen          alterna pantalla completa en la enfocada
+  send-to-scratchpad         guarda la ventana enfocada en el scratchpad
+  toggle-scratchpad          invoca u oculta la ventana del scratchpad
   cycle-layout               pasa al siguiente modo de teselado
   layout <modo>              master-stack · centered-master · spiral
                              grid · columns · rows · monocle
diff --git a/crates/apps/mirada/src/main.rs b/crates/apps/mirada/src/main.rs
index d57103b..90def87 100644
--- a/crates/apps/mirada/src/main.rs
+++ b/crates/apps/mirada/src/main.rs
@@ -25,7 +25,7 @@
 //!   j / k        foco siguiente/anterior   , / .          nmaster −/+
 //!   Shift+j / k  mueve la enfocada         1..9           ir a escritorio
 //!   Enter        promueve a maestra        Ctrl+1..9      enviar a escritorio
-//!   o            siguiente monitor
+//!   o            siguiente monitor         ` / Shift+`    scratchpad ver/guardar
 //! ```
 //!
 //! Los pips de escritorio y las ventanas del lienzo son **clicables**, y
@@ -301,6 +301,8 @@ impl Mirada {
             "h" => self.act(DesktopAction::ShrinkMaster),
             "l" => self.act(DesktopAction::GrowMaster),
             "o" => self.act(DesktopAction::FocusOutputNext),
+            "`" if shift => self.act(DesktopAction::SendToScratchpad),
+            "`" => self.act(DesktopAction::ToggleScratchpad),
             "enter" => self.act(DesktopAction::PromoteToMaster),
             "," => self.act(DesktopAction::IncMaster),
             "." => self.act(DesktopAction::DecMaster),
diff --git a/crates/modules/mirada/SDD.md b/crates/modules/mirada/SDD.md
index 7760779..9f1b3bc 100644
--- a/crates/modules/mirada/SDD.md
+++ b/crates/modules/mirada/SDD.md
@@ -128,6 +128,11 @@ que un front-end (`Keybind` → `lookup` → `apply`); hay otros tres:
   el escritorio pedido ya lo muestra otra salida); `FocusOutputNext`
   (`Super+o`) mueve el foco entre monitores. El foco del teclado es
   único — sólo la ventana enfocada de la salida enfocada.
+- **Scratchpad** — `SendToScratchpad` guarda la ventana enfocada (sale
+  del teselado, en ningún escritorio); `ToggleScratchpad` (`` Super+` ``)
+  la invoca flotando y centrada en el escritorio actual, o la oculta —
+  estilo terminal desplegable. `Desktop.scratchpad: Vec<WindowId>`;
+  `mirada-ctl windows` la lista como `esc scratch`.
 - **Layout y área maestra por el API** — los 7 modos se intercambian
   (`SetLayout`/`CycleLayout`, `mirada-ctl layout spiral`); el área
   maestra se redimensiona (`grow`/`shrink-master`, `Super+l`/`Super+h`);
@@ -170,7 +175,7 @@ a las ya abiertas.
 ## Estado
 
 Implementado y verde: `mirada-layout` (32 tests), `mirada-protocol`
-(11), `mirada-brain` (58), `mirada-link` (7), `mirada-body` (14), las
+(11), `mirada-brain` (63), `mirada-link` (7), `mirada-body` (14), las
 apps `mirada` y `mirada-compositor` (compilan; verificación visual
 manual) y `mirada-ctl` (CLI, probado vía el ejemplo `headless-ctl`).
 
diff --git a/crates/modules/mirada/mirada-brain/src/action.rs b/crates/modules/mirada/mirada-brain/src/action.rs
index fd6445e..a59b40f 100644
--- a/crates/modules/mirada/mirada-brain/src/action.rs
+++ b/crates/modules/mirada/mirada-brain/src/action.rs
@@ -42,6 +42,10 @@ pub enum DesktopAction {
     ToggleFloat,
     /// Alterna pantalla completa en la ventana enfocada.
     ToggleFullscreen,
+    /// Guarda la ventana enfocada en el scratchpad (la oculta).
+    SendToScratchpad,
+    /// Invoca u oculta la ventana del scratchpad — aparece flotando.
+    ToggleScratchpad,
     /// Pasa al siguiente modo de teselado.
     CycleLayout,
     /// Fija un modo de teselado concreto.
@@ -106,6 +110,8 @@ impl fmt::Display for DesktopAction {
             DesktopAction::CloseFocused => f.write_str("close-focused"),
             DesktopAction::ToggleFloat => f.write_str("toggle-float"),
             DesktopAction::ToggleFullscreen => f.write_str("toggle-fullscreen"),
+            DesktopAction::SendToScratchpad => f.write_str("send-to-scratchpad"),
+            DesktopAction::ToggleScratchpad => f.write_str("toggle-scratchpad"),
             DesktopAction::CycleLayout => f.write_str("cycle-layout"),
             DesktopAction::SetLayout(m) => write!(f, "layout:{}", layout_slug(*m)),
             DesktopAction::GrowMaster => f.write_str("grow-master"),
@@ -136,6 +142,8 @@ impl FromStr for DesktopAction {
             "close-focused" => Self::CloseFocused,
             "toggle-float" => Self::ToggleFloat,
             "toggle-fullscreen" => Self::ToggleFullscreen,
+            "send-to-scratchpad" => Self::SendToScratchpad,
+            "toggle-scratchpad" => Self::ToggleScratchpad,
             "cycle-layout" => Self::CycleLayout,
             "grow-master" => Self::GrowMaster,
             "shrink-master" => Self::ShrinkMaster,
@@ -197,6 +205,7 @@ pub fn default_keymap() -> Vec<(String, DesktopAction)> {
         ("Super+q".into(), DesktopAction::CloseFocused),
         ("Super+f".into(), DesktopAction::ToggleFloat),
         ("Super+Shift+f".into(), DesktopAction::ToggleFullscreen),
+        ("Super+`".into(), DesktopAction::ToggleScratchpad),
         ("Super+space".into(), DesktopAction::CycleLayout),
         ("Super+t".into(), DesktopAction::SetLayout(LayoutMode::MasterStack)),
         ("Super+m".into(), DesktopAction::SetLayout(LayoutMode::Monocle)),
diff --git a/crates/modules/mirada/mirada-brain/src/ctl.rs b/crates/modules/mirada/mirada-brain/src/ctl.rs
index 7fa1aca..bd77d12 100644
--- a/crates/modules/mirada/mirada-brain/src/ctl.rs
+++ b/crates/modules/mirada/mirada-brain/src/ctl.rs
@@ -52,7 +52,8 @@ pub struct WindowLine {
     pub id: WindowId,
     pub app_id: String,
     pub title: String,
-    /// Escritorio virtual donde está (1-based).
+    /// Escritorio virtual donde está (1-based); `0` = guardada en el
+    /// scratchpad, en ningún escritorio.
     pub workspace: usize,
     /// `true` si es la ventana enfocada del escritorio activo.
     pub focused: bool,
diff --git a/crates/modules/mirada/mirada-brain/src/desktop.rs b/crates/modules/mirada/mirada-brain/src/desktop.rs
index 23407a8..7c31699 100644
--- a/crates/modules/mirada/mirada-brain/src/desktop.rs
+++ b/crates/modules/mirada/mirada-brain/src/desktop.rs
@@ -50,6 +50,9 @@ pub struct Desktop {
     keymap: Keymap,
     /// Reglas de ventana — escritorio/flotante por `app_id`/título.
     rules: Rules,
+    /// Ventanas del scratchpad: se invocan flotando y se ocultan a
+    /// voluntad; mientras están guardadas no viven en ningún escritorio.
+    scratchpad: Vec<WindowId>,
 }
 
 impl Default for Desktop {
@@ -78,6 +81,7 @@ impl Desktop {
             windows: HashMap::new(),
             keymap,
             rules: Rules::default(),
+            scratchpad: Vec::new(),
         }
     }
 
@@ -157,6 +161,7 @@ impl Desktop {
             }
             BodyEvent::WindowClosed { id } => {
                 self.windows.remove(&id);
+                self.scratchpad.retain(|&w| w != id);
                 for ws in &mut self.workspaces {
                     ws.remove(id);
                 }
@@ -257,6 +262,47 @@ impl Desktop {
                 }
                 self.relayout()
             }
+            DesktopAction::SendToScratchpad => {
+                let Some(id) = self.workspaces[active].focused() else {
+                    return Vec::new();
+                };
+                for ws in &mut self.workspaces {
+                    ws.remove(id);
+                }
+                if !self.scratchpad.contains(&id) {
+                    self.scratchpad.push(id);
+                }
+                self.relayout()
+            }
+            DesktopAction::ToggleScratchpad => {
+                // ¿Hay alguna ventana del scratchpad en el escritorio activo?
+                let shown: Vec<WindowId> = self.workspaces[active]
+                    .windows()
+                    .iter()
+                    .copied()
+                    .filter(|id| self.scratchpad.contains(id))
+                    .collect();
+                if !shown.is_empty() {
+                    for id in shown {
+                        self.workspaces[active].remove(id);
+                    }
+                    self.relayout()
+                } else if let Some(&id) = self.scratchpad.first() {
+                    // La traemos de donde esté y la mostramos flotando.
+                    for ws in &mut self.workspaces {
+                        ws.remove(id);
+                    }
+                    let rect = self
+                        .screen()
+                        .map(centered_float_rect)
+                        .unwrap_or_else(|| Rect::new(100, 100, 800, 600));
+                    self.workspaces[active].add(id);
+                    self.workspaces[active].set_floating(id, Some(rect));
+                    self.relayout()
+                } else {
+                    Vec::new()
+                }
+            }
             DesktopAction::CycleLayout => {
                 let next = self.workspaces[active].params().mode.next();
                 self.workspaces[active].set_mode(next);
@@ -435,6 +481,20 @@ impl Desktop {
                 });
             }
         }
+        // Ventanas guardadas en el scratchpad — en ningún escritorio.
+        for &id in &self.scratchpad {
+            let stashed = !self.workspaces.iter().any(|ws| ws.windows().contains(&id));
+            if stashed {
+                let info = self.windows.get(&id);
+                lines.push(crate::ctl::WindowLine {
+                    id,
+                    app_id: info.map(|i| i.app_id.clone()).unwrap_or_default(),
+                    title: info.map(|i| i.title.clone()).unwrap_or_default(),
+                    workspace: 0, // 0 = guardada en el scratchpad
+                    focused: false,
+                });
+            }
+        }
         lines
     }
 }
@@ -873,4 +933,64 @@ mod tests {
         assert_eq!(d.workspace_loads()[1], 1);
         assert_eq!(d.outputs().len(), 1);
     }
+
+    // --- Scratchpad ----------------------------------------------------
+
+    #[test]
+    fn send_to_scratchpad_hides_the_focused_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2); // enfocada
+        d.apply(DesktopAction::SendToScratchpad);
+        assert_eq!(d.workspace_loads()[0], 1); // sólo queda la 1
+        assert!(d.window_info(2).is_some()); // sigue registrada
+    }
+
+    #[test]
+    fn toggle_scratchpad_shows_then_hides_the_stashed_window() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        open(&mut d, 2);
+        d.apply(DesktopAction::SendToScratchpad); // guarda la 2
+        assert_eq!(d.workspace_loads()[0], 1);
+        // Toggle la invoca, flotando.
+        let cmds = d.apply(DesktopAction::ToggleScratchpad);
+        assert!(places(&cmds).iter().find(|x| x.id == 2).unwrap().floating);
+        assert_eq!(d.workspace_loads()[0], 2);
+        // Toggle de nuevo la oculta.
+        d.apply(DesktopAction::ToggleScratchpad);
+        assert_eq!(d.workspace_loads()[0], 1);
+    }
+
+    #[test]
+    fn a_scratchpad_window_follows_you_across_workspaces() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        d.apply(DesktopAction::ToggleScratchpad); // mostrada en el escritorio 1
+        assert_eq!(d.workspace_loads()[0], 1);
+        d.apply(DesktopAction::SwitchWorkspace(1)); // al escritorio 2
+        d.apply(DesktopAction::ToggleScratchpad); // estaba en el 1 → la trae al 2
+        assert_eq!(d.workspace_loads()[1], 1);
+        assert_eq!(d.workspace_loads()[0], 0);
+    }
+
+    #[test]
+    fn closing_a_stashed_window_drops_it_from_the_scratchpad() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        d.on_event(BodyEvent::WindowClosed { id: 1 });
+        // Ya no hay nada que invocar.
+        assert!(d.apply(DesktopAction::ToggleScratchpad).is_empty());
+    }
+
+    #[test]
+    fn window_lines_show_a_stashed_window_as_workspace_zero() {
+        let mut d = desktop_with_screen();
+        open(&mut d, 1);
+        d.apply(DesktopAction::SendToScratchpad);
+        let line = d.window_lines().into_iter().find(|l| l.id == 1).unwrap();
+        assert_eq!(line.workspace, 0);
+    }
 }
diff --git a/crates/modules/mirada/mirada-brain/src/keymap.rs b/crates/modules/mirada/mirada-brain/src/keymap.rs
index 8318cb2..897cc0d 100644
--- a/crates/modules/mirada/mirada-brain/src/keymap.rs
+++ b/crates/modules/mirada/mirada-brain/src/keymap.rs
@@ -239,6 +239,8 @@ const KEYMAP_HEADER: &str = "\
 //   close-focused                    cierra la enfocada
 //   toggle-float                     alterna flotante / teselada
 //   toggle-fullscreen                alterna pantalla completa
+//   send-to-scratchpad               guarda la enfocada en el scratchpad
+//   toggle-scratchpad                invoca / oculta la del scratchpad
 //   cycle-layout                     siguiente modo de teselado
 //   layout:<modo>                    master-stack | centered-master | spiral
 //                                    grid | columns | rows | monocle
diff --git a/vamos.txt b/vamos.txt
index 8a240bb..41dff69 100644
--- a/vamos.txt
+++ b/vamos.txt
@@ -1082,5 +1082,13 @@
 
 
 
+  Scratchpad — terminal desplegable (Super+`):
+  send-to-scratchpad guarda la ventana enfocada (sale del teselado, en ningún escritorio).
+  toggle-scratchpad la invoca flotando y centrada en el escritorio actual, o la oculta.
+  Una ventana del scratchpad te sigue: invocarla desde otro escritorio la trae consigo.
+  mirada-ctl windows la lista como «esc scratch».
+
+
+