tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main modules/shuma-term/src/lib.rs Rust · 545 líneas · 19.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de
2//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app
3//! Llimphi.
4//!
5//! Lo monta sobre dos piezas que ya existen:
6//!
7//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform
8//!   (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y
9//!   entrega los bytes crudos por un canal MPSC. El módulo no toca
10//!   syscalls — sólo consume eventos.
11//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla
12//!   ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes
13//!   y al renderizar pide `screen().contents()`.
14//!
15//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg +
16//! Action + apply/on_key/open_shortcut/view + Palette`.
17//!
18//! ## Cómo lo enchufa una app (resumen)
19//!
20//! ```ignore
21//! struct Model { term: Option<ShumaTermState>, … }
22//! enum Msg { Term(ShumaTermMsg), Tick, … }
23//!
24//! // open: shuma_term::spawn("/home/user", 100, 30)?
25//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg)
26//! //         si term.is_none() y open_shortcut(ev) → Msg::Term(Open)
27//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY
28//! // apply: match action { Close → model.term = None, SetStatus(s) → … }
29//! // view:  si term.is_some() → push view(...)
30//! ```
31
32#![forbid(unsafe_code)]
33
34use std::time::Instant;
35
36use llimphi_compositor::llimphi_layout::taffy::{
37    prelude::{length, percent, FlexDirection, Size, Style},
38    AlignItems, Rect,
39};
40use llimphi_compositor::llimphi_text::Alignment;
41use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
42use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle};
43
44/// Capabilities que aporta este módulo al host. El host las puede
45/// agregar a `provides` en su `card_core::Card` para que el broker
46/// chasqui descubra que la instancia ofrece terminal integrado.
47pub const CAPABILITIES: &[&str] = &["editor.terminal"];
48
49/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo
50/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a
51/// [`spawn_with`].
52pub const DEFAULT_COLS: u16 = 100;
53pub const DEFAULT_ROWS: u16 = 24;
54
55const SCROLLBACK: usize = 2000;
56
57// =====================================================================
58// State
59// =====================================================================
60
61/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un
62/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone`
63/// (los handles son únicos), y el host lo embebe como
64/// `Option<ShumaTermState>`.
65pub struct ShumaTermState {
66    handle: RunHandle,
67    killer: Killer,
68    parser: vt100::Parser,
69    cols: u16,
70    rows: u16,
71    /// Si el shell ya emitió `Exited(code)`. El panel se queda visible
72    /// para que el usuario pueda leer la última salida antes de cerrar.
73    exit_code: Option<i32>,
74    /// CWD inicial — útil para el header sin tener que tocar /proc.
75    cwd: String,
76    started_at: Instant,
77}
78
79impl ShumaTermState {
80    /// Bytes que el módulo ya consumió desde el PTY. Útil para tests y
81    /// debug — no es parte del contrato Tier 1.
82    pub fn screen_contents(&self) -> String {
83        self.parser.screen().contents()
84    }
85
86    pub fn cols(&self) -> u16 {
87        self.cols
88    }
89    pub fn rows(&self) -> u16 {
90        self.rows
91    }
92    pub fn exit_code(&self) -> Option<i32> {
93        self.exit_code
94    }
95    pub fn cwd(&self) -> &str {
96        &self.cwd
97    }
98
99    /// Envía bytes crudos al stdin del PTY. Para hosts que ya producen sus
100    /// propios códigos de tecla y no quieren pasar por [`apply`] —p. ej. el
101    /// backend layer-shell de pata, que recibe `Keysym` de SCTK y no
102    /// `KeyEvent` de llimphi. Los hosts con `KeyEvent` deberían usar
103    /// `apply(ShumaTermMsg::KeyInput(ev))` (que internamente llama a esto).
104    pub fn send_input(&self, bytes: Vec<u8>) -> bool {
105        if bytes.is_empty() {
106            return false;
107        }
108        self.handle.write_input(bytes)
109    }
110
111    /// Reescala el PTY y el buffer vt100 a `cols`×`rows`. No-op si el tamaño
112    /// no cambió. El host la llama cuando el panel que lo hospeda cambia de
113    /// tamaño (en pata: al abrir/redimensionar el drawer Quake).
114    pub fn resize(&mut self, cols: u16, rows: u16) {
115        let cols = cols.max(2);
116        let rows = rows.max(1);
117        if cols == self.cols && rows == self.rows {
118            return;
119        }
120        self.cols = cols;
121        self.rows = rows;
122        self.handle.resize(rows, cols);
123        self.parser.screen_mut().set_size(rows, cols);
124    }
125
126    /// Drena los eventos pendientes del PTY (bytes + exit). Atajo de
127    /// `apply(ShumaTermMsg::Tick)` para hosts que tickean a mano.
128    pub fn tick(&mut self) -> ShumaTermAction {
129        drain(self)
130    }
131}
132
133impl Drop for ShumaTermState {
134    fn drop(&mut self) {
135        // Si el host descarta el state (panel cerrado), no dejamos al
136        // shell huérfano consumiendo CPU. SIGTERM educado primero;
137        // shuma-exec se encarga del SIGKILL si hace falta.
138        self.killer.term();
139    }
140}
141
142/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd`
143/// con tamaño de PTY por defecto.
144pub fn spawn(cwd: impl Into<String>) -> ShumaTermState {
145    spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS)
146}
147
148/// Variante con control fino de programa, args y tamaño.
149pub fn spawn_with(
150    cwd: impl Into<String>,
151    program: String,
152    args: Vec<String>,
153    cols: u16,
154    rows: u16,
155) -> ShumaTermState {
156    let cwd = cwd.into();
157    let spec = CommandSpec {
158        exec: Exec::Pty { program, args, cols, rows },
159        cwd: cwd.clone(),
160        capture_limit: 0,
161        spill_path: None,
162        stdin_data: None,
163        env: Vec::new(),
164        capture_stages: false,
165    };
166    let handle = shuma_exec::run(&spec);
167    let killer = handle.killer();
168    ShumaTermState {
169        handle,
170        killer,
171        parser: vt100::Parser::new(rows, cols, SCROLLBACK),
172        cols,
173        rows,
174        exit_code: None,
175        cwd,
176        started_at: Instant::now(),
177    }
178}
179
180fn default_shell() -> String {
181    std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
182}
183
184// =====================================================================
185// Msg / Action
186// =====================================================================
187
188/// Vocabulario interno. El host lo wrapea en su `Msg`.
189#[derive(Debug, Clone)]
190pub enum ShumaTermMsg {
191    /// Símbolo conveniente para que el host dispatche al detectar el
192    /// shortcut. El módulo no crea el state él mismo — el host lo crea
193    /// con [`spawn`] porque conoce el cwd canónico de la app.
194    Open,
195    /// El usuario pidió cerrar el panel.
196    Close,
197    /// Tecla mientras el panel está enfocado. Se traduce a bytes y se
198    /// reenvía al PTY.
199    KeyInput(KeyEvent),
200    /// Tick del host: drena eventos pendientes del PTY (bytes y exit).
201    /// El host debe enviar este Msg de forma periódica (en cada frame,
202    /// o cuando hay actividad). Sin Tick el terminal no avanza.
203    Tick,
204    /// Mata el shell (SIGTERM); el panel queda visible mostrando el
205    /// estado final hasta que el host reciba `Close`.
206    Terminate,
207}
208
209/// Efecto solicitado al host.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum ShumaTermAction {
212    None,
213    /// El host debería remover el state del modelo.
214    Close,
215    /// El host debería actualizar su barra de estado.
216    SetStatus(String),
217}
218
219// =====================================================================
220// apply / on_key / open_shortcut
221// =====================================================================
222
223/// Aplica un mensaje al estado.
224pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction {
225    match msg {
226        ShumaTermMsg::Open => ShumaTermAction::None,
227        ShumaTermMsg::Close => ShumaTermAction::Close,
228        ShumaTermMsg::Terminate => {
229            state.killer.term();
230            ShumaTermAction::SetStatus("shuma · SIGTERM".into())
231        }
232        ShumaTermMsg::Tick => drain(state),
233        ShumaTermMsg::KeyInput(ev) => {
234            // Interceptaciones del módulo (no llegan al PTY):
235            //   Ctrl+Shift+W → cierra el panel.
236            // Cualquier otra combinación se traduce a bytes y se envía.
237            if ev.state == KeyState::Pressed
238                && ev.modifiers.ctrl
239                && ev.modifiers.shift
240                && matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w"))
241            {
242                return ShumaTermAction::Close;
243            }
244            let bytes = key_to_bytes(&ev);
245            if !bytes.is_empty() {
246                state.handle.write_input(bytes);
247            }
248            ShumaTermAction::None
249        }
250    }
251}
252
253/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para
254/// todo evento `Pressed` — el terminal **traga** las teclas; el host no
255/// debe reusarlas para sus propios atajos mientras este panel esté
256/// activo (la excepción es el atajo de apertura, que el host filtra
257/// antes de delegar).
258pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option<ShumaTermMsg> {
259    if event.state != KeyState::Pressed {
260        return None;
261    }
262    Some(ShumaTermMsg::KeyInput(event.clone()))
263}
264
265/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que
266/// VS Code. Los hosts pueden ignorarlo y usar otro.
267pub fn open_shortcut(event: &KeyEvent) -> bool {
268    event.state == KeyState::Pressed
269        && event.modifiers.ctrl
270        && !event.modifiers.shift
271        && matches!(&event.key, Key::Character(s) if s == "`")
272}
273
274// =====================================================================
275// Drenado del PTY
276// =====================================================================
277
278fn drain(state: &mut ShumaTermState) -> ShumaTermAction {
279    let mut bytes_in = 0usize;
280    let mut final_action = ShumaTermAction::None;
281    for ev in state.handle.try_events() {
282        match ev {
283            RunEvent::Bytes(b) => {
284                bytes_in += b.len();
285                state.parser.process(&b);
286            }
287            RunEvent::Exited(code) => {
288                state.exit_code = Some(code);
289                let elapsed = state.started_at.elapsed().as_secs_f64();
290                final_action = ShumaTermAction::SetStatus(format!(
291                    "shuma · exit {code} · {elapsed:.1}s"
292                ));
293            }
294            RunEvent::Failed(err) => {
295                state.exit_code = Some(-1);
296                final_action =
297                    ShumaTermAction::SetStatus(format!("shuma · falló: {err}"));
298            }
299            // Stdout/Stderr/Truncated/Spilled no aplican al modo Pty.
300            _ => {}
301        }
302    }
303    if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 {
304        // Nada que reportar — el repaint que el host hará por el frame
305        // basta para mostrar lo nuevo.
306        ShumaTermAction::None
307    } else {
308        final_action
309    }
310}
311
312// =====================================================================
313// Mapeo KeyEvent → bytes
314// =====================================================================
315
316/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal
317/// xterm espera. Cubre el subset usable (chars + control + flechas +
318/// home/end/page + fn keys), suficiente para shells modernos, TUIs
319/// (vim, htop, less) y CLIs interactivas (claude code, fzf).
320pub fn key_to_bytes(ev: &KeyEvent) -> Vec<u8> {
321    if ev.state != KeyState::Pressed {
322        return Vec::new();
323    }
324
325    // Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3
326    // estándar (xterm-256color).
327    if let Key::Named(named) = &ev.key {
328        return named_to_bytes(*named);
329    }
330
331    // Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03).
332    if let Key::Character(s) = &ev.key {
333        if ev.modifiers.ctrl && !ev.modifiers.alt {
334            if let Some(b) = ctrl_byte(s) {
335                return vec![b];
336            }
337        }
338        // Alt+x → ESC + x (convención xterm meta-sends-escape).
339        if ev.modifiers.alt {
340            let mut out = vec![0x1b];
341            out.extend_from_slice(s.as_bytes());
342            return out;
343        }
344    }
345
346    // Caso general: si el backend ya nos dio el texto resultante
347    // (con shift/IME aplicados), eso es lo correcto para mandar.
348    if let Some(text) = &ev.text {
349        return text.as_bytes().to_vec();
350    }
351    Vec::new()
352}
353
354fn named_to_bytes(k: NamedKey) -> Vec<u8> {
355    match k {
356        // PTYs en modo raw esperan CR para Enter; el driver convierte a LF.
357        NamedKey::Enter => b"\r".to_vec(),
358        // Backspace moderno = DEL (0x7f). Los shells lo entienden mejor
359        // que 0x08, que se reserva para ^H en TUIs viejos.
360        NamedKey::Backspace => vec![0x7f],
361        NamedKey::Tab => b"\t".to_vec(),
362        NamedKey::Escape => vec![0x1b],
363        NamedKey::ArrowUp => b"\x1b[A".to_vec(),
364        NamedKey::ArrowDown => b"\x1b[B".to_vec(),
365        NamedKey::ArrowRight => b"\x1b[C".to_vec(),
366        NamedKey::ArrowLeft => b"\x1b[D".to_vec(),
367        NamedKey::Home => b"\x1b[H".to_vec(),
368        NamedKey::End => b"\x1b[F".to_vec(),
369        NamedKey::PageUp => b"\x1b[5~".to_vec(),
370        NamedKey::PageDown => b"\x1b[6~".to_vec(),
371        NamedKey::Delete => b"\x1b[3~".to_vec(),
372        NamedKey::Insert => b"\x1b[2~".to_vec(),
373        NamedKey::F1 => b"\x1bOP".to_vec(),
374        NamedKey::F2 => b"\x1bOQ".to_vec(),
375        NamedKey::F3 => b"\x1bOR".to_vec(),
376        NamedKey::F4 => b"\x1bOS".to_vec(),
377        NamedKey::F5 => b"\x1b[15~".to_vec(),
378        NamedKey::F6 => b"\x1b[17~".to_vec(),
379        NamedKey::F7 => b"\x1b[18~".to_vec(),
380        NamedKey::F8 => b"\x1b[19~".to_vec(),
381        NamedKey::F9 => b"\x1b[20~".to_vec(),
382        NamedKey::F10 => b"\x1b[21~".to_vec(),
383        NamedKey::F11 => b"\x1b[23~".to_vec(),
384        NamedKey::F12 => b"\x1b[24~".to_vec(),
385        _ => Vec::new(),
386    }
387}
388
389/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26).
390/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS),
391/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL).
392fn ctrl_byte(s: &str) -> Option<u8> {
393    let c = s.chars().next()?;
394    match c {
395        'a'..='z' => Some((c as u8) - b'a' + 1),
396        'A'..='Z' => Some((c as u8) - b'A' + 1),
397        '@' => Some(0),
398        '[' => Some(0x1b),
399        '\\' => Some(0x1c),
400        ']' => Some(0x1d),
401        '^' => Some(0x1e),
402        '_' => Some(0x1f),
403        '?' => Some(0x7f),
404        ' ' => Some(0), // Ctrl+Space = NUL, convención xterm
405        _ => None,
406    }
407}
408
409// =====================================================================
410// View
411// =====================================================================
412
413/// Paleta visual del terminal. Monospace; fondo más oscuro que el
414/// panel general para que el terminal "viva" visualmente.
415#[derive(Debug, Clone)]
416pub struct ShumaTermPalette {
417    pub bg_panel: llimphi_compositor::peniko::Color,
418    pub bg_header: llimphi_compositor::peniko::Color,
419    pub fg_text: llimphi_compositor::peniko::Color,
420    pub fg_muted: llimphi_compositor::peniko::Color,
421}
422
423impl ShumaTermPalette {
424    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
425        Self {
426            bg_panel: t.bg_panel_alt,
427            bg_header: t.bg_panel,
428            fg_text: t.fg_text,
429            fg_muted: t.fg_muted,
430        }
431    }
432}
433
434const HEADER_H: f32 = 18.0;
435const ROW_H: f32 = 14.0;
436const CHAR_W: f32 = 7.5;
437
438/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del
439/// host. `height_px` es la altura total del panel — el módulo divide
440/// entre header + grid.
441pub fn view<HostMsg, F>(
442    state: &ShumaTermState,
443    palette: &ShumaTermPalette,
444    height_px: f32,
445    to_host: F,
446) -> View<HostMsg>
447where
448    HostMsg: Clone + 'static,
449    F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static,
450{
451    let _ = to_host; // v0 no monta eventos puntuales sobre el grid
452
453    let header_text = match state.exit_code {
454        Some(code) => format!(
455            "shuma · {} · exit {code} · Ctrl+Shift+W cierra",
456            state.cwd
457        ),
458        None => format!(
459            "shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell",
460            state.cwd, state.cols, state.rows
461        ),
462    };
463
464    let header = View::new(Style {
465        size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
466        padding: Rect {
467            left: length(8.0_f32),
468            right: length(8.0_f32),
469            top: length(0.0_f32),
470            bottom: length(0.0_f32),
471        },
472        align_items: Some(AlignItems::CENTER),
473        flex_shrink: 0.0,
474        ..Default::default()
475    })
476    .fill(palette.bg_header)
477    .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
478
479    let contents = state.parser.screen().contents();
480    let grid_h = (height_px - HEADER_H).max(0.0);
481    let max_rows = ((grid_h / ROW_H) as usize).max(1);
482
483    // Tomamos las últimas `max_rows` líneas — preferimos mostrar el
484    // tail (donde está el cursor / prompt) si el render no alcanza
485    // para toda la pantalla.
486    let all_lines: Vec<&str> = contents.split('\n').collect();
487    let start = all_lines.len().saturating_sub(max_rows);
488    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
489    for line in &all_lines[start..] {
490        rows.push(
491            View::new(Style {
492                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
493                padding: Rect {
494                    left: length(8.0_f32),
495                    right: length(8.0_f32),
496                    top: length(0.0_f32),
497                    bottom: length(0.0_f32),
498                },
499                align_items: Some(AlignItems::CENTER),
500                flex_shrink: 0.0,
501                ..Default::default()
502            })
503            .fill(palette.bg_panel)
504            .text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start),
505        );
506    }
507    // Si el render quedó corto, rellenamos con líneas vacías para que el
508    // panel mantenga su altura visual.
509    while rows.len() < max_rows {
510        rows.push(
511            View::new(Style {
512                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
513                flex_shrink: 0.0,
514                ..Default::default()
515            })
516            .fill(palette.bg_panel),
517        );
518    }
519
520    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
521    children.push(header);
522    children.extend(rows);
523
524    View::new(Style {
525        flex_direction: FlexDirection::Column,
526        size: Size { width: percent(1.0_f32), height: length(height_px) },
527        flex_shrink: 0.0,
528        ..Default::default()
529    })
530    .fill(palette.bg_panel)
531    .children(children)
532}
533
534/// Estimación heurística de cuántas columnas caben en `width_px` con la
535/// fuente actual. Útil para que el host calcule el tamaño antes de
536/// llamar a [`spawn_with`].
537pub fn cols_for_width(width_px: f32) -> u16 {
538    ((width_px / CHAR_W).floor() as u16).max(20)
539}
540
541/// Idem para filas a partir de la altura disponible del panel
542/// (descontando el header).
543pub fn rows_for_height(height_px: f32) -> u16 {
544    (((height_px - HEADER_H) / ROW_H).floor() as u16).max(5)
545}