feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "llimphi-module-shuma-term"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-module-shuma-term — terminal integrado tipo Ctrl+\\` de VS Code. Módulo Llimphi sobre shuma-exec (PTY real) + vt100 (emulación). Cualquier app Llimphi puede enchufar un terminal sandboxeado por el shell del usuario."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
shuma-exec = { path = "../../../shuma/sandbox/shuma-exec" }
|
||||
vt100 = { workspace = true }
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-module-shuma-term
|
||||
|
||||
> Terminal embebida (shell shuma) de [llimphi](../../README.md).
|
||||
|
||||
Wrapper de [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) montable adentro de cualquier app. Útil para `nada`, IDE-like setups.
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-module-shuma-term
|
||||
|
||||
> Embedded terminal (shuma shell) of [llimphi](../../README.md).
|
||||
|
||||
Wrapper of [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) mountable inside any app. Useful for `nada`, IDE-like setups.
|
||||
@@ -0,0 +1,511 @@
|
||||
//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de
|
||||
//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app
|
||||
//! Llimphi.
|
||||
//!
|
||||
//! Lo monta sobre dos piezas que ya existen:
|
||||
//!
|
||||
//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform
|
||||
//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y
|
||||
//! entrega los bytes crudos por un canal MPSC. El módulo no toca
|
||||
//! syscalls — sólo consume eventos.
|
||||
//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla
|
||||
//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes
|
||||
//! y al renderizar pide `screen().contents()`.
|
||||
//!
|
||||
//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg +
|
||||
//! Action + apply/on_key/open_shortcut/view + Palette`.
|
||||
//!
|
||||
//! ## Cómo lo enchufa una app (resumen)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! struct Model { term: Option<ShumaTermState>, … }
|
||||
//! enum Msg { Term(ShumaTermMsg), Tick, … }
|
||||
//!
|
||||
//! // open: shuma_term::spawn("/home/user", 100, 30)?
|
||||
//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg)
|
||||
//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open)
|
||||
//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY
|
||||
//! // apply: match action { Close → model.term = None, SetStatus(s) → … }
|
||||
//! // view: si term.is_some() → push view(...)
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, FlexDirection, Size, Style},
|
||||
AlignItems, Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
|
||||
use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle};
|
||||
|
||||
/// Capabilities que aporta este módulo al host. El host las puede
|
||||
/// agregar a `provides` en su `card_core::Card` para que el broker
|
||||
/// chasqui descubra que la instancia ofrece terminal integrado.
|
||||
pub const CAPABILITIES: &[&str] = &["editor.terminal"];
|
||||
|
||||
/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo
|
||||
/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a
|
||||
/// [`spawn_with`].
|
||||
pub const DEFAULT_COLS: u16 = 100;
|
||||
pub const DEFAULT_ROWS: u16 = 24;
|
||||
|
||||
const SCROLLBACK: usize = 2000;
|
||||
|
||||
// =====================================================================
|
||||
// State
|
||||
// =====================================================================
|
||||
|
||||
/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un
|
||||
/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone`
|
||||
/// (los handles son únicos), y el host lo embebe como
|
||||
/// `Option<ShumaTermState>`.
|
||||
pub struct ShumaTermState {
|
||||
handle: RunHandle,
|
||||
killer: Killer,
|
||||
parser: vt100::Parser,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
/// Si el shell ya emitió `Exited(code)`. El panel se queda visible
|
||||
/// para que el usuario pueda leer la última salida antes de cerrar.
|
||||
exit_code: Option<i32>,
|
||||
/// CWD inicial — útil para el header sin tener que tocar /proc.
|
||||
cwd: String,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
impl ShumaTermState {
|
||||
/// Bytes que el módulo ya consumió desde el PTY. Útil para tests y
|
||||
/// debug — no es parte del contrato Tier 1.
|
||||
pub fn screen_contents(&self) -> String {
|
||||
self.parser.screen().contents()
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> u16 {
|
||||
self.cols
|
||||
}
|
||||
pub fn rows(&self) -> u16 {
|
||||
self.rows
|
||||
}
|
||||
pub fn exit_code(&self) -> Option<i32> {
|
||||
self.exit_code
|
||||
}
|
||||
pub fn cwd(&self) -> &str {
|
||||
&self.cwd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ShumaTermState {
|
||||
fn drop(&mut self) {
|
||||
// Si el host descarta el state (panel cerrado), no dejamos al
|
||||
// shell huérfano consumiendo CPU. SIGTERM educado primero;
|
||||
// shuma-exec se encarga del SIGKILL si hace falta.
|
||||
self.killer.term();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd`
|
||||
/// con tamaño de PTY por defecto.
|
||||
pub fn spawn(cwd: impl Into<String>) -> ShumaTermState {
|
||||
spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS)
|
||||
}
|
||||
|
||||
/// Variante con control fino de programa, args y tamaño.
|
||||
pub fn spawn_with(
|
||||
cwd: impl Into<String>,
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> ShumaTermState {
|
||||
let cwd = cwd.into();
|
||||
let spec = CommandSpec {
|
||||
exec: Exec::Pty { program, args, cols, rows },
|
||||
cwd: cwd.clone(),
|
||||
capture_limit: 0,
|
||||
spill_path: None,
|
||||
stdin_data: None,
|
||||
capture_stages: false,
|
||||
};
|
||||
let handle = shuma_exec::run(&spec);
|
||||
let killer = handle.killer();
|
||||
ShumaTermState {
|
||||
handle,
|
||||
killer,
|
||||
parser: vt100::Parser::new(rows, cols, SCROLLBACK),
|
||||
cols,
|
||||
rows,
|
||||
exit_code: None,
|
||||
cwd,
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Msg / Action
|
||||
// =====================================================================
|
||||
|
||||
/// Vocabulario interno. El host lo wrapea en su `Msg`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ShumaTermMsg {
|
||||
/// Símbolo conveniente para que el host dispatche al detectar el
|
||||
/// shortcut. El módulo no crea el state él mismo — el host lo crea
|
||||
/// con [`spawn`] porque conoce el cwd canónico de la app.
|
||||
Open,
|
||||
/// El usuario pidió cerrar el panel.
|
||||
Close,
|
||||
/// Tecla mientras el panel está enfocado. Se traduce a bytes y se
|
||||
/// reenvía al PTY.
|
||||
KeyInput(KeyEvent),
|
||||
/// Tick del host: drena eventos pendientes del PTY (bytes y exit).
|
||||
/// El host debe enviar este Msg de forma periódica (en cada frame,
|
||||
/// o cuando hay actividad). Sin Tick el terminal no avanza.
|
||||
Tick,
|
||||
/// Mata el shell (SIGTERM); el panel queda visible mostrando el
|
||||
/// estado final hasta que el host reciba `Close`.
|
||||
Terminate,
|
||||
}
|
||||
|
||||
/// Efecto solicitado al host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShumaTermAction {
|
||||
None,
|
||||
/// El host debería remover el state del modelo.
|
||||
Close,
|
||||
/// El host debería actualizar su barra de estado.
|
||||
SetStatus(String),
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// apply / on_key / open_shortcut
|
||||
// =====================================================================
|
||||
|
||||
/// Aplica un mensaje al estado.
|
||||
pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction {
|
||||
match msg {
|
||||
ShumaTermMsg::Open => ShumaTermAction::None,
|
||||
ShumaTermMsg::Close => ShumaTermAction::Close,
|
||||
ShumaTermMsg::Terminate => {
|
||||
state.killer.term();
|
||||
ShumaTermAction::SetStatus("shuma · SIGTERM".into())
|
||||
}
|
||||
ShumaTermMsg::Tick => drain(state),
|
||||
ShumaTermMsg::KeyInput(ev) => {
|
||||
// Interceptaciones del módulo (no llegan al PTY):
|
||||
// Ctrl+Shift+W → cierra el panel.
|
||||
// Cualquier otra combinación se traduce a bytes y se envía.
|
||||
if ev.state == KeyState::Pressed
|
||||
&& ev.modifiers.ctrl
|
||||
&& ev.modifiers.shift
|
||||
&& matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w"))
|
||||
{
|
||||
return ShumaTermAction::Close;
|
||||
}
|
||||
let bytes = key_to_bytes(&ev);
|
||||
if !bytes.is_empty() {
|
||||
state.handle.write_input(bytes);
|
||||
}
|
||||
ShumaTermAction::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para
|
||||
/// todo evento `Pressed` — el terminal **traga** las teclas; el host no
|
||||
/// debe reusarlas para sus propios atajos mientras este panel esté
|
||||
/// activo (la excepción es el atajo de apertura, que el host filtra
|
||||
/// antes de delegar).
|
||||
pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option<ShumaTermMsg> {
|
||||
if event.state != KeyState::Pressed {
|
||||
return None;
|
||||
}
|
||||
Some(ShumaTermMsg::KeyInput(event.clone()))
|
||||
}
|
||||
|
||||
/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que
|
||||
/// VS Code. Los hosts pueden ignorarlo y usar otro.
|
||||
pub fn open_shortcut(event: &KeyEvent) -> bool {
|
||||
event.state == KeyState::Pressed
|
||||
&& event.modifiers.ctrl
|
||||
&& !event.modifiers.shift
|
||||
&& matches!(&event.key, Key::Character(s) if s == "`")
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Drenado del PTY
|
||||
// =====================================================================
|
||||
|
||||
fn drain(state: &mut ShumaTermState) -> ShumaTermAction {
|
||||
let mut bytes_in = 0usize;
|
||||
let mut final_action = ShumaTermAction::None;
|
||||
for ev in state.handle.try_events() {
|
||||
match ev {
|
||||
RunEvent::Bytes(b) => {
|
||||
bytes_in += b.len();
|
||||
state.parser.process(&b);
|
||||
}
|
||||
RunEvent::Exited(code) => {
|
||||
state.exit_code = Some(code);
|
||||
let elapsed = state.started_at.elapsed().as_secs_f64();
|
||||
final_action = ShumaTermAction::SetStatus(format!(
|
||||
"shuma · exit {code} · {elapsed:.1}s"
|
||||
));
|
||||
}
|
||||
RunEvent::Failed(err) => {
|
||||
state.exit_code = Some(-1);
|
||||
final_action =
|
||||
ShumaTermAction::SetStatus(format!("shuma · falló: {err}"));
|
||||
}
|
||||
// Stdout/Stderr/Truncated/Spilled no aplican al modo Pty.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 {
|
||||
// Nada que reportar — el repaint que el host hará por el frame
|
||||
// basta para mostrar lo nuevo.
|
||||
ShumaTermAction::None
|
||||
} else {
|
||||
final_action
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Mapeo KeyEvent → bytes
|
||||
// =====================================================================
|
||||
|
||||
/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal
|
||||
/// xterm espera. Cubre el subset usable (chars + control + flechas +
|
||||
/// home/end/page + fn keys), suficiente para shells modernos, TUIs
|
||||
/// (vim, htop, less) y CLIs interactivas (claude code, fzf).
|
||||
pub fn key_to_bytes(ev: &KeyEvent) -> Vec<u8> {
|
||||
if ev.state != KeyState::Pressed {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3
|
||||
// estándar (xterm-256color).
|
||||
if let Key::Named(named) = &ev.key {
|
||||
return named_to_bytes(*named);
|
||||
}
|
||||
|
||||
// Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03).
|
||||
if let Key::Character(s) = &ev.key {
|
||||
if ev.modifiers.ctrl && !ev.modifiers.alt {
|
||||
if let Some(b) = ctrl_byte(s) {
|
||||
return vec![b];
|
||||
}
|
||||
}
|
||||
// Alt+x → ESC + x (convención xterm meta-sends-escape).
|
||||
if ev.modifiers.alt {
|
||||
let mut out = vec![0x1b];
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso general: si el backend ya nos dio el texto resultante
|
||||
// (con shift/IME aplicados), eso es lo correcto para mandar.
|
||||
if let Some(text) = &ev.text {
|
||||
return text.as_bytes().to_vec();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn named_to_bytes(k: NamedKey) -> Vec<u8> {
|
||||
match k {
|
||||
// PTYs en modo raw esperan CR para Enter; el driver convierte a LF.
|
||||
NamedKey::Enter => b"\r".to_vec(),
|
||||
// Backspace moderno = DEL (0x7f). Los shells lo entienden mejor
|
||||
// que 0x08, que se reserva para ^H en TUIs viejos.
|
||||
NamedKey::Backspace => vec![0x7f],
|
||||
NamedKey::Tab => b"\t".to_vec(),
|
||||
NamedKey::Escape => vec![0x1b],
|
||||
NamedKey::ArrowUp => b"\x1b[A".to_vec(),
|
||||
NamedKey::ArrowDown => b"\x1b[B".to_vec(),
|
||||
NamedKey::ArrowRight => b"\x1b[C".to_vec(),
|
||||
NamedKey::ArrowLeft => b"\x1b[D".to_vec(),
|
||||
NamedKey::Home => b"\x1b[H".to_vec(),
|
||||
NamedKey::End => b"\x1b[F".to_vec(),
|
||||
NamedKey::PageUp => b"\x1b[5~".to_vec(),
|
||||
NamedKey::PageDown => b"\x1b[6~".to_vec(),
|
||||
NamedKey::Delete => b"\x1b[3~".to_vec(),
|
||||
NamedKey::Insert => b"\x1b[2~".to_vec(),
|
||||
NamedKey::F1 => b"\x1bOP".to_vec(),
|
||||
NamedKey::F2 => b"\x1bOQ".to_vec(),
|
||||
NamedKey::F3 => b"\x1bOR".to_vec(),
|
||||
NamedKey::F4 => b"\x1bOS".to_vec(),
|
||||
NamedKey::F5 => b"\x1b[15~".to_vec(),
|
||||
NamedKey::F6 => b"\x1b[17~".to_vec(),
|
||||
NamedKey::F7 => b"\x1b[18~".to_vec(),
|
||||
NamedKey::F8 => b"\x1b[19~".to_vec(),
|
||||
NamedKey::F9 => b"\x1b[20~".to_vec(),
|
||||
NamedKey::F10 => b"\x1b[21~".to_vec(),
|
||||
NamedKey::F11 => b"\x1b[23~".to_vec(),
|
||||
NamedKey::F12 => b"\x1b[24~".to_vec(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26).
|
||||
/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS),
|
||||
/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL).
|
||||
fn ctrl_byte(s: &str) -> Option<u8> {
|
||||
let c = s.chars().next()?;
|
||||
match c {
|
||||
'a'..='z' => Some((c as u8) - b'a' + 1),
|
||||
'A'..='Z' => Some((c as u8) - b'A' + 1),
|
||||
'@' => Some(0),
|
||||
'[' => Some(0x1b),
|
||||
'\\' => Some(0x1c),
|
||||
']' => Some(0x1d),
|
||||
'^' => Some(0x1e),
|
||||
'_' => Some(0x1f),
|
||||
'?' => Some(0x7f),
|
||||
' ' => Some(0), // Ctrl+Space = NUL, convención xterm
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// View
|
||||
// =====================================================================
|
||||
|
||||
/// Paleta visual del terminal. Monospace; fondo más oscuro que el
|
||||
/// panel general para que el terminal "viva" visualmente.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShumaTermPalette {
|
||||
pub bg_panel: llimphi_ui::llimphi_raster::peniko::Color,
|
||||
pub bg_header: llimphi_ui::llimphi_raster::peniko::Color,
|
||||
pub fg_text: llimphi_ui::llimphi_raster::peniko::Color,
|
||||
pub fg_muted: llimphi_ui::llimphi_raster::peniko::Color,
|
||||
}
|
||||
|
||||
impl ShumaTermPalette {
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
Self {
|
||||
bg_panel: t.bg_panel_alt,
|
||||
bg_header: t.bg_panel,
|
||||
fg_text: t.fg_text,
|
||||
fg_muted: t.fg_muted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HEADER_H: f32 = 18.0;
|
||||
const ROW_H: f32 = 14.0;
|
||||
const CHAR_W: f32 = 7.5;
|
||||
|
||||
/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del
|
||||
/// host. `height_px` es la altura total del panel — el módulo divide
|
||||
/// entre header + grid.
|
||||
pub fn view<HostMsg, F>(
|
||||
state: &ShumaTermState,
|
||||
palette: &ShumaTermPalette,
|
||||
height_px: f32,
|
||||
to_host: F,
|
||||
) -> View<HostMsg>
|
||||
where
|
||||
HostMsg: Clone + 'static,
|
||||
F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static,
|
||||
{
|
||||
let _ = to_host; // v0 no monta eventos puntuales sobre el grid
|
||||
|
||||
let header_text = match state.exit_code {
|
||||
Some(code) => format!(
|
||||
"shuma · {} · exit {code} · Ctrl+Shift+W cierra",
|
||||
state.cwd
|
||||
),
|
||||
None => format!(
|
||||
"shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell",
|
||||
state.cwd, state.cols, state.rows
|
||||
),
|
||||
};
|
||||
|
||||
let header = View::new(Style {
|
||||
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
|
||||
padding: Rect {
|
||||
left: length(8.0_f32),
|
||||
right: length(8.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_header)
|
||||
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
|
||||
|
||||
let contents = state.parser.screen().contents();
|
||||
let grid_h = (height_px - HEADER_H).max(0.0);
|
||||
let max_rows = ((grid_h / ROW_H) as usize).max(1);
|
||||
|
||||
// Tomamos las últimas `max_rows` líneas — preferimos mostrar el
|
||||
// tail (donde está el cursor / prompt) si el render no alcanza
|
||||
// para toda la pantalla.
|
||||
let all_lines: Vec<&str> = contents.split('\n').collect();
|
||||
let start = all_lines.len().saturating_sub(max_rows);
|
||||
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
|
||||
for line in &all_lines[start..] {
|
||||
rows.push(
|
||||
View::new(Style {
|
||||
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
|
||||
padding: Rect {
|
||||
left: length(8.0_f32),
|
||||
right: length(8.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_panel)
|
||||
.text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start),
|
||||
);
|
||||
}
|
||||
// Si el render quedó corto, rellenamos con líneas vacías para que el
|
||||
// panel mantenga su altura visual.
|
||||
while rows.len() < max_rows {
|
||||
rows.push(
|
||||
View::new(Style {
|
||||
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_panel),
|
||||
);
|
||||
}
|
||||
|
||||
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
|
||||
children.push(header);
|
||||
children.extend(rows);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size { width: percent(1.0_f32), height: length(height_px) },
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_panel)
|
||||
.children(children)
|
||||
}
|
||||
|
||||
/// Estimación heurística de cuántas columnas caben en `width_px` con la
|
||||
/// fuente actual. Útil para que el host calcule el tamaño antes de
|
||||
/// llamar a [`spawn_with`].
|
||||
pub fn cols_for_width(width_px: f32) -> u16 {
|
||||
((width_px / CHAR_W).floor() as u16).max(20)
|
||||
}
|
||||
|
||||
/// Idem para filas a partir de la altura disponible del panel
|
||||
/// (descontando el header).
|
||||
pub fn rows_for_height(height_px: f32) -> u16 {
|
||||
(((height_px - HEADER_H) / ROW_H).floor() as u16).max(5)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Smoke test del terminal: spawnea un shell, le tipea `echo hola`,
|
||||
//! drena hasta ver el output, y verifica que el contenido del screen
|
||||
//! contenga "hola". Cierre con SIGTERM se valida por el Drop.
|
||||
//!
|
||||
//! Requiere `/bin/sh` y un sistema Linux real (no corre en sandbox
|
||||
//! puro). Es razonable porque shuma-exec ya lo asume.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use llimphi_module_shuma_term::{self as term, ShumaTermAction, ShumaTermMsg};
|
||||
|
||||
#[test]
|
||||
fn echo_a_traves_del_pty_aparece_en_el_screen() {
|
||||
let mut state = term::spawn_with(
|
||||
"/tmp".to_string(),
|
||||
"/bin/sh".to_string(),
|
||||
Vec::new(),
|
||||
80,
|
||||
24,
|
||||
);
|
||||
|
||||
// El shell escribe su prompt al arrancar; lo drenamos sin asumir
|
||||
// su contenido (cambia por distro).
|
||||
spin_drain(&mut state, Duration::from_millis(200));
|
||||
|
||||
// Tipeamos el comando. Sin Llimphi alrededor llamamos a write_input
|
||||
// directamente — el módulo permite hacerlo via KeyInput, pero
|
||||
// construir KeyEvents acá es ruido para este test.
|
||||
write_raw(&mut state, b"echo hola_del_test\n");
|
||||
|
||||
// Esperamos hasta 2s a que el output llegue al screen.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut visto = false;
|
||||
while Instant::now() < deadline {
|
||||
spin_drain(&mut state, Duration::from_millis(50));
|
||||
if state.screen_contents().contains("hola_del_test") {
|
||||
visto = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
visto,
|
||||
"esperaba ver 'hola_del_test' en el screen, contenido actual:\n{}",
|
||||
state.screen_contents()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_w_emite_action_close_sin_pasar_al_pty() {
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
|
||||
|
||||
let mut state = term::spawn_with(
|
||||
"/tmp".to_string(),
|
||||
"/bin/sh".to_string(),
|
||||
Vec::new(),
|
||||
80,
|
||||
24,
|
||||
);
|
||||
spin_drain(&mut state, Duration::from_millis(100));
|
||||
|
||||
let ev = KeyEvent {
|
||||
key: Key::Character("w".into()),
|
||||
state: KeyState::Pressed,
|
||||
text: Some("w".into()),
|
||||
modifiers: Modifiers { ctrl: true, shift: true, ..Modifiers::default() },
|
||||
repeat: false,
|
||||
};
|
||||
let action = term::apply(&mut state, ShumaTermMsg::KeyInput(ev));
|
||||
assert_eq!(action, ShumaTermAction::Close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_to_bytes_mapea_los_casos_canonicos() {
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
|
||||
|
||||
let mk = |key: Key, mods: Modifiers, text: Option<&str>| KeyEvent {
|
||||
key,
|
||||
state: KeyState::Pressed,
|
||||
text: text.map(|s| s.to_string()),
|
||||
modifiers: mods,
|
||||
repeat: false,
|
||||
};
|
||||
|
||||
// Enter → CR (no LF — el driver del PTY lo expande).
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(Key::Named(NamedKey::Enter), Modifiers::default(), None)),
|
||||
b"\r"
|
||||
);
|
||||
// Backspace → DEL.
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(
|
||||
Key::Named(NamedKey::Backspace),
|
||||
Modifiers::default(),
|
||||
None
|
||||
)),
|
||||
vec![0x7f]
|
||||
);
|
||||
// ArrowUp → CSI A.
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(
|
||||
Key::Named(NamedKey::ArrowUp),
|
||||
Modifiers::default(),
|
||||
None
|
||||
)),
|
||||
b"\x1b[A"
|
||||
);
|
||||
// Ctrl+C → 0x03.
|
||||
let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(Key::Character("c".into()), ctrl, Some("c"))),
|
||||
vec![0x03]
|
||||
);
|
||||
// Texto plano (con shift aplicado por el backend) → ese mismo texto.
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(Key::Character("A".into()), Modifiers::default(), Some("A"))),
|
||||
b"A"
|
||||
);
|
||||
// Alt+x → ESC + x.
|
||||
let alt = Modifiers { alt: true, ..Modifiers::default() };
|
||||
assert_eq!(
|
||||
term::key_to_bytes(&mk(Key::Character("x".into()), alt, Some("x"))),
|
||||
vec![0x1b, b'x']
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Pequeño polling: dispara Tick varias veces durante `total` para que
|
||||
/// el módulo drene los bytes que el reader thread haya emitido.
|
||||
fn spin_drain(state: &mut llimphi_module_shuma_term::ShumaTermState, total: Duration) {
|
||||
let deadline = Instant::now() + total;
|
||||
while Instant::now() < deadline {
|
||||
term::apply(state, ShumaTermMsg::Tick);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Atajo: enviar bytes crudos al PTY sin construir un KeyEvent. Usa la
|
||||
/// API pública via un truco — convertimos a un KeyEvent "texto" para
|
||||
/// evitar exponer write_input crudo en el contrato.
|
||||
fn write_raw(state: &mut llimphi_module_shuma_term::ShumaTermState, bytes: &[u8]) {
|
||||
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
|
||||
// Texto entero (incluyendo el LF) en un solo KeyInput. apply() lo
|
||||
// copia tal cual al PTY via la rama `text`.
|
||||
let s = std::str::from_utf8(bytes).expect("test usa ascii");
|
||||
let ev = KeyEvent {
|
||||
// Key::Character vacío para que no entremos por la rama ctrl/alt.
|
||||
key: Key::Character("".into()),
|
||||
state: KeyState::Pressed,
|
||||
text: Some(s.to_string()),
|
||||
modifiers: Modifiers::default(),
|
||||
repeat: false,
|
||||
};
|
||||
term::apply(state, ShumaTermMsg::KeyInput(ev));
|
||||
}
|
||||
Reference in New Issue
Block a user