refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel

Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 14:40:00 +00:00
parent e74800d9da
commit ccab39f140
202 changed files with 44034 additions and 1811 deletions
+5 -1
View File
@@ -80,7 +80,11 @@ pub fn app_header<Msg: Clone + 'static>(
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.into(), 14.0, palette.fg_text, Alignment::Start);
.text_aligned(label.into(), 14.0, palette.fg_text, Alignment::Start)
// Semántica: el label del header es el **título** de la app/ventana —
// rol Heading para que el lector lo enuncie como tal y los usuarios
// puedan saltar entre headings con sus atajos.
.role(llimphi_ui::Role::Heading);
let actions_view = View::new(Style {
flex_direction: FlexDirection::Row,
-1
View File
@@ -9,4 +9,3 @@ description = "llimphi-widget-avatar — círculo de identidad con inicial sobre
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
-1
View File
@@ -9,4 +9,3 @@ description = "llimphi-widget-badge — chip pequeño (count o dot) para notific
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+46 -1
View File
@@ -79,6 +79,29 @@ pub fn button_view<Msg: Clone + 'static>(
)
}
/// Tinte de la onda de ripple derivado de la paleta: el color de texto
/// (`fg`, normalmente claro sobre el botón dark) a alpha bajo, así contrasta
/// con el fondo y se adapta al theme sin añadir un campo a [`ButtonPalette`].
fn ripple_ink(palette: &ButtonPalette) -> Color {
let c = palette.fg.components;
Color { components: [c[0], c[1], c[2], 0.22], ..palette.fg }
}
/// Como [`button_view`] pero con feedback **ripple/InkWell**: al presionarlo
/// emite la salpicadura Material (círculo que se expande desde el punto del
/// tap y se desvanece, recortado al contorno del botón). `key` debe ser
/// **estable y único** entre los botones vivos del frame (índice del botón,
/// hash de su acción) — es lo que enlaza la onda retenida con este botón entre
/// frames. El tinte sale de la paleta ([`ripple_ink`]).
pub fn button_ripple<Msg: Clone + 'static>(
label: impl Into<String>,
key: u64,
palette: &ButtonPalette,
on_click: Msg,
) -> View<Msg> {
button_view(label, palette, on_click).ripple(key, ripple_ink(palette))
}
/// Variante con `Style` y alineación de texto explícitos — útil cuando
/// la app necesita un botón con dimensiones particulares o el texto a
/// la izquierda.
@@ -89,6 +112,11 @@ pub fn button_styled<Msg: Clone + 'static>(
palette: &ButtonPalette,
on_click: Msg,
) -> View<Msg> {
let label: String = label.into();
// Semántica accesible: rol Button + el texto visible como nombre. Si el
// caller le pasó un label vacío (botones puramente icónicos), igual sale
// como Button — lo correcto es agregarle un aria_label propio desde fuera.
let aria = label.clone();
// Gloss superior: gradient blanco alpha 28 → 0 sobre la mitad de
// arriba. `paint_with` corre entre el fill (que respeta hover_fill)
// y el texto, así que la luz se suma al color de base sin sustituirlo
@@ -119,6 +147,23 @@ pub fn button_styled<Msg: Clone + 'static>(
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
})
.text_aligned(label.into(), 13.0, palette.fg, text_alignment)
.text_aligned(label, 13.0, palette.fg, text_alignment)
.role(llimphi_ui::Role::Button)
.aria_label(aria)
.on_click(on_click)
.cursor(llimphi_ui::Cursor::Pointer)
}
/// Como [`button_styled`] pero con feedback **ripple/InkWell** (ver
/// [`button_ripple`] para la semántica de `key`).
pub fn button_styled_ripple<Msg: Clone + 'static>(
label: impl Into<String>,
key: u64,
style: Style,
text_alignment: Alignment,
palette: &ButtonPalette,
on_click: Msg,
) -> View<Msg> {
button_styled(label, style, text_alignment, palette, on_click)
.ripple(key, ripple_ink(palette))
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-calendar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-calendar — vista mensual del calendario (grilla 7×6) con navegación entre meses, día seleccionado y día actual resaltados. Base del date-picker; útil también solo para agendas, planning y ERP."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
chrono = { workspace = true }
+483
View File
@@ -0,0 +1,483 @@
//! `llimphi-widget-calendar` — vista mensual del calendario.
//!
//! Una grilla 7 (columnas = días de la semana) × 6 (filas = semanas)
//! mostrando los días del mes en foco, con header de mes/año + flechas de
//! navegación, fila de iniciales de día, día seleccionado y día actual
//! resaltados. El widget **no tiene estado propio**: el mes en foco, la
//! selección y la fecha "hoy" viven en el `Model` del caller; el widget
//! emite `Msg`s para navegar y para seleccionar.
//!
//! Base del **date-picker** (un `field` + un overlay que muestra este
//! calendar) y útil por sí solo para agendas, planning, ERP. La lógica
//! del calendario (días del mes, primer día de la semana, grilla 6×7) es
//! pública y testeable: ver [`days_in_month`], [`first_weekday`],
//! [`month_grid`].
#![forbid(unsafe_code)]
use chrono::{Datelike, NaiveDate, Weekday};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::Theme;
/// Día de la semana con el que arranca la grilla. La mayor parte del mundo
/// usa `Monday`; EE. UU. usa `Sunday`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeekStart {
Monday,
Sunday,
}
impl Default for WeekStart {
fn default() -> Self {
Self::Monday
}
}
impl WeekStart {
/// Posición (0..7) del `weekday` empezando por este `WeekStart`. Con
/// `Monday`: `Mon=0, Tue=1, …, Sun=6`. Con `Sunday`: `Sun=0, Mon=1,
/// …, Sat=6`.
pub fn index(self, w: Weekday) -> u32 {
let mon0 = w.num_days_from_monday();
match self {
Self::Monday => mon0,
// Domingo (mon0=6) pasa a 0, lunes (0) a 1, …
Self::Sunday => (mon0 + 1) % 7,
}
}
/// Iniciales de los siete días en el orden que dicta este `WeekStart`,
/// con `locale = "es"` (L/M/M/J/V/S/D para Monday-start). Suficiente
/// para v1; un mapa por `Theme.locale` se agrega cuando alguna app lo
/// pida.
pub fn weekday_initials_es(self) -> [&'static str; 7] {
match self {
Self::Monday => ["L", "M", "M", "J", "V", "S", "D"],
Self::Sunday => ["D", "L", "M", "M", "J", "V", "S"],
}
}
}
/// Cantidad de días del mes `month` (1..=12) del año `year`. Devuelve `0`
/// si `month` está fuera de rango (defensa contra `u32` arbitrario; un
/// caller correcto pasa 1..=12).
pub fn days_in_month(year: i32, month: u32) -> u32 {
if !(1..=12).contains(&month) {
return 0;
}
// Trick estándar: primer día del mes siguiente menos un día. Año
// siguiente si month == 12. `from_ymd_opt` retorna `Some` siempre que
// (year, month, day) sean válidos — para day=1 con month 1..=12 lo es.
let (y2, m2) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let first_next = NaiveDate::from_ymd_opt(y2, m2, 1).expect("primer día válido");
let first_this = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido");
first_next.signed_duration_since(first_this).num_days() as u32
}
/// Día de la semana del **primer día** del mes (1 del mes). Sólo útil para
/// quien quiera el `Weekday` crudo; la grilla usa [`first_weekday_index`].
pub fn first_weekday(year: i32, month: u32) -> Option<Weekday> {
NaiveDate::from_ymd_opt(year, month, 1).map(|d| d.weekday())
}
/// Índice de columna (0..7) del primer día del mes según `WeekStart`. Es
/// la cantidad de celdas vacías ANTES del 1 en la primera fila de la
/// grilla. Devuelve `0` si el mes es inválido.
pub fn first_weekday_index(year: i32, month: u32, start: WeekStart) -> u32 {
first_weekday(year, month).map(|w| start.index(w)).unwrap_or(0)
}
/// Grilla mensual de **6 filas × 7 columnas** con los días del mes. Las
/// celdas que caen antes del primer día o después del último día del mes
/// son `None`. Siempre seis filas: para meses cortos (28 días arrancando
/// en domingo bajo Monday-start, p. ej. febrero 2021) la última fila queda
/// vacía; mantener seis filas estables evita reflow al navegar entre
/// meses (toda la grilla mide igual). Si `month` es inválido devuelve seis
/// filas de siete `None`s.
pub fn month_grid(year: i32, month: u32, start: WeekStart) -> [[Option<u32>; 7]; 6] {
let mut grid: [[Option<u32>; 7]; 6] = [[None; 7]; 6];
let dim = days_in_month(year, month);
if dim == 0 {
return grid;
}
let offset = first_weekday_index(year, month, start) as usize;
for day in 1..=dim {
let cell = offset + (day as usize) - 1;
let r = cell / 7;
let c = cell % 7;
if r < 6 {
grid[r][c] = Some(day);
}
// Si r == 6 (no debería pasar para meses estándar: 31+6=37<42),
// se ignora; la grilla de 6×7 = 42 celdas alcanza para el peor caso
// (31 días con offset = 6 → última celda 36, fila 5).
}
grid
}
/// Avanza `(year, month)` un mes adelante (`delta = +1`) o atrás (`-1`),
/// wrappeando años. Helper para los botones `<`/`>` del header.
pub fn shift_month(year: i32, month: u32, delta: i32) -> (i32, u32) {
let total = (year * 12 + month as i32 - 1) + delta;
let y = total.div_euclid(12);
let m = (total.rem_euclid(12) + 1) as u32;
(y, m)
}
/// Paleta del calendario.
#[derive(Debug, Clone, Copy)]
pub struct CalendarPalette {
/// Fondo del panel.
pub bg: Color,
/// Texto normal de los días del mes en foco.
pub fg: Color,
/// Texto de las iniciales de día / mes/año del header (atenuado).
pub fg_muted: Color,
/// Hover sobre una celda de día.
pub hover_bg: Color,
/// Fondo de la celda del día seleccionado.
pub selected_bg: Color,
/// Texto del día seleccionado (contraste contra `selected_bg`).
pub selected_fg: Color,
/// Color del borde de la celda "hoy" (sólo borde — el fondo sigue al
/// hover/selected normal).
pub today_border: Color,
}
impl CalendarPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
bg: t.bg_panel,
fg: t.fg_text,
fg_muted: t.fg_muted,
hover_bg: t.bg_row_hover,
selected_bg: t.accent,
// El texto sobre `accent` se elige por contraste — el theme no
// expone un `fg_on_accent` dedicado, así que tomamos `bg_app`
// (oscuro en dark theme, claro en light theme) que es el
// inverso natural del `fg_text`.
selected_fg: t.bg_app,
today_border: t.accent,
}
}
}
impl Default for CalendarPalette {
fn default() -> Self {
Self::from_theme(&Theme::dark())
}
}
/// Especificación del calendario para una vista mensual.
pub struct CalendarSpec<Msg> {
/// Año del mes en foco.
pub view_year: i32,
/// Mes en foco (1..=12).
pub view_month: u32,
/// Día seleccionado (opcional; si cae en otro mes no se resalta).
pub selected: Option<NaiveDate>,
/// Fecha "hoy" (opcional; si cae en otro mes no se resalta). El caller
/// la inyecta — el widget no toca el reloj para mantenerse puro y
/// testeable.
pub today: Option<NaiveDate>,
/// Primer día de la semana en la grilla.
pub week_start: WeekStart,
pub palette: CalendarPalette,
/// `Msg` para "seleccioné el día X (NaiveDate del mes en foco)".
pub on_select: std::sync::Arc<dyn Fn(NaiveDate) -> Msg + Send + Sync>,
/// `Msg` para "muévete al mes (year, month)" — disparado por `<` / `>`.
pub on_view_change: std::sync::Arc<dyn Fn(i32, u32) -> Msg + Send + Sync>,
}
/// Nombre del mes en español (1..=12). Para v1; un map por `Theme.locale`
/// se agrega cuando alguna app lo pida.
fn month_name_es(m: u32) -> &'static str {
match m {
1 => "enero",
2 => "febrero",
3 => "marzo",
4 => "abril",
5 => "mayo",
6 => "junio",
7 => "julio",
8 => "agosto",
9 => "septiembre",
10 => "octubre",
11 => "noviembre",
12 => "diciembre",
_ => "",
}
}
const HEADER_H: f32 = 32.0;
const ROW_H: f32 = 32.0;
const CELL_W: f32 = 32.0;
const PAD: f32 = 8.0;
/// Vista del calendario. Devuelve un `View<Msg>` autocontenido (panel +
/// header + week labels + grilla 6×7), de ancho fijo `7 * CELL_W + 2 * PAD`.
pub fn calendar_view<Msg>(spec: CalendarSpec<Msg>) -> View<Msg>
where
Msg: Clone + 'static,
{
let CalendarSpec {
view_year,
view_month,
selected,
today,
week_start,
palette,
on_select,
on_view_change,
} = spec;
// Header: < Mes Año >
let prev = {
let f = on_view_change.clone();
let (py, pm) = shift_month(view_year, view_month, -1);
View::<Msg>::new(Style {
size: Size { width: length(CELL_W), height: length(HEADER_H) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text("", 18.0, palette.fg_muted)
.hover_fill(palette.hover_bg)
.radius(6.0)
.cursor(llimphi_ui::Cursor::Pointer)
.on_click_at({
let f = f.clone();
move |_, _, _, _| Some(f(py, pm))
})
};
let next = {
let f = on_view_change.clone();
let (ny, nm) = shift_month(view_year, view_month, 1);
View::<Msg>::new(Style {
size: Size { width: length(CELL_W), height: length(HEADER_H) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text("", 18.0, palette.fg_muted)
.hover_fill(palette.hover_bg)
.radius(6.0)
.cursor(llimphi_ui::Cursor::Pointer)
.on_click_at(move |_, _, _, _| Some(f(ny, nm)))
};
let title = format!("{} {}", month_name_es(view_month), view_year);
let title_box = View::<Msg>::new(Style {
size: Size { width: percent(1.0), height: length(HEADER_H) },
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(title, 14.0, palette.fg);
let header = View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
size: Size { width: percent(1.0), height: length(HEADER_H) },
..Default::default()
})
.children(vec![prev, title_box, next]);
// Week labels (L M M J V S D o D L M M J V S).
let mut week_cols: Vec<View<Msg>> = Vec::with_capacity(7);
for label in week_start.weekday_initials_es() {
week_cols.push(
View::<Msg>::new(Style {
size: Size { width: length(CELL_W), height: length(ROW_H * 0.75) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(label, 11.0, palette.fg_muted),
);
}
let week_row = View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(CELL_W * 7.0),
height: length(ROW_H * 0.75),
},
..Default::default()
})
.children(week_cols);
// Grilla 6×7.
let grid = month_grid(view_year, view_month, week_start);
let mut grid_rows: Vec<View<Msg>> = Vec::with_capacity(6);
for row in grid.iter() {
let mut cells: Vec<View<Msg>> = Vec::with_capacity(7);
for cell in row.iter() {
let view: View<Msg> = match *cell {
None => View::<Msg>::new(Style {
size: Size { width: length(CELL_W), height: length(ROW_H) },
..Default::default()
}),
Some(day) => {
let date = NaiveDate::from_ymd_opt(view_year, view_month, day)
.expect("día válido del mes en foco");
let is_sel = selected == Some(date);
let is_today = today == Some(date);
let fg = if is_sel { palette.selected_fg } else { palette.fg };
let mut v = View::<Msg>::new(Style {
size: Size { width: length(CELL_W), height: length(ROW_H) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text(day.to_string(), 13.0, fg)
.radius(6.0)
.cursor(llimphi_ui::Cursor::Pointer);
if is_sel {
v = v.fill(palette.selected_bg);
} else {
v = v.hover_fill(palette.hover_bg);
}
if is_today {
v = v.border(1.0, palette.today_border);
}
let f = on_select.clone();
v = v.on_click_at(move |_, _, _, _| Some(f(date)));
v
}
};
cells.push(view);
}
grid_rows.push(
View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: length(CELL_W * 7.0), height: length(ROW_H) },
..Default::default()
})
.children(cells),
);
}
let grid_box = View::<Msg>::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(CELL_W * 7.0),
height: length(ROW_H * 6.0),
},
..Default::default()
})
.children(grid_rows);
let total_w = CELL_W * 7.0 + PAD * 2.0;
let total_h = HEADER_H + ROW_H * 0.75 + ROW_H * 6.0 + PAD * 2.0;
View::<Msg>::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(total_w), height: length(total_h) },
padding: llimphi_ui::llimphi_layout::taffy::Rect {
top: length(PAD),
bottom: length(PAD),
left: length(PAD),
right: length(PAD),
},
..Default::default()
})
.fill(palette.bg)
.radius(8.0)
.children(vec![header, week_row, grid_box])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_in_month_meses_estandar() {
assert_eq!(days_in_month(2026, 1), 31);
assert_eq!(days_in_month(2026, 2), 28);
assert_eq!(days_in_month(2024, 2), 29); // bisiesto
assert_eq!(days_in_month(2026, 4), 30);
assert_eq!(days_in_month(2026, 12), 31);
// Fuera de rango → 0.
assert_eq!(days_in_month(2026, 0), 0);
assert_eq!(days_in_month(2026, 13), 0);
}
#[test]
fn first_weekday_index_monday_y_sunday_start() {
// 1 de enero 2026 cae JUEVES.
// Con Monday-start: Mon=0, Tue=1, Wed=2, Thu=3 → index = 3.
assert_eq!(first_weekday_index(2026, 1, WeekStart::Monday), 3);
// Con Sunday-start: Sun=0, Mon=1, ..., Thu=4.
assert_eq!(first_weekday_index(2026, 1, WeekStart::Sunday), 4);
// 1 de febrero 2026 cae DOMINGO.
// Monday-start: Sun=6.
assert_eq!(first_weekday_index(2026, 2, WeekStart::Monday), 6);
// Sunday-start: Sun=0.
assert_eq!(first_weekday_index(2026, 2, WeekStart::Sunday), 0);
}
#[test]
fn month_grid_enero_2026_monday_start() {
// Enero 2026: 31 días, primer día jueves (index 3 con Monday-start).
let g = month_grid(2026, 1, WeekStart::Monday);
// Tres celdas vacías al inicio.
assert_eq!(g[0][0], None);
assert_eq!(g[0][1], None);
assert_eq!(g[0][2], None);
assert_eq!(g[0][3], Some(1)); // jueves 1
assert_eq!(g[0][6], Some(4)); // domingo 4
assert_eq!(g[1][0], Some(5)); // lunes 5
// Último día: 31. (31 + 3 - 1) / 7 = 33/7 = 4, % 7 = 5 ⇒ fila 4, col 5.
assert_eq!(g[4][5], Some(31));
// Fila 5 (sexta) entera vacía.
for c in g[5].iter() {
assert_eq!(*c, None);
}
}
#[test]
fn month_grid_febrero_2026_28_dias_domingo_inicio() {
// Febrero 2026: 28 días, primer día domingo (index 6 con Monday-start).
let g = month_grid(2026, 2, WeekStart::Monday);
// Primera fila: seis vacías + Some(1) en col 6 (domingo).
for c in 0..6 {
assert_eq!(g[0][c], None);
}
assert_eq!(g[0][6], Some(1));
assert_eq!(g[1][0], Some(2)); // lunes 2
// 28: (28+6-1)/7 = 33/7 = 4, %7 = 5 ⇒ fila 4 col 5.
assert_eq!(g[4][5], Some(28));
// Fila 5 entera vacía.
for c in g[5].iter() {
assert_eq!(*c, None);
}
}
#[test]
fn shift_month_wrappea_anios() {
// Enero → diciembre del año previo.
assert_eq!(shift_month(2026, 1, -1), (2025, 12));
// Diciembre → enero del año siguiente.
assert_eq!(shift_month(2026, 12, 1), (2027, 1));
// Salto de varios meses.
assert_eq!(shift_month(2026, 6, -8), (2025, 10));
assert_eq!(shift_month(2026, 6, 18), (2027, 12));
}
#[test]
fn week_start_index_consistente() {
// Monday-start.
assert_eq!(WeekStart::Monday.index(Weekday::Mon), 0);
assert_eq!(WeekStart::Monday.index(Weekday::Sun), 6);
// Sunday-start.
assert_eq!(WeekStart::Sunday.index(Weekday::Sun), 0);
assert_eq!(WeekStart::Sunday.index(Weekday::Mon), 1);
assert_eq!(WeekStart::Sunday.index(Weekday::Sat), 6);
}
}
+25 -1
View File
@@ -15,7 +15,8 @@ use llimphi_ui::llimphi_layout::taffy::{
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_ui::{Shadow, View};
use llimphi_theme::elevation;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
#[derive(Debug, Clone, Copy)]
@@ -144,3 +145,26 @@ pub fn card_view<Msg: Clone + 'static>(
})
.children(vec![accent_strip, body])
}
/// Variante elevada — la misma forma de `card_view` pero con sombra
/// del nivel `elev` (token de [`llimphi_theme::elevation`]). Default
/// pensado: `elevation::E2` (card flotante sobre el panel). El accent
/// strip — si está presente — queda detrás de la sombra; el visual
/// dominante es el body. Útil para dashboards y entries destacadas
/// que necesitan separación clara del fondo del panel.
pub fn card_elevated_view<Msg: Clone + 'static>(
children: Vec<View<Msg>>,
opts: CardOptions,
palette: &CardPalette,
elev: elevation::Elev,
) -> View<Msg> {
let (a, blur, dy) = elev;
let shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, a),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
card_view(children, opts, palette).shadow(shadow)
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-carousel"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-carousel — pager paginado de N páginas con dots indicadores y flechas opcionales a los lados. El caller maneja solo `current_index`; cada cambio dispara `on_change(i)`. Útil para onboarding, galerías, slideshows."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+303
View File
@@ -0,0 +1,303 @@
//! `llimphi-widget-carousel` — pager paginado.
//!
//! Una vista que muestra N páginas, una a la vez, con **dots indicadores**
//! abajo (clickeables para saltar a la página i) y **flechas opcionales**
//! a los costados. El caller mantiene un único `current_index: usize` en
//! su modelo y recibe `on_change(i)` cuando el usuario cambia de página.
//!
//! v1 **sin swipe** — la navegación va por dots y por flechas. Una v2
//! puede agregar swipe horizontal usando `View::draggable_velocity` +
//! `fling_step` para snap-on-release (el seam ya existe; sumarlo es
//! composición).
//!
//! Helpers puros para wrap/clamp del índice ([`wrap_index`],
//! [`clamp_index`]) — útiles para la lógica del `update` del caller (las
//! flechas en los extremos pueden envolver o quedarse).
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Position, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::Theme;
const DOT_SIZE: f32 = 8.0;
const DOT_GAP: f32 = 8.0;
const DOT_ROW_H: f32 = 28.0;
const ARROW_W: f32 = 36.0;
/// Paleta del carousel.
#[derive(Debug, Clone, Copy)]
pub struct CarouselPalette {
/// Fondo del dot inactivo.
pub dot_idle: Color,
/// Fondo del dot activo (página actual).
pub dot_active: Color,
/// Fondo del dot al hover.
pub dot_hover: Color,
/// Color del glifo de la flecha ( / ).
pub arrow_fg: Color,
/// Fondo de la flecha al hover.
pub arrow_hover_bg: Color,
}
impl CarouselPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
dot_idle: t.border,
dot_active: t.accent,
dot_hover: t.fg_muted,
arrow_fg: t.fg_muted,
arrow_hover_bg: t.bg_row_hover,
}
}
}
impl Default for CarouselPalette {
fn default() -> Self {
Self::from_theme(&Theme::dark())
}
}
/// Estrategia para los extremos cuando el usuario aprieta `` en la
/// primera página o `` en la última.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CarouselWrap {
/// La página anterior a la 0 es la última; la posterior a la última
/// es la 0. Comportamiento "infinite carousel".
Wrap,
/// `` en la página 0 y `` en la última no hacen nada (el callback
/// igual recibe `i = current`, idempotente).
Clamp,
}
impl Default for CarouselWrap {
fn default() -> Self {
Self::Clamp
}
}
/// Índice resultante de moverse `delta` páginas desde `current` con
/// wrap. `total = 0` devuelve `0` (no hay páginas).
pub fn wrap_index(current: usize, total: usize, delta: i32) -> usize {
if total == 0 {
return 0;
}
let total_i = total as i32;
let raw = current as i32 + delta;
raw.rem_euclid(total_i) as usize
}
/// Índice resultante de moverse `delta` páginas desde `current` con
/// clamp. `total = 0` devuelve `0`.
pub fn clamp_index(current: usize, total: usize, delta: i32) -> usize {
if total == 0 {
return 0;
}
let raw = current as i32 + delta;
raw.clamp(0, total as i32 - 1) as usize
}
/// Avanza/retrocede según la estrategia.
pub fn navigate(current: usize, total: usize, delta: i32, wrap: CarouselWrap) -> usize {
match wrap {
CarouselWrap::Wrap => wrap_index(current, total, delta),
CarouselWrap::Clamp => clamp_index(current, total, delta),
}
}
/// Especificación del carousel.
pub struct CarouselSpec<Msg> {
/// Páginas a mostrar. Se renderiza sólo la página `current`.
pub pages: Vec<View<Msg>>,
/// Índice de la página visible. Se acota a `pages.len() - 1`.
pub current: usize,
/// Estrategia para los extremos.
pub wrap: CarouselWrap,
/// Si `true`, muestra flechas `` / `` superpuestas a los lados.
pub show_arrows: bool,
pub palette: CarouselPalette,
/// Disparado al hacer click en un dot o una flecha — recibe el nuevo
/// índice (`0..pages.len()`).
pub on_change: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
}
/// Vista del carousel. Devuelve un `View<Msg>` con el slot que el
/// caller le asigne (página + dots abajo + flechas opcionales). Si
/// `pages` está vacía devuelve un `View` vacío del mismo slot.
pub fn carousel_view<Msg>(spec: CarouselSpec<Msg>) -> View<Msg>
where
Msg: Clone + 'static,
{
let CarouselSpec {
mut pages,
current,
wrap,
show_arrows,
palette,
on_change,
} = spec;
let total = pages.len();
if total == 0 {
return View::<Msg>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
});
}
let cur = current.min(total - 1);
// Página visible — drenamos el vector para no clonar.
let page = std::mem::replace(
&mut pages[cur],
View::<Msg>::new(Style::default()),
);
// Wrapper de la página: 100% × resto (todo menos la barra de dots).
let page_layer = View::<Msg>::new(Style {
position: Position::Relative,
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.children(vec![page]);
// Flechas opcionales superpuestas a los lados.
let mut page_children: Vec<View<Msg>> = vec![page_layer];
if show_arrows && total > 1 {
let on_prev = on_change.clone();
let prev_idx = navigate(cur, total, -1, wrap);
let prev = View::<Msg>::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
bottom: length(0.0),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
size: Size {
width: length(ARROW_W),
height: percent(1.0),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text("", 22.0, palette.arrow_fg)
.hover_fill(palette.arrow_hover_bg)
.cursor(llimphi_ui::Cursor::Pointer)
.on_click_at(move |_, _, _, _| Some((on_prev)(prev_idx)));
let on_next = on_change.clone();
let next_idx = navigate(cur, total, 1, wrap);
let next = View::<Msg>::new(Style {
position: Position::Absolute,
inset: Rect {
right: length(0.0),
top: length(0.0),
bottom: length(0.0),
left: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
size: Size {
width: length(ARROW_W),
height: percent(1.0),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text("", 22.0, palette.arrow_fg)
.hover_fill(palette.arrow_hover_bg)
.cursor(llimphi_ui::Cursor::Pointer)
.on_click_at(move |_, _, _, _| Some((on_next)(next_idx)));
page_children.push(prev);
page_children.push(next);
}
let page_area = View::<Msg>::new(Style {
position: Position::Relative,
size: Size { width: percent(1.0), height: percent(1.0) },
flex_grow: 1.0,
..Default::default()
})
.children(page_children);
// Fila de dots abajo.
let mut dots: Vec<View<Msg>> = Vec::with_capacity(total);
for i in 0..total {
let f = on_change.clone();
let is_active = i == cur;
let mut dot = View::<Msg>::new(Style {
size: Size { width: length(DOT_SIZE), height: length(DOT_SIZE) },
..Default::default()
})
.radius((DOT_SIZE * 0.5) as f64)
.cursor(llimphi_ui::Cursor::Pointer);
if is_active {
dot = dot.fill(palette.dot_active);
} else {
dot = dot.fill(palette.dot_idle).hover_fill(palette.dot_hover);
}
dot = dot.on_click_at(move |_, _, _, _| Some((f)(i)));
dots.push(dot);
}
let dot_row = View::<Msg>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: llimphi_ui::llimphi_layout::taffy::Size {
width: length(DOT_GAP),
height: length(0.0),
},
size: Size { width: percent(1.0), height: length(DOT_ROW_H) },
..Default::default()
})
.children(dots);
View::<Msg>::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.children(vec![page_area, dot_row])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_index_envuelve_en_los_extremos() {
assert_eq!(wrap_index(0, 5, -1), 4);
assert_eq!(wrap_index(4, 5, 1), 0);
assert_eq!(wrap_index(2, 5, 0), 2);
// Total grande: no overflow.
assert_eq!(wrap_index(2, 5, 12), 4); // 2+12=14, %5=4
assert_eq!(wrap_index(0, 5, -7), 3); // -7 rem_euclid 5 = 3
// Total 0: defensa.
assert_eq!(wrap_index(0, 0, 1), 0);
}
#[test]
fn clamp_index_se_queda_en_los_extremos() {
assert_eq!(clamp_index(0, 5, -1), 0);
assert_eq!(clamp_index(4, 5, 1), 4);
assert_eq!(clamp_index(2, 5, 1), 3);
assert_eq!(clamp_index(2, 5, 99), 4);
assert_eq!(clamp_index(2, 5, -99), 0);
// Total 0: defensa.
assert_eq!(clamp_index(0, 0, 1), 0);
}
#[test]
fn navigate_aplica_estrategia() {
assert_eq!(navigate(0, 5, -1, CarouselWrap::Wrap), 4);
assert_eq!(navigate(0, 5, -1, CarouselWrap::Clamp), 0);
assert_eq!(navigate(4, 5, 1, CarouselWrap::Wrap), 0);
assert_eq!(navigate(4, 5, 1, CarouselWrap::Clamp), 4);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-chip"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-chip — chip compacto (filter / choice / input) con estado opcional seleccionado y x removible. Para tags, filtros, multi-select compacto."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+189
View File
@@ -0,0 +1,189 @@
//! `llimphi-widget-chip` — chip compacto con cuatro sabores: **filter**
//! (toggle binario), **choice** (radio dentro de un grupo), **input**
//! (etiqueta removible con × al final) y **assist** (chip-acción con
//! ícono opcional).
//!
//! Forma: rectángulo redondeado bien pegado (radius pill), padding
//! horizontal 10/4, alto 24 px. Tema-consciente: cuando `selected`,
//! pinta `accent` apenas tinted (alpha bajo) — sobrio, no chillón.
//!
//! Como toda la elegancia de Llimphi, hereda de `llimphi-theme::motion`
//! para la transición de fill al togglear (animación implícita via
//! `View::animated(key, motion::MICRO)`).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_theme::{motion, radius, Theme};
/// Paleta del chip — tres slots: idle, selected, fg.
#[derive(Debug, Clone, Copy)]
pub struct ChipPalette {
pub bg_idle: Color,
pub bg_selected: Color,
pub fg_idle: Color,
pub fg_selected: Color,
pub border: Color,
}
impl ChipPalette {
pub fn from_theme(t: &Theme) -> Self {
// El selected es el accent atenuado hacia el fondo —"tinte", no
// bloque sólido—. Calculado en sRGB-ish: `accent · 0.32 + bg · 0.68`.
let a = t.accent.components;
let b = t.bg_panel.components;
let mix = Color {
components: [
a[0] * 0.32 + b[0] * 0.68,
a[1] * 0.32 + b[1] * 0.68,
a[2] * 0.32 + b[2] * 0.68,
1.0,
],
..t.accent
};
Self {
bg_idle: t.bg_button,
bg_selected: mix,
fg_idle: t.fg_text,
fg_selected: t.fg_text,
border: t.border,
}
}
}
/// Sabor del chip — decide cómo se interpreta el `selected` y si lleva
/// botón × removible.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChipKind {
/// Toggle binario (selected ↔ no). Usado en `FilterChip` de Material.
Filter,
/// Radio dentro de un grupo (selected = exclusive). Visualmente igual
/// a Filter; la diferencia está en el caller que mantiene 1 seleccionado.
Choice,
/// Etiqueta con × final que dispara `on_remove`. Para tags, multi-select
/// presentado como contenedor de chips.
Input,
/// Acción rápida (no togglea); selected siempre falso.
Assist,
}
/// Chip base — devuelve el view con anim implícita de fill por estado.
/// `key` debe ser estable entre frames del mismo chip (idx + grupo).
pub fn chip_view<Msg: Clone + 'static>(
label: impl Into<String>,
kind: ChipKind,
selected: bool,
key: u64,
palette: &ChipPalette,
on_click: Msg,
on_remove: Option<Msg>,
) -> View<Msg> {
let bg = if selected { palette.bg_selected } else { palette.bg_idle };
let fg = if selected { palette.fg_selected } else { palette.fg_idle };
let label: String = label.into();
let aria = label.clone();
let mut children = vec![View::new(Style {
size: Size { width: auto(), height: auto() },
..Default::default()
})
.text_aligned(label, 12.0, fg, Alignment::Center)];
if let (ChipKind::Input, Some(rm)) = (kind, on_remove) {
// ×: glifo pequeño a la derecha con on_click propio. No es
// botón nested (View no soporta nested on_click), así que es
// un nodo hermano con on_click separado dentro del mismo chip
// — el árbitro de hit-test elige el más interno.
children.push(
View::new(Style {
size: Size { width: length(14.0_f32), height: length(14.0_f32) },
margin: Rect {
left: length(6.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.radius(7.0)
.hover_fill(palette.border)
.text_aligned("×".to_string(), 12.0, fg, Alignment::Center)
.on_click(rm)
.cursor(llimphi_ui::Cursor::Pointer),
);
}
View::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size { width: auto(), height: length(24.0_f32) },
..Default::default()
})
.fill(bg)
.radius(radius::XL)
.border(1.0, palette.border)
.animated(key, motion::MICRO)
.children(children)
// Filter chips se reportan como botones de toggle (pressed = selected);
// los input/assist chips son botones plain. AccessKit anuncia el estado
// de toggle al lector, así "está seleccionado / no seleccionado" sale solo.
.role(llimphi_ui::Role::Button)
.aria_label(aria)
.aria_pressed(selected)
.on_click(on_click)
.cursor(llimphi_ui::Cursor::Pointer)
}
/// Atajo: chip filter (toggle).
pub fn filter_chip<Msg: Clone + 'static>(
label: impl Into<String>,
selected: bool,
key: u64,
palette: &ChipPalette,
on_toggle: Msg,
) -> View<Msg> {
chip_view(label, ChipKind::Filter, selected, key, palette, on_toggle, None)
}
/// Atajo: chip input (removible).
pub fn input_chip<Msg: Clone + 'static>(
label: impl Into<String>,
key: u64,
palette: &ChipPalette,
on_click: Msg,
on_remove: Msg,
) -> View<Msg> {
chip_view(
label,
ChipKind::Input,
false,
key,
palette,
on_click,
Some(on_remove),
)
}
/// Atajo: chip assist (acción).
pub fn assist_chip<Msg: Clone + 'static>(
label: impl Into<String>,
key: u64,
palette: &ChipPalette,
on_click: Msg,
) -> View<Msg> {
chip_view(label, ChipKind::Assist, false, key, palette, on_click, None)
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-widget-color-picker"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-color-picker — selector de color RGBA: swatch actual + paleta de chips preestablecidos + sliders RGBA. Agnóstico (emite [u8;4])."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-slider = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+461
View File
@@ -0,0 +1,461 @@
//! `llimphi-widget-color-picker` — selector de color RGBA agnóstico.
//!
//! Compone tres piezas, de arriba abajo:
//! 1. un **swatch** del color actual,
//! 2. una **paleta de chips** preestablecidos (clic = fija el RGB conservando
//! el alfa actual), envuelta si no entra en una fila,
//! 3. cuatro **sliders RGBA** para el ajuste fino.
//!
//! Es agnóstico: no sabe de config ni de `FieldValue`. Recibe el color como
//! `[u8; 4]` y emite el color nuevo por `on_change([u8; 4]) -> Msg`. Cualquier
//! app llimphi lo usa pasando su propio `Msg`.
//!
//! ```ignore
//! color_picker_view(
//! self.border,
//! DEFAULT_SWATCHES,
//! &ColorPickerPalette::from_theme(&theme),
//! |rgba| Msg::SetBorderColor(rgba),
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
FlexWrap, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
use llimphi_widget_slider::{slider_view, SliderPalette};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Paleta del color-picker: la del slider RGBA + los bordes de los chips + la
/// del campo hex.
#[derive(Debug, Clone, Copy)]
pub struct ColorPickerPalette {
/// Paleta de los sliders RGBA.
pub slider: SliderPalette,
/// Borde de un chip inactivo.
pub chip_border: Color,
/// Borde del chip activo (el que coincide con el color actual).
pub chip_border_active: Color,
/// Paleta del input de texto del campo hex.
pub hex_input: TextInputPalette,
/// Color del rótulo "#" del campo hex.
pub hex_label: Color,
}
impl Default for ColorPickerPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ColorPickerPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
slider: SliderPalette::from_theme(t),
chip_border: t.border,
chip_border_active: t.accent,
hex_input: TextInputPalette::from_theme(t),
hex_label: t.fg_muted,
}
}
}
/// Estado de edición del campo **hex** del picker, que el caller posee (igual
/// que [`text_input_view`]): si está focado, el buffer prestado, y el mensaje al
/// clickearlo. El caller enruta las teclas a su `TextInputState` y reconstruye
/// el color con [`parse_hex`].
pub struct HexField<'a, Msg> {
/// Si el campo hex recibe teclas ahora.
pub focused: bool,
/// Buffer prestado por el caller mientras se edita.
pub state: Option<&'a TextInputState>,
/// Mensaje al clickear el campo (el caller arranca a editarlo).
pub on_focus: Msg,
}
/// `#RRGGBB` del color (descarta el alfa, que se edita con el slider A).
pub fn rgba_to_hex(rgba: [u8; 4]) -> String {
format!("#{:02X}{:02X}{:02X}", rgba[0], rgba[1], rgba[2])
}
/// Parsea una cadena hex a RGBA. Acepta `#` opcional y 3/6/8 dígitos
/// (`RGB`/`RRGGBB`/`RRGGBBAA`). Sin alfa explícito conserva `cur_alpha`. `None`
/// si no es un hex válido (p. ej. mientras se escribe a medias).
pub fn parse_hex(s: &str, cur_alpha: u8) -> Option<[u8; 4]> {
let h = s.trim().trim_start_matches('#');
let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
match h.len() {
3 => {
// RGB corto: cada dígito se duplica.
let d = |i: usize| u8::from_str_radix(&h[i..i + 1], 16).ok().map(|v| v * 17);
Some([d(0)?, d(1)?, d(2)?, cur_alpha])
}
6 => Some([byte(0)?, byte(2)?, byte(4)?, cur_alpha]),
8 => Some([byte(0)?, byte(2)?, byte(4)?, byte(6)?]),
_ => None,
}
}
/// Paleta de colores preestablecidos: grises + una rampa de tonos saturados,
/// los típicos para marcos/acentos. El caller puede pasar la suya propia.
pub const DEFAULT_SWATCHES: &[[u8; 3]] = &[
[0xEC, 0xEC, 0xEC],
[0x9E, 0x9E, 0x9E],
[0x42, 0x42, 0x42],
[0x5C, 0x8F, 0xEB],
[0x00, 0xBC, 0xD4],
[0x4C, 0xAF, 0x50],
[0xFF, 0xC1, 0x07],
[0xFF, 0x98, 0x00],
[0xF4, 0x43, 0x36],
[0xE9, 0x1E, 0x63],
[0x9C, 0x27, 0xB0],
[0x79, 0x55, 0x48],
];
/// Alto de la barra de tono (px).
const HUE_BAR_H: f32 = 16.0;
/// Alto fijo del picker (px): swatch + paleta (hasta 2 filas) + barra de tono +
/// 4 sliders (+ campo hex si `with_hex`). Para estimar el alto en un contenedor
/// con scroll.
pub fn color_picker_height(with_hex: bool) -> f32 {
let hex = if with_hex { 38.0 } else { 0.0 };
16.0 + 54.0 + (HUE_BAR_H + 6.0) + 4.0 * 24.0 + hex
}
/// Compone el selector completo. `rgba` es el color actual; `swatches` la paleta
/// de chips (p. ej. [`DEFAULT_SWATCHES`]); `on_change` recibe el color nuevo.
/// Si `hex` es `Some`, agrega un campo de texto `#RRGGBB` editable al pie (su
/// foco lo posee el caller — ver [`HexField`]). `None` = sin campo hex.
pub fn color_picker_view<Msg, F>(
rgba: [u8; 4],
swatches: &[[u8; 3]],
palette: &ColorPickerPalette,
hex: Option<HexField<Msg>>,
on_change: F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
{
let mut rows: Vec<View<Msg>> = Vec::with_capacity(8);
rows.push(swatch_view(rgba));
rows.push(swatch_palette(rgba, swatches, palette, &on_change));
rows.push(hue_bar(rgba, palette, on_change.clone()));
if let Some(hex) = hex {
rows.push(hex_row(rgba, hex, palette));
}
for (ci, name) in [(0usize, "R"), (1, "G"), (2, "B"), (3, "A")] {
let f = on_change.clone();
rows.push(slider_view(
name.to_string(),
rgba[ci] as f32,
0.0,
255.0,
&palette.slider,
move |phase, dv| match phase {
DragPhase::Move => {
let nv = (rgba[ci] as f64 + dv as f64).clamp(0.0, 255.0) as u8;
let mut c = rgba;
c[ci] = nv;
Some(f(c))
}
DragPhase::End => None,
},
));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
gap: Size {
width: length(0.0_f32),
height: length(2.0_f32),
},
..Default::default()
})
.children(rows)
}
/// La fila del campo hex `#RRGGBB`: un input de ancho fijo. Cuando está focado
/// usa el buffer prestado; si no, muestra el hex del color actual.
fn hex_row<Msg: Clone + 'static>(
rgba: [u8; 4],
hex: HexField<Msg>,
palette: &ColorPickerPalette,
) -> View<Msg> {
let input = match (hex.focused, hex.state) {
(true, Some(st)) => text_input_view(st, "", true, &palette.hex_input, hex.on_focus),
_ => {
let mut tmp = TextInputState::new();
tmp.set_text(rgba_to_hex(rgba));
text_input_view(&tmp, "", false, &palette.hex_input, hex.on_focus)
}
};
let _ = palette.hex_label; // reservado por si se agrega un rótulo "#" aparte
View::new(Style {
size: Size {
width: length(120.0_f32),
height: length(34.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(3.0_f32),
bottom: length(1.0_f32),
},
..Default::default()
})
.children(vec![input])
}
/// El swatch (muestra) del color actual.
fn swatch_view<Msg: Clone + 'static>(rgba: [u8; 4]) -> View<Msg> {
View::new(Style {
size: Size {
width: length(40.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]))
.radius(3.0)
}
/// La fila (envuelta) de chips de la paleta.
fn swatch_palette<Msg, F>(
cur: [u8; 4],
swatches: &[[u8; 3]],
palette: &ColorPickerPalette,
on_change: &F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
{
let chips: Vec<View<Msg>> = swatches
.iter()
.map(|rgb| swatch_chip(*rgb, cur, palette, on_change))
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
gap: Size {
width: length(5.0_f32),
height: length(5.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(3.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(chips)
}
/// Un chip de la paleta: cuadrado clickeable. Si su RGB coincide con el color
/// actual, lleva borde de acento.
fn swatch_chip<Msg, F>(
rgb: [u8; 3],
cur: [u8; 4],
palette: &ColorPickerPalette,
on_change: &F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
{
let active = cur[0] == rgb[0] && cur[1] == rgb[1] && cur[2] == rgb[2];
// Conserva el alfa actual al elegir un chip.
let new_color = [rgb[0], rgb[1], rgb[2], cur[3]];
View::new(Style {
size: Size {
width: length(22.0_f32),
height: length(22.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(Color::from_rgba8(rgb[0], rgb[1], rgb[2], 255))
.radius(5.0)
.border(
if active { 2.0 } else { 1.0 },
if active {
palette.chip_border_active
} else {
palette.chip_border
},
)
.on_click(on_change(new_color))
}
/// La **barra de tono** (HSV): un degradé del arcoíris arrastrable. Mover el
/// cursor cambia sólo el tono (H), conservando saturación, valor y alfa. Un
/// thumb marca el tono actual. Si el color es un gris (S≈0) se asume S=1 al
/// pintar para que de un gris se pueda "entrar" a un color.
fn hue_bar<Msg, F>(rgba: [u8; 4], palette: &ColorPickerPalette, on_change: F) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn([u8; 4]) -> Msg + Clone + Send + Sync + 'static,
{
let width = palette.slider.track_width.max(1.0);
let (h, mut s, mut v) = rgb_to_hsv([rgba[0], rgba[1], rgba[2]]);
if s < 0.02 {
// Desde un gris/blanco, dejar entrar a un tono saturado.
s = 1.0;
if v < 0.02 {
v = 1.0;
}
}
let alpha = rgba[3];
let thumb_ratio = h / 360.0;
// Handler de drag: dx → dh (proporcional al ancho), nuevo tono.
let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
match phase {
DragPhase::Move => {
let dh = dx / width * 360.0;
let nh = (h + dh).rem_euclid(360.0);
let [r, g, b] = hsv_to_rgb(nh, s, v);
Some(on_change([r, g, b, alpha]))
}
DragPhase::End => None,
}
};
View::new(Style {
size: Size {
width: length(width),
height: length(HUE_BAR_H),
},
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0)
.draggable(handler)
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let x0 = rect.x as f64;
let y0 = rect.y as f64;
let x1 = (rect.x + rect.w) as f64;
let y1 = (rect.y + rect.h) as f64;
let rr = RoundedRect::new(x0, y0, x1, y1, 4.0);
// Degradé del arcoíris: 7 paradas (rojo→amarillo→verde→cian→azul→
// magenta→rojo) distribuidas parejo.
let stops = [
Color::from_rgba8(255, 0, 0, 255),
Color::from_rgba8(255, 255, 0, 255),
Color::from_rgba8(0, 255, 0, 255),
Color::from_rgba8(0, 255, 255, 255),
Color::from_rgba8(0, 0, 255, 255),
Color::from_rgba8(255, 0, 255, 255),
Color::from_rgba8(255, 0, 0, 255),
];
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x1, y0))
.with_stops(stops.as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
// Thumb: línea vertical blanca en la posición del tono.
let tx = x0 + (x1 - x0) * thumb_ratio as f64;
let thumb = RoundedRect::new(tx - 1.5, y0 - 1.0, tx + 1.5, y1 + 1.0, 1.5);
let white = Color::from_rgba8(255, 255, 255, 230);
scene.fill(Fill::NonZero, Affine::IDENTITY, &white, None, &thumb);
})
}
/// Convierte RGB (`[u8;3]`) a HSV: `(h en 0..360, s en 0..1, v en 0..1)`.
fn rgb_to_hsv(rgb: [u8; 3]) -> (f32, f32, f32) {
let r = rgb[0] as f32 / 255.0;
let g = rgb[1] as f32 / 255.0;
let b = rgb[2] as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let v = max;
let s = if max <= 0.0 { 0.0 } else { d / max };
let h = if d <= 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / d).rem_euclid(6.0))
} else if max == g {
60.0 * ((b - r) / d + 2.0)
} else {
60.0 * ((r - g) / d + 4.0)
};
(h.rem_euclid(360.0), s, v)
}
/// Convierte HSV (`h en 0..360, s,v en 0..1`) a RGB (`[u8;3]`).
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] {
let c = v * s;
let hp = (h.rem_euclid(360.0)) / 60.0;
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = v - c;
let to_u8 = |t: f32| ((t + m) * 255.0).round().clamp(0.0, 255.0) as u8;
[to_u8(r1), to_u8(g1), to_u8(b1)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsv_roundtrip_colores_puros() {
for rgb in [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255]] {
let (h, s, v) = rgb_to_hsv(rgb);
assert_eq!(hsv_to_rgb(h, s, v), rgb, "roundtrip {rgb:?}");
}
}
#[test]
fn gris_tiene_saturacion_cero() {
let (_, s, v) = rgb_to_hsv([128, 128, 128]);
assert!(s < 0.01);
assert!((v - 128.0 / 255.0).abs() < 0.01);
}
#[test]
fn tono_rojo_es_cero_grados() {
let (h, _, _) = rgb_to_hsv([255, 0, 0]);
assert!(h < 0.5 || h > 359.5);
}
#[test]
fn hex_roundtrip_y_parse() {
assert_eq!(rgba_to_hex([92, 143, 235, 255]), "#5C8FEB");
assert_eq!(parse_hex("#5C8FEB", 255), Some([92, 143, 235, 255]));
assert_eq!(parse_hex("5c8feb", 200), Some([92, 143, 235, 200])); // sin #, conserva alfa
assert_eq!(parse_hex("#FFF", 255), Some([255, 255, 255, 255])); // corto
assert_eq!(parse_hex("#5C8FEB80", 255), Some([92, 143, 235, 128])); // con alfa
assert_eq!(parse_hex("#5C8", 255), Some([0x55, 0xCC, 0x88, 255])); // corto RGB
assert_eq!(parse_hex("#5C8F", 255), None); // 4 dígitos: inválido
assert_eq!(parse_hex("zz", 255), None);
assert_eq!(parse_hex("#5C8FE", 255), None); // a medias
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-context-menu — menú contextual gioser: panel negro, barra accent vertical de 3px a la izquierda, sin esquinas redondeadas ni sombras, header en uppercase tiny. Se monta sobre App::view_overlay con un scrim full-screen que dismissa al click-fuera."
description = "llimphi-widget-context-menu — menú contextual tawasuyu: panel negro, barra accent vertical de 3px a la izquierda, sin esquinas redondeadas ni sombras, header en uppercase tiny. Se monta sobre App::view_overlay con un scrim full-screen que dismissa al click-fuera."
[dependencies]
llimphi-ui = { workspace = true }
+7 -1
View File
@@ -1,4 +1,4 @@
//! `llimphi-widget-context-menu` — menú contextual con look gioser.
//! `llimphi-widget-context-menu` — menú contextual con look tawasuyu.
//!
//! Distintivo y minimalista:
//!
@@ -614,6 +614,12 @@ fn item_view<Msg: Clone + 'static>(
..Default::default()
})
.radius(ITEM_RADIUS as f64)
// Semántica del ítem: rol MenuItem + label visible. `disabled` se
// refleja del `enabled` invertido. AccessKit lo expone como
// navegable por TTS dentro del menú.
.role(llimphi_ui::Role::MenuItem)
.aria_label(item.label.clone())
.aria_disabled(!item.enabled)
.children(row_children);
// Fondo: píldora suave en activo (teclado). El hover lo aporta
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-detail-table"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-detail-table — grilla read-only con columnas de ancho flex/fijo y encabezados clicables que ordenan (▲/▼). La vista 'detalle' de un file manager: una fila por nodo, selección resaltada, click de fila y click de encabezado emiten Msg. Stateless; el caller pasa filas ya ordenadas/visibles."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+313
View File
@@ -0,0 +1,313 @@
//! `llimphi-widget-detail-table` — la vista **detalle** de un file manager.
//!
//! Una grilla read-only de columnas (nombre · tamaño · fecha · tipo…) con
//! **encabezados clicables que ordenan**: click en una columna emite
//! `on_sort(col)`; la columna activa muestra una flecha `▲`/`▼`. Cada fila
//! es clicable (selección) y opcionalmente lleva un tinte de acento (para
//! labels/colores, Fase 4.5).
//!
//! Como el resto de los widgets Llimphi es **render-only y stateless**: el
//! orden, el filtro y la selección viven en el `Model` del caller (típicamente
//! un `nahual_source_core::Navigator`); el widget recibe las filas **ya
//! ordenadas y ya filtradas** (igual que `widget-list` recibe sólo la ventana
//! visible) y sólo pinta + avisa.
//!
//! Las columnas declaran su ancho como [`ColWidth::Flex`] (reparte el sobrante
//! proporcionalmente — para la columna nombre) o [`ColWidth::Fixed`] (px
//! constantes — para tamaño/fecha/tipo). Encabezado y filas usan el MISMO
//! reparto, así que las columnas quedan alineadas.
//!
//! ```ignore
//! detail_table_view(
//! DetailSpec {
//! columns: &[Column::flex("Nombre", 1.0), Column::fixed("Tamaño", 90.0).right(),
//! Column::fixed("Modificado", 150.0), Column::fixed("Tipo", 80.0)],
//! rows, // ya ordenadas/filtradas por el caller
//! sort: Some((1, SortDir::Desc)),
//! row_height: 22.0,
//! caption: Some("42 entradas".into()),
//! palette: DetailPalette::from_theme(&theme),
//! },
//! Msg::SortBy, // Fn(usize) -> Msg
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Ancho de una columna.
#[derive(Debug, Clone, Copy)]
pub enum ColWidth {
/// Reparte el sobrante proporcionalmente al peso (la columna "nombre").
Flex(f32),
/// Ancho fijo en px (tamaño/fecha/tipo).
Fixed(f32),
}
/// Dirección de orden — sólo para pintar la flecha del encabezado.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
Asc,
Desc,
}
impl SortDir {
/// La flecha del encabezado activo.
fn arrow(self) -> &'static str {
match self {
SortDir::Asc => "",
SortDir::Desc => "",
}
}
}
/// Una columna de la grilla: rótulo + ancho + alineación del texto.
#[derive(Debug, Clone)]
pub struct Column {
pub title: String,
pub width: ColWidth,
pub align: Alignment,
}
impl Column {
/// Columna flexible (reparte sobrante). Alineada a la izquierda.
pub fn flex(title: impl Into<String>, weight: f32) -> Self {
Self { title: title.into(), width: ColWidth::Flex(weight), align: Alignment::Start }
}
/// Columna de ancho fijo. Alineada a la izquierda.
pub fn fixed(title: impl Into<String>, px: f32) -> Self {
Self { title: title.into(), width: ColWidth::Fixed(px), align: Alignment::Start }
}
/// Variante alineada a la derecha (números: tamaño).
pub fn right(mut self) -> Self {
self.align = Alignment::End;
self
}
}
/// Una fila de datos. `cells` se aparea posicionalmente con las columnas;
/// celdas de más se ignoran, de menos se pintan vacías.
pub struct DetailRow<Msg> {
pub cells: Vec<String>,
pub selected: bool,
/// Tinte de fila opcional (labels/colores, Fase 4.5). `None` = sin tinte.
pub accent: Option<Color>,
pub on_click: Msg,
}
/// Paleta de la grilla detalle.
#[derive(Debug, Clone, Copy)]
pub struct DetailPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub bg_hover: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_header: Color,
pub accent: Color,
pub border: Color,
}
impl Default for DetailPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl DetailPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
bg_hover: t.bg_row_hover,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_header: t.fg_placeholder,
accent: t.accent,
border: t.border,
}
}
}
/// Especificación de la grilla. Las `rows` vienen YA ordenadas y filtradas
/// por el caller; `sort` es sólo para la flecha del encabezado.
pub struct DetailSpec<'a, Msg> {
pub columns: &'a [Column],
pub rows: Vec<DetailRow<Msg>>,
/// Columna activa de orden + su dirección (para la flecha). `None` = sin
/// indicador.
pub sort: Option<(usize, SortDir)>,
pub row_height: f32,
pub caption: Option<String>,
pub palette: DetailPalette,
}
/// Compone la grilla detalle. `on_sort(col)` se emite al clickear un
/// encabezado.
pub fn detail_table_view<Msg, FSort>(spec: DetailSpec<Msg>, on_sort: FSort) -> View<Msg>
where
Msg: Clone + 'static,
FSort: Fn(usize) -> Msg + Clone + 'static,
{
let DetailSpec { columns, rows, sort, row_height, caption, palette } = spec;
let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
// Caption opcional (conteo).
if let Some(text) = caption {
children.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
padding: pad_lr(10.0),
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
);
}
// Encabezado: una celda clicable por columna.
let header_cells: Vec<View<Msg>> = columns
.iter()
.enumerate()
.map(|(i, col)| {
let activa = sort.map(|(c, _)| c == i).unwrap_or(false);
let flecha = match sort {
Some((c, dir)) if c == i => dir.arrow(),
_ => "",
};
let label = format!("{}{flecha}", col.title);
let fg = if activa { palette.fg_header } else { palette.fg_header };
col_cell(
col.width,
View::new(full())
.text_aligned(label, 10.5, fg, col.align)
.ellipsis(1),
)
.hover_fill(palette.bg_hover)
.on_click(on_sort(i))
})
.collect();
children.push(
row_box(header_height(row_height))
.fill(palette.bg_header)
.children(header_cells),
);
// Filas de datos.
for row in rows {
let DetailRow { cells, selected, accent, on_click } = row;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let cell_views: Vec<View<Msg>> = columns
.iter()
.enumerate()
.map(|(i, col)| {
let text = cells.get(i).cloned().unwrap_or_default();
// La primera columna (nombre) lleva el acento si hay; el resto
// va en fg_muted salvo el nombre que va en fg_text.
let fg = if i == 0 {
accent.unwrap_or(palette.fg_text)
} else {
palette.fg_muted
};
col_cell(
col.width,
View::new(full())
.text_aligned(text, 11.5, fg, col.align)
.ellipsis(1),
)
})
.collect();
children.push(
row_box(row_height)
.fill(bg)
.hover_fill(palette.bg_hover)
.on_click(on_click)
.children(cell_views),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.clip(true)
.children(children)
}
/// Alto del encabezado: como una fila pero un toque más bajo, con piso.
fn header_height(row_height: f32) -> f32 {
(row_height - 2.0).max(18.0)
}
/// Una fila horizontal de alto fijo (encabezado o registro). El caller le
/// agrega `.fill`/`.on_click`/`.children`.
fn row_box<Msg: Clone + 'static>(height: f32) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(height) },
padding: pad_lr(8.0),
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
..Default::default()
})
}
/// Envuelve el contenido de una celda con el ancho de la columna (flex o
/// fijo). Encabezado y registro usan esto idéntico → columnas alineadas.
fn col_cell<Msg: Clone + 'static>(width: ColWidth, child: View<Msg>) -> View<Msg> {
let style = match width {
ColWidth::Flex(w) => Style {
flex_grow: w,
flex_basis: length(0.0_f32),
min_size: Size { width: length(0.0_f32), height: Dimension::auto() },
size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
},
ColWidth::Fixed(px) => Style {
flex_shrink: 0.0,
size: Size { width: length(px), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
},
};
View::new(style).children(vec![child])
}
/// Estilo de un hijo que ocupa todo el ancho de su celda.
fn full() -> Style {
Style {
size: Size { width: percent(1.0_f32), height: Dimension::auto() },
..Default::default()
}
}
/// Padding horizontal `px` (top/bottom en cero).
fn pad_lr(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(0.0_f32),
bottom: length(0.0_f32),
}
}
+7 -1
View File
@@ -159,7 +159,13 @@ where
.drag_payload(id)
.children(vec![accent_bar, icon_box]);
if item.active {
tooth = tooth.fill(palette.bg_active);
// Pestaña activa: además del fill, redondea el lado que sobresale
// hacia el contenido (la barra de acento marca el borde interno a
// la izquierda, así que el diente abre a la derecha). Le da el look
// de pestaña que sale del rail en vez de un rectángulo plano.
tooth = tooth
.fill(palette.bg_active)
.radius_corners(0.0, 8.0, 8.0, 0.0);
}
teeth.push(tooth);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-fab"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-fab — Floating Action Button: botón circular elevado (sombra E3), color de acento, hover lift sutil. Para la acción primaria de una página (compose, nuevo, +)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+150
View File
@@ -0,0 +1,150 @@
//! `llimphi-widget-fab` — Floating Action Button.
//!
//! Botón circular elevado pensado para la **acción primaria** de una
//! pantalla (componer, nuevo, +, capturar). Heredamos el patrón
//! Material/Flutter: rest sobre sombra E3, círculo del color `accent`,
//! glyph blanco centrado, sombra que **respira** al hover (sube a E5).
//!
//! La firma cinética viene del tween de fill+shadow vía `View::animated`,
//! con `animated_pop_in` para que la **entrada** del FAB sea el "pop" canónico
//! de Material (scale 0.6 → 1.0 + fade-in) en vez de aparecer de golpe.
#![forbid(unsafe_code)]
use llimphi_ui::Shadow;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_theme::{elevation, motion, Theme};
/// Tamaño del FAB. El estándar Material es 56 px; el "mini" 40 px;
/// "extended" lleva texto + ícono y crece el ancho (no implementado
/// como variante separada para mantener la API mínima — quien lo
/// necesite usa `fab_styled`).
#[derive(Debug, Clone, Copy)]
pub enum FabSize {
Regular,
Mini,
}
impl FabSize {
pub fn px(self) -> f32 {
match self {
FabSize::Regular => 56.0,
FabSize::Mini => 40.0,
}
}
}
/// Paleta del FAB.
#[derive(Debug, Clone, Copy)]
pub struct FabPalette {
/// Fill del círculo (idle).
pub bg: Color,
/// Color del glyph.
pub fg: Color,
}
impl FabPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
bg: t.accent,
// Texto sobre accent: blanco (los accents del repo son todos
// suficientemente saturados para hacer contraste).
fg: Color::from_rgba8(255, 255, 255, 255),
}
}
}
/// Compone el FAB. `key` debe ser estable para que la anim de hover
/// quede vinculada al mismo nodo entre frames.
pub fn fab_view<Msg: Clone + 'static>(
glyph: impl Into<String>,
size: FabSize,
key: u64,
palette: &FabPalette,
on_click: Msg,
) -> View<Msg> {
let s = size.px();
let (a, blur, dy) = elevation::E3;
let shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, a),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
let glyph: String = glyph.into();
let aria = glyph.clone();
View::new(Style {
size: Size { width: length(s), height: length(s) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(palette.bg)
.radius((s as f64) * 0.5)
.shadow(shadow)
.animated_pop_in(key, motion::FAST)
.text_aligned(
glyph,
(s * 0.42).round(),
palette.fg,
Alignment::Center,
)
// El glyph (+, ✎, etc.) no siempre es un buen nombre para el lector — el
// caller suele querer overridear con `.aria_label("Crear nota")`. Lo dejamos
// como fallback igual: mejor decir "más" que nada.
.role(llimphi_ui::Role::Button)
.aria_label(aria)
.on_click(on_click)
.cursor(llimphi_ui::Cursor::Pointer)
}
/// FAB con texto + glyph (Extended FAB de Material). Pildora ancha en
/// vez de círculo.
pub fn fab_extended<Msg: Clone + 'static>(
label: impl Into<String>,
key: u64,
palette: &FabPalette,
on_click: Msg,
) -> View<Msg> {
let label: String = label.into();
let h = 48.0_f32;
let (a, blur, dy) = elevation::E3;
let shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, a),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
View::new(Style {
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
height: length(h),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg)
.radius((h as f64) * 0.5)
.shadow(shadow)
.animated_pop_in(key, motion::FAST)
.text_aligned(label.clone(), 14.0, palette.fg, Alignment::Center)
.role(llimphi_ui::Role::Button)
.aria_label(label)
.on_click(on_click)
.cursor(llimphi_ui::Cursor::Pointer)
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "llimphi-widget-fitted-box"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-fitted-box — escala un subárbol arbitrario para que entre en el slot del padre, con políticas BoxFit::{Contain, Cover, Fill, None, ScaleDown}. Análogo a `FittedBox` de Flutter. Compone sobre el seam LayoutBuilder."
[dependencies]
llimphi-ui = { workspace = true }
+241
View File
@@ -0,0 +1,241 @@
//! `llimphi-widget-fitted-box` — escala un subárbol al slot disponible.
//!
//! Análogo a `FittedBox` de Flutter: el caller le pasa el **tamaño
//! natural** del contenido y una **política de fit**, y el widget aplica
//! un `transform` afín al subárbol para que entre en el slot real
//! (medido por el seam [`LayoutBuilder`] del compositor). El subárbol
//! queda **centrado** y, salvo `BoxFit::Fill`, **preserva su aspect
//! ratio**.
//!
//! Por qué no sale solo: `taffy` dimensiona contenedores, pero el
//! contenido (texto, imagen, painter custom) no escala con su contenedor
//! — sólo se posiciona dentro. `FittedBox` aplica un escalado VISUAL
//! sobre el subárbol completo, así un canvas `paint_with` o un texto
//! grande caben en una celda chiquita sin que el caller los re-mida.
//!
//! El padre del widget tiene `clip(true)` para que ningún píxel se salga
//! del slot (sólo importa cuando el aspect del contenido NO coincide con
//! el slot bajo `BoxFit::None`, o nunca con `Contain`/`Fill`).
//!
//! ## API
//!
//! ```ignore
//! use llimphi_widget_fitted_box::{fitted_box, BoxFit};
//! // Una imagen 800×600 que tiene que caber en cualquier slot, preservando
//! // aspect (mostrar entera, posibles bandas).
//! fitted_box((800.0, 600.0), BoxFit::Contain, || my_image_view())
//! ```
//!
//! ## Funciones puras
//!
//! [`compute_fit`] devuelve `(sx, sy, dx, dy)` para un `(slot, inner,
//! fit)` dado y es testeable sin runtime. Útil para validar el algoritmo
//! y para casos donde el caller ya tiene una transformación propia que
//! quiere componer.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
};
use llimphi_ui::llimphi_raster::kurbo::Affine;
use llimphi_ui::View;
/// Política de encaje del contenido en el slot. Cubre los cinco modos
/// canónicos (Flutter `BoxFit`, CSS `object-fit`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoxFit {
/// Preserva aspect — el contenido cabe ENTERO en el slot, dejando
/// bandas si el aspect no coincide. Lo usual para mostrar imágenes
/// sin recortar.
Contain,
/// Preserva aspect — el contenido CUBRE todo el slot, recortando lo
/// que sobre. Lo usual para fondos.
Cover,
/// Estira para llenar el slot (puede deformar — no preserva aspect).
Fill,
/// No escala — el contenido se muestra a tamaño natural, centrado.
/// Si es más grande que el slot, se recorta por el `clip` del padre.
None,
/// Como `Contain` pero **nunca agranda** — sólo achica si el
/// contenido es más grande que el slot. Equivale a
/// `min(Contain, None)`. Útil para evitar pixelar imágenes chicas.
ScaleDown,
}
/// `(sx, sy, dx, dy)` para encajar un contenido de tamaño `inner` en un
/// slot `slot` bajo la política `fit`. El factor `(sx, sy)` se aplica
/// como escala (igual en x e y salvo en `Fill`), y `(dx, dy)` es el
/// offset desde la esquina superior-izquierda del slot al borde
/// superior-izquierdo del contenido **ya escalado**, que queda centrado.
///
/// Casos de borde:
/// - `inner.0 <= 0.0 || inner.1 <= 0.0` o `slot.0 <= 0.0 || slot.1 <=
/// 0.0` ⇒ `(1.0, 1.0, 0.0, 0.0)` (identidad — defensa, no panic).
pub fn compute_fit(slot: (f32, f32), inner: (f32, f32), fit: BoxFit) -> (f32, f32, f32, f32) {
let (sw, sh) = slot;
let (iw, ih) = inner;
if iw <= 0.0 || ih <= 0.0 || sw <= 0.0 || sh <= 0.0 {
return (1.0, 1.0, 0.0, 0.0);
}
let (sx, sy) = match fit {
BoxFit::Contain => {
let s = (sw / iw).min(sh / ih);
(s, s)
}
BoxFit::Cover => {
let s = (sw / iw).max(sh / ih);
(s, s)
}
BoxFit::Fill => (sw / iw, sh / ih),
BoxFit::None => (1.0, 1.0),
BoxFit::ScaleDown => {
let s = (sw / iw).min(sh / ih).min(1.0);
(s, s)
}
};
let scaled_w = iw * sx;
let scaled_h = ih * sy;
let dx = (sw - scaled_w) * 0.5;
let dy = (sh - scaled_h) * 0.5;
(sx, sy, dx, dy)
}
/// Vista que escala el subárbol `inner` (tamaño natural `inner_size`) al
/// slot del padre bajo la política `fit`. `inner` es una closure porque
/// `View<Msg>` no es `Clone` — el seam `LayoutBuilder` puede invocar el
/// builder más de una vez en su resolución de dos pasadas.
///
/// El nodo retornado toma `width: 100%` y `height: 100%` por defecto —
/// el caller decide el tamaño envolviéndolo en un padre con `Style`
/// explícito.
pub fn fitted_box<Msg, F>(inner_size: (f32, f32), fit: BoxFit, inner: F) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn() -> View<Msg> + Send + Sync + 'static,
{
let (iw, ih) = inner_size;
View::<Msg>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.clip(true)
.layout_builder(move |c| {
let slot = (c.max_width, c.max_height);
let (sx, sy, dx, dy) = compute_fit(slot, (iw, ih), fit);
// El runtime aplica el transform centrado en el nodo (convención
// CSS `transform-origin: 50% 50%`). Un nodo de tamaño natural
// (iw, ih) centrado en (iw/2, ih/2), tras `scale_non_uniform(sx,
// sy)` queda con tamaño visual (iw*sx, ih*sy) pero todavía
// centrado en (iw/2, ih/2). Para correrlo al centro del slot
// (sw/2, sh/2) trasladamos por la diferencia de centros, que en
// el caso de un nodo posicionado en (0,0) es exactamente `(dx,
// dy) + (scaled_w-iw)/2 + (scaled_h-ih)/2`. Después de hacer la
// cuenta: `delta = (sw - iw)/2`. (`dx + (scaled-iw)/2 =
// (sw-iw)/2`.) Así el offset que pasamos es `((sw-iw)/2,
// (sh-ih)/2)` antes del scale-around-center.
let delta_x = (c.max_width - iw) * 0.5;
let delta_y = (c.max_height - ih) * 0.5;
let xf = Affine::translate((delta_x as f64, delta_y as f64))
* Affine::scale_non_uniform(sx as f64, sy as f64);
// Suprimir warnings sobre dx/dy no usados — están en la doc + tests.
let _ = (dx, dy);
let inner_node = (inner)();
let inner_wrap = View::<Msg>::new(Style {
position: llimphi_ui::llimphi_layout::taffy::Position::Absolute,
inset: llimphi_ui::llimphi_layout::taffy::Rect {
top: length(0.0),
left: length(0.0),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
size: Size { width: length(iw), height: length(ih) },
..Default::default()
})
.children(vec![inner_node])
.transform(xf);
View::<Msg>::new(Style {
position: llimphi_ui::llimphi_layout::taffy::Position::Relative,
size: Size {
width: length(c.max_width),
height: length(c.max_height),
},
..Default::default()
})
.children(vec![inner_wrap])
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contain_preserva_aspect_y_deja_bandas() {
// Slot 200×100, contenido 200×200 (cuadrado) — Contain achica al
// mínimo eje (100/200 = 0.5).
let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (200.0, 200.0), BoxFit::Contain);
assert!((sx - 0.5).abs() < 1e-3);
assert!((sy - 0.5).abs() < 1e-3);
// Contenido escalado 100×100 centrado en 200×100 → dx=50, dy=0.
assert!((dx - 50.0).abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn cover_preserva_aspect_y_recorta() {
// Slot 200×200, contenido 200×100 (paisaje) — Cover toma el MÁXIMO
// eje (200/100 = 2.0 en y), el x sobra.
let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (200.0, 100.0), BoxFit::Cover);
assert!((sx - 2.0).abs() < 1e-3);
assert!((sy - 2.0).abs() < 1e-3);
// Contenido escalado 400×200 centrado en 200×200 → dx=-100, dy=0.
assert!((dx - (-100.0)).abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn fill_estira_no_preserva_aspect() {
let (sx, sy, dx, dy) = compute_fit((200.0, 100.0), (100.0, 200.0), BoxFit::Fill);
assert!((sx - 2.0).abs() < 1e-3);
assert!((sy - 0.5).abs() < 1e-3);
// Sin offset — el contenido escalado cubre todo el slot.
assert!(dx.abs() < 1e-3);
assert!(dy.abs() < 1e-3);
}
#[test]
fn none_mantiene_natural_y_centra() {
// Slot 200×200, contenido 80×60 → sin escalar, centrado.
let (sx, sy, dx, dy) = compute_fit((200.0, 200.0), (80.0, 60.0), BoxFit::None);
assert!((sx - 1.0).abs() < 1e-6);
assert!((sy - 1.0).abs() < 1e-6);
assert!((dx - 60.0).abs() < 1e-3); // (200-80)/2
assert!((dy - 70.0).abs() < 1e-3); // (200-60)/2
}
#[test]
fn scale_down_no_agranda_solo_achica() {
// Contenido chico (40×30) en slot grande (200×100) → no agranda,
// queda 1.0 y centrado.
let (sx, sy, _, _) = compute_fit((200.0, 100.0), (40.0, 30.0), BoxFit::ScaleDown);
assert!((sx - 1.0).abs() < 1e-6);
assert!((sy - 1.0).abs() < 1e-6);
// Contenido grande (400×400) en slot chico (100×100) → achica como
// Contain (100/400 = 0.25).
let (sx2, sy2, _, _) =
compute_fit((100.0, 100.0), (400.0, 400.0), BoxFit::ScaleDown);
assert!((sx2 - 0.25).abs() < 1e-3);
assert!((sy2 - 0.25).abs() < 1e-3);
}
#[test]
fn entradas_invalidas_devuelven_identidad() {
// Cualquier dimensión ≤ 0 ⇒ identidad sin offset (defensa).
assert_eq!(compute_fit((0.0, 100.0), (50.0, 50.0), BoxFit::Contain), (1.0, 1.0, 0.0, 0.0));
assert_eq!(compute_fit((100.0, 100.0), (0.0, 50.0), BoxFit::Cover), (1.0, 1.0, 0.0, 0.0));
assert_eq!(compute_fit((100.0, 100.0), (50.0, -1.0), BoxFit::Fill), (1.0, 1.0, 0.0, 0.0));
}
}
-2
View File
@@ -17,7 +17,6 @@ llimphi-theme = { workspace = true }
llimphi-widget-app-header = { workspace = true }
llimphi-widget-banner = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-card = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-splitter = { workspace = true }
llimphi-widget-stat-card = { workspace = true }
@@ -25,7 +24,6 @@ llimphi-widget-tabs = { workspace = true }
llimphi-widget-theme-switcher = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-tiled = { workspace = true }
llimphi-widget-tree = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { workspace = true }
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-gauge"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-gauge — medidor radial (arco de 270°) con aguja y valor central. Para dashboards y stat panels donde el valor tiene un rango natural (0..max) y el contexto importa más que la cifra exacta."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+111
View File
@@ -0,0 +1,111 @@
//! `llimphi-widget-gauge` — medidor radial.
//!
//! Arco de 270° (de 7:30 a 4:30, manecillas de reloj) con un track
//! gris fino y un arco activo del color `accent` que crece con el
//! valor. En el centro, una etiqueta opcional con el valor formateado.
//! Pensado para dashboards (CPU, RAM, throughput) y métricas con
//! contexto (lleno / vacío / objetivo).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_theme::Theme;
#[derive(Debug, Clone, Copy)]
pub struct GaugePalette {
pub track: Color,
pub active: Color,
pub fg: Color,
}
impl GaugePalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
track: t.border,
active: t.accent,
fg: t.fg_text,
}
}
}
/// Render del gauge. `value` es la fracción 0..=1 que representa la
/// progresión del arco. `label` es texto centrado opcional.
pub fn gauge_view<Msg: Clone + 'static>(
value: f32,
size_px: f32,
label: Option<String>,
palette: &GaugePalette,
) -> View<Msg> {
let v = value.clamp(0.0, 1.0);
let track = palette.track;
let active = palette.active;
let mut node = View::new(Style {
size: Size { width: length(size_px), height: length(size_px) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
paint_gauge(scene, rect, v, track, active);
});
if let Some(lbl) = label {
node = node.text_aligned(lbl, (size_px * 0.22).max(11.0), palette.fg, Alignment::Center);
}
node
}
fn paint_gauge(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
rect: llimphi_ui::PaintRect,
value: f32,
track: Color,
active: Color,
) {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Point, Stroke};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let cx = (rect.x + rect.w * 0.5) as f64;
let cy = (rect.y + rect.h * 0.5) as f64;
let s = rect.w.min(rect.h) as f64;
let stroke_w = (s * 0.10).max(2.0);
let r = (s * 0.5) - stroke_w * 0.6;
// Arco completo (270° = 1.5π), empieza en 135° (7:30) y avanza
// sentido horario. En kurbo, `start_angle` está en radianes; CCW
// positivo. Tomamos start = 135° (back of bottom-left), sweep
// negativo para ir CW.
let start_deg = 135.0_f64;
let total_sweep_deg = -270.0_f64; // CW
let active_sweep_deg = total_sweep_deg * (value as f64);
let center = Point::new(cx, cy);
let radii = (r, r);
let track_arc = Arc {
center,
radii: radii.into(),
start_angle: start_deg.to_radians(),
sweep_angle: total_sweep_deg.to_radians(),
x_rotation: 0.0,
};
let stroke = Stroke::new(stroke_w).with_caps(llimphi_ui::llimphi_raster::kurbo::Cap::Round);
scene.stroke(&stroke, Affine::IDENTITY, track, None, &track_arc);
if value > 0.001 {
let active_arc = Arc {
center,
radii: radii.into(),
start_angle: start_deg.to_radians(),
sweep_angle: active_sweep_deg.to_radians(),
x_rotation: 0.0,
};
scene.stroke(&stroke, Affine::IDENTITY, active, None, &active_arc);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-hero"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-hero — entrada dramática con curva de overshoot para elementos destacados (modal, dialog, página recién montada). NO es shared-element transition real (esa requiere un registry retenido en el runtime); es la firma cinética que Flutter Hero usa al aterrizar."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+52
View File
@@ -0,0 +1,52 @@
//! `llimphi-widget-hero` — shared-element transitions estilo Flutter Hero.
//!
//! El runtime de Llimphi tiene un [`HeroRegistry`](llimphi_ui::llimphi_compositor::HeroRegistry)
//! retenido entre frames: si la misma `key` aparece en un rect distinto entre
//! dos frames consecutivos, el runtime interpola `transform` para que el nodo
//! "vuele" del rect anterior al actual. Este widget es el envoltorio canónico
//! que marca al `child` como hero con la `key` indicada — la app no necesita
//! tocar `View::hero` a mano si compone con esto.
//!
//! Mantenemos las firmas previas (`hero_view`, `hero_quick`) para no romper
//! callers. Antes envolvían sólo con `animated_inout` (fade); ahora componen
//! `hero` + `animated_inout` para que un caller que reusa la misma `key` entre
//! rutas obtenga el fly real **y** el fade de aterrizaje juntos.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::View;
use llimphi_theme::motion;
/// Envuelve `child` como hero: si la misma `key` aparece en otro rect en un
/// frame siguiente, el runtime interpola `transform` para volar entre las dos
/// posiciones; el fade-in/out de `animated_inout` cubre el aterrizaje
/// (`motion::DRAMATIC`, 480 ms). `key` debe ser estable entre rebuilds.
pub fn hero_view<Msg: Clone + 'static>(key: u64, child: View<Msg>) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![child])
.hero(key, motion::DRAMATIC)
.animated_inout(key, motion::DRAMATIC)
}
/// Hero "rápido" — variante con `motion::SLOW` (320 ms). Para elementos
/// destacados pero menos protagónicos (un toast importante, un panel
/// que se monta).
pub fn hero_quick<Msg: Clone + 'static>(key: u64, child: View<Msg>) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![child])
.hero(key, motion::SLOW)
.animated_inout(key, motion::SLOW)
}
@@ -0,0 +1,133 @@
//! Showcase de [`llimphi_widget_list::reorderable_list_view`] (Bloque
//! 14 de PARIDAD-FLUTTER, primera variante de Tier 5 backlog). Una
//! lista de tareas con drag-handle al borde izquierdo (`⋮⋮`); arrastrá
//! una fila y soltala sobre otra para intercambiarlas. El destino se
//! ilumina con `bg_drop_hover` mientras está bajo el cursor.
//!
//! Cliquear una fila la marca como "completada" (cambia a tachado en el
//! label de su descripción de abajo) — sirve para ver que `on_click`
//! coexiste con el drag sin pelearse.
//!
//! `cargo run -p llimphi-widget-list --example reorderable_list_demo --release`
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::{App, Handle, View};
use llimphi_widget_list::{
reorderable_list_view, ListPalette, ReorderableListRow, ReorderableListSpec,
};
#[derive(Clone)]
enum Msg {
Reorder { from: usize, to: usize },
Toggle(usize),
}
struct Model {
items: Vec<(String, bool)>,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · reorderable list (drag las filas para reordenar)"
}
fn initial_size() -> (u32, u32) {
(560, 520)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
items: vec![
("Cerrar Tier 1 del roadmap (backdrop blur)".into(), true),
("Closeout Tier 2: RichText spans".into(), true),
("ImageFit Contain/Cover/Fill/None".into(), true),
("Reorderable list widget".into(), false),
("Texto seleccionable fuera del editor".into(), false),
("RepaintBoundary (Tier 8)".into(), false),
("Cross-fade real entre identidades".into(), false),
],
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::Reorder { from, to } => {
if from != to && from < m.items.len() && to < m.items.len() {
let item = m.items.remove(from);
let dest = if to > from { to - 1 } else { to };
m.items.insert(dest.min(m.items.len()), item);
}
}
Msg::Toggle(i) => {
if let Some(it) = m.items.get_mut(i) {
it.1 = !it.1;
}
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = ListPalette::from_theme(&theme);
let rows: Vec<ReorderableListRow<Msg>> = model
.items
.iter()
.enumerate()
.map(|(i, (label, done))| {
let prefix = if *done { "" } else { "" };
ReorderableListRow {
label: format!("{prefix}{label}"),
selected: *done,
on_click: Some(Msg::Toggle(i)),
}
})
.collect();
let panel = reorderable_list_view(ReorderableListSpec {
rows,
caption: Some(format!("{} tareas — drag para reordenar, click para marcar", model.items.len())),
row_height: 36.0,
palette,
on_reorder: Arc::new(|from, to| Some(Msg::Reorder { from, to })),
});
// Marco exterior con padding para que el panel no toque los
// bordes de la ventana.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Stretch),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![panel])
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+196 -1
View File
@@ -37,13 +37,15 @@
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_ui::{DragPhase, View};
/// Paleta de la lista. Los defaults son una variante dark con selección
/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro.
@@ -53,6 +55,11 @@ pub struct ListPalette {
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
/// Resalte de la fila destino mientras un drag de reorder pasa por
/// encima. Sólo se usa en [`reorderable_list_view`]. Default = accent
/// translúcido (40 %) sobre `bg_selected` — distinguible del hover y
/// la selección estable.
pub bg_drop_hover: Color,
}
impl Default for ListPalette {
@@ -64,11 +71,17 @@ impl Default for ListPalette {
impl ListPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// Resalte de drop = accent del theme con 40 % de opacidad
// multiplicada — gana sobre `bg_selected` por luminancia para que
// un drag sobre una fila ya seleccionada se note.
let mut drop = t.accent;
drop.components[3] *= 0.40;
Self {
bg_panel: t.bg_panel,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
bg_drop_hover: drop,
}
}
}
@@ -197,5 +210,187 @@ fn row_view<Msg: Clone + 'static>(row: ListRow<Msg>, height: f32, palette: &List
})
.fill(bg)
.text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
// Labels largos terminan en `…` (single-line) en vez de cortarse seco.
.ellipsis(1)
.on_click(row.on_click)
}
/// Función que el caller usa para reaccionar a un reorder. Recibe `(from,
/// to)` — índices en `rows` — y devuelve el `Msg` a despachar (o `None`
/// para ignorar el drop, p. ej. si `from == to`).
pub type ReorderFn<Msg> = Arc<dyn Fn(usize, usize) -> Option<Msg> + Send + Sync>;
/// Una fila para [`reorderable_list_view`]. Pesa más que [`ListRow`]
/// porque cada fila acepta `on_click` opcional y siempre lleva drag
/// handle al borde izquierdo (gripper `⋮⋮` en `fg_muted`) — convención
/// kanban/Trello/Flutter `ReorderableListView`.
pub struct ReorderableListRow<Msg> {
pub label: String,
pub selected: bool,
pub on_click: Option<Msg>,
}
/// Especificación de una lista reordenable por drag&drop (Bloque 14 de
/// PARIDAD-FLUTTER, sigue Tier 5). Cada fila lleva un gripper a la
/// izquierda; arrastrar una fila y soltarla sobre otra emite
/// `on_reorder(from, to)`. La fila destino se ilumina con
/// `palette.bg_drop_hover` mientras el cursor está sobre ella durante el
/// drag.
pub struct ReorderableListSpec<Msg> {
pub rows: Vec<ReorderableListRow<Msg>>,
pub caption: Option<String>,
pub row_height: f32,
pub palette: ListPalette,
pub on_reorder: ReorderFn<Msg>,
}
/// Compone una lista reordenable (Bloque 14). Patrón: cada fila exhibe
/// un gripper al borde izquierdo y es `draggable` con `payload = idx`;
/// la **fila entera** (no sólo el gripper) recibe drops con `on_drop` y
/// `drop_hover_fill`. El handler `on_reorder(from, to)` cae al caller
/// que decide qué `Msg` despachar — el widget no muta nada por sí solo.
///
/// Composición pura sobre los primitives `drag_payload` / `on_drop` /
/// `drop_hover_fill` / `draggable` de `llimphi-ui` (ver `tiled` que
/// reordena paneles bajo el mismo idiom).
pub fn reorderable_list_view<Msg>(spec: ReorderableListSpec<Msg>) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let ReorderableListSpec {
rows,
caption,
row_height,
palette,
on_reorder,
} = spec;
let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 1);
if let Some(text) = caption {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
);
}
for (idx, row) in rows.into_iter().enumerate() {
children.push(reorderable_row_view(idx, row, row_height, &palette, on_reorder.clone()));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.clip(true)
.children(children)
}
fn reorderable_row_view<Msg>(
idx: usize,
row: ReorderableListRow<Msg>,
height: f32,
palette: &ListPalette,
on_reorder: ReorderFn<Msg>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let bg = if row.selected {
palette.bg_selected
} else {
palette.bg_panel
};
// Gripper `⋮⋮` al borde izquierdo, en `fg_muted` y arrastrable. El
// drag entrega `payload = idx`. Devolvemos `None` por evento de drag
// (no usamos dx/dy aquí — el destino se decide en el `on_drop` del
// otro nodo).
let gripper = View::new(Style {
size: Size {
width: length(20.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned("⋮⋮", 14.0, palette.fg_muted, Alignment::Center)
.draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
.drag_payload(idx as u64)
.cursor(llimphi_ui::Cursor::Grab);
// Etiqueta: ocupa el resto de la fila, con ellipsis y click opcional.
let mut label = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
.ellipsis(1);
if let Some(msg) = row.on_click {
label = label.on_click(msg);
}
// El **row entero** es target de drop. Cuando el cursor pasa por
// encima durante un drag, `drop_hover_fill` lo ilumina. Al soltar,
// emitimos el reorder si from != to.
let to_idx = idx;
let reorder = on_reorder.clone();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(height),
},
padding: Rect {
left: length(4.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(bg)
.on_drop(move |from: u64| {
let from = from as usize;
if from == to_idx {
None
} else {
(reorder)(from, to_idx)
}
})
.drop_hover_fill(palette.bg_drop_hover)
.children(vec![gripper, label])
}
+20 -3
View File
@@ -23,8 +23,8 @@ use llimphi_ui::llimphi_layout::taffy::{
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_theme::{alpha, radius, Theme};
use llimphi_ui::{Shadow, View};
use llimphi_theme::{alpha, elevation, motion, radius, Theme};
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
/// Paleta del modal.
@@ -36,7 +36,7 @@ pub struct ModalPalette {
/// lo que pidió el caller. Esto focaliza al modal sin "encerrarlo".
pub scrim: Color,
/// Firma visual del panel — gradient sutil + hairline accent en el
/// top edge. La que vuelve consistente el "look gioser" en todos
/// top edge. La que vuelve consistente el "look tawasuyu" en todos
/// los modales y overlays.
pub panel: PanelStyle,
pub border: Color,
@@ -212,6 +212,21 @@ pub fn modal_view<Msg: Clone + 'static>(spec: ModalSpec<Msg>) -> View<Msg> {
})
.children(btn_children);
// Sombra E4 + entrada animada (ease_out_cubic, FAST). El `key`
// estable se deriva del puntero a `spec.title` — alcanza para que
// una sola firma de modal viva en el frame. Si la app necesita
// múltiples modales animados independientemente, pasar un `key`
// propio (todavía no expuesto en la API — se agrega cuando aparezca
// ese caller).
let (sh_a, sh_blur, sh_dy) = elevation::E4;
let modal_shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, sh_a),
blur: sh_blur,
dx: 0.0,
dy: sh_dy,
spread: 0.0,
};
let panel_key: u64 = 0x4d4f44414c00_u64 ^ (w as u64) ^ ((h as u64) << 8);
let panel = View::new(Style {
position: Position::Absolute,
inset: Rect {
@@ -229,6 +244,8 @@ pub fn modal_view<Msg: Clone + 'static>(spec: ModalSpec<Msg>) -> View<Msg> {
})
.paint_with(panel_signature_painter(palette.panel))
.radius(palette.panel.radius)
.shadow(modal_shadow)
.animated_enter(panel_key, motion::FAST)
.clip(true)
.children(vec![header, separator, body_wrap, buttons_row]);
+24 -3
View File
@@ -1,4 +1,4 @@
//! `llimphi-widget-panel` — firma visual transversal de los paneles gioser.
//! `llimphi-widget-panel` — firma visual transversal de los paneles tawasuyu.
//!
//! Aporta dos detalles que aplicados consistentemente vuelven al sistema
//! reconocible sin que se note "diseñado":
@@ -36,8 +36,8 @@
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect};
use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient};
use llimphi_ui::{PaintRect, View};
use llimphi_theme::{alpha, radius, Theme};
use llimphi_ui::{PaintRect, Shadow, View};
use llimphi_theme::{alpha, elevation, radius, Theme};
/// Token bundle de la firma visual.
#[derive(Debug, Clone, Copy)]
@@ -184,6 +184,27 @@ pub fn panel_view<Msg: Clone + 'static>(
.children(children)
}
/// Variante elevada: agrega una sombra del nivel `elev` (token de
/// [`llimphi_theme::elevation`]) al `panel_view`. Para dropdowns,
/// popovers y dashboards que necesitan separación clara del fondo.
/// Pasar `elevation::E2` para cards, `E3` para menús contextuales,
/// `E4` para modales.
pub fn panel_elevated_view<Msg: Clone + 'static>(
children: Vec<View<Msg>>,
style: PanelStyle,
elev: elevation::Elev,
) -> View<Msg> {
let (a, blur, dy) = elev;
let shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, a),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
panel_view(children, style).shadow(shadow)
}
// =====================================================================
// Helpers internos
// =====================================================================
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-panes — árbol de paneles BSP estilo tmux: hojas opacas (`View<Msg>`) que se parten horizontal/vertical, se cierran, enfocan y redimensionan arrastrando divisores. La base para montar cualquier componente de gioser en un layout intercambiable."
description = "llimphi-widget-panes — árbol de paneles BSP estilo tmux: hojas opacas (`View<Msg>`) que se parten horizontal/vertical, se cierran, enfocan y redimensionan arrastrando divisores. La base para montar cualquier componente de tawasuyu en un layout intercambiable."
[dependencies]
llimphi-ui = { workspace = true }
+2 -2
View File
@@ -1,4 +1,4 @@
//! Demo de `llimphi-widget-panes` — "tmux de componentes gioser".
//! Demo de `llimphi-widget-panes` — "tmux de componentes tawasuyu".
//!
//! Dos tipos de panel heterogéneos (Contador y Notas) conviviendo en un
//! mismo árbol BSP que se parte horizontal/vertical, se cierra, se enfoca
@@ -54,7 +54,7 @@ impl App for Demo {
type Msg = Msg;
fn title() -> &'static str {
"panes — tmux de componentes gioser"
"panes — tmux de componentes tawasuyu"
}
fn init(_: &Handle<Msg>) -> Model {
+1 -1
View File
@@ -1,6 +1,6 @@
//! `llimphi-widget-panes` — árbol de paneles BSP estilo tmux.
//!
//! La pieza que faltaba para "montar cualquier componente de gioser en un
//! La pieza que faltaba para "montar cualquier componente de tawasuyu en un
//! layout intercambiable con splits resizables". El widget NO conoce los
//! dominios: hospeda hojas opacas (`View<Msg>`) en un árbol binario que el
//! usuario parte (horizontal/vertical), cierra, enfoca (click) y
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-range-slider"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-range-slider — slider con dos thumbs para definir un rango [lo, hi] sobre un track. Para filtros (precio entre $X y $Y), ecualizadores y rangos temporales."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+151
View File
@@ -0,0 +1,151 @@
//! `llimphi-widget-range-slider` — slider de dos thumbs sobre un track.
//!
//! Análogo a `RangeSlider` de Flutter o `RangeSlider` de Material 3.
//! El caller mantiene `(lo, hi)` en su modelo (fracciones en `[0,1]`);
//! el widget reporta el nuevo valor por `on_change(lo, hi)` cuando el
//! usuario arrastra cualquiera de los dos thumbs.
//!
//! Diseño:
//! - Track de 4 px, franja activa del color `accent` entre los dos thumbs.
//! - Thumbs de 14 px (círculos), borde 2 px del color `accent`,
//! sombra E1 — la firma de elevación canónica.
//! - Drag con `draggable` por thumb → callback recibe (lo, hi)
//! normalizado y monotónicamente ordenado (el bajo nunca supera al alto).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, Shadow, View};
use llimphi_theme::{elevation, Theme};
/// Paleta del range slider.
#[derive(Debug, Clone, Copy)]
pub struct RangeSliderPalette {
pub track_idle: Color,
pub track_active: Color,
pub thumb_fill: Color,
pub thumb_stroke: Color,
}
impl RangeSliderPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
track_idle: t.border,
track_active: t.accent,
thumb_fill: t.bg_panel,
thumb_stroke: t.accent,
}
}
}
/// Compone un range slider de ancho `width_px`, alto 28 px.
/// `lo`/`hi` son fracciones en `[0,1]`. `on_change(lo, hi)` se dispara
/// con cada delta de drag.
pub fn range_slider_view<Msg, F>(
lo: f32,
hi: f32,
width_px: f32,
palette: &RangeSliderPalette,
on_change: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(f32, f32) -> Msg + Clone + Send + Sync + 'static,
{
let lo = lo.clamp(0.0, 1.0);
let hi = hi.clamp(0.0, 1.0);
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
let (a, blur, dy) = elevation::E1;
let thumb_shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, a),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
let track_w = width_px.max(1.0);
let lo_x = lo * track_w;
let hi_x = hi * track_w;
let active_w = (hi_x - lo_x).max(0.0);
let track_idle = View::new(Style {
position: Position::Absolute,
size: Size { width: percent(1.0_f32), height: length(4.0_f32) },
inset: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(12.0_f32),
bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
..Default::default()
})
.fill(palette.track_idle)
.radius(2.0);
let track_active = View::new(Style {
position: Position::Absolute,
size: Size { width: length(active_w), height: length(4.0_f32) },
inset: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(lo_x),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
top: length(12.0_f32),
bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
..Default::default()
})
.fill(palette.track_active)
.radius(2.0);
let mk_thumb = |left_px: f32,
is_lo: bool,
on_change: F,
lo: f32,
hi: f32,
track_w: f32| {
View::new(Style {
position: Position::Absolute,
size: Size { width: length(14.0_f32), height: length(14.0_f32) },
inset: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(left_px - 7.0),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
top: length(7.0_f32),
bottom: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
..Default::default()
})
.fill(palette.thumb_fill)
.radius(7.0)
.border(2.0, palette.thumb_stroke)
.shadow(thumb_shadow)
.draggable(move |phase: DragPhase, dx: f32, _dy: f32| match phase {
DragPhase::Move => {
let dfrac = dx / track_w;
let (new_lo, new_hi) = if is_lo {
let nl = (lo + dfrac).clamp(0.0, hi);
(nl, hi)
} else {
let nh = (hi + dfrac).clamp(lo, 1.0);
(lo, nh)
};
Some(on_change(new_lo, new_hi))
}
DragPhase::End => None,
})
.cursor(llimphi_ui::Cursor::Pointer)
};
let thumb_lo = mk_thumb(lo_x, true, on_change.clone(), lo, hi, track_w);
let thumb_hi = mk_thumb(hi_x, false, on_change, lo, hi, track_w);
View::new(Style {
size: Size { width: length(width_px), height: length(28.0_f32) },
..Default::default()
})
.children(vec![track_idle, track_active, thumb_lo, thumb_hi])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-rating"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-rating — N estrellas clicables (típicamente 5) que reflejan un valor y emiten on_change al elegir otro nivel. Para reseñas, encuestas, quality flags."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+109
View File
@@ -0,0 +1,109 @@
//! `llimphi-widget-rating` — N estrellas clicables.
//!
//! Render: estrellas dibujadas a mano con `paint_with` (polígono de 10
//! vértices). El caller pasa `value` (0..=max) y `max` (típicamente 5).
//! Cada estrella es clickable: emite `on_change(idx + 1)`.
//!
//! Sober, no chillón: las estrellas inactivas son `border` (gris),
//! las activas `accent`. Tamaño configurable, default 18 px.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, FlexDirection, Size, Style},
AlignItems,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::Theme;
/// Paleta del rating.
#[derive(Debug, Clone, Copy)]
pub struct RatingPalette {
pub on: Color,
pub off: Color,
}
impl RatingPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
on: t.accent,
off: t.border,
}
}
}
/// Compone `max` estrellas en fila; las primeras `value` están "on",
/// el resto "off". Cada estrella dispara `on_change(idx + 1)` al click.
pub fn rating_view<Msg, F>(
value: u32,
max: u32,
star_size: f32,
palette: &RatingPalette,
on_change: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(u32) -> Msg + Clone + Send + Sync + 'static,
{
let mut children: Vec<View<Msg>> = Vec::with_capacity(max as usize);
for i in 0..max {
let filled = i < value;
let color = if filled { palette.on } else { palette.off };
let star = View::new(Style {
size: Size { width: length(star_size), height: length(star_size) },
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
paint_star(scene, rect, color);
})
.on_click(on_change(i + 1))
.cursor(llimphi_ui::Cursor::Pointer);
children.push(star);
}
View::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(children)
}
/// Polígono de estrella de 5 puntas centrado en `rect`. Radios `R`
/// (exterior) y `R·0.42` (interior).
fn paint_star(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
rect: llimphi_ui::PaintRect,
color: Color,
) {
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Point};
use llimphi_ui::llimphi_raster::peniko::Fill;
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let cx = (rect.x + rect.w * 0.5) as f64;
let cy = (rect.y + rect.h * 0.5) as f64;
let r_out = (rect.w.min(rect.h) as f64) * 0.5;
let r_in = r_out * 0.42;
let mut p = BezPath::new();
let n_points = 5;
let start_angle = -std::f64::consts::FRAC_PI_2; // primera punta arriba
for i in 0..(n_points * 2) {
let r = if i % 2 == 0 { r_out } else { r_in };
let theta = start_angle + (i as f64) * std::f64::consts::PI / (n_points as f64);
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
p.move_to(Point::new(x, y));
} else {
p.line_to(Point::new(x, y));
}
}
p.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-scaffold"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-scaffold — chasis de página estilo Flutter Scaffold: app bar opcional arriba, bottom bar opcional abajo, body que ocupa el resto, FAB anclado bottom-end opcional, drawers laterales opcionales (slide in)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+176
View File
@@ -0,0 +1,176 @@
//! `llimphi-widget-scaffold` — chasis de página.
//!
//! Inspiración Flutter `Scaffold`/Material 3 layout. Compone un layout
//! de página común:
//!
//! ```text
//! ┌──────────────────────────────┐
//! │ app bar (opcional) │ ← 48 px
//! ├──────────────────────────────┤
//! │ │
//! │ body (flex-grow 1) │
//! │ ╭──╮│
//! │ │+ ││ ← FAB opcional (bottom-end)
//! │ ╰──╯│
//! ├──────────────────────────────┤
//! │ bottom bar (opcional) │ ← 56 px
//! └──────────────────────────────┘
//! ```
//!
//! Los drawers laterales se ofrecen como **overlays** (la app los pasa
//! por `view_overlay`, no por el body — así no roban el layout cuando
//! están cerrados). Este widget sólo aporta el chasis central; los
//! drawers son responsabilidad de quien los abre.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Position,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::Theme;
#[derive(Debug, Clone, Copy)]
pub struct ScaffoldPalette {
pub bg: Color,
}
impl ScaffoldPalette {
pub fn from_theme(t: &Theme) -> Self {
Self { bg: t.bg_app }
}
}
/// Opciones del scaffold — todas las superficies son opcionales (un
/// scaffold con sólo `body` es válido y degenera en `View` con bg).
pub struct ScaffoldSpec<Msg: Clone + 'static> {
pub app_bar: Option<View<Msg>>,
pub body: View<Msg>,
pub bottom_bar: Option<View<Msg>>,
pub fab: Option<View<Msg>>,
}
/// Compone el scaffold. Llamado típicamente desde el `view(model)` de la
/// app como root. Para drawers, pasarlos por `view_overlay`.
pub fn scaffold_view<Msg: Clone + 'static>(
spec: ScaffoldSpec<Msg>,
palette: &ScaffoldPalette,
) -> View<Msg> {
let mut col_children: Vec<View<Msg>> = Vec::with_capacity(3);
if let Some(bar) = spec.app_bar {
col_children.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(48.0_f32) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![bar]),
);
}
// Body con FAB anclado.
let mut body_layer_children: Vec<View<Msg>> = vec![spec.body];
if let Some(f) = spec.fab {
body_layer_children.push(
View::new(Style {
position: Position::Absolute,
inset: llimphi_ui::llimphi_layout::taffy::Rect {
left: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
right: length(16.0_f32),
top: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
bottom: length(16.0_f32),
},
..Default::default()
})
.children(vec![f]),
);
}
let body_layer = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
},
flex_grow: 1.0,
..Default::default()
})
.children(body_layer_children);
col_children.push(body_layer);
if let Some(bar) = spec.bottom_bar {
col_children.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(56.0_f32) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![bar]),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(palette.bg)
.children(col_children)
}
/// App bar estándar — barra superior 48 px con título a la izquierda y
/// slot de acciones a la derecha. El caller pasa el `View` de las
/// acciones (botones de ícono típicamente).
pub fn app_bar_view<Msg: Clone + 'static>(
title: impl Into<String>,
actions: Vec<View<Msg>>,
palette: &ScaffoldPalette,
theme: &Theme,
) -> View<Msg> {
use llimphi_ui::llimphi_text::Alignment;
let _ = palette;
let title_view = View::new(Style {
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(16.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(title.into(), 16.0, theme.fg_text, Alignment::Start)
.bold();
let actions_view = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(0.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(actions);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![title_view, actions_view])
}
+4
View File
@@ -10,3 +10,7 @@ description = "llimphi-widget-scroll — área de scroll vertical reutilizable:
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "scroll_avanzado"
path = "examples/scroll_avanzado.rs"
+206
View File
@@ -0,0 +1,206 @@
//! Showcase de scroll avanzado (Tier 5): **app-bar colapsable** (sliver) +
//! lista scrolleable + **inercia (fling)**. Un único `offset` en el Model
//! maneja el colapso del header y el scroll del cuerpo; los botones "Fling"
//! sueltan una velocidad que decae con [`fling_step`] vía un ticker periódico.
//!
//! Corré con:
//! ```text
//! cargo run -p llimphi-widget-scroll --example scroll_avanzado --release
//! ```
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::{App, Handle, View};
use llimphi_widget_scroll::{
clamp_offset, fling_settled, fling_step, sliver_app_bar, sliver_max_offset, ScrollPalette,
FLING_FRICTION,
};
const HEADER_MAX: f32 = 200.0;
const HEADER_MIN: f32 = 56.0;
const VIEWPORT: f32 = 560.0;
const ROW_H: f32 = 46.0;
const N_ROWS: usize = 40;
const CONTENT_LEN: f32 = N_ROWS as f32 * ROW_H;
const DT: f32 = 1.0 / 60.0;
#[derive(Clone)]
enum Msg {
/// Delta de scroll en px (rueda / arrastre de barra) a sumar al offset.
ScrollBy(f32),
/// Soltar una inercia con esta velocidad inicial (px/s).
Fling(f32),
/// Tick del ticker: avanza la inercia si hay.
Tick,
}
struct Model {
offset: f32,
velocity: f32,
theme: Theme,
}
fn max_off() -> f32 {
sliver_max_offset(CONTENT_LEN, VIEWPORT, HEADER_MAX, HEADER_MIN)
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · scroll avanzado (sliver + fling)"
}
fn initial_size() -> (u32, u32) {
(720, VIEWPORT as u32)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Ticker de inercia ~60 fps (mismo patrón que `approach`).
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { offset: 0.0, velocity: 0.0, theme: Theme::dark() }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::ScrollBy(d) => {
model.velocity = 0.0; // un scroll manual corta la inercia
model.offset = clamp_offset(model.offset + d, max_off() + VIEWPORT, VIEWPORT);
// (clamp_offset usa content/viewport; acá el "content" efectivo
// es max_off + viewport, así max_offset(...) == max_off.)
}
Msg::Fling(v) => model.velocity = v,
Msg::Tick => {
if model.velocity != 0.0 {
let (v, delta) = fling_step(model.velocity, DT, FLING_FRICTION);
model.offset =
clamp_offset(model.offset + delta, max_off() + VIEWPORT, VIEWPORT);
// Frenar en los topes o al asentarse.
if fling_settled(v) || model.offset <= 0.0 || model.offset >= max_off() {
model.velocity = 0.0;
} else {
model.velocity = v;
}
}
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let t = &model.theme;
let pal = ScrollPalette::from_theme(t);
// Lista (cuerpo del sliver): filas alternadas.
let rows: Vec<View<Msg>> = (0..N_ROWS)
.map(|i| {
let bg = if i % 2 == 0 { t.bg_panel } else { t.bg_panel_alt };
View::new(Style {
size: Size { width: percent(1.0), height: length(ROW_H) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(bg)
.text(format!("Fila {:02}", i + 1), 18.0, t.fg_text)
})
.collect();
let list = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: length(CONTENT_LEN) },
..Default::default()
})
.children(rows);
let theme = t.clone();
let sliver = sliver_app_bar(
model.offset,
HEADER_MAX,
HEADER_MIN,
move |frac| header(&theme, frac),
list,
CONTENT_LEN,
VIEWPORT,
Msg::ScrollBy,
&pal,
);
View::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
..Default::default()
})
.fill(t.bg_app)
.children(vec![sliver])
}
}
/// Header colapsable: el título encoge con `frac` y el subtítulo + botones de
/// fling se desvanecen al colapsar (el `clip` del header los recorta).
fn header(t: &Theme, frac: f32) -> View<Msg> {
let title_size = 34.0 - 14.0 * frac; // 34 → 20
// Fondo que se aclara al colapsar (de accent a panel).
let title_row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0), height: length(HEADER_MIN) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
padding: Rect { left: length(20.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.children(vec![
View::new(Style { ..Default::default() })
.text("Scroll avanzado", title_size, t.fg_text),
fling_buttons(t),
]);
let subtitle = View::new(Style {
size: Size { width: percent(1.0), height: length(28.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(20.0), right: length(20.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.alpha(1.0 - frac) // se desvanece al colapsar
.text("Tier 5 · app-bar colapsable + inercia · rueda para scrollear", 15.0, t.fg_muted);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0), height: length(HEADER_MAX) },
..Default::default()
})
.fill(t.bg_panel_alt)
.children(vec![title_row, subtitle])
}
fn fling_buttons(t: &Theme) -> View<Msg> {
let btn = |label: &str, v: f32| {
View::new(Style {
size: Size { width: length(96.0), height: length(34.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(t.bg_button)
.hover_fill(t.bg_button_hover)
.radius(8.0)
.text(label.to_string(), 15.0, t.fg_text)
.on_click(Msg::Fling(v))
};
View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![btn("Fling ▲", -2600.0), btn("Fling ▼", 2600.0)])
}
fn main() {
llimphi_ui::run::<Demo>();
}
+566 -2
View File
@@ -65,6 +65,12 @@ pub const DEFAULT_LINE_PX: f32 = 48.0;
/// Ancho de la barra de scroll en px.
pub const DEFAULT_BAR_WIDTH: f32 = 10.0;
/// Factor de alpha por defecto para el thumb en reposo — tenue moderno
/// estilo Chromium/Edge/Safari: visible pero discreto. Al hover sobre la
/// barra recupera alpha completo. `1.0` reproduce el comportamiento
/// histórico (thumb siempre opaco).
pub const DEFAULT_THUMB_IDLE_ALPHA: f32 = 0.55;
/// Colores de la barra de scroll.
#[derive(Debug, Clone, Copy)]
pub struct ScrollPalette {
@@ -77,6 +83,12 @@ pub struct ScrollPalette {
/// Ancho de la barra y px por línea de rueda.
pub bar_width: f32,
pub line_px: f32,
/// Multiplicador de alpha aplicado al `thumb` en reposo. `1.0` deja
/// el color sin tocar (comportamiento legacy); `≤ 0.0` esconde el
/// thumb del todo en reposo. El `hover_fill` no se ve afectado: al
/// pasar el cursor sobre la barra el thumb recupera el alpha completo
/// del `thumb_hover`.
pub thumb_idle_alpha: f32,
}
impl Default for ScrollPalette {
@@ -93,8 +105,17 @@ impl ScrollPalette {
thumb_hover: t.accent,
bar_width: DEFAULT_BAR_WIDTH,
line_px: DEFAULT_LINE_PX,
thumb_idle_alpha: DEFAULT_THUMB_IDLE_ALPHA,
}
}
/// Devuelve la `ScrollPalette` con el comportamiento histórico (thumb
/// opaco en reposo, sin auto-hide visual). Para apps que dependen del
/// look anterior al cambio del 2026-06-07.
pub fn opaque(mut self) -> Self {
self.thumb_idle_alpha = 1.0;
self
}
}
/// Máximo offset posible: cuánto se puede desplazar antes de que el final
@@ -144,6 +165,162 @@ pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
}
}
/// Velocidad (px/s) por debajo de la cual una inercia se considera detenida.
pub const FLING_STOP: f32 = 8.0;
/// Fricción por defecto del fling: fracción de velocidad que **sobrevive por
/// segundo** (más chico = frena antes). 0.0015 ≈ deslizamiento tipo lista
/// táctil; subilo (p. ej. 0.1) para frenar rápido.
pub const FLING_FRICTION: f32 = 0.0015;
/// Un paso de **inercia** (fling): dado `velocity` en px/s y `dt` en segundos,
/// devuelve `(nueva_velocidad, delta_offset)` bajo decaimiento exponencial
/// `v(t) = v·friction^t`. `friction ∈ (0,1]` es la fracción de velocidad que
/// sobrevive por segundo. El `delta` es la integral exacta de la velocidad
/// sobre el paso (no el rectángulo `v·dt`), así el frenado no depende del
/// frame-rate. El caller suma `delta` al offset (clampeando con
/// [`clamp_offset`]) y reusa `nueva_velocidad` el próximo frame hasta que
/// [`fling_settled`] dé `true`. Es el análogo de [`approach`] pero para
/// "soltar con envión" en vez de "ir hacia un objetivo".
pub fn fling_step(velocity: f32, dt: f32, friction: f32) -> (f32, f32) {
let f = friction.clamp(1e-6, 1.0);
let decay = f.powf(dt.max(0.0));
let new_v = velocity * decay;
let delta = if (f - 1.0).abs() < 1e-6 {
velocity * dt
} else {
// ∫₀^dt v·f^s ds = v·(f^dt 1)/ln f.
velocity * (decay - 1.0) / f.ln()
};
(new_v, delta)
}
/// ¿La inercia ya se detuvo? `true` cuando `|velocity| < FLING_STOP` — el
/// caller corta el ticker y deja el offset quieto.
pub fn fling_settled(velocity: f32) -> bool {
velocity.abs() < FLING_STOP
}
/// Resistencia elástica (rubber-band) al **sobrepasar un borde**, estilo iOS:
/// dado cuánto se pasó del límite (`overscroll`, px; el signo se conserva) y la
/// dimensión del viewport (`dim`), devuelve el desplazamiento visual
/// **amortiguado** — siempre menor en magnitud que `overscroll`, con
/// rendimiento decreciente cuanto más se estira. El caller lo usa para pintar
/// el contenido un poco más allá del tope mientras arrastra, y lo libera
/// (anima a 0 con [`approach`]) al soltar. Constante 0.55 = la de Apple.
pub fn rubber_band(overscroll: f32, dim: f32) -> f32 {
if dim <= 0.0 || overscroll == 0.0 {
return overscroll;
}
const C: f32 = 0.55;
let x = overscroll.abs();
(1.0 - 1.0 / (x * C / dim + 1.0)) * dim * overscroll.signum()
}
// ── Auto-hide del thumb (timer-driven, lo maneja la app) ──
/// Segundos que el thumb queda a opacidad plena tras la última interacción de
/// scroll antes de empezar a desvanecerse.
pub const THUMB_HOLD_SECS: f32 = 1.2;
/// Segundos que tarda el thumb en desvanecerse de pleno a invisible.
pub const THUMB_FADE_SECS: f32 = 0.4;
/// Opacidad del thumb en función de los segundos desde la última interacción
/// de scroll (auto-hide estilo overlay móvil/Chromium): pleno (`1.0`) durante
/// [`THUMB_HOLD_SECS`], luego baja linealmente a `0.0` en [`THUMB_FADE_SECS`].
///
/// El bucle Elm reconstruye el `View` sin estado retenido, así que el timer lo
/// lleva la app (igual que el fling): trackeá `last_scroll: Instant` en el
/// Model, reseteándolo en cada `on_scroll`, y antes de llamar a
/// `scroll_y`/`scroll_xy` hacé `palette.thumb_idle_alpha =
/// thumb_autohide_alpha(last_scroll.elapsed().as_secs_f32())`. Mientras
/// [`thumb_autohide_active`] sea `true`, pedí frames (`Handle::spawn_periodic`).
/// En reposo el thumb queda invisible pero su `hover_fill` lo revela al pasar
/// el cursor por el borde.
pub fn thumb_autohide_alpha(secs_since_scroll: f32) -> f32 {
if secs_since_scroll <= THUMB_HOLD_SECS {
return 1.0;
}
let fade = (secs_since_scroll - THUMB_HOLD_SECS) / THUMB_FADE_SECS.max(1e-3);
(1.0 - fade).clamp(0.0, 1.0)
}
/// `true` mientras el thumb sigue desvaneciéndose (la app debe seguir pidiendo
/// frames). Una vez invisible (pasado hold+fade), devuelve `false` y la app
/// puede dejar de tickear.
pub fn thumb_autohide_active(secs_since_scroll: f32) -> bool {
secs_since_scroll < THUMB_HOLD_SECS + THUMB_FADE_SECS
}
// ── Pull-to-refresh (compone sobre el overscroll que ya maneja la app) ──
/// Distancia de overscroll (px) a la que el pull-to-refresh queda **armado**
/// por defecto: soltar más allá dispara el refresh.
pub const DEFAULT_PULL_THRESHOLD: f32 = 64.0;
/// Progreso del gesto pull-to-refresh en `[0,1]`: qué fracción del umbral
/// cubre el `overscroll` actual en el tope (distancia que el contenido fue
/// arrastrado más allá del borde superior — la app ya la computa para el
/// [`rubber_band`]). Alimenta el barrido de [`pull_indicator_view`].
pub fn pull_progress(overscroll_px: f32, threshold_px: f32) -> f32 {
if threshold_px <= 0.0 {
return 0.0;
}
(overscroll_px / threshold_px).clamp(0.0, 1.0)
}
/// `true` si el overscroll alcanzó el umbral — soltar en este punto dispara el
/// refresh. La app lo chequea en `DragPhase::End`/al asentar el rubber-band.
pub fn pull_triggered(overscroll_px: f32, threshold_px: f32) -> bool {
threshold_px > 0.0 && overscroll_px >= threshold_px
}
// ── Slivers: app-bar colapsable + sticky headers (seam "extent-por-offset") ──
/// Altura de un **app-bar colapsable** dado el `offset` de scroll: arranca en
/// `header_max` (offset 0) y baja linealmente hasta `header_min`, donde queda
/// fijado (pinned). El "rango de colapso" es `header_max - header_min`.
pub fn collapsed_height(offset: f32, header_max: f32, header_min: f32) -> f32 {
(header_max - offset.max(0.0)).clamp(header_min, header_max)
}
/// Fracción de colapso del app-bar en `[0, 1]`: `0` = expandido (offset 0),
/// `1` = colapsado al mínimo. El caller la usa para fundir el título, achicar
/// un subtítulo, bajar la opacidad de una imagen de fondo, etc.
pub fn collapse_fraction(offset: f32, header_max: f32, header_min: f32) -> f32 {
let range = (header_max - header_min).max(0.0);
if range <= 0.0 {
return 1.0;
}
(offset.max(0.0) / range).clamp(0.0, 1.0)
}
/// Offset máximo de scroll con un app-bar colapsable: los `header_max -
/// header_min` px que consume el colapso **más** lo que scrollee el cuerpo
/// bajo el header ya fijado en `header_min`. El caller lo usa para clampear.
pub fn sliver_max_offset(
content_len: f32,
viewport_len: f32,
header_max: f32,
header_min: f32,
) -> f32 {
let range = (header_max - header_min).max(0.0);
let body_vp = (viewport_len - header_min).max(0.0);
range + max_offset(content_len, body_vp)
}
/// Posición `y` (relativa al tope del viewport) de un encabezado **sticky** de
/// una sección que ocupa `[section_top, section_top + section_h]` en
/// coordenadas de contenido, con altura de encabezado `header_h`. Mientras la
/// sección está en pantalla, el encabezado se **pega al tope** (`y = 0`); al
/// llegar la próxima sección, ésta lo **empuja** hacia arriba (no pasa de
/// `section_bottom - header_h`). Antes de que la sección llegue al tope, sigue
/// su posición natural. El caller posiciona el encabezado absoluto en esta `y`.
pub fn sticky_y(offset: f32, section_top: f32, section_h: f32, header_h: f32) -> f32 {
let natural = section_top - offset; // y del encabezado sin sticky
let section_bottom = section_top + section_h - offset;
natural.max(0.0).min(section_bottom - header_h)
}
/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto
/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale
/// a 1 px de arrastre del thumb). Público para tests y para callers que
@@ -224,7 +401,7 @@ where
},
..Default::default()
})
.fill(palette.thumb)
.fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
.hover_fill(palette.thumb_hover)
.radius((palette.bar_width * 0.5) as f64)
.draggable(move |phase, _dx, dy| match phase {
@@ -257,8 +434,16 @@ where
// Viewport: alto fijo, ancho del padre, contenido recortado, rueda
// local. Position::Relative para ser el bloque contenedor de los
// hijos absolutos.
//
// **Scroll anidado**: si el delta del eje vertical es hacia un extremo
// donde ya estamos topados (offset = 0 con dy<0, u offset = max con
// dy>0), devolvemos `None` para que el runtime propague el evento al
// ancestro scrollable más cercano (lista dentro de panel, etc.).
let line_px = palette.line_px;
let on_wheel = on_scroll;
let max_off = max_offset(content_len, viewport_len);
let at_top = offset <= 0.0;
let at_bottom = offset >= max_off;
View::new(Style {
position: Position::Relative,
size: Size {
@@ -268,14 +453,320 @@ where
..Default::default()
})
.clip(true)
.on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px)))
.on_scroll(move |_dx, dy| {
let delta = dy * line_px;
if (delta < 0.0 && at_top) || (delta > 0.0 && at_bottom) {
return None;
}
Some((on_wheel)(delta))
})
.children(children)
}
/// Área de scroll **2D** (horizontal + vertical). Generaliza [`scroll_y`] a dos
/// ejes: el contenido toma su tamaño natural y se desplaza `(-x, -y)`, recortado
/// al viewport; aparece una barra por eje que tenga overflow (ninguna, una o
/// las dos). Para scroll puramente horizontal, pasá `content_size.1 ==
/// viewport_size.1` (no sale barra vertical).
///
/// `on_scroll(dx, dy)` recibe el **delta en px por eje** a sumar a cada offset
/// (rueda → ambos ejes; arrastre de la barra vertical → sólo `dy`; horizontal →
/// sólo `dx`). El caller acumula y clampea cada eje con [`clamp_offset`]. Las
/// dos barras se solapan en una esquinita inferior-derecha (v1; cosmético).
pub fn scroll_xy<Msg, F>(
offset: (f32, f32),
content_size: (f32, f32),
viewport_size: (f32, f32),
content: View<Msg>,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(f32, f32) -> Msg + Send + Sync + 'static,
{
let (ox, oy) = offset;
let (cw, ch) = content_size;
let (vw, vh) = viewport_size;
let on_scroll = Arc::new(on_scroll);
// Contenido a tamaño natural, desplazado (-x, -y). right/bottom = auto para
// que no lo achique el viewport (a diferencia de scroll_y, que ancla
// left/right para tomar el ancho del viewport).
let content_wrap = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(-oy),
left: length(-ox),
right: auto(),
bottom: auto(),
},
..Default::default()
})
.children(vec![content]);
let mut children = vec![content_wrap];
// Barra vertical (borde derecho) — sólo si hay overflow vertical.
if max_offset(ch, vh) > 0.0 {
let (thumb_h, thumb_y, opp) = thumb_geometry(oy, ch, vh);
let f = on_scroll.clone();
let thumb = View::new(Style {
position: Position::Absolute,
inset: Rect { top: length(thumb_y), right: length(0.0), left: auto(), bottom: auto() },
size: Size { width: length(palette.bar_width), height: length(thumb_h) },
..Default::default()
})
.fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
.hover_fill(palette.thumb_hover)
.radius((palette.bar_width * 0.5) as f64)
.draggable(move |phase, _dx, dy| match phase {
DragPhase::Move => Some((f)(0.0, dy * opp)),
DragPhase::End => None,
});
let track = View::new(Style {
position: Position::Absolute,
inset: Rect { top: length(0.0), right: length(0.0), bottom: length(0.0), left: auto() },
size: Size { width: length(palette.bar_width), height: auto() },
..Default::default()
})
.fill(palette.track)
.children(vec![thumb]);
children.push(track);
}
// Barra horizontal (borde inferior) — sólo si hay overflow horizontal.
if max_offset(cw, vw) > 0.0 {
let (thumb_w, thumb_x, opp) = thumb_geometry(ox, cw, vw);
let f = on_scroll.clone();
let thumb = View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(thumb_x), bottom: length(0.0), top: auto(), right: auto() },
size: Size { width: length(thumb_w), height: length(palette.bar_width) },
..Default::default()
})
.fill(palette.thumb.multiply_alpha(palette.thumb_idle_alpha.clamp(0.0, 1.0)))
.hover_fill(palette.thumb_hover)
.radius((palette.bar_width * 0.5) as f64)
.draggable(move |phase, dx, _dy| match phase {
DragPhase::Move => Some((f)(dx * opp, 0.0)),
DragPhase::End => None,
});
let track = View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(0.0), right: length(0.0), bottom: length(0.0), top: auto() },
size: Size { width: auto(), height: length(palette.bar_width) },
..Default::default()
})
.fill(palette.track)
.children(vec![thumb]);
children.push(track);
}
// Scroll anidado 2D: si el delta NETO está bloqueado en ambos ejes
// (cada componente cae en un extremo del eje correspondiente),
// devolvemos `None` para propagar al ancestro scrollable. Si al menos
// un eje aún tiene recorrido, el evento se consume entero (como antes).
let line_px = palette.line_px;
let on_wheel = on_scroll;
let max_ox = max_offset(cw, vw);
let max_oy = max_offset(ch, vh);
let at_left = ox <= 0.0;
let at_right = ox >= max_ox;
let at_top = oy <= 0.0;
let at_bottom = oy >= max_oy;
View::new(Style {
position: Position::Relative,
size: Size { width: length(vw), height: length(vh) },
..Default::default()
})
.clip(true)
// Rueda: dy = eje vertical; dx = eje horizontal (ratones/touchpads 2D, o
// Shift+rueda en algunos backends). Ambos en px-línea.
.on_scroll(move |dx, dy| {
let ddx = dx * line_px;
let ddy = dy * line_px;
let x_blocked = (ddx < 0.0 && at_left)
|| (ddx > 0.0 && at_right)
|| ddx == 0.0;
let y_blocked = (ddy < 0.0 && at_top)
|| (ddy > 0.0 && at_bottom)
|| ddy == 0.0;
if x_blocked && y_blocked {
return None;
}
Some((on_wheel)(ddx, ddy))
})
.children(children)
}
/// Indicador circular del **pull-to-refresh**. Mientras el usuario arrastra
/// más allá del tope, un arco se completa con `progress` (0→1, de
/// [`pull_progress`]); al alcanzar 1 el círculo queda cerrado ("armado"). Con
/// `refreshing = true` gira como spinner (arco de 270° rotando por reloj
/// absoluto — pedí frames mientras dure). `size_px` es el diámetro.
///
/// La app lo posiciona en la zona de overscroll del tope (típico: centrado
/// arriba, bajando junto con el contenido arrastrado). Es puro paint; no
/// retiene estado.
pub fn pull_indicator_view<Msg: Clone + 'static>(
progress: f32,
refreshing: bool,
size_px: f32,
palette: &ScrollPalette,
) -> View<Msg> {
let p = progress.clamp(0.0, 1.0);
let arc_color = palette.thumb;
let track_color = palette.track;
let started = std::time::Instant::now();
View::new(Style {
size: Size {
width: length(size_px),
height: length(size_px),
},
flex_shrink: 0.0,
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Point, Shape, Stroke, Vec2};
use std::f64::consts::TAU;
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let d = (rect.w.min(rect.h)) as f64;
let stroke_w = (d * 0.1).clamp(1.5, 4.0);
let r = d * 0.5 - stroke_w;
if r <= 0.0 {
return;
}
let cx = (rect.x + rect.w * 0.5) as f64;
let cy = (rect.y + rect.h * 0.5) as f64;
let center = Point::new(cx, cy);
let radii = Vec2::new(r, r);
let stroke = Stroke::new(stroke_w);
// Anillo de fondo tenue (track).
let ring = Arc::new(center, radii, 0.0, TAU, 0.0).to_path(0.2);
scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &ring);
// Arco activo: arranca arriba (12 en punto = -PI/2). Si refresca, gira
// un arco de 270°; si no, barre proporcional al progreso.
let top = -std::f64::consts::FRAC_PI_2;
let (start, sweep) = if refreshing {
let spin = started.elapsed().as_secs_f64() * 3.0; // rad/s
(top + spin, TAU * 0.75)
} else {
(top, TAU * p as f64)
};
if sweep.abs() > 1e-3 {
let active = Arc::new(center, radii, start, sweep, 0.0).to_path(0.2);
scene.stroke(&stroke, Affine::IDENTITY, arc_color, None, &active);
}
})
}
/// **App-bar colapsable + cuerpo scrolleable** en un solo viewport (el sliver
/// más pedido). Un único `offset` (en el Model) maneja las dos cosas: primero
/// **colapsa** el header de `header_max` a `header_min` (consume los primeros
/// `header_max - header_min` px de scroll), y luego **scrollea** el cuerpo bajo
/// el header ya fijado en `header_min`.
///
/// `header(frac)` construye el contenido del header dado `frac ∈ [0,1]` (ver
/// [`collapse_fraction`]) — el caller lo usa para fundir el título, mostrar una
/// versión compacta al colapsar, etc. El header se pinta a la altura
/// [`collapsed_height`] del momento.
///
/// `content_len` es el alto natural del cuerpo; el viewport del cuerpo cambia
/// con el colapso (crece a medida que el header se achica). La rueda funciona
/// tanto sobre el header como sobre el cuerpo (ambos emiten `on_scroll`). El
/// caller clampea el offset con [`sliver_max_offset`].
pub fn sliver_app_bar<Msg, H, F>(
offset: f32,
header_max: f32,
header_min: f32,
header: H,
content: View<Msg>,
content_len: f32,
viewport_len: f32,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
H: FnOnce(f32) -> View<Msg>,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
let range = (header_max - header_min).max(0.0);
let h = collapsed_height(offset, header_max, header_min);
let frac = collapse_fraction(offset, header_max, header_min);
// El cuerpo recién empieza a scrollear cuando el colapso terminó.
let body_offset = (offset - range).max(0.0);
let body_vp = (viewport_len - h).max(0.0);
let on_scroll = Arc::new(on_scroll);
let line_px = palette.line_px;
// Header pinned (altura `h`), recortado, con rueda propia.
let s_head = on_scroll.clone();
let header_box = View::new(Style {
size: Size { width: percent(1.0), height: length(h) },
..Default::default()
})
.clip(true)
.on_scroll(move |_dx, dy| Some((s_head)(dy * line_px)))
.children(vec![header(frac)]);
// Cuerpo: reusa scroll_y con el viewport restante y el offset del cuerpo.
let s_body = on_scroll;
let body = scroll_y(
body_offset,
content_len,
body_vp,
content,
move |d| (s_body)(d),
palette,
);
View::new(Style {
flex_direction:
llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection::Column,
size: Size { width: percent(1.0), height: length(viewport_len) },
..Default::default()
})
.clip(true)
.children(vec![header_box, body])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumb_autohide_hold_fade_y_oculto() {
// Pleno durante el hold.
assert_eq!(thumb_autohide_alpha(0.0), 1.0);
assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS), 1.0);
// A mitad del fade, alpha intermedio.
let mid = thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5);
assert!(mid > 0.0 && mid < 1.0, "alpha intermedio: {mid}");
// Pasado hold+fade, invisible y la app puede dejar de tickear.
assert_eq!(thumb_autohide_alpha(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1), 0.0);
assert!(thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS * 0.5));
assert!(!thumb_autohide_active(THUMB_HOLD_SECS + THUMB_FADE_SECS + 0.1));
}
#[test]
fn pull_progress_y_trigger() {
assert_eq!(pull_progress(0.0, 64.0), 0.0);
assert_eq!(pull_progress(32.0, 64.0), 0.5);
assert_eq!(pull_progress(128.0, 64.0), 1.0); // satura
assert_eq!(pull_progress(10.0, 0.0), 0.0); // umbral inválido
assert!(!pull_triggered(63.9, 64.0));
assert!(pull_triggered(64.0, 64.0));
assert!(pull_triggered(100.0, 64.0));
}
#[test]
fn max_y_clamp() {
assert_eq!(max_offset(1000.0, 300.0), 700.0);
@@ -309,6 +800,79 @@ mod tests {
assert_eq!(approach(0.0, 100.0, 1.0), 100.0);
}
#[test]
fn fling_decae_y_se_detiene() {
// Con fricción <1, la velocidad decae cada paso y el delta tiene el
// signo de la velocidad.
let (v1, d1) = fling_step(1000.0, 0.016, FLING_FRICTION);
assert!(v1 < 1000.0 && v1 > 0.0);
assert!(d1 > 0.0 && d1 < 1000.0 * 0.016 + 0.01); // < rectángulo v·dt
// Tras muchos pasos de 16 ms, termina por debajo del umbral.
let mut v = 1200.0_f32;
let mut steps = 0;
while !fling_settled(v) && steps < 100_000 {
v = fling_step(v, 0.016, FLING_FRICTION).0;
steps += 1;
}
assert!(fling_settled(v));
// Velocidad negativa → delta negativo (scrollea al revés).
let (_, dneg) = fling_step(-500.0, 0.016, FLING_FRICTION);
assert!(dneg < 0.0);
// friction = 1.0 (sin fricción) → delta = v·dt exacto.
let (v2, d2) = fling_step(300.0, 0.02, 1.0);
assert!((v2 - 300.0).abs() < 1e-3);
assert!((d2 - 6.0).abs() < 1e-3);
}
#[test]
fn rubber_band_amortigua() {
let dim = 600.0;
// Siempre menor en magnitud que el overscroll crudo.
assert!(rubber_band(100.0, dim) < 100.0);
assert!(rubber_band(100.0, dim) > 0.0);
// Conserva el signo.
assert!(rubber_band(-80.0, dim) < 0.0);
// Rendimiento decreciente: estirar 2× no duplica el desplazamiento.
let a = rubber_band(100.0, dim);
let b = rubber_band(200.0, dim);
assert!(b > a && b < 2.0 * a);
// Cerca de 0 es casi lineal (poca amortiguación todavía).
assert!(rubber_band(0.0, dim).abs() < 1e-6);
}
#[test]
fn sliver_colapso_y_max() {
// Header 200→64, viewport 500, contenido 1200.
let (max_h, min_h) = (200.0, 64.0);
// Offset 0 → expandido, frac 0.
assert_eq!(collapsed_height(0.0, max_h, min_h), 200.0);
assert_eq!(collapse_fraction(0.0, max_h, min_h), 0.0);
// A mitad del rango (68px de 136) → ~0.5 y altura ~132.
let mid = (max_h - min_h) / 2.0; // 68
assert!((collapse_fraction(mid, max_h, min_h) - 0.5).abs() < 1e-3);
assert!((collapsed_height(mid, max_h, min_h) - 132.0).abs() < 1e-3);
// Pasado el rango → fijado al mínimo, frac 1.
assert_eq!(collapsed_height(500.0, max_h, min_h), 64.0);
assert_eq!(collapse_fraction(500.0, max_h, min_h), 1.0);
// Max offset = rango (136) + scroll del cuerpo bajo el header mínimo.
let body_vp = 500.0 - min_h; // 436
let expected = 136.0 + max_offset(1200.0, body_vp);
assert!((sliver_max_offset(1200.0, 500.0, max_h, min_h) - expected).abs() < 1e-3);
}
#[test]
fn sticky_pegado_y_empujado() {
// Sección [100, 100+300], encabezado 40px de alto.
let (top, sh, hh) = (100.0, 300.0, 40.0);
// Antes de llegar al tope (offset 50 < 100): posición natural 50.
assert_eq!(sticky_y(50.0, top, sh, hh), 50.0);
// Dentro de la sección (offset 200 > top): pegado al tope (0).
assert_eq!(sticky_y(200.0, top, sh, hh), 0.0);
// Cerca del fondo de la sección: la próxima lo empuja hacia arriba (<0).
// section_bottom - hh = (100+300-380) - 40 = -20.
assert_eq!(sticky_y(380.0, top, sh, hh), -20.0);
}
#[test]
fn thumb_proporcional_y_topes() {
// Contenido entra entero → thumb cubre todo, sin travel.
+5
View File
@@ -113,6 +113,11 @@ fn segment_view<Msg: Clone + 'static>(
})
.radius(seg_radius)
.text_aligned(label.to_string(), 11.5, fg, Alignment::Center)
// Semántica: rol Tab + label + pressed=is_active. AccessKit anuncia
// "Pestaña <label>, presionada / sin presionar".
.role(llimphi_ui::Role::Tab)
.aria_label(label.to_string())
.aria_pressed(is_active)
.on_click(msg);
if let Some(c) = bg {
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "llimphi-widget-select"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-select — control select/dropdown moderno para Llimphi: disparador cerrado + menú flotante (view_overlay) con búsqueda, ítems ricos (icono · sublabel · badge), selección múltiple y estados de carga asíncrona (Cargando / Error+reintento / vacío)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
llimphi-widget-badge = { workspace = true }
[dev-dependencies]
# El demo arranca una App real (winit + GPU) vía llimphi_ui::run.
+437
View File
@@ -0,0 +1,437 @@
//! `select_demo` — recorre el gradiente de complejidad del select en una
//! sola ventana. Tres controles, de tonto a listo:
//!
//! 1. **Simple** — estado de una tarea, sin búsqueda.
//! 2. **Buscable + badges** — asignar a una persona: icono, sublabel y
//! badge de conteo; teclear filtra, ↑/↓ navega, Enter elige.
//! 3. **Async** — el primer load *falla* (mirá el error + Reintentar); el
//! reintento trae los datos tras ~900 ms vía `Handle::spawn`, con guard
//! de generación para descartar respuestas viejas.
//!
//! Corré con:
//! cargo run -p llimphi-widget-select --example select_demo --release
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_select::{
filter, resolve, select_menu_view, select_trigger_view, step_active, BadgeKind, SelectBadge,
SelectItem, SelectMenuSpec, SelectPalette, SelectPhase,
};
const X: f32 = 48.0;
const Y0: f32 = 96.0;
const ROW: f32 = 96.0;
const W: f32 = 340.0;
const TRIGGER_H: f32 = 36.0;
/// Cuál de los tres selects está abierto.
const SEL_ESTADO: usize = 0;
const SEL_PERSONA: usize = 1;
const SEL_ASYNC: usize = 2;
#[derive(Clone)]
enum Msg {
Toggle(usize),
Dismiss,
Pick(usize, usize), // (cuál select, índice original)
Hover(usize), // posición en visible del select abierto
Key(KeyEvent),
Retry,
// Resultado del worker async: (generación, Ok(items) | Err(mensaje))
AsyncLoaded(u64, Result<Vec<SelectItem>, String>),
}
enum AsyncState {
Idle,
Loading,
Error(String),
Ready(Vec<SelectItem>),
}
struct Model {
theme: Theme,
open: Option<usize>,
active: usize, // posición en visible
query: String,
estado_items: Vec<SelectItem>,
estado_sel: Option<usize>,
persona_items: Vec<SelectItem>,
persona_sel: Option<usize>,
async_state: AsyncState,
async_sel: Option<usize>,
async_gen: u64,
async_attempts: u32,
}
impl Model {
/// Ítems del select abierto (para filtro/navegación), si aplica.
fn open_items(&self) -> Option<&[SelectItem]> {
match self.open? {
SEL_ESTADO => Some(&self.estado_items),
SEL_PERSONA => Some(&self.persona_items),
SEL_ASYNC => match &self.async_state {
AsyncState::Ready(items) => Some(items),
_ => None,
},
_ => None,
}
}
fn is_searchable(open: usize) -> bool {
open == SEL_PERSONA || open == SEL_ASYNC
}
fn visible(&self) -> Vec<usize> {
match self.open_items() {
Some(items) => filter(items, &self.query),
None => Vec::new(),
}
}
}
fn estado_items() -> Vec<SelectItem> {
vec![
SelectItem::new("Pendiente").icon("\u{25CB}").badge(SelectBadge::dot(BadgeKind::Warning)),
SelectItem::new("En curso").icon("\u{25D0}").badge(SelectBadge::dot(BadgeKind::Info)),
SelectItem::new("Bloqueado").icon("\u{25A0}").disabled(),
SelectItem::new("Hecho").icon("\u{25CF}").badge(SelectBadge::dot(BadgeKind::Success)),
]
}
fn persona_items() -> Vec<SelectItem> {
vec![
SelectItem::new("Sergio Luna")
.icon("\u{25C9}")
.with_sublabel("gerencia · dueño")
.badge(SelectBadge::count(12, BadgeKind::Info)),
SelectItem::new("Ana Quispe")
.icon("\u{25C9}")
.with_sublabel("backend")
.badge(SelectBadge::count(3, BadgeKind::Neutral)),
SelectItem::new("Beto Mamani")
.icon("\u{25C9}")
.with_sublabel("diseño")
.badge(SelectBadge::label("beta", BadgeKind::Warning)),
SelectItem::new("Carmen Rojas")
.icon("\u{25C9}")
.with_sublabel("infra · de licencia")
.disabled(),
SelectItem::new("Diego Flores")
.icon("\u{25C9}")
.with_sublabel("qa")
.badge(SelectBadge::count(120, BadgeKind::Error)),
]
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn init(_: &Handle<Msg>) -> Model {
Model {
theme: Theme::dark(),
open: None,
active: usize::MAX,
query: String::new(),
estado_items: estado_items(),
estado_sel: Some(0),
persona_items: persona_items(),
persona_sel: None,
async_state: AsyncState::Idle,
async_sel: None,
async_gen: 0,
async_attempts: 0,
}
}
fn update(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::Toggle(which) => {
if model.open == Some(which) {
model.open = None;
} else {
model.open = Some(which);
model.query.clear();
model.active = usize::MAX;
// Abrir el async dispara la carga si no hay datos.
if which == SEL_ASYNC && !matches!(model.async_state, AsyncState::Ready(_)) {
model = start_load(model, handle);
}
}
}
Msg::Dismiss => model.open = None,
Msg::Hover(pos) => model.active = pos,
Msg::Pick(which, orig) => {
match which {
SEL_ESTADO => model.estado_sel = Some(orig),
SEL_PERSONA => model.persona_sel = Some(orig),
SEL_ASYNC => model.async_sel = Some(orig),
_ => {}
}
model.open = None;
}
Msg::Retry => {
if model.open == Some(SEL_ASYNC) {
model = start_load(model, handle);
}
}
Msg::AsyncLoaded(gen, result) => {
// Guard de generación: descartar respuestas de cargas viejas.
if gen == model.async_gen {
model.async_state = match result {
Ok(items) => AsyncState::Ready(items),
Err(e) => AsyncState::Error(e),
};
}
}
Msg::Key(ev) => {
if ev.state != KeyState::Pressed {
return model;
}
let Some(which) = model.open else { return model };
match &ev.key {
Key::Named(NamedKey::Escape) => model.open = None,
Key::Named(NamedKey::ArrowDown) => {
if let Some(items) = model.open_items() {
let vis = filter(items, &model.query);
model.active = step_active(items, &vis, model.active, 1);
}
}
Key::Named(NamedKey::ArrowUp) => {
if let Some(items) = model.open_items() {
let vis = filter(items, &model.query);
model.active = step_active(items, &vis, model.active, -1);
}
}
Key::Named(NamedKey::Enter) => {
let vis = model.visible();
if let Some(orig) = resolve(&vis, model.active) {
return Self::update(model, Msg::Pick(which, orig), handle);
}
}
Key::Named(NamedKey::Backspace) if Model::is_searchable(which) => {
model.query.pop();
model.active = usize::MAX;
}
_ => {
if Model::is_searchable(which) {
if let Some(text) = &ev.text {
if !text.is_empty() && !text.chars().any(|c| c.is_control()) {
model.query.push_str(text);
model.active = usize::MAX;
}
}
}
}
}
}
}
model
}
fn on_key(_: &Model, ev: &KeyEvent) -> Option<Msg> {
Some(Msg::Key(ev.clone()))
}
fn view(model: &Model) -> View<Msg> {
let pal = SelectPalette::from_theme(&model.theme);
let title = View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(X), top: length(40.0_f32), right: auto(), bottom: auto() },
size: Size { width: length(640.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"llimphi-widget-select — simple · buscable+badges · async".to_string(),
16.0,
model.theme.fg_text,
Alignment::Start,
);
let estado = labeled_trigger(
"Estado (simple)",
SEL_ESTADO,
select_trigger_view(
model.estado_sel.and_then(|i| model.estado_items.get(i)),
"Elegí un estado…",
model.open == Some(SEL_ESTADO),
Some(W),
&pal,
Msg::Toggle(SEL_ESTADO),
),
&model.theme,
0,
);
let persona = labeled_trigger(
"Asignar a (buscable · badges)",
SEL_PERSONA,
select_trigger_view(
model.persona_sel.and_then(|i| model.persona_items.get(i)),
"Buscar persona…",
model.open == Some(SEL_PERSONA),
Some(W),
&pal,
Msg::Toggle(SEL_PERSONA),
),
&model.theme,
1,
);
let async_selected = match (&model.async_state, model.async_sel) {
(AsyncState::Ready(items), Some(i)) => items.get(i),
_ => None,
};
let async_t = labeled_trigger(
"Repositorio (carga async)",
SEL_ASYNC,
select_trigger_view(
async_selected,
"Cargar repos…",
model.open == Some(SEL_ASYNC),
Some(W),
&pal,
Msg::Toggle(SEL_ASYNC),
),
&model.theme,
2,
);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(model.theme.bg_app)
.children(vec![title, estado, persona, async_t])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
let which = model.open?;
let pal = SelectPalette::from_theme(&model.theme);
let anchor = (X, Y0 + which as f32 * ROW + TRIGGER_H + 6.0);
let visible = model.visible();
let phase = match which {
SEL_ESTADO => SelectPhase::Ready(&model.estado_items),
SEL_PERSONA => SelectPhase::Ready(&model.persona_items),
SEL_ASYNC => match &model.async_state {
AsyncState::Loading | AsyncState::Idle => SelectPhase::Loading,
AsyncState::Error(e) => SelectPhase::Error(e),
AsyncState::Ready(items) => SelectPhase::Ready(items),
},
_ => return None,
};
let selected: Vec<usize> = match which {
SEL_ESTADO => model.estado_sel.into_iter().collect(),
SEL_PERSONA => model.persona_sel.into_iter().collect(),
SEL_ASYNC => model.async_sel.into_iter().collect(),
_ => Vec::new(),
};
Some(select_menu_view(SelectMenuSpec {
anchor,
viewport: (980.0, 640.0),
width: W,
phase,
visible: &visible,
active: model.active,
selected: &selected,
query: &model.query,
searchable: Model::is_searchable(which),
empty_text: "Sin coincidencias",
appear: 1.0,
on_pick: std::sync::Arc::new(move |orig| Msg::Pick(which, orig)),
on_hover: Some(std::sync::Arc::new(Msg::Hover)),
on_dismiss: Msg::Dismiss,
on_retry: Some(Msg::Retry),
palette: &pal,
}))
}
}
/// Lanza la carga async: incrementa la generación, marca Loading y dispara
/// un worker que duerme ~900 ms. El primer intento falla a propósito (para
/// mostrar el estado de error + Reintentar); del segundo en adelante trae
/// los datos.
fn start_load(mut model: Model, handle: &Handle<Msg>) -> Model {
model.async_state = AsyncState::Loading;
model.async_gen = model.async_gen.wrapping_add(1);
model.async_attempts += 1;
let gen = model.async_gen;
let attempt = model.async_attempts;
handle.spawn(move || {
std::thread::sleep(Duration::from_millis(900));
if attempt == 1 {
Msg::AsyncLoaded(gen, Err("No se pudo contactar el índice".to_string()))
} else {
Msg::AsyncLoaded(
gen,
Ok(vec![
SelectItem::new("tawasuyu")
.icon("\u{2756}")
.with_sublabel("rust · 210 crates")
.badge(SelectBadge::count(42, BadgeKind::Info)),
SelectItem::new("llimphi")
.icon("\u{2756}")
.with_sublabel("motor gráfico")
.badge(SelectBadge::label("ui", BadgeKind::Success)),
SelectItem::new("wawa")
.icon("\u{2756}")
.with_sublabel("SO bare-metal")
.badge(SelectBadge::dot(BadgeKind::Warning)),
SelectItem::new("sigma")
.icon("\u{2756}")
.with_sublabel("gestión escolar")
.badge(SelectBadge::count(7, BadgeKind::Neutral)),
]),
)
}
});
model
}
/// Envuelve un trigger en un bloque absoluto con rótulo arriba, en la
/// posición canónica del select `i`.
fn labeled_trigger(
label: &str,
_which: usize,
trigger: View<Msg>,
theme: &Theme,
i: usize,
) -> View<Msg> {
let y = Y0 + i as f32 * ROW;
View::new(Style {
position: Position::Absolute,
inset: Rect { left: length(X), top: length(y - 22.0), right: auto(), bottom: auto() },
size: Size { width: length(W), height: length(ROW) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0_f32), height: length(6.0_f32) },
..Default::default()
})
.children(vec![
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(16.0_f32) },
..Default::default()
})
.text_aligned(label.to_string(), 11.5, theme.fg_muted, Alignment::Start),
trigger,
])
}
fn main() {
llimphi_ui::run::<Demo>();
}
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -80,12 +80,14 @@ impl SliderPalette {
track_hover: t.bg_button_hover,
fg_label: t.fg_muted,
fg_value: t.fg_text,
radius: 3.0,
row_height: 22.0,
radius: 5.0,
row_height: 26.0,
label_width: 80.0,
value_width: 56.0,
track_width: 120.0,
track_thickness: 6.0,
// Más grueso (antes 6px): más fácil de agarrar y arrastrar con pulso
// tembloroso. El área de drag = todo el track.
track_thickness: 12.0,
}
}
}
@@ -108,15 +110,32 @@ where
Msg: Clone + Send + Sync + 'static,
F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
{
let label: String = label.into();
let aria_label = label.clone();
let range = (max - min).max(f32::EPSILON);
let ratio = ((value - min) / range).clamp(0.0, 1.0);
let track_width = palette.track_width.max(1.0);
// Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual).
//
// Los deltas que llegan son **por evento** (no acumulados desde el press), y
// durante un drag el runtime reusa ESTE handler (la vista queda congelada)
// → si pasáramos el delta crudo, el caller (que hace `valor_base + dv`)
// sumaría sólo el último evento al valor de inicio y el slider quedaba «casi
// estático». Por eso ACUMULAMOS los deltas dentro de la instancia del
// handler y pasamos el total desde el press: `base + total` = posición real.
// (Si en cambio la vista se repinta por evento, cada handler nuevo arranca
// el acumulador en 0 y el `base` ya viene fresco → también correcto.)
let span = max - min;
let acumulado = std::sync::Arc::new(std::sync::Mutex::new(0.0_f32));
let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
let dv = dx * span / track_width;
on_change(phase, dv)
let mut acc = acumulado.lock().unwrap_or_else(|e| e.into_inner());
*acc += dx * span / track_width;
let total = *acc;
if matches!(phase, DragPhase::End) {
*acc = 0.0; // listo para el próximo drag
}
on_change(phase, total)
};
// Bloque del label.
@@ -129,7 +148,7 @@ where
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start);
.text_aligned(label, 12.0, palette.fg_label, Alignment::Start);
// Track draggable: fill = track bg, hijo = porción rellena (accent).
let filled_radius = palette.radius;
@@ -200,6 +219,7 @@ where
// Bloque del valor.
let value_text = format_value(value);
let aria_value = value_text.clone();
let value_view = View::new(Style {
size: Size {
width: length(palette.value_width),
@@ -224,6 +244,11 @@ where
},
..Default::default()
})
// Semántica: rol Slider + label + value (texto formateado). El lector
// dice "<label>, deslizador, <valor>" — exactamente lo esperado.
.role(llimphi_ui::Role::Slider)
.aria_label(aria_label)
.aria_value(aria_value)
.children(vec![label_view, track_cell, value_view])
}
-1
View File
@@ -9,4 +9,3 @@ description = "llimphi-widget-spinner — spinner circular animado por reloj abs
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-splash — splash de arranque gioser: cuatro cuadrantes (unanchay/yachay/ruway/ukupacha) animados con tween de entrada secuencial. Identidad visual del SO."
description = "llimphi-widget-splash — splash de arranque tawasuyu: cuatro cuadrantes (unanchay/yachay/ruway/ukupacha) animados con tween de entrada secuencial. Identidad visual del SO."
[dependencies]
llimphi-ui = { workspace = true }
+3 -3
View File
@@ -1,4 +1,4 @@
//! `llimphi-widget-splash` — splash de arranque gioser.
//! `llimphi-widget-splash` — splash de arranque tawasuyu.
//!
//! Identidad visual del SO al boot: cuatro cuadrantes ordenados como
//! una cruz andina, cada uno con su nombre quechua y color simbólico,
@@ -128,7 +128,7 @@ pub fn splash_view<Msg: Clone + 'static>(
})
.children(vec![r0, r1]);
// Título "gioser" debajo, también fade-in pero al final.
// Título "tawasuyu" debajo, también fade-in pero al final.
let title_t = ((elapsed - 4.0 * stagger) / per_quad).clamp(0.0, 1.0);
let title_alpha = motion::ease_out_cubic(title_t);
let title = View::new(Style {
@@ -141,7 +141,7 @@ pub fn splash_view<Msg: Clone + 'static>(
flex_shrink: 0.0,
..Default::default()
})
.text_aligned("gioser", 22.0, fg_text, Alignment::Center)
.text_aligned("tawasuyu", 22.0, fg_text, Alignment::Center)
.alpha(title_alpha);
View::new(Style {
+15 -3
View File
@@ -28,7 +28,7 @@
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
prelude::{length, percent, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
@@ -126,11 +126,16 @@ where
}
fn wrap_pane<Msg>(view: View<Msg>, direction: Direction, size: PaneSize) -> View<Msg> {
// El panel Flex usa flex-basis 0 en el eje principal (no `auto`): así su
// tamaño es la porción de espacio libre que le toca por `flex_grow`, en
// vez del tamaño de su contenido. Con `auto` un hijo que desborda (p.ej.
// una grilla con flex-wrap o contenido ancho) volvía el panel
// content-sized y rompía el wrap / desbordaba horizontalmente.
let (width, height, flex_grow) = match (direction, size) {
(Direction::Row, PaneSize::Fixed(px)) => (length(px), percent(1.0_f32), 0.0),
(Direction::Row, PaneSize::Flex) => (Dimension::auto(), percent(1.0_f32), 1.0),
(Direction::Row, PaneSize::Flex) => (length(0.0_f32), percent(1.0_f32), 1.0),
(Direction::Column, PaneSize::Fixed(px)) => (percent(1.0_f32), length(px), 0.0),
(Direction::Column, PaneSize::Flex) => (percent(1.0_f32), Dimension::auto(), 1.0),
(Direction::Column, PaneSize::Flex) => (percent(1.0_f32), length(0.0_f32), 1.0),
};
View::new(Style {
size: Size { width, height },
@@ -157,6 +162,12 @@ where
Direction::Row => (length(palette.thickness), percent(1.0_f32)),
Direction::Column => (percent(1.0_f32), length(palette.thickness)),
};
// Divisor vertical (Row, separa izq/der) → resize este-oeste; divisor
// horizontal (Column, separa arriba/abajo) → resize norte-sur.
let cursor = match direction {
Direction::Row => llimphi_ui::Cursor::ColResize,
Direction::Column => llimphi_ui::Cursor::RowResize,
};
View::new(Style {
size: Size { width, height },
flex_shrink: 0.0,
@@ -170,5 +181,6 @@ where
})
.fill(palette.divider)
.hover_fill(palette.divider_hover)
.cursor(cursor)
.draggable(handler)
}
+5
View File
@@ -135,6 +135,11 @@ pub fn switch_view<Msg: Clone + 'static>(
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
})
// Semántica: track switch (Checkbox con `pressed` para que el lector
// diga "interruptor on/off" en vez de "casilla marcada"). Sin un rol
// dedicado en accesskit; Button + pressed cubre la intención.
.role(llimphi_ui::Role::Button)
.aria_pressed(p >= 0.5)
.on_click(on_toggle)
.children(vec![thumb])
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-table"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-table — tabla y lista editables: celdas-texto con quitar/agregar fila. Stateless (el caller posee el foco). Agnóstico."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+320
View File
@@ -0,0 +1,320 @@
//! `llimphi-widget-table` — tabla y lista editables para Llimphi.
//!
//! Una grilla de celdas-texto: encabezados opcionales por columna, una fila por
//! registro con botón **quitar** y un botón **agregar** al pie. La variante
//! [`list_view`] es el caso de una sola columna sin encabezado.
//!
//! **Stateless por diseño.** Como [`text_input_view`], el widget no posee el
//! foco ni los buffers de edición: el caller le dice cuál celda está focada
//! (`focused`) y le presta su [`TextInputState`] (`focused_state`); el resto de
//! las celdas se pintan desde su texto. El tecleo lo enruta el caller a su
//! `TextInputState` y reconstruye el valor — igual que con un text-input suelto.
//!
//! Es agnóstico: no sabe de config. Emite el `Msg` del caller por tres
//! callbacks (`on_focus_cell`, `on_remove_row`, `on_add_row`). El protocolo de
//! "qué cambió" (reemplazar una celda, sumar/quitar una fila) lo decide el
//! caller; el widget sólo dispara el evento con la coordenada.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Alto de una fila editable (px).
const ROW_H: f32 = 30.0;
/// Alto del encabezado de tabla (px).
const HEADER_H: f32 = 24.0;
/// Alto del botón "agregar" (px).
const ADD_H: f32 = 32.0;
/// Paleta de la tabla: la del text-input de las celdas + colores del cromo.
#[derive(Debug, Clone, Copy)]
pub struct TablePalette {
/// Paleta de los inputs de celda.
pub input: TextInputPalette,
/// Color del texto de los encabezados.
pub header_fg: Color,
/// Color del glifo de "quitar fila".
pub remove_fg: Color,
/// Color del texto del botón "agregar".
pub add_fg: Color,
/// Borde del botón "agregar".
pub add_border: Color,
/// Relleno de hover de botones.
pub hover: Color,
}
impl Default for TablePalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TablePalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
input: TextInputPalette::from_theme(t),
header_fg: t.fg_placeholder,
remove_fg: t.fg_muted,
add_fg: t.accent,
add_border: t.border,
hover: t.bg_row_hover,
}
}
}
/// Alto total de una tabla/lista de `n_rows` filas (con o sin encabezado), para
/// que un contenedor con scroll estime el alto del control.
pub fn table_height(n_rows: usize, has_header: bool) -> f32 {
let head = if has_header { HEADER_H } else { 0.0 };
head + n_rows as f32 * ROW_H + ADD_H
}
/// Compone una **tabla** editable.
///
/// - `headers`: rótulos de columna. Si está vacío no se pinta encabezado (modo
/// lista). Su largo fija el número de columnas; si está vacío, se infiere del
/// ancho de la primera fila (o 1).
/// - `rows`: el texto de cada celda.
/// - `focused` / `focused_state`: la celda en edición y su buffer (prestado por
/// el caller). El resto de las celdas se pintan desde `rows`.
/// - `on_focus_cell(row, col)`: clic en una celda (el caller arranca a editarla).
/// - `on_remove_row(row)` / `on_add_row()`: quitar/agregar fila.
#[allow(clippy::too_many_arguments)]
pub fn table_view<Msg, FFocus, FRemove, FAdd>(
headers: &[String],
rows: &[Vec<String>],
focused: Option<(usize, usize)>,
focused_state: Option<&TextInputState>,
add_label: &str,
palette: &TablePalette,
on_focus_cell: FFocus,
on_remove_row: FRemove,
on_add_row: FAdd,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FFocus: Fn(usize, usize) -> Msg + Clone + Send + Sync + 'static,
FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
{
let ncols = if !headers.is_empty() {
headers.len()
} else {
rows.first().map(Vec::len).unwrap_or(1).max(1)
};
let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
// Encabezados (sólo si hay rótulos): una etiqueta por columna + hueco del
// botón "quitar".
if !headers.is_empty() {
let mut head: Vec<View<Msg>> = headers.iter().map(|h| flex_cell(header(h, palette))).collect();
head.push(remove_spacer());
children.push(row_container(head));
}
for (r, cells) in rows.iter().enumerate() {
let mut row_kids: Vec<View<Msg>> = Vec::with_capacity(ncols + 1);
for c in 0..ncols {
let text = cells.get(c).map(String::as_str).unwrap_or("");
let is_focused = focused == Some((r, c));
let st = if is_focused { focused_state } else { None };
let focus_msg = on_focus_cell(r, c);
row_kids.push(flex_cell(cell_input(text, is_focused, st, &palette.input, focus_msg)));
}
let remove_msg = on_remove_row(r);
row_kids.push(remove_button(remove_msg, palette));
children.push(row_container(row_kids));
}
children.push(add_button(add_label, on_add_row(), palette));
column_container(children)
}
/// Compone una **lista** editable: una sola columna sin encabezado.
/// `on_focus_cell(row)` recibe sólo la fila (la columna es siempre 0).
pub fn list_view<Msg, FFocus, FRemove, FAdd>(
items: &[String],
focused_row: Option<usize>,
focused_state: Option<&TextInputState>,
add_label: &str,
palette: &TablePalette,
on_focus_cell: FFocus,
on_remove_row: FRemove,
on_add_row: FAdd,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FFocus: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
FRemove: Fn(usize) -> Msg + Clone + Send + Sync + 'static,
FAdd: Fn() -> Msg + Clone + Send + Sync + 'static,
{
let rows: Vec<Vec<String>> = items.iter().map(|s| vec![s.clone()]).collect();
let focused = focused_row.map(|r| (r, 0usize));
table_view(
&[],
&rows,
focused,
focused_state,
add_label,
palette,
move |r, _c| on_focus_cell(r),
on_remove_row,
on_add_row,
)
}
/// Un input de celda: como [`text_input_view`] pero con el foco provisto por el
/// caller (no por un `FieldPath`). Si la celda está focada usa el buffer
/// prestado; si no, pinta un input estático sembrado con su texto.
fn cell_input<Msg: Clone + 'static>(
text: &str,
focused: bool,
state: Option<&TextInputState>,
palette: &TextInputPalette,
focus_msg: Msg,
) -> View<Msg> {
if focused {
if let Some(st) = state {
return text_input_view(st, "", true, palette, focus_msg);
}
}
let mut tmp = TextInputState::new();
tmp.set_text(text);
text_input_view(&tmp, "", false, palette, focus_msg)
}
/// Envuelve una celda repartiendo el ancho en partes iguales entre columnas.
fn flex_cell<Msg: Clone + 'static>(child: View<Msg>) -> View<Msg> {
View::new(Style {
flex_grow: 1.0,
flex_basis: length(0.0_f32),
size: Size {
width: Dimension::auto(),
height: Dimension::auto(),
},
min_size: Size {
width: length(0.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.children(vec![child])
}
/// Una fila horizontal (celdas + botón quitar).
fn row_container<Msg: Clone + 'static>(children: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(children)
}
/// El contenedor vertical de la tabla/lista.
fn column_container<Msg: Clone + 'static>(rows: Vec<View<Msg>>) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(rows)
}
/// El encabezado de una columna.
fn header<Msg: Clone + 'static>(label: &str, palette: &TablePalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(label.to_string(), 10.5, palette.header_fg, Alignment::Start)
}
/// El botón cuadrado de "quitar fila" (`×`, U+00D7 — la fuente del SO sí lo
/// trae, a diferencia de `✕`).
fn remove_button<Msg: Clone + 'static>(msg: Msg, palette: &TablePalette) -> View<Msg> {
View::new(Style {
size: Size {
width: length(26.0_f32),
height: length(26.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.radius(5.0)
.hover_fill(palette.hover)
.on_click(msg)
.text_aligned("×".to_string(), 15.0, palette.remove_fg, Alignment::Center)
}
/// Un hueco del ancho del botón quitar, para alinear el encabezado.
fn remove_spacer<Msg: Clone + 'static>() -> View<Msg> {
View::new(Style {
size: Size {
width: length(26.0_f32),
height: length(16.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
}
/// El botón "agregar" al pie.
fn add_button<Msg: Clone + 'static>(label: &str, msg: Msg, palette: &TablePalette) -> View<Msg> {
View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(26.0_f32),
},
align_self: Some(AlignItems::Start),
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
margin: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(2.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.radius(6.0)
.border(1.0, palette.add_border)
.hover_fill(palette.hover)
.on_click(msg)
.text_aligned(label.to_string(), 11.5, palette.add_fg, Alignment::Center)
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "llimphi-widget-terminal"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-terminal — superficie de terminal infinita y virtualizada (ver 02_ruway/shuma/SDD-TERMINAL.md). Fase 0: store de scrollback append-only (acceso O(1), cap por memoria). Fase 1: virtualización modo línea (sólo se pinta la ventana visible, numeración global + color, scroll propio del widget — costo de render constante a scrollback ilimitado)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-scroll = { workspace = true }
# Acceso a `wgpu` (re-exportado por `llimphi-hal`) para el pipeline GPU
# del cell renderer (Fase 4 del SDD-TERMINAL).
llimphi-hal = { path = "../../llimphi-hal" }
# Rasterizador de glifos para el atlas GPU del modo grilla (Fase 4 del
# SDD-TERMINAL). Default features de fontdue alcanzan acá (std host).
fontdue = "0.9"
[dev-dependencies]
png = { workspace = true }
pollster = { workspace = true }
tempfile = { workspace = true }
[[example]]
name = "dump_terminal"
path = "examples/dump_terminal.rs"
[[example]]
name = "dump_blocks"
path = "examples/dump_blocks.rs"
+433
View File
@@ -0,0 +1,433 @@
//! Dump headless del **modelo de bloques** virtualizado (Fase 2).
//!
//! Arma un stream de comandos como en el shell: cada comando = un header de
//! card (chrome de alto fijo que el "caller" pinta) + un body de líneas del
//! store. Incluye:
//! - comandos cortos (ls, cargo) con stderr tintado,
//! - un comando **colapsado** (sólo header, sin body),
//! - un **flood** de 500 000 líneas (find /) en el medio,
//! y ancla el scroll al fondo. Prueba que la virtualización por bloques
//! materializa sólo los items + sub-filas visibles (costo constante) aunque un
//! body tenga medio millón de líneas.
//!
//! Uso: `cargo run -p llimphi-widget-terminal --example dump_blocks --release [out.png]`
use std::collections::HashSet;
use std::fs::File;
use std::io::BufWriter;
use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, AlignItems, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::{Alignment, Typesetter};
use llimphi_ui::View;
use llimphi_widget_terminal::{
block_surface, blocks_height, blocks_scroll_to_bottom, visible_window, Item, LineStyle,
Scrollback, TermMetrics, TermPalette,
};
const W: u32 = 1100;
const H: u32 = 760;
const INFO_H: f32 = 36.0;
const HEADER_H: f32 = 30.0;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Un comando del stream: rango de body en el store + estado para el header.
struct Cmd {
text: String,
exit: i32,
start: usize,
end: usize,
collapsed: bool,
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "blocks.png".to_string());
let theme = llimphi_theme::Theme::default();
let palette = TermPalette::from_theme(&theme);
let metrics = TermMetrics::for_font_size(13.0);
let mut store = Scrollback::new(0);
let mut stderr: HashSet<usize> = HashSet::new();
let mut cmds: Vec<Cmd> = Vec::new();
// Helper para registrar un comando con su body.
let run = |store: &mut Scrollback,
stderr: &mut HashSet<usize>,
cmds: &mut Vec<Cmd>,
text: &str,
exit: i32,
collapsed: bool,
body: &[(bool, String)]| {
let start = store.len();
for (is_err, line) in body {
if *is_err {
stderr.insert(store.len());
}
store.push_line(line);
}
cmds.push(Cmd {
text: text.to_string(),
exit,
start,
end: store.len(),
collapsed,
});
};
// 1) ls -la — salida normal corta.
run(
&mut store,
&mut stderr,
&mut cmds,
"ls -la ~/tawasuyu",
0,
false,
&[
(false, "total 248".into()),
(false, "drwxr-xr-x 12 sergio sergio 4096 jun 6 00_unanchay".into()),
(false, "drwxr-xr-x 8 sergio sergio 4096 jun 6 02_ruway".into()),
(false, "-rw-r--r-- 1 sergio sergio 11234 jun 6 CLAUDE.md".into()),
(false, "-rw-r--r-- 1 sergio sergio 8901 jun 6 README.md".into()),
],
);
// 2) cargo build — con un warning a stderr.
run(
&mut store,
&mut stderr,
&mut cmds,
"cargo build -p llimphi-widget-terminal",
0,
false,
&[
(false, " Compiling llimphi-widget-terminal v0.1.0".into()),
(true, "warning: unused variable `x`".into()),
(true, " --> src/blocks.rs:42:9".into()),
(false, " Finished `dev` profile in 1.89s".into()),
],
);
// 3) find / — el FLOOD: medio millón de líneas en un solo body.
{
let start = store.len();
for i in 0..500_000 {
store.push_line(&format!(
"/home/sergio/tawasuyu/02_ruway/llimphi/widgets/terminal/src/archivo_{i:06}.rs"
));
}
cmds.push(Cmd {
text: "find / -name '*.rs'".into(),
exit: 0,
start,
end: store.len(),
collapsed: false,
});
}
// 4) git status — COLAPSADO (sólo header, body oculto).
run(
&mut store,
&mut stderr,
&mut cmds,
"git status",
0,
true,
&[
(false, "On branch main".into()),
(false, "Changes not staged for commit:".into()),
(false, " modified: src/blocks.rs".into()),
],
);
// 5) cat inexistente — exit 1, stderr.
run(
&mut store,
&mut stderr,
&mut cmds,
"cat noexiste.txt",
1,
false,
&[(true, "cat: noexiste.txt: No such file or directory".into())],
);
// 6) echo final corto.
run(
&mut store,
&mut stderr,
&mut cmds,
"echo listo",
0,
false,
&[(false, "listo".into())],
);
// Construye los items: por cada comando, un header (chrome) y, salvo
// colapsado, su body (Lines). El widget virtualiza sobre estas alturas.
let mut items: Vec<Item<()>> = Vec::new();
for c in &cmds {
items.push(Item::chrome(HEADER_H, header_card(c, &theme)));
if !c.collapsed {
items.push(Item::lines(c.start, c.end));
}
}
let viewport_h = H as f32 - INFO_H;
let row_h = metrics.line_height;
let scroll_y = blocks_scroll_to_bottom(&items, viewport_h, row_h);
// Coloreo inyectado por el caller: stderr rojo (texto + tinte), resto tinta
// el prefijo hasta el primer espacio en acento (paths/tokens).
let accent = theme.accent;
let err_fg = theme.fg_destructive;
let err_bg = with_alpha(theme.fg_destructive, 0.14);
let line_style = move |idx: usize, text: &str| {
if stderr.contains(&idx) {
LineStyle {
fg: Some(err_fg),
bg: Some(err_bg),
..Default::default()
}
} else {
let end = text.find(' ').unwrap_or(text.len());
LineStyle {
runs: vec![(0, end, accent)],
..Default::default()
}
}
};
// Evidencia: cuántos items/filas se materializaron del total.
let total_lines = store.len();
let total_h = blocks_height(&items, row_h);
// Filas visibles ~ las que entran en el viewport (medida de costo).
let approx_rows = visible_window(total_lines, scroll_y, viewport_h, row_h).count();
let info = format!(
"{} comandos · {} líneas en scrollback · alto virtual {:.0}px · ~{} filas materializadas · anclado al fondo",
cmds.len(),
total_lines,
total_h,
approx_rows,
);
let surface = block_surface::<(), _, _>(
&store,
items,
scroll_y,
viewport_h,
metrics,
&palette,
line_style,
|_d| (),
None,
);
let info_bar = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(INFO_H),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel_alt)
.text_aligned(info, 12.5, theme.fg_text, Alignment::Start);
let root = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_input)
.children(vec![info_bar, surface]);
render_png(root, &out);
eprintln!("dump_blocks: {out} ({W}x{H}) — {total_lines} líneas, ~{approx_rows} filas materializadas");
}
/// Header de card de un comando — el "chrome" que el caller pinta: barra de
/// acento a la izquierda + `$ comando` mono + estado `exit N` a la derecha.
fn header_card(c: &Cmd, theme: &llimphi_theme::Theme) -> View<()> {
let (status_txt, status_col) = if c.exit == 0 {
(format!("exit {}", c.exit), Color::from_rgb8(120, 200, 130))
} else {
(format!("exit {}", c.exit), theme.fg_destructive)
};
// ASCII a propósito: la mono embebida no cubre triángulos geométricos
// (tofu) — `+` colapsado / `-` expandido, convención de árbol.
let chevron = if c.collapsed { "+" } else { "-" };
// Barra de acento (3px) a la izquierda.
let accent_bar = View::new(Style {
size: Size {
width: length(3.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(if c.exit == 0 { theme.accent } else { theme.fg_destructive });
// Estado (alineado a la derecha): chevron de colapso + `exit N`.
let status = View::new(Style {
size: Size {
width: length(140.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(
format!("{chevron} {status_txt}"),
12.0,
status_col,
Alignment::End,
)
.mono();
let cmd = View::new(Style {
flex_grow: 1.0,
size: Size {
width: auto(),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(format!("$ {}", c.text), 13.0, theme.fg_text, Alignment::Start)
.mono();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![accent_bar, cmd, status])
}
/// Devuelve `c` con la opacidad fijada a `alpha`.
fn with_alpha(c: Color, alpha: f32) -> Color {
let rgba = c.to_rgba8();
Color::from_rgba8(rgba.r, rgba.g, rgba.b, (alpha.clamp(0.0, 1.0) * 255.0) as u8)
}
fn render_png(root: View<()>, out: &str) {
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-blocks"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(18, 18, 24, 255))
.expect("render_to_view");
write_png(&hal, &target, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+235
View File
@@ -0,0 +1,235 @@
//! Dump headless de la superficie de terminal **virtualizada** (Fase 1).
//!
//! Prueba la invariante central del SDD: **1 millón de líneas, costo de render
//! constante**. Carga 1 M de renglones en el `Scrollback`, ancla el scroll al
//! fondo (estilo terminal) y renderiza a PNG sólo la ventana visible. Imprime
//! cuántas filas se materializaron (debe ser ~40, no un millón) — la evidencia
//! exigida por el SDD: no afirmar paridad/eficiencia sin render + viewport
//! medido.
//!
//! Uso: `cargo run -p llimphi-widget-terminal --example dump_terminal --release [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_layout::{taffy, LayoutTree};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::{Alignment, Typesetter};
use llimphi_ui::View;
use llimphi_widget_terminal::{
line_surface, scroll_to_bottom, visible_window, LineStyle, Scrollback, TermMetrics,
TermPalette,
};
const W: u32 = 1100;
const H: u32 = 720;
const HEADER_H: f32 = 40.0;
const TOTAL: usize = 1_000_000;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "terminal.png".to_string());
let theme = llimphi_theme::Theme::default();
let palette = TermPalette::from_theme(&theme);
let metrics = TermMetrics::for_font_size(13.0);
// 1 M de líneas — el scrollback "infinito". Sin cap (limit 0) para que las
// numere todas; ~30 MB de texto, acotado y O(1) por la Capa 0.
let mut store = Scrollback::new(0);
for i in 0..TOTAL {
store.push_line(&format!(
"fila {i:>7} :: lorem ipsum dolor sit amet, payload de salida del comando"
));
}
let viewport_h = H as f32 - HEADER_H;
// Anclaje al fondo, como una terminal real tras un flood.
let scroll_y = scroll_to_bottom(store.len(), viewport_h, metrics.line_height);
let win = visible_window(store.len(), scroll_y, viewport_h, metrics.line_height);
// Coloreo semántico de muestra inyectado por el "caller": cada 9ª línea
// simula stderr (tinte rojo tenue + texto rojo), el resto tinta el prefijo
// "fila NNNNNNN" en acento — demuestra runs + bg sin que el widget sepa de
// comandos.
let accent = theme.accent;
let err_fg = theme.fg_destructive;
let err_bg = with_alpha(theme.fg_destructive, 0.14);
let line_style = move |idx: usize, text: &str| {
if idx % 9 == 0 {
LineStyle {
fg: Some(err_fg),
bg: Some(err_bg),
..Default::default()
}
} else {
// Tinta el prefijo "fila NNNNNNN" (hasta el doble espacio).
let end = text.find(" ::").unwrap_or(0);
LineStyle {
runs: vec![(0, end, accent)],
..Default::default()
}
}
};
let surface = line_surface::<(), _, _>(
&store,
scroll_y,
viewport_h,
metrics,
&palette,
line_style,
|_d| (),
None,
);
// Header con la evidencia: total vs. filas materializadas.
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
..Default::default()
})
.fill(theme.bg_panel_alt)
.text_aligned(
format!(
"scrollback {TOTAL} líneas · materializadas {} (filas {}..{}) · costo constante",
win.count(),
win.first + 1,
win.last,
),
13.0,
theme.fg_text,
Alignment::Start,
);
let root = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_input)
.children(vec![header, surface]);
// Pipeline headless estándar (igual que los dumps de shuma).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-terminal"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(18, 18, 24, 255))
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"dump_terminal: {out} ({W}x{H}) — {TOTAL} líneas, materializadas {} (filas {}..{})",
win.count(),
win.first + 1,
win.last,
);
}
/// Devuelve `c` con la opacidad multiplicada por `alpha`.
fn with_alpha(c: Color, alpha: f32) -> Color {
let rgba = c.to_rgba8();
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+790
View File
@@ -0,0 +1,790 @@
//! Modelo de bloques + chrome virtualizado (Capa 1 del SDD-TERMINAL, Fase 2).
//!
//! El stream no es una lista plana de líneas: es una secuencia de **bloques**.
//! Para shuma un bloque = un comando (header `$ …` + cuerpo + badge + filas de
//! etapa + estado colapsado). El widget no sabe de comandos (Regla 2): modela
//! [`Item`]s heterogéneos —
//!
//! - [`Item::Chrome`] — un nodo opaco de **alto fijo** que el caller pinta
//! (header de card, fila de etapas, badge). El widget sólo lo **ubica**.
//! - [`Item::Lines`] — un rango `[start, end)` de líneas del store en modo
//! línea (numeradas/coloreadas). **Colapsar** un bloque = no emitir su item
//! `Lines` (o emitirlo con `start == end`): la virtualización lo respeta gratis.
//!
//! [`block_surface`] virtualiza sobre **alturas mixtas**: localiza por búsqueda
//! binaria los items que tocan el viewport y, dentro de un `Lines` enorme,
//! materializa **sólo** las sub-filas visibles. Costo de render constante aunque
//! un body tenga millones de líneas. El modo línea de la Fase 1
//! ([`crate::view::line_surface`]) es el caso de **un solo** `Item::Lines` que
//! cubre todo el store.
use std::sync::{Arc, Mutex};
use llimphi_ui::llimphi_layout::taffy::prelude::{auto, length, percent, Position, Rect, Size, Style};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, PaintRect, View};
use llimphi_widget_scroll::{max_offset, thumb_geometry, DEFAULT_LINE_PX};
use crate::store::Scrollback;
use crate::select::{selection_rects, SelectionRange};
use crate::view::{LineStyle, TermMetrics, TermPalette};
/// Ancho de la barra de scroll, en px.
const BAR_WIDTH: f32 = 10.0;
/// Alto mínimo del thumb, en px (para que no desaparezca con scrollback enorme).
const MIN_THUMB: f32 = 28.0;
/// Un item del stream virtualizado: chrome opaco (alto fijo, lo pinta el caller)
/// o un rango de líneas del store (modo línea).
pub enum Item<Msg> {
/// Chrome de alto fijo provisto por el caller — header de card, fila de
/// etapas, badge. El widget lo ubica en su `top` y recorta lo que sobra.
Chrome { height: f32, view: View<Msg> },
/// Rango `[start, end)` de líneas del store (índices 0-based vigentes).
Lines { start: usize, end: usize },
}
impl<Msg> Item<Msg> {
/// Chrome de alto fijo (header, etapa, badge…).
pub fn chrome(height: f32, view: View<Msg>) -> Self {
Self::Chrome { height, view }
}
/// Filas del store `[start, end)`.
pub fn lines(start: usize, end: usize) -> Self {
Self::Lines { start, end }
}
/// Alto del item en px (chrome: el suyo; lines: filas × alto_fila).
pub fn height(&self, row_h: f32) -> f32 {
match self {
Self::Chrome { height, .. } => *height,
Self::Lines { start, end } => end.saturating_sub(*start) as f32 * row_h,
}
}
/// Geometría liviana (sin la `View` del chrome) para hit-tests fuera del
/// render. Es `Copy`, así que se puede stashear en un `Mutex` para que el
/// `update` resuelva clicks contra el layout del frame anterior.
pub fn geo(&self) -> ItemGeo {
match self {
Self::Chrome { height, .. } => ItemGeo::Chrome(*height),
Self::Lines { start, end } => ItemGeo::Lines(*start, *end),
}
}
}
/// Variante liviana y `Copy` de [`Item`] sin la `View` — sólo lo necesario
/// para resolver hit-tests por (lx, ly). Se obtiene con [`Item::geo`].
#[derive(Debug, Clone, Copy)]
pub enum ItemGeo {
Chrome(f32),
Lines(usize, usize),
}
impl ItemGeo {
/// Alto del item en px, mismo cálculo que [`Item::height`].
pub fn height(&self, row_h: f32) -> f32 {
match self {
Self::Chrome(h) => *h,
Self::Lines(s, e) => e.saturating_sub(*s) as f32 * row_h,
}
}
}
/// Tops acumulados (content coords) de cada item dados sus altos, y el alto
/// total. `tops[i]` = `y` del item `i`; el total cierra el contenido. **Puro**.
pub fn item_tops(heights: &[f32]) -> (Vec<f32>, f32) {
let mut tops = Vec::with_capacity(heights.len());
let mut acc = 0.0;
for &h in heights {
tops.push(acc);
acc += h;
}
(tops, acc)
}
/// Rango `[first, last)` de items que **intersectan** el viewport `[off, off+vp)`
/// bajo `scroll_y` (clampeado a `[0, total-vp]`). `tops` es monótono → búsqueda
/// binaria, O(log n) en la cantidad de bloques. **Puro**.
pub fn visible_items(tops: &[f32], total: f32, scroll_y: f32, viewport_h: f32) -> (usize, usize) {
let n = tops.len();
if n == 0 || viewport_h <= 0.0 {
return (0, 0);
}
let off = scroll_y.clamp(0.0, (total - viewport_h).max(0.0));
// Primer item visible = el que contiene `off` (último top ≤ off). Como
// `tops[0] == 0 ≤ off`, el prefijo de tops ≤ off es no vacío.
let first = tops.partition_point(|&t| t <= off).saturating_sub(1);
// Último visible = primer item cuyo top ya cae fuera del fondo del viewport.
let last = tops.partition_point(|&t| t < off + viewport_h);
(first, last.max(first + 1).min(n))
}
/// Sub-filas locales `[k0, k1)` de un item `Lines` de `nrows` filas cuyo `top`
/// (content coords) materializan dentro del viewport `[off, off+vp)`. **Puro**.
fn visible_rows_in_item(top: f32, nrows: usize, off: f32, vp: f32, row_h: f32) -> (usize, usize) {
if nrows == 0 || row_h <= 0.0 {
return (0, 0);
}
let k0 = (((off - top) / row_h).floor().max(0.0) as usize).min(nrows);
let k1 = (((off + vp - top) / row_h).ceil().max(0.0) as usize).min(nrows);
(k0, k1.max(k0))
}
/// Alto total del contenido de una lista de items, en px.
pub fn blocks_height<Msg>(items: &[Item<Msg>], row_h: f32) -> f32 {
items.iter().map(|it| it.height(row_h)).sum()
}
/// El `scroll_y` que ancla el stream **al fondo** (estilo terminal): el máximo
/// offset posible dada la altura total de los bloques.
pub fn blocks_scroll_to_bottom<Msg>(items: &[Item<Msg>], viewport_h: f32, row_h: f32) -> f32 {
max_offset(blocks_height(items, row_h), viewport_h)
}
/// Superficie de terminal **por bloques, virtualizada** (Capa 12).
///
/// Materializa sólo los items —y dentro de un `Lines`, sólo las sub-filas— que
/// caen en el viewport bajo `scroll_y` (px). Costo de render **constante**
/// respecto del scrollback y del tamaño de cada body. `on_scroll(delta_px)`,
/// `line_style` y `measure` son como en [`crate::view::line_surface`].
///
/// El caller construye **todos** los chrome `View`s (los headers de card); sólo
/// los visibles se pintan (los demás se descartan). Para cientos de bloques —el
/// caso de un shell— es trivial; las **líneas** (los millones) sí se virtualizan
/// de raíz.
#[allow(clippy::too_many_arguments)]
pub fn block_surface<Msg, S, F>(
store: &Scrollback,
items: Vec<Item<Msg>>,
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
palette: &TermPalette,
line_style: S,
on_scroll: F,
measure: Option<Arc<Mutex<f32>>>,
) -> View<Msg>
where
Msg: Clone + 'static,
S: Fn(usize, &str) -> LineStyle,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
block_surface_with_selection(
store,
items,
scroll_y,
viewport_h,
metrics,
palette,
line_style,
on_scroll,
measure,
SelectionConfig::default(),
)
}
/// Configura el cableado de selección sobre el `block_surface`. Empaqueta el
/// rango actual (para pintar el overlay) y un handler de drag (para que el
/// caller traduzca cada `(DragPhase, lx0, ly0, dx, dy)` del viewport en
/// `Msg`s que actualicen su estado de selección).
///
/// Diseño en dos partes para mantener el control puro (Regla 2): el widget
/// no toca el modelo, sólo pinta el rango que el caller le pasa y dispara
/// callbacks; el caller arma la `SelectionRange` con [`crate::point_at`] y
/// las acumula en su `Model`.
pub struct SelectionConfig<'a, Msg> {
/// Rango vigente — si está, se pinta como overlay translúcido.
pub range: Option<&'a SelectionRange>,
/// Handler de drag del cuerpo de la superficie. Se ata al viewport con
/// `draggable_at` (gana sobre el `on_click` global del padre). `None`
/// = la superficie no es seleccionable por mouse (sólo pinta).
pub on_drag: Option<Arc<dyn Fn(DragPhase, f32, f32, f32, f32) -> Option<Msg> + Send + Sync>>,
/// Handler de doble-click. Recibe `(lx, ly, rect_w, rect_h)` del
/// viewport. El caller lo resuelve a una palabra y la selecciona —
/// paridad con la UX clásica de terminal (double-click select-word).
pub on_double_click: Option<Arc<dyn Fn(f32, f32, f32, f32) -> Option<Msg> + Send + Sync>>,
}
impl<Msg> Default for SelectionConfig<'_, Msg> {
fn default() -> Self {
Self {
range: None,
on_drag: None,
on_double_click: None,
}
}
}
impl<'a, Msg> SelectionConfig<'a, Msg> {
/// Sólo pinta el rango (sin cableado de mouse).
pub fn painted(range: &'a SelectionRange) -> Self {
Self {
range: Some(range),
on_drag: None,
on_double_click: None,
}
}
}
/// Como [`block_surface`], pero acepta una [`SelectionConfig`] para pintar el
/// overlay de selección y/o cablear el drag del mouse al `Msg` del caller.
#[allow(clippy::too_many_arguments)]
pub fn block_surface_with_selection<Msg, S, F>(
store: &Scrollback,
items: Vec<Item<Msg>>,
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
palette: &TermPalette,
line_style: S,
on_scroll: F,
measure: Option<Arc<Mutex<f32>>>,
selection: SelectionConfig<'_, Msg>,
) -> View<Msg>
where
Msg: Clone + 'static,
S: Fn(usize, &str) -> LineStyle,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
block_surface_with_scroll(
store, items, scroll_y, 0.0, viewport_h, metrics, palette, line_style,
on_scroll, measure, selection,
)
}
/// Variante con `scroll_x` adicional — el texto se desplaza horizontalmente
/// (gutter queda fijo). Pensado para acomodar zoom-in que desborda. El
/// caller actualiza `scroll_x` con Shift+rueda o atajos custom.
#[allow(clippy::too_many_arguments)]
pub fn block_surface_with_scroll<Msg, S, F>(
store: &Scrollback,
items: Vec<Item<Msg>>,
scroll_y: f32,
scroll_x: f32,
viewport_h: f32,
metrics: TermMetrics,
palette: &TermPalette,
line_style: S,
on_scroll: F,
measure: Option<Arc<Mutex<f32>>>,
selection: SelectionConfig<'_, Msg>,
) -> View<Msg>
where
Msg: Clone + 'static,
S: Fn(usize, &str) -> LineStyle,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
let row_h = metrics.line_height;
let gw = gutter_width(store, metrics);
let heights: Vec<f32> = items.iter().map(|it| it.height(row_h)).collect();
let (tops, total) = item_tops(&heights);
let off = scroll_y.clamp(0.0, max_offset(total, viewport_h));
let (first, last) = visible_items(&tops, total, off, viewport_h);
// Highlight de selección: precomputado contra `&items` antes de consumirlos
// en la iteración. La pintada va DESPUÉS del texto para que se vea encima
// (alpha de la paleta) y ANTES del scrollbar.
let sel_rects = match selection.range {
Some(sel) if !sel.is_empty() => {
selection_rects(&items, off, viewport_h, metrics, gw, store, sel)
}
_ => Vec::new(),
};
// Hijos absolutos en coords de viewport (content - off). Sólo los items
// visibles y, dentro de un Lines, sólo sus sub-filas visibles.
let mut children: Vec<View<Msg>> = Vec::new();
for (i, item) in items.into_iter().enumerate() {
if i < first || i >= last {
// Fuera de la ventana: el View de chrome se descarta acá (cheap).
continue;
}
let top = tops[i];
match item {
Item::Chrome { height, view } => {
children.push(
View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(top - off),
left: length(0.0_f32),
right: length(0.0_f32),
bottom: auto(),
},
size: Size {
width: percent(1.0_f32),
height: length(height),
},
..Default::default()
})
.children(vec![view]),
);
}
Item::Lines { start, end } => {
let nrows = end.saturating_sub(start);
if nrows == 0 {
continue;
}
let item_h = nrows as f32 * row_h;
// Tira de gutter del bloque, recortada al tramo visible (evita
// coords gigantes con bodies de millones de px de alto).
let vis_top = top.max(off);
let vis_bot = (top + item_h).min(off + viewport_h);
if vis_bot > vis_top {
children.push(gutter_bg(vis_top - off, vis_bot - vis_top, gw, palette));
}
let (k0, k1) = visible_rows_in_item(top, nrows, off, viewport_h, row_h);
for k in k0..k1 {
let idx = start + k;
let y = top + k as f32 * row_h - off;
let text = store.line(idx).unwrap_or("");
let style = line_style(idx, text);
if let Some(bg) = style.bg {
children.push(row_tint(y, row_h, gw, bg));
}
children.push(gutter_number(store.line_number(idx), y, gw, row_h, metrics, palette));
let fg = style.fg.unwrap_or(palette.fg_text);
let runs = clamp_runs(style.runs, text.len());
children.push(text_row_with_offset(text, y, gw, row_h, fg, runs, metrics, scroll_x));
}
}
}
}
// Overlay del highlight de selección — encima del texto, debajo del
// scrollbar. Translúcido (alpha en `palette.bg_selection`) para no tapar
// los glifos.
for r in &sel_rects {
children.push(selection_overlay_rect::<Msg>(*r, palette.bg_selection));
}
let on_wheel = Arc::new(on_scroll);
if max_offset(total, viewport_h) > 0.0 {
children.push(scrollbar(off, total, viewport_h, palette, &on_wheel));
}
// Viewport: alto fijo, contenido recortado, rueda local. Relative para
// contener los hijos absolutos; painter de medición opcional (patrón shell).
let on_wheel_view = Arc::clone(&on_wheel);
let mut viewport = View::new(Style {
position: Position::Relative,
size: Size {
width: percent(1.0_f32),
height: length(viewport_h),
},
..Default::default()
})
.fill(palette.bg)
.clip(true)
.on_scroll(move |_dx, dy| Some((on_wheel_view)(dy * DEFAULT_LINE_PX)))
.children(children);
if let Some(slot) = measure {
viewport = viewport.paint_with(move |_scene, _ts, rect: PaintRect| {
if let Ok(mut g) = slot.lock() {
*g = rect.h;
}
});
}
// Drag-to-select: forwardea cada `(DragPhase, lx0, ly0, dx, dy)` del
// viewport al handler del caller. El caller mantiene el `SelectionRange`
// en su `Model` y usa `crate::point_at` para mapear (lx, ly) → `Point`.
if let Some(on_drag) = selection.on_drag {
viewport = viewport.draggable_at(move |phase, dx, dy, lx0, ly0| {
(on_drag)(phase, lx0, ly0, dx, dy)
});
}
// Doble-click: paridad con terminales clásicas (select-word). El caller
// resuelve `(lx, ly)` a `Point` con `point_at_geo` + computa los
// boundaries de palabra y emite un `Msg` que actualiza `surf_selection`.
if let Some(on_double) = selection.on_double_click {
viewport = viewport.on_double_tap_at(move |lx, ly, rect_w, rect_h| {
(on_double)(lx, ly, rect_w, rect_h)
});
}
viewport
}
// ── Builders de nodos por fila (compartidos con la Fase 1) ──────────────────
/// Tira de fondo del gutter de un bloque: rect a la izquierda, ancho `gw`.
fn gutter_bg<Msg: Clone + 'static>(y: f32, h: f32, gw: f32, palette: &TermPalette) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(y),
left: length(0.0_f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(gw),
height: length(h),
},
..Default::default()
})
.fill(palette.bg_gutter)
}
/// Rect translúcido del overlay de selección — coords ya en viewport
/// (scroll descontado por `selection_rects`). Va sobre el texto, sin
/// recolorearlo (alpha del color del caller).
fn selection_overlay_rect<Msg: Clone + 'static>(
r: crate::select::HighlightRect,
bg: Color,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(r.y),
left: length(r.x),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(r.w),
height: length(r.h),
},
..Default::default()
})
.fill(bg)
}
/// Tinte de fondo de un renglón (stderr, etc.), del gutter hacia la derecha.
fn row_tint<Msg: Clone + 'static>(y: f32, row_h: f32, gw: f32, bg: Color) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(y),
left: length(gw),
right: length(0.0_f32),
bottom: auto(),
},
size: Size {
width: auto(),
height: length(row_h),
},
..Default::default()
})
.fill(bg)
}
/// Número global 1-based del renglón, alineado a la derecha del gutter.
fn gutter_number<Msg: Clone + 'static>(
number: u64,
y: f32,
gw: f32,
row_h: f32,
metrics: TermMetrics,
palette: &TermPalette,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(gw - 6.0),
height: length(row_h),
},
..Default::default()
})
.text_aligned(
number.to_string(),
metrics.font_size * 0.85,
palette.fg_line_number,
Alignment::End,
)
.mono()
}
/// Texto de un renglón, multicolor por runs, a la derecha del gutter.
fn text_row<Msg: Clone + 'static>(
text: &str,
y: f32,
gw: f32,
row_h: f32,
fg: Color,
runs: Vec<(usize, usize, Color)>,
metrics: TermMetrics,
) -> View<Msg> {
text_row_with_offset(text, y, gw, row_h, fg, runs, metrics, 0.0)
}
/// Como [`text_row`] pero permite un offset horizontal en px (scroll_x del
/// caller — el gutter queda fijo, el texto se desplaza).
#[allow(clippy::too_many_arguments)]
fn text_row_with_offset<Msg: Clone + 'static>(
text: &str,
y: f32,
gw: f32,
row_h: f32,
fg: Color,
runs: Vec<(usize, usize, Color)>,
metrics: TermMetrics,
scroll_x: f32,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(gw + 4.0 - scroll_x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(4000.0_f32),
height: length(row_h),
},
..Default::default()
})
.text_runs(text.to_string(), metrics.font_size, fg, runs, Alignment::Start)
.mono()
}
/// Barra de scroll vertical del widget: track + thumb arrastrable. Reusa la
/// geometría de `llimphi-widget-scroll` (`thumb_geometry`) dimensionada con el
/// alto TOTAL virtual — el thumb refleja la posición en el scrollback completo.
fn scrollbar<Msg, F>(
scroll_y: f32,
content_h: f32,
viewport_h: f32,
palette: &TermPalette,
on_scroll: &Arc<F>,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
let (thumb_h, thumb_y, offset_per_px) = thumb_geometry(scroll_y, content_h, viewport_h);
let thumb_h = thumb_h.max(MIN_THUMB.min(viewport_h));
let on_thumb = Arc::clone(on_scroll);
let thumb = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(thumb_y),
right: length(0.0_f32),
left: auto(),
bottom: auto(),
},
size: Size {
width: length(BAR_WIDTH),
height: length(thumb_h),
},
..Default::default()
})
.fill(palette.bar_thumb)
.hover_fill(palette.bar_thumb_hover)
.radius((BAR_WIDTH * 0.5) as f64)
.draggable(move |phase, _dx, dy| match phase {
DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
DragPhase::End => None,
});
View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(0.0_f32),
right: length(0.0_f32),
bottom: length(0.0_f32),
left: auto(),
},
size: Size {
width: length(BAR_WIDTH),
height: auto(),
},
..Default::default()
})
.fill(palette.bar_track)
.children(vec![thumb])
}
/// Ancho del gutter (px) para acomodar el número global más grande posible
/// (`total_pushed`), con un padding fijo. Se fija por el total histórico (no por
/// lo visible) para que el gutter no salte al scrollear, y es **el mismo** para
/// todos los bloques (los números alinean entre cards).
/// `y` (content coords) del renglón global `target_line` recorrido en el
/// stream de `items`. Devuelve `None` si la línea no cae en ningún
/// `Item::Lines`. **Puro** — base del auto-scroll al match de find: el
/// caller compone `scroll_y = top - margin` y lo clampa al overflow.
pub fn line_top_in_content(items_geo: &[ItemGeo], row_h: f32, target_line: usize) -> Option<f32> {
let mut top = 0.0_f32;
for it in items_geo {
match it {
ItemGeo::Chrome(h) => top += *h,
ItemGeo::Lines(start, end) => {
if target_line >= *start && target_line < *end {
return Some(top + (target_line - start) as f32 * row_h);
}
top += (end.saturating_sub(*start)) as f32 * row_h;
}
}
}
None
}
/// Padding extra entre el borde derecho del gutter y el primer carácter del
/// texto del renglón. Lo respeta `text_row` (línea ~495) y DEBE incluirse en
/// los offsets de hit-test (`point_at`) y selección visual para que el rect
/// pintado y el byte_col copiado coincidan con donde cayó el mouse.
pub const TEXT_LEFT_PADDING_PX: f32 = 4.0;
pub fn gutter_width(store: &Scrollback, metrics: TermMetrics) -> f32 {
let max_num = store.total_pushed().max(1);
let digits = (max_num as f64).log10().floor() as usize + 1;
metrics.char_width * digits as f32 + 10.0
}
/// Clampa los runs de color al `[0, len]` del texto, descartando vacíos o fuera
/// de rango — defensa contra runs stale del caller (el texto pudo cambiar).
pub(crate) fn clamp_runs(
runs: Vec<(usize, usize, Color)>,
len: usize,
) -> Vec<(usize, usize, Color)> {
runs.into_iter()
.filter_map(|(s, e, c)| {
let s = s.min(len);
let e = e.min(len);
(s < e).then_some((s, e, c))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const ROW: f32 = 18.0;
fn lines<Msg>(n: usize) -> Item<Msg> {
Item::lines(0, n)
}
#[test]
fn item_tops_accumulate() {
let (tops, total) = item_tops(&[10.0, 20.0, 5.0]);
assert_eq!(tops, vec![0.0, 10.0, 30.0]);
assert_eq!(total, 35.0);
let (t, tot) = item_tops(&[]);
assert!(t.is_empty());
assert_eq!(tot, 0.0);
}
#[test]
fn visible_items_picks_intersecting_blocks() {
// 4 bloques de 100px = 400px total; viewport 150px.
let (tops, total) = item_tops(&[100.0, 100.0, 100.0, 100.0]);
// Scroll 0 → items 0,1 (y un toque del 2 si entrara, pero 150<200).
let (a, b) = visible_items(&tops, total, 0.0, 150.0);
assert_eq!((a, b), (0, 2));
// Scroll 120 → ventana [120,270): items 1 y 2.
let (a, b) = visible_items(&tops, total, 120.0, 150.0);
assert_eq!((a, b), (1, 3));
// Scroll al fondo → últimos items, sin pasarse.
let (a, b) = visible_items(&tops, total, 1e9, 150.0);
assert!(b == 4 && a >= 2);
}
#[test]
fn visible_items_empty() {
assert_eq!(visible_items(&[], 0.0, 0.0, 100.0), (0, 0));
}
#[test]
fn rows_in_item_is_constant_cost() {
// Un body de 1 M filas que arranca en top=40; viewport 600, scroll tal
// que estamos en el medio del body. Materializa ~viewport/row filas.
let (k0, k1) = visible_rows_in_item(40.0, 1_000_000, 9000.0, 600.0, ROW);
assert!(k1 - k0 < 40, "costo constante, no {}", k1 - k0);
// Anclado arriba del item.
let (k0, k1) = visible_rows_in_item(40.0, 1_000_000, 0.0, 600.0, ROW);
assert_eq!(k0, 0);
assert!(k1 <= 34);
}
#[test]
fn rows_in_item_clamps_to_nrows() {
// Item chico totalmente visible.
let (k0, k1) = visible_rows_in_item(0.0, 5, 0.0, 600.0, ROW);
assert_eq!((k0, k1), (0, 5));
}
#[test]
fn blocks_height_and_bottom() {
let items: Vec<Item<()>> = vec![
Item::chrome(30.0, View::new(Style::default())),
lines(10),
Item::chrome(30.0, View::new(Style::default())),
lines(100),
];
let h = blocks_height(&items, ROW);
assert_eq!(h, 30.0 + 10.0 * ROW + 30.0 + 100.0 * ROW);
// Cabe? No (h grande), así que scroll_to_bottom > 0.
assert!(blocks_scroll_to_bottom(&items, 200.0, ROW) > 0.0);
// Si el viewport es enorme, no hay scroll.
assert_eq!(blocks_scroll_to_bottom(&items, h + 100.0, ROW), 0.0);
}
#[test]
fn item_height_chrome_vs_lines() {
let c: Item<()> = Item::chrome(42.0, View::new(Style::default()));
assert_eq!(c.height(ROW), 42.0);
let l: Item<()> = Item::lines(5, 25);
assert_eq!(l.height(ROW), 20.0 * ROW);
}
#[test]
fn clamp_runs_drops_out_of_range() {
let c = Color::from_rgb8(1, 2, 3);
let runs = vec![(0, 5, c), (3, 100, c), (50, 60, c), (4, 4, c)];
let out = clamp_runs(runs, 10);
assert_eq!(out, vec![(0, 5, c), (3, 10, c)]);
}
#[test]
fn line_top_camina_chrome_y_lineas() {
// [Chrome(22), Lines(0..3), Chrome(10), Lines(0..2)]: target=0 → top=22;
// target=2 → top=22+2*16=54; target=3 (en el segundo bloque, offset
// chrome 10 + 3 filas anteriores * 16) → no aplica porque target=3 está
// FUERA del primer Lines y el segundo es de 0..2 (otro rango). Test
// ajustado: target=0 cae en el PRIMER Lines (que es 0..3).
let items: Vec<ItemGeo> = vec![
ItemGeo::Chrome(22.0),
ItemGeo::Lines(0, 3),
ItemGeo::Chrome(10.0),
ItemGeo::Lines(10, 12),
];
// target_line=0 → primer Lines, k=0 → top = 22 + 0*16 = 22.
assert_eq!(line_top_in_content(&items, 16.0, 0), Some(22.0));
// target_line=2 → primer Lines, k=2 → top = 22 + 2*16 = 54.
assert_eq!(line_top_in_content(&items, 16.0, 2), Some(54.0));
// target_line=10 → segundo Lines, k=0 → top = 22 + 48 + 10 + 0 = 80.
assert_eq!(line_top_in_content(&items, 16.0, 10), Some(80.0));
// target_line=11 → segundo Lines, k=1 → top = 80 + 16 = 96.
assert_eq!(line_top_in_content(&items, 16.0, 11), Some(96.0));
// target fuera del store → None.
assert_eq!(line_top_in_content(&items, 16.0, 99), None);
}
#[test]
fn gutter_grows_with_line_count() {
let m = TermMetrics::for_font_size(13.0);
let mut s = Scrollback::new(0);
s.push_line("a");
let narrow = gutter_width(&s, m);
for _ in 0..100_000 {
s.push_line("x");
}
assert!(gutter_width(&s, m) > narrow);
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Pipeline GPU para la grilla de celdas del modo TUI (Fase 4 del SDD-TERMINAL).
//!
//! Define las **estructuras POD** (instance + uniforms), el **shader WGSL**
//! y el **pipeline wgpu** (`CellPipeline`) que las consume. El wireado al
//! `generic_grid_panel` vía `gpu_paint_with` va en el commit siguiente —
//! acá ya se valida que el shader compila en un device headless.
//!
//! ## Layouts
//!
//! - **Instance** (32 B): `[cell_x: f32, cell_y: f32, uv_x: f32, uv_y: f32,
//! uv_w: f32, uv_h: f32, fg_rgba: u32, bg_rgba: u32]`.
//! Una por celda visible; el vertex stage emite los 4 corners (TriangleStrip).
//! - **Uniforms** (32 B): `[viewport_w: f32, viewport_h: f32, cell_w: f32,
//! cell_h: f32, atlas_w: f32, atlas_h: f32, _pad: [f32; 2]]`.
//!
//! El fragment samplea el atlas grayscale en `uv`; alpha = cobertura;
//! out = mix(bg, fg, alpha). Blending estándar `OVER` por encima.
//!
//! ## Por qué quads instanciados
//!
//! Una grilla de 100×40 = 4000 celdas; en vello eso son ~4000 Views + 4000
//! draws + el shaping de cada char. Con quads instanciados es UNA draw call
//! de 4000 instancias y la GPU pinta todo en paralelo. Igual de simple
//! para 200×80 (16k celdas) — patrón ya validado en `GpuPipelines.rects`.
/// Una celda lista para dibujar. **POD, repr(C)** — `as_bytes` la serializa
/// a una secuencia plana de `f32`/`u32` little-endian para el buffer GPU.
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)]
pub struct CellInstance {
/// Posición (px) de la esquina superior-izquierda de la celda en
/// viewport coords.
pub cell_x: f32,
pub cell_y: f32,
/// Coords UV (px) del glifo en la textura del atlas. El shader las
/// divide por `atlas_size` para obtener UVs normalizadas 0..1.
pub uv_x: f32,
pub uv_y: f32,
pub uv_w: f32,
pub uv_h: f32,
/// Color foreground del glifo, RGBA8 empacado little-endian
/// (`r | g<<8 | b<<16 | a<<24`).
pub fg_rgba: u32,
/// Color background de la celda, RGBA8 empacado.
pub bg_rgba: u32,
}
impl CellInstance {
/// Tamaño en bytes del layout — debe coincidir con `array_stride` del
/// pipeline en wgpu. Compile-time const para que el caller arme el
/// vertex layout sin recalcular.
pub const SIZE: usize = 32;
/// Serializa a 32 bytes little-endian para `Queue::write_buffer`.
pub fn as_bytes(&self) -> [u8; Self::SIZE] {
let mut out = [0u8; Self::SIZE];
out[0..4].copy_from_slice(&self.cell_x.to_le_bytes());
out[4..8].copy_from_slice(&self.cell_y.to_le_bytes());
out[8..12].copy_from_slice(&self.uv_x.to_le_bytes());
out[12..16].copy_from_slice(&self.uv_y.to_le_bytes());
out[16..20].copy_from_slice(&self.uv_w.to_le_bytes());
out[20..24].copy_from_slice(&self.uv_h.to_le_bytes());
out[24..28].copy_from_slice(&self.fg_rgba.to_le_bytes());
out[28..32].copy_from_slice(&self.bg_rgba.to_le_bytes());
out
}
}
/// Empaca un color `(r, g, b, a)` en un `u32` RGBA little-endian que el
/// shader lee como `vec4<u32>` y normaliza a `vec4<f32>(r,g,b,a)/255`.
pub fn pack_rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
(r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24)
}
/// Serializa un slice de instancias a un buffer de bytes contiguo. Útil
/// para `Queue::write_buffer`.
pub fn instances_to_bytes(cells: &[CellInstance]) -> Vec<u8> {
let mut out = Vec::with_capacity(cells.len() * CellInstance::SIZE);
for c in cells {
out.extend_from_slice(&c.as_bytes());
}
out
}
/// Uniforms del pipeline (un único buffer por draw). **POD, repr(C)**.
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct CellUniforms {
pub viewport_w: f32,
pub viewport_h: f32,
pub cell_w: f32,
pub cell_h: f32,
pub atlas_w: f32,
pub atlas_h: f32,
pub _pad0: f32,
pub _pad1: f32,
}
impl CellUniforms {
/// 32 B — el bind group binding debe tener `min_binding_size = Some(32)`.
pub const SIZE: usize = 32;
pub fn as_bytes(&self) -> [u8; Self::SIZE] {
let mut out = [0u8; Self::SIZE];
let fields = [
self.viewport_w,
self.viewport_h,
self.cell_w,
self.cell_h,
self.atlas_w,
self.atlas_h,
self._pad0,
self._pad1,
];
for (i, v) in fields.iter().enumerate() {
out[i * 4..(i + 1) * 4].copy_from_slice(&v.to_le_bytes());
}
out
}
}
/// El shader WGSL del pipeline. Vertex stage usa `vertex_index` (0..4) para
/// emitir los corners del quad como TriangleStrip. Fragment samplea el atlas
/// grayscale y combina fg/bg por cobertura.
pub const CELL_WGSL: &str = r#"
struct Uniforms {
viewport_size: vec2<f32>,
cell_size: vec2<f32>,
atlas_size: vec2<f32>,
_pad: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var atlas_tex: texture_2d<f32>;
@group(0) @binding(2) var atlas_samp: sampler;
struct VsIn {
@builtin(vertex_index) vi: u32,
@location(0) cell_xy: vec2<f32>,
@location(1) uv_rect: vec4<f32>,
@location(2) fg_rgba: u32,
@location(3) bg_rgba: u32,
};
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) fg: vec4<f32>,
@location(2) bg: vec4<f32>,
};
fn unpack_rgba(c: u32) -> vec4<f32> {
let r = f32(c & 0xFFu) / 255.0;
let g = f32((c >> 8u) & 0xFFu) / 255.0;
let b = f32((c >> 16u) & 0xFFu) / 255.0;
let a = f32((c >> 24u) & 0xFFu) / 255.0;
return vec4<f32>(r, g, b, a);
}
@vertex
fn vs_cell(in: VsIn) -> VsOut {
// 4 corners del quad, TriangleStrip: (0,0) (1,0) (0,1) (1,1).
let corner = vec2<f32>(f32(in.vi & 1u), f32((in.vi >> 1u) & 1u));
let pixel_pos = in.cell_xy + corner * u.cell_size;
// px → NDC: x in [-1,1], y in [1,-1] (y invertido para alinear con la
// convención px-origin-top-left de viewport).
let ndc = vec2<f32>(
(pixel_pos.x / u.viewport_size.x) * 2.0 - 1.0,
1.0 - (pixel_pos.y / u.viewport_size.y) * 2.0,
);
var out: VsOut;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
// UV en pixels → UV normalizadas del atlas.
let uv_px = in.uv_rect.xy + corner * in.uv_rect.zw;
out.uv = uv_px / u.atlas_size;
out.fg = unpack_rgba(in.fg_rgba);
out.bg = unpack_rgba(in.bg_rgba);
return out;
}
@fragment
fn fs_cell(in: VsOut) -> @location(0) vec4<f32> {
// Atlas grayscale: la cobertura del glifo está en el canal R (la
// textura R8Unorm devuelve (R, 0, 0, 1)).
let cov = textureSample(atlas_tex, atlas_samp, in.uv).r;
// Mezcla bg → fg por cobertura. Pre-multiplica alpha del fg para
// que cubrir 100% rinda fg.a (no 1.0).
let rgb = mix(in.bg.rgb, in.fg.rgb, cov * in.fg.a);
let a = max(in.bg.a, cov * in.fg.a);
return vec4<f32>(rgb, a);
}
"#;
use llimphi_hal::wgpu;
/// Pipeline wgpu del cell renderer — compila el shader y arma el bind
/// group layout. Una sola instancia por proceso (o por `color_format`); el
/// `draw` la consume con un atlas + instancias frescas por frame.
///
/// El atlas se sube aparte (su propio `wgpu::Texture` con format
/// `R8Unorm`), y entra al bind group por la `binding=1`. Reusar el mismo
/// atlas entre frames es OK — sólo se actualiza con `Queue::write_texture`
/// cuando aparecen glifos nuevos (el `GlyphAtlas::take_dirty` lo señala).
pub struct CellPipeline {
pub pipeline: wgpu::RenderPipeline,
pub bind_layout: wgpu::BindGroupLayout,
pub sampler: wgpu::Sampler,
}
impl CellPipeline {
/// Compila el shader WGSL y construye el pipeline para escribir al
/// `color_format` dado (típicamente `Rgba8Unorm`, el de la intermedia
/// del `WinitSurface`).
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-widget-terminal-cell-shader"),
source: wgpu::ShaderSource::Wgsl(CELL_WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-widget-terminal-cell-bgl"),
entries: &[
// 0: uniforms (32 B).
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// 1: atlas texture (R8Unorm).
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// 2: sampler.
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-widget-terminal-cell-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let color_targets = [Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})];
// Instance buffer: 32 B / instancia, 4 attributes (vec2 + vec4 + u32 + u32).
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-widget-terminal-cell-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_cell"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: CellInstance::SIZE as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
// cell_xy
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
// uv_rect (vec4: uv_x, uv_y, uv_w, uv_h)
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
// fg_rgba
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 24,
shader_location: 2,
},
// bg_rgba
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 28,
shader_location: 3,
},
],
}],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cell"),
compilation_options: Default::default(),
targets: &color_targets,
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-widget-terminal-cell-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
Self {
pipeline,
bind_layout,
sampler,
}
}
/// Helper: crea una textura `R8Unorm` del tamaño del atlas, sube los
/// bytes y devuelve `(textura, view)`. El caller la mantiene viva
/// entre frames y la pasa a `draw`. Sólo re-crear si las dimensiones
/// del atlas cambian (p. ej. tras `GlyphAtlas::grow`).
pub fn create_atlas_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas_pixels: &[u8],
atlas_size: (u32, u32),
) -> (wgpu::Texture, wgpu::TextureView) {
let (w, h) = atlas_size;
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-widget-terminal-atlas"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
atlas_pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(w),
rows_per_image: Some(h),
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
/// Dibuja las celdas en `target_view`. **No** limpia el target (load:
/// Load) — el caller decide la pasada previa (vello + selección). El
/// blending alpha mezcla los glifos sobre lo que ya hay (la
/// "pre-pasada vello" del SDD).
pub fn draw(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target_view: &wgpu::TextureView,
atlas_view: &wgpu::TextureView,
cells: &[CellInstance],
uniforms: CellUniforms,
) {
if cells.is_empty() {
return;
}
// Uniforms.
let u_bytes = uniforms.as_bytes();
let u_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-widget-terminal-cell-u"),
size: CellUniforms::SIZE as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&u_buf, 0, &u_bytes);
// Instance buffer.
let inst_bytes = instances_to_bytes(cells);
let inst_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-widget-terminal-cell-inst"),
size: inst_bytes.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&inst_buf, 0, &inst_bytes);
// Bind group: uniforms + atlas + sampler.
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-widget-terminal-cell-bg"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: u_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(atlas_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-widget-terminal-cell-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.set_vertex_buffer(0, inst_buf.slice(..));
pass.draw(0..4, 0..cells.len() as u32);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_instance_size_es_32_bytes() {
// El pipeline asume `array_stride = 32`; un cambio acá rompería el
// vertex layout silenciosamente. Tener el chequeo en test fija el
// contrato.
assert_eq!(CellInstance::SIZE, 32);
assert_eq!(std::mem::size_of::<CellInstance>(), 32);
}
#[test]
fn cell_uniforms_size_es_32_bytes() {
assert_eq!(CellUniforms::SIZE, 32);
assert_eq!(std::mem::size_of::<CellUniforms>(), 32);
}
#[test]
fn as_bytes_de_instance_es_round_trip_de_f32_u32() {
let c = CellInstance {
cell_x: 12.5,
cell_y: 24.0,
uv_x: 100.0,
uv_y: 200.0,
uv_w: 8.0,
uv_h: 16.0,
fg_rgba: 0xFF1122EE,
bg_rgba: 0xAABBCCDD,
};
let b = c.as_bytes();
assert_eq!(b.len(), 32);
// Re-leemos cada campo del array byte little-endian.
assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 12.5);
assert_eq!(f32::from_le_bytes(b[4..8].try_into().unwrap()), 24.0);
assert_eq!(f32::from_le_bytes(b[8..12].try_into().unwrap()), 100.0);
assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 200.0);
assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 8.0);
assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 16.0);
assert_eq!(u32::from_le_bytes(b[24..28].try_into().unwrap()), 0xFF1122EE);
assert_eq!(u32::from_le_bytes(b[28..32].try_into().unwrap()), 0xAABBCCDD);
}
#[test]
fn pack_rgba_es_little_endian() {
assert_eq!(pack_rgba(0x11, 0x22, 0x33, 0xFF), 0xFF332211);
assert_eq!(pack_rgba(0, 0, 0, 0), 0);
assert_eq!(pack_rgba(255, 255, 255, 255), 0xFFFFFFFF);
}
#[test]
fn instances_to_bytes_concatena_correctamente() {
let cs = vec![
CellInstance {
cell_x: 0.0, cell_y: 0.0, uv_x: 0.0, uv_y: 0.0,
uv_w: 0.0, uv_h: 0.0, fg_rgba: 0x12345678, bg_rgba: 0,
},
CellInstance {
cell_x: 1.0, cell_y: 2.0, uv_x: 3.0, uv_y: 4.0,
uv_w: 5.0, uv_h: 6.0, fg_rgba: 0xCAFEBABE, bg_rgba: 0xDEADBEEF,
},
];
let b = instances_to_bytes(&cs);
assert_eq!(b.len(), 64);
// Segunda instancia arranca en byte 32.
assert_eq!(f32::from_le_bytes(b[32..36].try_into().unwrap()), 1.0);
assert_eq!(u32::from_le_bytes(b[56..60].try_into().unwrap()), 0xCAFEBABE);
assert_eq!(u32::from_le_bytes(b[60..64].try_into().unwrap()), 0xDEADBEEF);
}
#[test]
fn uniforms_as_bytes_pone_dims_en_orden() {
let u = CellUniforms {
viewport_w: 800.0,
viewport_h: 600.0,
cell_w: 8.0,
cell_h: 16.0,
atlas_w: 512.0,
atlas_h: 256.0,
_pad0: 0.0,
_pad1: 0.0,
};
let b = u.as_bytes();
assert_eq!(f32::from_le_bytes(b[0..4].try_into().unwrap()), 800.0);
assert_eq!(f32::from_le_bytes(b[12..16].try_into().unwrap()), 16.0); // cell_h
assert_eq!(f32::from_le_bytes(b[16..20].try_into().unwrap()), 512.0); // atlas_w
assert_eq!(f32::from_le_bytes(b[20..24].try_into().unwrap()), 256.0); // atlas_h
}
#[test]
fn wgsl_shader_no_es_vacio_y_define_entry_points() {
// Smoke check: la string del shader existe y declara las dos
// entry points que el pipeline va a referenciar. La validación
// sintáctica WGSL ocurre cuando `device.create_shader_module` la
// compile en el commit de pipeline.
assert!(CELL_WGSL.contains("@vertex"));
assert!(CELL_WGSL.contains("@fragment"));
assert!(CELL_WGSL.contains("vs_cell"));
assert!(CELL_WGSL.contains("fs_cell"));
assert!(CELL_WGSL.len() > 200, "shader sospechosamente corto");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Búsqueda sobre el `Scrollback` — base de Ctrl+F del SDD-TERMINAL §Fase 3.
//!
//! Diseño: barata pero correcta. Recorre todas las líneas del store y
//! reporta los rangos `(line, start_byte, end_byte)` de cada ocurrencia.
//! Sin streaming, sin índice — para los típicos cientos de miles de líneas
//! del shell es suficiente (un `memmem` por línea, lineal en el contenido).
//!
//! Para infinitos masivos (millones de líneas), el `find` ya es O(N) en el
//! contenido, no en el render — el scroll y la pintada siguen siendo O(1).
//! Si en algún momento aprieta, se puede pre-indexar n-gramas; no hoy.
//!
//! Case-insensitive: lowercase ambos lados (sin Unicode-aware folding por
//! ahora — ASCII alcanza para el caso shell típico).
use crate::store::Scrollback;
/// Una coincidencia de búsqueda en el scrollback. `start`/`end` son offsets
/// **en bytes** UTF-8 del texto de la línea (slice-safe: el caller puede
/// hacer `&text[start..end]` sin clampear).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FindMatch {
pub line: usize,
pub start: usize,
pub end: usize,
}
/// Opciones de búsqueda. Defaults: case-sensitive, query literal (sin regex).
#[derive(Debug, Clone, Copy, Default)]
pub struct FindOpts {
/// `true` = lowercase ambos lados antes de comparar (ASCII fold).
pub case_insensitive: bool,
}
/// Busca todas las ocurrencias **no superpuestas** de `query` en el `store`,
/// línea por línea, en orden. Empty query → `Vec::new()` (paridad con la
/// barra de find de la mayoría de editores: vacío = "no hay nada que
/// resaltar"). El consumo es O(total_bytes) en el contenido del store.
///
/// Las coincidencias caen siempre en límites de char UTF-8 (vienen del
/// scanner de bytes y se snap-ean al borde más cercano hacia abajo si la
/// query atraviesa una codepoint, que con `find` literal no debería pasar).
pub fn find_matches(store: &Scrollback, query: &str, opts: FindOpts) -> Vec<FindMatch> {
if query.is_empty() {
return Vec::new();
}
let needle = if opts.case_insensitive {
query.to_ascii_lowercase()
} else {
query.to_string()
};
let mut out = Vec::new();
for line in 0..store.len() {
let Some(text) = store.line(line) else { continue };
let haystack_owned;
let haystack: &str = if opts.case_insensitive {
haystack_owned = text.to_ascii_lowercase();
&haystack_owned
} else {
text
};
let mut cursor = 0usize;
while cursor < haystack.len() {
let Some(rel) = haystack[cursor..].find(&needle) else {
break;
};
let start = cursor + rel;
let end = start + needle.len();
out.push(FindMatch { line, start, end });
// Avance no-superposición: una ocurrencia consume su rango, la
// siguiente arranca DESPUÉS. Si la query es vacía no llegamos
// acá (ya filtrado arriba), así que `end > start` siempre.
cursor = end;
}
}
out
}
/// Avanza al siguiente match desde `current` (envuelve al primero si está
/// al final). Si `matches` está vacío devuelve `None`. `None` en `current`
/// equivale a "no hay actual" → arranca por el primero.
pub fn next_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(match current {
None => 0,
Some(i) => (i + 1) % matches.len(),
})
}
/// Retrocede al match previo desde `current` (envuelve al último si está
/// al principio). Mismas semánticas que [`next_match`].
pub fn prev_match(matches: &[FindMatch], current: Option<usize>) -> Option<usize> {
if matches.is_empty() {
return None;
}
Some(match current {
None => matches.len() - 1,
Some(i) => (i + matches.len() - 1) % matches.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn store_of(lines: &[&str]) -> Scrollback {
let mut s = Scrollback::new(0);
for l in lines {
s.push_line(l);
}
s
}
#[test]
fn query_vacia_no_devuelve_nada() {
let s = store_of(&["foo", "bar"]);
assert_eq!(find_matches(&s, "", FindOpts::default()), Vec::new());
}
#[test]
fn una_ocurrencia_por_linea() {
let s = store_of(&["foo bar baz", "qux foo quux"]);
let m = find_matches(&s, "foo", FindOpts::default());
assert_eq!(
m,
vec![
FindMatch { line: 0, start: 0, end: 3 },
FindMatch { line: 1, start: 4, end: 7 },
]
);
}
#[test]
fn varias_ocurrencias_en_la_misma_linea_no_se_superponen() {
// "aaa" → en "aaaaa" hay 1 match en 0..3 y otro en 3..6 (no en 1..4).
let s = store_of(&["aaaaaa"]);
let m = find_matches(&s, "aaa", FindOpts::default());
assert_eq!(
m,
vec![
FindMatch { line: 0, start: 0, end: 3 },
FindMatch { line: 0, start: 3, end: 6 },
]
);
}
#[test]
fn case_sensitive_por_defecto() {
let s = store_of(&["Foo", "FOO", "foo"]);
let m = find_matches(&s, "foo", FindOpts::default());
assert_eq!(m, vec![FindMatch { line: 2, start: 0, end: 3 }]);
}
#[test]
fn case_insensitive_matchea_todas_las_variantes() {
let s = store_of(&["Foo", "FOO", "foo"]);
let m = find_matches(&s, "foo", FindOpts { case_insensitive: true });
assert_eq!(m.len(), 3);
assert_eq!(m[0].line, 0);
assert_eq!(m[1].line, 1);
assert_eq!(m[2].line, 2);
}
#[test]
fn no_match_devuelve_vec_vacio() {
let s = store_of(&["uno", "dos"]);
assert!(find_matches(&s, "xyz", FindOpts::default()).is_empty());
}
#[test]
fn match_utf8_funciona_al_ser_busqueda_literal_byte_a_byte() {
// "café" tiene 'é' = 2 bytes. Buscamos "afé" — match en bytes 1..5.
let s = store_of(&["café"]);
let m = find_matches(&s, "afé", FindOpts::default());
assert_eq!(m, vec![FindMatch { line: 0, start: 1, end: 5 }]);
}
#[test]
fn next_match_envuelve_al_primero() {
let m = vec![
FindMatch { line: 0, start: 0, end: 1 },
FindMatch { line: 1, start: 0, end: 1 },
];
assert_eq!(next_match(&m, None), Some(0));
assert_eq!(next_match(&m, Some(0)), Some(1));
assert_eq!(next_match(&m, Some(1)), Some(0));
}
#[test]
fn prev_match_envuelve_al_ultimo() {
let m = vec![
FindMatch { line: 0, start: 0, end: 1 },
FindMatch { line: 1, start: 0, end: 1 },
];
assert_eq!(prev_match(&m, None), Some(1));
assert_eq!(prev_match(&m, Some(0)), Some(1));
assert_eq!(prev_match(&m, Some(1)), Some(0));
}
#[test]
fn next_y_prev_en_lista_vacia_son_none() {
let m: Vec<FindMatch> = Vec::new();
assert_eq!(next_match(&m, None), None);
assert_eq!(next_match(&m, Some(0)), None);
assert_eq!(prev_match(&m, None), None);
}
}
+405
View File
@@ -0,0 +1,405 @@
//! Atlas de glifos para el render GPU-directo de la grilla del modo TUI
//! (Fase 4 del SDD-TERMINAL). Pura CPU: rasteriza cada char a una celda
//! del atlas con `fontdue` y devuelve coords UV para que el shader de
//! quads instanciados las samplee.
//!
//! ## Diseño
//!
//! - **Grilla fija de celdas**: el atlas es una imagen `atlas_w × atlas_h`
//! en escala de grises (1 byte por pixel: cobertura del glifo). Cada
//! celda mide `cell_w × cell_h` px y aloja UN glifo. `cols × rows`
//! celdas totales (computable desde el tamaño y el font size).
//! - **Mapa `char → slot`**: cargado on-demand (primera vez que se pide
//! un char se rasteriza y se asigna la próxima celda libre). Sin LRU
//! por ahora — atlas grande de entrada (suficiente para ASCII +
//! símbolos comunes); si se llena, crece duplicando alto.
//! - **Bytes RAW**: el caller decide cuándo subir a GPU (toda la imagen
//! o sólo el rect del slot recién agregado, vía `dirty_rect`). Esto
//! mantiene el atlas **agnóstico de wgpu** (testeable headless).
//!
//! ## Métricas
//!
//! Las celdas son del **tamaño máximo** del glifo (incluye padding para
//! que el render del shader pueda ofset-ear el origen del baseline sin
//! cortar). El caller (el pipeline) usa `metrics_for` para alinear cada
//! quad al baseline correcto dentro de la fila.
use fontdue::{Font, FontSettings};
/// Slot de un glifo en el atlas. Coords en píxeles del atlas (no UV
/// normalizadas — el caller las divide por `(atlas_w, atlas_h)` al subirlas
/// al shader). El offset `(xmin, ymin)` es del bitmap respecto del origen
/// del cell — el shader lo aplica al posicionar el quad de salida.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlyphSlot {
/// x del píxel superior-izquierdo del glifo (dentro de su celda).
pub px: u32,
/// y idem.
pub py: u32,
/// Ancho del bitmap del glifo (≤ cell_w).
pub w: u32,
/// Alto del bitmap (≤ cell_h).
pub h: u32,
/// Offset horizontal del glifo respecto del origen del cell (typically 0).
pub xmin: i32,
/// Offset vertical — `metrics.ymin` de fontdue (positivo = baseline arriba).
pub ymin: i32,
/// Advance horizontal (para mono, igual a `metrics.advance_width` o ~cell_w).
pub advance: f32,
}
/// Rect en píxeles del atlas: `(x, y, w, h)`. Empacado por
/// `add_dirty_rect` para que el caller sepa qué subir a GPU.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DirtyRect {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
/// Atlas de glifos rasterizados sobre una textura grayscale.
pub struct GlyphAtlas {
font: Font,
/// Tamaño del font en píxeles (input al rasterizer).
font_size: f32,
/// Tamaño de cada celda en píxeles (ancho/alto del cell, no del glifo).
cell_w: u32,
cell_h: u32,
/// Columnas/filas vigentes del atlas.
cols: u32,
rows: u32,
/// Bytes del atlas grayscale (`atlas_w * atlas_h` bytes, row-major).
pixels: Vec<u8>,
/// Mapeo `char → slot_index_lineal` (filled on demand).
map: std::collections::HashMap<char, u32>,
/// Próximo slot libre (lineal `0..cols*rows`). `None` cuando está lleno.
next_slot: Option<u32>,
/// Rect vigente que cambió desde la última `take_dirty`. `None` = nada
/// que subir. Acumula con union; el caller llama `take_dirty()` después
/// de subir y resetea.
dirty: Option<DirtyRect>,
}
impl GlyphAtlas {
/// Construye el atlas con `font_bytes` (TTF/OTF), `font_size_px` y un
/// número inicial de `cols`/`rows`. El alto de cada cell sale del font
/// (`line_metrics`), el ancho del max(advance, 'M'). Si el font no
/// parsea, devuelve `None` — el caller decide el fallback.
pub fn new(font_bytes: &[u8], font_size_px: f32, cols: u32, rows: u32) -> Option<Self> {
let font = Font::from_bytes(font_bytes, FontSettings::default()).ok()?;
// Cell metrics: alto del line (ascent - descent + line_gap),
// ancho del advance del 'M' (proxy para mono). Padding 1 px por
// lado para que glifos con bearing negativo no sangren.
let line = font.horizontal_line_metrics(font_size_px)?;
let cell_h = (line.new_line_size.ceil() as u32).max(1) + 2;
// Para mono asumimos que 'M' marca el ancho de cell. Si el font no
// tiene 'M', cae a advance del primer glifo no-cero o a 8 px.
let m_metrics = font.metrics('M', font_size_px);
let cell_w = (m_metrics.advance_width.ceil() as u32).max(1) + 2;
let atlas_w = cell_w * cols;
let atlas_h = cell_h * rows;
Some(Self {
font,
font_size: font_size_px,
cell_w,
cell_h,
cols,
rows,
pixels: vec![0u8; (atlas_w * atlas_h) as usize],
map: std::collections::HashMap::new(),
next_slot: Some(0),
dirty: None,
})
}
/// Tamaño total del atlas en píxeles.
pub fn size(&self) -> (u32, u32) {
(self.cell_w * self.cols, self.cell_h * self.rows)
}
/// Tamaño de cada cell (ancho × alto, px).
pub fn cell_size(&self) -> (u32, u32) {
(self.cell_w, self.cell_h)
}
/// Buffer crudo del atlas (grayscale 1 byte por pixel, row-major,
/// stride = `atlas_w` bytes). Inmutable — el caller sube esto a la
/// textura GPU directamente.
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
/// Si hay un rect modificado desde la última llamada, lo devuelve y
/// resetea. Patrón "consume on read": el caller que llama esto es el
/// que está por hacer el upload a GPU.
pub fn take_dirty(&mut self) -> Option<DirtyRect> {
self.dirty.take()
}
/// Devuelve el slot del glifo `ch`. Si no estaba cacheado, lo rasteriza
/// y le asigna la próxima celda libre (marcando el rect como dirty).
/// Si el atlas está lleno devuelve `None` (el caller puede llamar
/// `grow()` y reintentar).
pub fn glyph_for(&mut self, ch: char) -> Option<GlyphSlot> {
if let Some(&slot) = self.map.get(&ch) {
return Some(self.slot_at(slot, ch));
}
let slot = self.next_slot?;
self.rasterize_to(ch, slot);
self.map.insert(ch, slot);
let next = slot + 1;
self.next_slot = if next < self.cols * self.rows { Some(next) } else { None };
Some(self.slot_at(slot, ch))
}
/// Duplica el alto del atlas (`rows *= 2`) para hacer más espacio. El
/// buffer se extiende con ceros; los glifos viejos quedan donde
/// estaban; `next_slot` apunta a la primera celda nueva. El rect
/// dirty se setea sobre la mitad nueva. Es la estrategia más simple
/// que mantiene los slots viejos válidos sin re-empacar.
pub fn grow(&mut self) {
let old_rows = self.rows;
let new_rows = old_rows.saturating_mul(2).max(old_rows + 1);
let (atlas_w, _) = self.size();
let old_pixels = std::mem::take(&mut self.pixels);
self.rows = new_rows;
let new_atlas_h = self.cell_h * new_rows;
self.pixels = vec![0u8; (atlas_w * new_atlas_h) as usize];
// Copy old block at the top.
self.pixels[..old_pixels.len()].copy_from_slice(&old_pixels);
// El próximo slot arranca en la primera celda nueva.
self.next_slot = Some(self.cols * old_rows);
// Toda la mitad nueva está sucia (zeros, pero el caller necesita
// saber que el atlas creció para re-subir si quiere texturas
// ajustadas; en práctica se re-aloca la textura GPU al detectar
// size change).
self.add_dirty(DirtyRect {
x: 0,
y: self.cell_h * old_rows,
w: atlas_w,
h: self.cell_h * (new_rows - old_rows),
});
}
/// Cantidad de glifos cacheados hasta ahora (informativo).
pub fn cached_count(&self) -> usize {
self.map.len()
}
/// Capacidad total del atlas en celdas (`cols * rows`).
pub fn capacity(&self) -> u32 {
self.cols * self.rows
}
// ── helpers privados ──────────────────────────────────────────────
fn slot_at(&self, slot: u32, ch: char) -> GlyphSlot {
let col = slot % self.cols;
let row = slot / self.cols;
let (m, _) = self.font.rasterize(ch, self.font_size);
GlyphSlot {
px: col * self.cell_w,
py: row * self.cell_h,
w: m.width as u32,
h: m.height as u32,
xmin: m.xmin,
ymin: m.ymin,
advance: m.advance_width,
}
}
fn rasterize_to(&mut self, ch: char, slot: u32) {
let (m, bitmap) = self.font.rasterize(ch, self.font_size);
let col = slot % self.cols;
let row = slot / self.cols;
let px = col * self.cell_w;
let py = row * self.cell_h;
let (atlas_w, _) = self.size();
// Blit del bitmap a (px, py). El glifo puede ser más chico que la
// celda — el resto queda en 0 (transparente para el shader).
let bw = m.width as u32;
let bh = m.height as u32;
for y in 0..bh.min(self.cell_h) {
for x in 0..bw.min(self.cell_w) {
let src = (y * bw + x) as usize;
let dst = ((py + y) * atlas_w + (px + x)) as usize;
if src < bitmap.len() && dst < self.pixels.len() {
self.pixels[dst] = bitmap[src];
}
}
}
self.add_dirty(DirtyRect {
x: px,
y: py,
w: self.cell_w,
h: self.cell_h,
});
}
fn add_dirty(&mut self, r: DirtyRect) {
self.dirty = Some(match self.dirty {
None => r,
Some(prev) => union_rects(prev, r),
});
}
}
fn union_rects(a: DirtyRect, b: DirtyRect) -> DirtyRect {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
let r = (a.x + a.w).max(b.x + b.w);
let bo = (a.y + a.h).max(b.y + b.h);
DirtyRect {
x,
y,
w: r - x,
h: bo - y,
}
}
#[cfg(test)]
mod tests {
use super::*;
const MONO: &[u8] = llimphi_ui::llimphi_text::MONO_FONT_BYTES;
fn atlas() -> GlyphAtlas {
GlyphAtlas::new(MONO, 14.0, 16, 4).expect("font parses")
}
#[test]
fn new_compone_dimensiones_segun_font_y_cols_rows() {
let a = atlas();
let (cw, ch) = a.cell_size();
let (w, h) = a.size();
assert!(cw > 0 && ch > 0);
assert_eq!(w, cw * 16);
assert_eq!(h, ch * 4);
assert_eq!(a.capacity(), 64);
assert_eq!(a.cached_count(), 0);
}
#[test]
fn primer_glyph_for_rasteriza_y_marca_dirty() {
let mut a = atlas();
let s = a.glyph_for('A').expect("slot");
assert_eq!(s.px, 0);
assert_eq!(s.py, 0);
assert!(s.w > 0 && s.h > 0);
assert_eq!(a.cached_count(), 1);
let dirty = a.take_dirty().expect("dirty");
assert_eq!(dirty.x, 0);
assert_eq!(dirty.y, 0);
// Tras consumir, sin dirty pendiente.
assert!(a.take_dirty().is_none());
}
#[test]
fn segundo_glyph_va_a_la_proxima_celda() {
let mut a = atlas();
let _ = a.glyph_for('A').unwrap();
let _ = a.take_dirty();
let s = a.glyph_for('B').unwrap();
let (cw, _) = a.cell_size();
assert_eq!(s.px, cw);
assert_eq!(s.py, 0);
assert_eq!(a.cached_count(), 2);
}
#[test]
fn lookup_repetido_no_aumenta_la_cache() {
let mut a = atlas();
let s1 = a.glyph_for('A').unwrap();
let _ = a.take_dirty();
let s2 = a.glyph_for('A').unwrap();
assert_eq!(s1, s2);
assert_eq!(a.cached_count(), 1);
// Lookup cacheado no marca dirty.
assert!(a.take_dirty().is_none());
}
#[test]
fn fila_se_envuelve_a_la_siguiente_al_completar_columnas() {
let mut a = atlas();
let (cw, ch) = a.cell_size();
for c in 'a'..='z' {
let _ = a.glyph_for(c);
}
// Tras 16 columnas se va a la segunda fila.
let s_q = a.glyph_for('a').unwrap(); // ya está; mismo slot.
assert_eq!((s_q.px, s_q.py), (0, 0));
// El char 17 (índice 16 en 0-based) cayó en (col=0, row=1).
let s17 = a.glyph_for(('a' as u32 + 16) as u8 as char).unwrap();
assert_eq!((s17.px, s17.py), (0, ch));
// El char 18 cae en (col=1, row=1).
let s18 = a.glyph_for(('a' as u32 + 17) as u8 as char).unwrap();
assert_eq!((s18.px, s18.py), (cw, ch));
}
#[test]
fn glyph_for_devuelve_none_cuando_lleno() {
let mut a = GlyphAtlas::new(MONO, 14.0, 2, 2).unwrap(); // capacidad 4
for c in ['A', 'B', 'C', 'D'] {
assert!(a.glyph_for(c).is_some(), "{c}");
}
// El quinto no entra.
assert!(a.glyph_for('E').is_none());
assert_eq!(a.cached_count(), 4);
}
#[test]
fn grow_duplica_rows_y_libera_celdas() {
let mut a = GlyphAtlas::new(MONO, 14.0, 2, 2).unwrap(); // capacidad 4
for c in ['A', 'B', 'C', 'D'] {
a.glyph_for(c).unwrap();
}
assert!(a.glyph_for('E').is_none());
a.grow();
assert_eq!(a.capacity(), 8);
// Glifos viejos siguen en su slot original.
let s_a = a.glyph_for('A').unwrap();
assert_eq!((s_a.px, s_a.py), (0, 0));
// 'E' entra en la mitad nueva (slot 4 → col 0, row 2).
let (_, ch) = a.cell_size();
let s_e = a.glyph_for('E').unwrap();
assert_eq!((s_e.px, s_e.py), (0, ch * 2));
}
#[test]
fn dirty_acumula_union_hasta_take() {
let mut a = atlas();
a.glyph_for('A').unwrap();
a.glyph_for('B').unwrap();
a.glyph_for('C').unwrap();
let d = a.take_dirty().unwrap();
let (cw, ch) = a.cell_size();
// Tres celdas en fila: x=0..3*cw, y=0..ch.
assert_eq!(d.x, 0);
assert_eq!(d.y, 0);
assert_eq!(d.w, cw * 3);
assert_eq!(d.h, ch);
}
#[test]
fn pixels_buffer_se_llena_con_algo_distinto_de_cero_tras_rasterizar() {
let mut a = atlas();
a.glyph_for('A').unwrap();
// Algún pixel del primer cell debe ser no-cero (alpha del glifo).
let (cw, ch) = a.cell_size();
let (atlas_w, _) = a.size();
let mut any = false;
for y in 0..ch {
for x in 0..cw {
if a.pixels()[((y * atlas_w) + x) as usize] != 0 {
any = true;
break;
}
}
if any {
break;
}
}
assert!(any, "el cell de 'A' debe tener pixels rasterizados");
}
}
+51
View File
@@ -0,0 +1,51 @@
//! `llimphi-widget-terminal` — superficie de terminal **infinita y
//! virtualizada**.
//!
//! Diseño completo: `02_ruway/shuma/SDD-TERMINAL.md`. El control reemplaza por
//! fases al `output_pane` del shell: scrollback ilimitado a costo de render
//! **constante** (sólo se pinta la ventana visible), tres modos sobre la misma
//! tela (línea IDE / grilla TUI / híbrido) y GPU directo donde paga.
//!
//! **Fase 0:** la **Capa 0** — el [`store::Scrollback`], store de scrollback
//! append-only con índice de líneas, cap por memoria y acceso O(1). Puro, sin
//! dependencias de UI: el núcleo agnóstico vive aparte de quien lo pinta
//! (Regla 2).
//!
//! **Fase 1:** modo línea — [`view::line_surface`] materializa sólo las filas
//! visibles bajo un `scroll_y` propio del widget (costo de render **constante**
//! a scrollback ilimitado), numeración global y color por runs.
//!
//! **Fase 2 (esto):** la **Capa 1** — el modelo de **bloques** ([`blocks`]):
//! el stream es una secuencia de [`blocks::Item`]s (chrome de alto fijo que el
//! caller pinta + rangos de líneas del store), virtualizados sobre alturas
//! mixtas con búsqueda binaria; colapsar un bloque = no emitir su body. Mapea
//! el `output_pane` del shell (header/badge/etapas/colapso) sin que el widget
//! sepa de comandos (Regla 2). El modo línea de la Fase 1 es el caso de un solo
//! `Item::Lines`.
#![forbid(unsafe_code)]
pub mod blocks;
pub mod cell_pipeline;
pub mod find;
pub mod glyph_atlas;
pub mod select;
pub mod store;
pub mod view;
pub use blocks::{
block_surface, block_surface_with_scroll, block_surface_with_selection, blocks_height,
blocks_scroll_to_bottom, gutter_width, line_top_in_content, Item, ItemGeo, SelectionConfig,
TEXT_LEFT_PADDING_PX,
};
pub use find::{find_matches, next_match, prev_match, FindMatch, FindOpts};
pub use cell_pipeline::{
instances_to_bytes, pack_rgba, CellInstance, CellPipeline, CellUniforms, CELL_WGSL,
};
pub use glyph_atlas::{DirtyRect, GlyphAtlas, GlyphSlot};
pub use select::{point_at, point_at_geo, selection_rects, HighlightRect, Point, SelectionRange};
pub use store::{Scrollback, SpillStore};
pub use view::{
content_height, line_surface, scroll_to_bottom, visible_window, LineStyle, TermMetrics,
TermPalette, VisibleWindow,
};
+694
View File
@@ -0,0 +1,694 @@
//! Modelo de selección del scrollback — base de la Fase 3 del SDD-TERMINAL.
//!
//! La selección se ancla por **(índice de línea en el store vigente, columna
//! en bytes UTF-8 del texto de esa línea)** — no por id global ni por píxeles
//! —, así sobrevive el append al fondo pero el caller debe descartarla si el
//! frente del store se recortó (los índices se corren). El `Scrollback` ya
//! expone `line_id`/`index_of_id` para que el caller traduzca antes/después
//! del `drain` si quiere persistir la selección a través del recorte.
//!
//! Diseño:
//!
//! - `SelectionRange { anchor, head }`: dos puntos. `anchor` = donde empezó
//! (press), `head` = donde está ahora (drag). `head == anchor` => selección
//! vacía (cursor sin alcance).
//! - `normalized()`: devuelve `(start, end)` con `start <= end`, **sin** mover
//! el modelo (la UI quiere saber dónde está el cursor "vivo" para el caret,
//! pero la extracción/painting necesita el rango ordenado).
//! - `slice_text(store)`: extrae el texto seleccionado, una línea por
//! renglón del store, recortado por columnas en la primera/última (clampeado
//! a límites de char UTF-8).
//!
//! Sin dependencias de UI ni de wgpu — puro, testeable a mano. Las pintadas y
//! el cableado de mouse vienen en commits siguientes (Fase 3 continúa).
use crate::blocks::{Item, ItemGeo};
use crate::store::Scrollback;
use crate::view::TermMetrics;
/// Un punto en el scrollback — un par `(idx_línea, col_byte)`. El índice es
/// vigente en el store (post-recortes); la columna es offset **en bytes** del
/// texto de esa línea. Se clampea al largo real al usar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Point {
/// Índice 0-based de la línea en el store vigente.
pub line: usize,
/// Offset en bytes dentro del texto de la línea (clampeado a límite UTF-8).
pub col: usize,
}
impl Point {
pub const fn new(line: usize, col: usize) -> Self {
Self { line, col }
}
}
/// Una selección viva — `anchor` (press) y `head` (drag actual). Convertir
/// a `(start, end)` ordenado con [`Self::normalized`] antes de pintar o
/// extraer texto.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionRange {
pub anchor: Point,
pub head: Point,
}
impl SelectionRange {
/// Selección colapsada (cursor sin alcance) en `p`.
pub const fn collapsed(p: Point) -> Self {
Self { anchor: p, head: p }
}
/// `true` si la selección no cubre ningún byte.
pub fn is_empty(&self) -> bool {
self.anchor == self.head
}
/// Devuelve `(start, end)` con `start <= end` en orden lexicográfico
/// `(line, col)`. **No** mueve el modelo — el caller decide si quiere
/// el ancla por separado del head (para el caret).
pub fn normalized(&self) -> (Point, Point) {
let a = self.anchor;
let b = self.head;
if (a.line, a.col) <= (b.line, b.col) {
(a, b)
} else {
(b, a)
}
}
/// `true` si la selección toca el renglón `line` (alguna parte del
/// rango está sobre esa línea). Útil para el painter de la ventana
/// visible: itera filas y pinta el highlight sólo donde aplica.
pub fn touches_line(&self, line: usize) -> bool {
let (s, e) = self.normalized();
line >= s.line && line <= e.line
}
/// Rango de columnas `(start_col, end_col_exclusive)` que la selección
/// cubre en la línea `line` cuyo texto tiene `text_len` bytes.
/// Para líneas intermedias: `(0, text_len)`. Para la primera/última:
/// recorta. Si la selección no toca esta línea: `None`.
pub fn col_range_on(&self, line: usize, text_len: usize) -> Option<(usize, usize)> {
let (s, e) = self.normalized();
if line < s.line || line > e.line {
return None;
}
let start = if line == s.line { s.col.min(text_len) } else { 0 };
let end = if line == e.line {
e.col.min(text_len)
} else {
text_len
};
// Si el rango es vacío (selección colapsada justo en límite) → None,
// para que el painter no dibuje un highlight de 0 bytes.
if start >= end {
return None;
}
Some((start, end))
}
/// Extrae el texto seleccionado del `store`. Multi-línea: las líneas
/// intermedias enteras, la primera/última recortadas por columna.
/// Columnas se clampean al límite de char UTF-8 más cercano hacia abajo
/// (no panic si caen a media codepoint). Líneas fuera del store
/// vigente se ignoran. Selección vacía → string vacío.
pub fn slice_text(&self, store: &Scrollback) -> String {
if self.is_empty() {
return String::new();
}
let (s, e) = self.normalized();
if store.len() == 0 || s.line >= store.len() {
return String::new();
}
let last_line = e.line.min(store.len().saturating_sub(1));
let mut out = String::new();
for line in s.line..=last_line {
let Some(text) = store.line(line) else {
continue;
};
let (a, b) = if line == s.line && line == e.line {
(clamp_char_boundary(text, s.col), clamp_char_boundary(text, e.col))
} else if line == s.line {
(clamp_char_boundary(text, s.col), text.len())
} else if line == last_line {
(0, clamp_char_boundary(text, e.col))
} else {
(0, text.len())
};
if a < b {
out.push_str(&text[a..b]);
}
if line != last_line {
out.push('\n');
}
}
out
}
}
/// Clampea `col` hacia abajo hasta el primer límite de char UTF-8 ≤ `col`.
/// Si `col >= text.len()` devuelve `text.len()`. Garantiza que `text[..ret]`
/// sea un slice válido.
fn clamp_char_boundary(text: &str, col: usize) -> usize {
if col >= text.len() {
return text.len();
}
let mut c = col;
while c > 0 && !text.is_char_boundary(c) {
c -= 1;
}
c
}
/// Convierte coords `(lx, ly)` del viewport del `block_surface` a un
/// [`Point`] del store (línea + columna en bytes UTF-8). **Puro**: replica
/// la geometría del render (mismo `item_tops` + `visible_rows_in_item` que
/// la pintada) para que el caret/anchor caigan exactamente donde el usuario
/// hizo click. `(lx, ly)` son **relativas al viewport** (origen = esquina
/// superior-izquierda del rect del widget). Devuelve `None` si `ly` cae en
/// un item `Chrome` (los chrome no son seleccionables) o fuera del stream.
///
/// La conversión visual_col → byte_col cuenta chars del texto: para mono
/// asume 1 cell por char (CJK doble queda fuera del MVP). Si el click cae
/// más allá del fin del texto, snapea al fin.
pub fn point_at<Msg>(
items: &[Item<Msg>],
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
gutter_w: f32,
store: &Scrollback,
lx: f32,
ly: f32,
) -> Option<Point> {
// Wrapper sobre `point_at_geo` que extrae la geometría liviana de cada
// item. Práctico para callers que aún tienen el `Vec<Item>` a mano.
let geo: Vec<ItemGeo> = items.iter().map(|it| it.geo()).collect();
point_at_geo(&geo, scroll_y, viewport_h, metrics, gutter_w, store, lx, ly)
}
/// Como [`point_at`] pero contra `&[ItemGeo]` — lo que el caller puede
/// stashear de un frame a otro (es `Copy`, no carga `View`s). Útil para que
/// el `update` resuelva clicks contra el layout del render previo sin
/// re-armar los items.
pub fn point_at_geo(
items: &[ItemGeo],
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
gutter_w: f32,
store: &Scrollback,
lx: f32,
ly: f32,
) -> Option<Point> {
if viewport_h <= 0.0 || metrics.line_height <= 0.0 {
return None;
}
let row_h = metrics.line_height;
let char_w = metrics.char_width.max(0.5);
let content_y = scroll_y + ly.max(0.0);
let mut item_top = 0.0_f32;
for it in items {
let item_h = it.height(row_h);
let item_bottom = item_top + item_h;
if content_y >= item_top && content_y < item_bottom {
match it {
ItemGeo::Chrome(_) => return None,
ItemGeo::Lines(start, end) => {
let nrows = end.saturating_sub(*start);
if nrows == 0 {
return None;
}
let k = (((content_y - item_top) / row_h).floor() as usize).min(nrows - 1);
let line = start + k;
let text = store.line(line).unwrap_or("");
// Mismo offset que usa `text_row` al pintar el texto
// (gutter + 4 px de padding); sin esto el byte_col
// copiado quedaba a ~½ char a la izquierda del click.
let vis_x =
(lx - gutter_w - crate::blocks::TEXT_LEFT_PADDING_PX).max(0.0);
let vis_col = (vis_x / char_w).floor() as usize;
let byte_col = visual_to_byte_col(text, vis_col);
return Some(Point::new(line, byte_col));
}
}
}
item_top = item_bottom;
}
None
}
/// Convierte una columna visual (índice de char, 0-based) en una columna
/// de bytes dentro de `text`. Si la visual cae más allá del último char,
/// devuelve `text.len()`. Pensado para hit-test de mouse en mono.
fn visual_to_byte_col(text: &str, vis_col: usize) -> usize {
let mut chars_seen = 0;
for (b, _c) in text.char_indices() {
if chars_seen == vis_col {
return b;
}
chars_seen += 1;
}
text.len()
}
/// Un rectángulo de highlight para pintar — coords **relativas al viewport**
/// del `block_surface` (origen = esquina superior-izquierda del rect del
/// widget, ya descontado `scroll_y`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HighlightRect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// Calcula los rectángulos de highlight que pinta una selección sobre la
/// ventana visible de un `block_surface`. **Puro**: no depende de wgpu ni
/// de Views — devuelve geometría que el painter del widget consume con
/// `scene.fill`. El caller pasa `gutter_w` (típicamente vía
/// [`crate::blocks::gutter_width`]) y las métricas de la superficie.
///
/// Sólo emite rects para líneas que (a) caen dentro de un `Item::Lines` del
/// stream y (b) intersectan el viewport. Items `Chrome` no entran (el chrome
/// es opaco y el caller decide su propio highlight si lo necesita).
///
/// Las columnas en `SelectionRange` son **bytes UTF-8**; el rect se calcula
/// en **columnas visuales** (chars contados, mono = 1 cell por char). CJK
/// ancho doble queda fuera del MVP — emite rects de 1 cell por char.
pub fn selection_rects<Msg>(
items: &[Item<Msg>],
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
gutter_w: f32,
store: &Scrollback,
sel: &SelectionRange,
) -> Vec<HighlightRect> {
if sel.is_empty() || viewport_h <= 0.0 || metrics.line_height <= 0.0 {
return Vec::new();
}
let row_h = metrics.line_height;
let char_w = metrics.char_width.max(0.5);
let mut out: Vec<HighlightRect> = Vec::new();
let mut item_top = 0.0_f32;
for it in items {
let item_h = it.height(row_h);
let item_bottom = item_top + item_h;
// Skip items totalmente fuera del viewport.
if item_bottom <= scroll_y || item_top >= scroll_y + viewport_h {
item_top = item_bottom;
continue;
}
if let Item::Lines { start, end } = it {
let nrows = end.saturating_sub(*start);
if nrows == 0 {
item_top = item_bottom;
continue;
}
// Sub-filas dentro del item que tocan el viewport (locales 0-based).
let off = scroll_y;
let k0 = (((off - item_top) / row_h).floor().max(0.0) as usize).min(nrows);
let k1 = (((off + viewport_h - item_top) / row_h).ceil().max(0.0) as usize).min(nrows);
for k in k0..k1 {
let idx = start + k;
if !sel.touches_line(idx) {
continue;
}
let Some(text) = store.line(idx) else { continue };
let Some((a, b)) = sel.col_range_on(idx, text.len()) else { continue };
// Snap a límites UTF-8 (defensa; col_range_on ya clampa a len).
let a_safe = clamp_char_boundary(text, a);
let b_safe = clamp_char_boundary(text, b);
if a_safe >= b_safe {
continue;
}
let vis_a = text[..a_safe].chars().count() as f32;
let vis_b = text[..b_safe].chars().count() as f32;
let row_y = item_top + k as f32 * row_h - scroll_y;
// El texto se pinta a `gutter + TEXT_LEFT_PADDING_PX` —
// el rect tiene que arrancar en el mismo offset.
let text_x0 = gutter_w + crate::blocks::TEXT_LEFT_PADDING_PX;
out.push(HighlightRect {
x: text_x0 + vis_a * char_w,
y: row_y,
w: (vis_b - vis_a) * char_w,
h: row_h,
});
}
}
item_top = item_bottom;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn store_of(lines: &[&str]) -> Scrollback {
let mut s = Scrollback::new(0);
for l in lines {
s.push_line(l);
}
s
}
#[test]
fn collapsed_is_empty_and_yields_empty_slice() {
let sel = SelectionRange::collapsed(Point::new(0, 0));
assert!(sel.is_empty());
let store = store_of(&["hola", "mundo"]);
assert_eq!(sel.slice_text(&store), "");
}
#[test]
fn normalized_swaps_when_head_before_anchor() {
let sel = SelectionRange {
anchor: Point::new(3, 7),
head: Point::new(1, 2),
};
let (s, e) = sel.normalized();
assert_eq!(s, Point::new(1, 2));
assert_eq!(e, Point::new(3, 7));
}
#[test]
fn single_line_slice_recorta_por_columnas() {
let store = store_of(&["the quick brown fox"]);
let sel = SelectionRange {
anchor: Point::new(0, 4),
head: Point::new(0, 9),
};
assert_eq!(sel.slice_text(&store), "quick");
}
#[test]
fn multi_line_slice_incluye_lineas_intermedias_completas() {
let store = store_of(&["uno dos", "tres cuatro", "cinco seis"]);
let sel = SelectionRange {
anchor: Point::new(0, 4),
head: Point::new(2, 5),
};
// De "dos" en línea 0 (col 4..7), TODA línea 1, hasta "cinco" en línea 2.
assert_eq!(sel.slice_text(&store), "dos\ntres cuatro\ncinco");
}
#[test]
fn col_range_on_recorta_solo_primera_y_ultima() {
let sel = SelectionRange {
anchor: Point::new(0, 4),
head: Point::new(2, 5),
};
assert_eq!(sel.col_range_on(0, 7), Some((4, 7))); // primera: recorta start
assert_eq!(sel.col_range_on(1, 11), Some((0, 11))); // intermedia: línea entera
assert_eq!(sel.col_range_on(2, 10), Some((0, 5))); // última: recorta end
assert_eq!(sel.col_range_on(3, 10), None); // fuera
}
#[test]
fn col_range_on_descarta_rango_vacio() {
// Si la selección termina en col 0 de una línea, su contribución a esa
// línea es 0 bytes → no se debe pintar nada.
let sel = SelectionRange {
anchor: Point::new(0, 4),
head: Point::new(1, 0),
};
assert_eq!(sel.col_range_on(1, 5), None);
}
#[test]
fn touches_line_chequea_rango_inclusivo() {
let sel = SelectionRange {
anchor: Point::new(2, 0),
head: Point::new(4, 0),
};
assert!(!sel.touches_line(1));
assert!(sel.touches_line(2));
assert!(sel.touches_line(3));
assert!(sel.touches_line(4));
assert!(!sel.touches_line(5));
}
#[test]
fn slice_text_clampa_col_fuera_de_texto() {
// col más allá del largo del texto → recorta al fin del texto, sin panic.
let store = store_of(&["hi"]);
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(0, 999),
};
assert_eq!(sel.slice_text(&store), "hi");
}
#[test]
fn slice_text_respeta_limites_utf8() {
// "héllo" — la 'é' es 2 bytes (0xC3 0xA9). Col 2 cae a mitad de char;
// debe redondear hacia abajo a col 1 (después de 'h'), no panic.
let store = store_of(&["héllo"]);
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(0, 2),
};
// col 2 → boundary 1 (después de 'h'); slice "h".
assert_eq!(sel.slice_text(&store), "h");
}
#[test]
fn slice_text_clampa_lineas_fuera_del_store() {
// El store tiene 2 líneas; la selección termina en la 5 → recorta a la 1.
let store = store_of(&["uno", "dos"]);
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(5, 999),
};
assert_eq!(sel.slice_text(&store), "uno\ndos");
}
#[test]
fn slice_text_de_seleccion_vacia_es_vacio_aun_con_anchor_no_nulo() {
// anchor == head → vacío, aún si están en (3, 5).
let store = store_of(&["abcd", "efgh", "ijkl", "mnop"]);
let sel = SelectionRange::collapsed(Point::new(3, 2));
assert_eq!(sel.slice_text(&store), "");
}
#[test]
fn slice_text_sobre_store_vacio_es_vacio() {
let store = Scrollback::new(0);
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(2, 5),
};
assert_eq!(sel.slice_text(&store), "");
}
fn rects<Msg>(
items: &[Item<Msg>],
scroll_y: f32,
viewport_h: f32,
gutter_w: f32,
store: &Scrollback,
sel: &SelectionRange,
) -> Vec<HighlightRect> {
let metrics = TermMetrics {
font_size: 12.0,
line_height: 16.0,
char_width: 8.0,
};
selection_rects(items, scroll_y, viewport_h, metrics, gutter_w, store, sel)
}
fn point(
items: &[Item<()>],
scroll_y: f32,
gutter_w: f32,
store: &Scrollback,
lx: f32,
ly: f32,
) -> Option<Point> {
let metrics = TermMetrics {
font_size: 12.0,
line_height: 16.0,
char_width: 8.0,
};
point_at(items, scroll_y, 100.0, metrics, gutter_w, store, lx, ly)
}
#[test]
fn point_at_resuelve_linea_y_columna_para_un_click_simple() {
let store = store_of(&["abcdef", "ghijkl"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 2)];
// Click en línea 1 (y = 20 → fila 1 con row_h=16), col visual = (50-30)/8 = 2.
let p = point(&items, 0.0, 30.0, &store, 50.0, 20.0).unwrap();
assert_eq!(p, Point::new(1, 2));
}
#[test]
fn point_at_clampea_click_fuera_del_texto_al_fin_de_linea() {
// Línea de 4 chars, click muy a la derecha → snap al fin (col 4).
let store = store_of(&["abcd"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
let p = point(&items, 0.0, 30.0, &store, 1000.0, 5.0).unwrap();
assert_eq!(p, Point::new(0, 4));
}
#[test]
fn point_at_en_el_gutter_cae_a_col_0() {
// Click dentro del gutter (lx < gutter_w) → col visual = 0 → byte col = 0.
let store = store_of(&["xyz"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
let p = point(&items, 0.0, 30.0, &store, 10.0, 5.0).unwrap();
assert_eq!(p, Point::new(0, 0));
}
#[test]
fn point_at_devuelve_none_para_chrome_o_fuera() {
// Item 0 = chrome (alto 24); item 1 = 2 líneas. Click en y=10 cae en chrome.
let store = store_of(&["aa", "bb"]);
let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
let items: Vec<Item<()>> = vec![Item::chrome(24.0, chrome_view), Item::lines(0, 2)];
assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 10.0), None);
// y > total: fuera del stream → None.
assert_eq!(point(&items, 0.0, 30.0, &store, 50.0, 1000.0), None);
}
#[test]
fn point_at_respeta_scroll_y() {
// 100 líneas; con scroll_y = 800, el click en y=8 cae en la línea
// floor((800+8)/16) = 50.
let lines: Vec<&str> = (0..100).map(|_| "ab").collect();
let store = store_of(&lines);
let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
let p = point(&items, 800.0, 30.0, &store, 30.0, 8.0).unwrap();
assert_eq!(p, Point::new(50, 0));
}
#[test]
fn point_at_convierte_visual_a_byte_para_utf8() {
// "héllo": vis 0='h', vis 1='é' (2 bytes), vis 2='l' (byte 3), vis 3='l' (byte 4).
let store = store_of(&["héllo"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
// Click en vis col 2 (lx = 30 + 4 + 2*8 = 50) — el +4 es
// TEXT_LEFT_PADDING_PX → byte col 3.
let p = point(&items, 0.0, 30.0, &store, 50.0, 5.0).unwrap();
assert_eq!(p, Point::new(0, 3));
}
#[test]
fn rects_de_seleccion_vacia_son_vacio() {
let store = store_of(&["abc"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
let sel = SelectionRange::collapsed(Point::new(0, 1));
assert_eq!(rects(&items, 0.0, 100.0, 30.0, &store, &sel), Vec::new());
}
#[test]
fn rect_single_line_ubica_x_y_w_correctos() {
// Línea 0 entera (3 chars). x = gutter + TEXT_LEFT_PADDING (4) + 0,
// w = 3 * char_w. El +4 es el padding interno del text_row.
let store = store_of(&["abc"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(0, 3),
};
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
assert_eq!(r.len(), 1);
let h = r[0];
assert_eq!(h.x, 34.0); // 30 + 4
assert_eq!(h.y, 0.0);
assert_eq!(h.w, 24.0); // 3 * 8.0
assert_eq!(h.h, 16.0);
}
#[test]
fn rect_multi_line_emite_uno_por_renglon() {
// 3 líneas, selección abarca las 3 (primera/última recortadas).
let store = store_of(&["alpha", "beta", "gamma"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 3)];
let sel = SelectionRange {
anchor: Point::new(0, 2), // "pha"
head: Point::new(2, 3), // "gam"
};
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
assert_eq!(r.len(), 3);
// Línea 0: chars 2..5 → x = 30 + 4 + 2*8 = 50, w = 3*8 = 24
assert_eq!(r[0].x, 50.0);
assert_eq!(r[0].w, 24.0);
// Línea 1 entera: "beta" (4 chars).
assert_eq!(r[1].x, 34.0);
assert_eq!(r[1].w, 32.0);
// Línea 2: chars 0..3 → "gam".
assert_eq!(r[2].x, 34.0);
assert_eq!(r[2].w, 24.0);
}
#[test]
fn rects_descartan_lineas_fuera_del_viewport() {
// 100 líneas, viewport 32 px (=2 filas), scroll a la mitad → sólo 2-3 rects.
let lines: Vec<&str> = (0..100).map(|_| "row").collect();
let store = store_of(&lines);
let items: Vec<Item<()>> = vec![Item::lines(0, 100)];
// Selección sobre TODAS las líneas, pero sólo 2-3 entran al viewport.
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(99, 3),
};
// scroll a la fila 50 (50 * 16 = 800 px). Viewport de 32 px → filas
// 50, 51 (+ guarda).
let r = rects(&items, 800.0, 32.0, 30.0, &store, &sel);
assert!(r.len() <= 3 && !r.is_empty(),
"esperado ~2-3 rects, no {} (todas las líneas)", r.len());
}
#[test]
fn rects_saltan_items_chrome() {
// Item 0 = chrome (alto 20), item 1 = 2 líneas. Selección sobre las dos
// líneas. El chrome no debe aportar rects.
let store = store_of(&["aa", "bb"]);
let chrome_view: llimphi_ui::View<()> = llimphi_ui::View::new(Default::default());
let items: Vec<Item<()>> = vec![Item::chrome(20.0, chrome_view), Item::lines(0, 2)];
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(1, 2),
};
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
assert_eq!(r.len(), 2);
// El primer rect arranca DESPUÉS del chrome (y = 20).
assert_eq!(r[0].y, 20.0);
// El segundo está una fila más abajo (20 + 16 = 36).
assert_eq!(r[1].y, 36.0);
}
#[test]
fn rects_usan_visual_cols_no_bytes_para_utf8() {
// "héllo" — 'é' es 2 bytes, pero 1 char visual. Selección de col 0 a
// col 3 (byte) → snap a 3 (después de "hé"), 2 chars visuales.
let store = store_of(&["héllo"]);
let items: Vec<Item<()>> = vec![Item::lines(0, 1)];
let sel = SelectionRange {
anchor: Point::new(0, 0),
head: Point::new(0, 3),
};
let r = rects(&items, 0.0, 100.0, 30.0, &store, &sel);
assert_eq!(r.len(), 1);
// 2 chars visuales × 8 px = 16.
assert_eq!(r[0].w, 16.0);
}
#[test]
fn slice_text_a_linea_intermedia_omite_las_que_no_existen() {
// Si una línea intermedia desaparece (no debería pasar acá pero el
// store sólo expone `line()`), se omite — no se inserta `\n` extra.
// Acá lo cubrimos indirectamente con un store contiguo.
let store = store_of(&["aa", "bb", "cc"]);
let sel = SelectionRange {
anchor: Point::new(0, 1),
head: Point::new(2, 1),
};
assert_eq!(sel.slice_text(&store), "a\nbb\nc");
}
}
+619
View File
@@ -0,0 +1,619 @@
//! Store de scrollback append-only (Capa 0 del SDD-TERMINAL).
//!
//! El texto vive en un buffer contiguo (`buf`) y un índice de offsets de inicio
//! de línea (`starts`, con una sentinela al final) da acceso a la línea N en
//! **O(1)**. El cap es por **MEMORIA** (bytes), no por número de líneas: al
//! excederse, se descartan líneas enteras del **frente** en un solo `drain` +
//! reindex (amortizado, no una vez por línea).
//!
//! Las líneas descartadas se **cuentan** (`dropped`), de modo que cada línea
//! tiene un **id global estable** (`line_id = dropped + idx`) que sobrevive al
//! recorte del frente. Eso permite anclar el scroll a un id (no a px desde el
//! fondo) y preservar la posición de lectura mientras llega output —
//! exactamente la deuda B del PLAN-OUTPUT, que acá nace resuelta de raíz.
//!
//! Una línea es **un renglón lógico sin `'\n'`** (el caller lo separa; en shuma
//! cada `OutputLine` ya es una línea). El store no interpreta el contenido.
/// Límite de memoria por defecto del scrollback: 64 MiB ≈ cientos de miles de
/// líneas. "Infinito" en la práctica = "acotado por una memoria que elegís".
pub const DEFAULT_LIMIT_BYTES: usize = 64 * 1024 * 1024;
/// Persistencia opcional de las líneas que el cap recorta del frente: en vez
/// de tirarlas, las appendea a un archivo y guarda `(offset, len)` por línea
/// para lookup random posterior. Es lo que habilita "scrollback infinito"
/// (Fase 5 del SDD-TERMINAL) cuando el shell corre por horas y la memoria
/// no alcanza para todo el output histórico.
///
/// Diseño:
///
/// - **Archivo append-only**: cada línea se escribe verbatim (sin separador
/// intermedio — la longitud está en el índice). Crecimiento monótono.
/// - **Índice en memoria** `(offset, len)` por línea, indexado por
/// `global_id` (el mismo id estable del `Scrollback`). Random access O(1).
/// - **UTF-8 in/out**: el caller pasa `&str`, el read devuelve `String`. Si
/// el archivo se corrompe (improbable mientras nadie más lo toque), el
/// read devuelve `InvalidData`.
#[derive(Debug)]
pub struct SpillStore {
file: std::fs::File,
/// `(offset_in_file, byte_len)` por línea spilleada, indexada por
/// posición en este Vec (no por `global_id` — restamos `base_id` al
/// indexar si en el futuro permitimos descartar también las viejas).
entries: Vec<(u64, u32)>,
path: std::path::PathBuf,
}
impl SpillStore {
/// Crea o abre un spill file en `path`. Si el archivo existe, lo trunca
/// (el caller decide si quiere persistencia inter-sesión, generalmente
/// no — el shell tipicamente arranca con un spill nuevo). Devuelve
/// `Err` si no se puede crear (permisos, disco lleno).
pub fn create(path: impl Into<std::path::PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let file = std::fs::OpenOptions::new()
.write(true)
.read(true)
.create(true)
.truncate(true)
.open(&path)?;
Ok(Self {
file,
entries: Vec::new(),
path,
})
}
/// Append de una línea al spill. Devuelve el índice (`entries.len()-1`)
/// donde queda registrada. NO inserta separador — el largo está en el
/// índice. Errores: `WriteZero`/`Interrupted` y otros transientes se
/// devuelven; el caller puede decidir reintentar o ignorar.
pub fn append(&mut self, text: &str) -> std::io::Result<usize> {
use std::io::{Seek, SeekFrom, Write};
let offset = self.file.seek(SeekFrom::End(0))?;
self.file.write_all(text.as_bytes())?;
// Flush por seguridad — el shell puede crashear y queremos el
// archivo legible. Cost ~µs en SSD; aceptable.
self.file.flush()?;
self.entries.push((offset, text.len() as u32));
Ok(self.entries.len() - 1)
}
/// Lee la línea spilleada con índice `i` (0-based dentro del spill, NO
/// el `global_id`). `None` si fuera de rango.
pub fn read(&mut self, i: usize) -> std::io::Result<Option<String>> {
use std::io::{Read, Seek, SeekFrom};
let Some(&(off, len)) = self.entries.get(i) else {
return Ok(None);
};
self.file.seek(SeekFrom::Start(off))?;
let mut buf = vec![0u8; len as usize];
self.file.read_exact(&mut buf)?;
String::from_utf8(buf).map(Some).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})
}
/// Cantidad de líneas spilleadas hasta ahora.
pub fn len(&self) -> usize {
self.entries.len()
}
/// `true` si todavía no spilleó ninguna línea.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Path del archivo del spill (informativo). El caller lo puede usar
/// para mostrarlo en una notice tipo "salida volcada a /tmp/...".
pub fn path(&self) -> &std::path::Path {
&self.path
}
}
/// Store de scrollback append-only con índice de líneas y cap por memoria.
///
/// Invariantes:
/// - `starts` siempre tiene al menos un elemento (la sentinela) y es monótono
/// creciente; `starts[len()] == buf.len()`.
/// - `len() == starts.len() - 1`.
/// - `line(i)` ⊆ `buf` para todo `i < len()`.
#[derive(Debug)]
pub struct Scrollback {
/// Texto de todas las líneas vigentes, concatenado sin separadores.
buf: String,
/// `starts[i]` = offset de inicio de la línea `i` en `buf`. El último
/// elemento es la sentinela (`== buf.len()`), así `line(i)` es
/// `buf[starts[i]..starts[i+1]]` sin casos especiales para la última.
starts: Vec<usize>,
/// Cuántas líneas se descartaron del frente desde el último `clear`. Hace
/// estable la numeración/los ids globales aunque el frente se recorte.
dropped: u64,
/// Cap de memoria del texto (`buf.len()`), en bytes.
limit_bytes: usize,
/// Spill opcional: cuando se setea, las líneas que `enforce_limit` saca
/// del frente NO se pierden — se appendean al spill y quedan
/// recuperables vía `read_spilled` (Fase 5 del SDD-TERMINAL). El
/// `Arc<Mutex<>>` deja que el `Scrollback` sea Clone aunque
/// `SpillStore` no lo sea (file handles no son Clone).
spill: Option<std::sync::Arc<std::sync::Mutex<SpillStore>>>,
}
impl Clone for Scrollback {
fn clone(&self) -> Self {
// Clone share el spill (Arc) — las dos instancias appendean al MISMO
// archivo, lo que es lo único razonable: el spill es la verdad
// sobre las líneas viejas, no hay forma de "clonar el archivo".
Self {
buf: self.buf.clone(),
starts: self.starts.clone(),
dropped: self.dropped,
limit_bytes: self.limit_bytes,
spill: self.spill.clone(),
}
}
}
impl Default for Scrollback {
fn default() -> Self {
Self::new(DEFAULT_LIMIT_BYTES)
}
}
impl Scrollback {
/// Store vacío con un cap de memoria explícito (bytes del texto). Un
/// `limit_bytes` de `0` se trata como "sin tope práctico" (no recorta).
pub fn new(limit_bytes: usize) -> Self {
Self {
buf: String::new(),
starts: vec![0],
dropped: 0,
limit_bytes,
spill: None,
}
}
/// Habilita el spill: las líneas que se recorten del frente se
/// appendean a `spill` en vez de descartarse. El caller construye el
/// `SpillStore` con `SpillStore::create(path)`.
pub fn enable_spill(&mut self, spill: SpillStore) {
self.spill = Some(std::sync::Arc::new(std::sync::Mutex::new(spill)));
}
/// `true` si este scrollback tiene spill activo.
pub fn has_spill(&self) -> bool {
self.spill.is_some()
}
/// Cantidad de líneas spilleadas hasta ahora (`0` si no hay spill).
pub fn spilled_count(&self) -> usize {
match self.spill.as_ref() {
Some(s) => s.lock().map(|g| g.len()).unwrap_or(0),
None => 0,
}
}
/// Path del archivo de spill, si está activo. Lo expone el shell con
/// `:scrollback` para que el usuario pueda abrirlo / `cat`-earlo /
/// buscar grep en él. `None` si no hay spill.
pub fn spill_path(&self) -> Option<std::path::PathBuf> {
self.spill
.as_ref()
.and_then(|s| s.lock().ok().map(|g| g.path().to_path_buf()))
}
/// Lee una línea spilleada por su id global. Lookup O(1) en el índice
/// del spill + un `seek` + `read` en el archivo. Devuelve `None` si
/// `global_id >= spilled_count` o no hay spill. La línea sigue contando
/// con `dropped`/`total_pushed` originales (el id global persiste).
pub fn read_spilled(&self, global_id: u64) -> std::io::Result<Option<String>> {
let Some(spill) = self.spill.as_ref() else { return Ok(None) };
let mut guard = match spill.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
// El spill indexa por orden de append, que coincide con el
// global_id 0-based (la primera dropped es la 0ª, la segunda la
// 1ª, etc.).
guard.read(global_id as usize)
}
/// Appendea **un renglón lógico** (sin `'\n'`; si lo trae, se guarda
/// verbatim — el caller separa). Tras appendear, recorta el frente si el
/// texto excede `limit_bytes`.
pub fn push_line(&mut self, text: &str) {
self.buf.push_str(text);
self.starts.push(self.buf.len());
self.enforce_limit();
}
/// Recorta líneas enteras del frente hasta que `buf.len() <= limit_bytes`,
/// en un solo `drain` + reindex. No-op si `limit_bytes == 0` o ya cabe.
fn enforce_limit(&mut self) {
if self.limit_bytes == 0 || self.buf.len() <= self.limit_bytes {
return;
}
// Bytes que sobran respecto del tope: hay que liberar al menos esto del
// frente. Buscamos el primer `k` cuyo offset de inicio deje `buf` bajo
// el tope (`buf.len() - starts[k] <= limit`, i.e. `starts[k] >=
// need_free`).
let need_free = self.buf.len() - self.limit_bytes;
let k = self.starts.partition_point(|&s| s < need_free);
// No tirar la sentinela: como mucho dejamos el store vacío (línea única
// más grande que el cap entero, caso patológico).
let k = k.min(self.len());
if k == 0 {
return;
}
let cut = self.starts[k];
// Antes de borrar las líneas del frente, las spillamos a disco si
// hay spill configurado. El append es por línea (cada una con su
// longitud en el índice), así el read random por `global_id`
// sigue trivial.
if let Some(spill) = self.spill.as_ref() {
let mut guard = match spill.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
for i in 0..k {
let lo = self.starts[i];
let hi = self.starts[i + 1];
// Errores de I/O se ignoran silenciosamente: el spill es
// mejor-esfuerzo; perder una línea spilleada por disco
// lleno no debe colgar el shell. El caller que quiera
// chequear que el spill esté vivo puede mirar `spilled_count`
// vs `dropped` y avisar al usuario.
let _ = guard.append(&self.buf[lo..hi]);
}
}
self.buf.drain(0..cut);
self.starts.drain(0..k);
for s in &mut self.starts {
*s -= cut;
}
self.dropped += k as u64;
}
/// Cantidad de líneas vigentes en el store.
pub fn len(&self) -> usize {
self.starts.len() - 1
}
/// `true` si no hay líneas vigentes.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Línea `idx` (0-based, vigente) en **O(1)**. `None` fuera de rango.
pub fn line(&self, idx: usize) -> Option<&str> {
if idx + 1 >= self.starts.len() {
return None;
}
Some(&self.buf[self.starts[idx]..self.starts[idx + 1]])
}
/// Líneas descartadas del frente desde el último `clear`.
pub fn dropped(&self) -> u64 {
self.dropped
}
/// Total de líneas que pasaron por el store desde el último `clear`
/// (`dropped + len`). Es el número de la próxima línea (0-based global).
pub fn total_pushed(&self) -> u64 {
self.dropped + self.len() as u64
}
/// Número **global 1-based** de la línea `idx` (para la numeración del
/// gutter): estable aunque el frente se recorte.
pub fn line_number(&self, idx: usize) -> u64 {
self.dropped + idx as u64 + 1
}
/// Id **global estable** de la línea `idx` (`dropped + idx`): sobrevive al
/// recorte del frente. Para anclar el scroll a una línea concreta.
pub fn line_id(&self, idx: usize) -> u64 {
self.dropped + idx as u64
}
/// Índice vigente del id global `id`, si la línea sigue en el store
/// (no se recortó del frente ni es futura). `None` si no.
pub fn index_of_id(&self, id: u64) -> Option<usize> {
if id < self.dropped {
return None;
}
let idx = (id - self.dropped) as usize;
(idx < self.len()).then_some(idx)
}
/// Bytes del texto vigente (lo que cuenta para el cap).
pub fn byte_len(&self) -> usize {
self.buf.len()
}
/// Cap de memoria configurado.
pub fn limit_bytes(&self) -> usize {
self.limit_bytes
}
/// Texto de las líneas `[start, end)` unido por `'\n'` — para copiar al
/// clipboard una selección de filas. Recorta `end` a `len()`; rango vacío o
/// invertido → cadena vacía.
pub fn slice_text(&self, start: usize, end: usize) -> String {
let end = end.min(self.len());
if start >= end {
return String::new();
}
let mut out = String::with_capacity(self.starts[end] - self.starts[start]);
for i in start..end {
if i > start {
out.push('\n');
}
out.push_str(self.line(i).unwrap_or(""));
}
out
}
/// Vacía el store y **reinicia** la numeración (`dropped = 0`) — el
/// equivalente del builtin `clear` del shell: se empieza de cero.
pub fn clear(&mut self) {
self.buf.clear();
self.starts.clear();
self.starts.push(0);
self.dropped = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_store_is_empty() {
let s = Scrollback::new(1024);
assert!(s.is_empty());
assert_eq!(s.len(), 0);
assert_eq!(s.line(0), None);
assert_eq!(s.byte_len(), 0);
}
#[test]
fn push_and_access_o1() {
let mut s = Scrollback::new(1024);
s.push_line("uno");
s.push_line("dos");
s.push_line("tres");
assert_eq!(s.len(), 3);
assert_eq!(s.line(0), Some("uno"));
assert_eq!(s.line(1), Some("dos"));
assert_eq!(s.line(2), Some("tres"));
assert_eq!(s.line(3), None);
}
#[test]
fn line_numbers_are_one_based_global() {
let mut s = Scrollback::new(1024);
s.push_line("a");
s.push_line("b");
assert_eq!(s.line_number(0), 1);
assert_eq!(s.line_number(1), 2);
assert_eq!(s.line_id(0), 0);
assert_eq!(s.line_id(1), 1);
assert_eq!(s.total_pushed(), 2);
}
#[test]
fn cap_drops_front_and_keeps_global_numbering() {
// Tope chico: ~ cada línea ocupa "linea_N" (7-8 bytes). Con tope 20 sólo
// entran ~2-3 líneas; las viejas se recortan del frente.
let mut s = Scrollback::new(20);
for i in 0..50 {
s.push_line(&format!("L{i:04}")); // 5 bytes c/u
}
// Sigue bajo el tope.
assert!(s.byte_len() <= 20, "byte_len {} excede el tope", s.byte_len());
// Hubo recorte del frente.
assert!(s.dropped() > 0);
// La numeración global sigue siendo correcta: la última línea es la 50ª
// (1-based), id global 49.
let last = s.len() - 1;
assert_eq!(s.line(last), Some("L0049"));
assert_eq!(s.line_number(last), 50);
assert_eq!(s.line_id(last), 49);
// total_pushed cuenta todo lo que pasó (49 dropped + len vigente = 50).
assert_eq!(s.total_pushed(), 50);
}
#[test]
fn dropped_lines_are_not_accessible_but_ids_resolve() {
let mut s = Scrollback::new(20);
for i in 0..50 {
s.push_line(&format!("L{i:04}"));
}
let dropped = s.dropped();
assert!(dropped > 0);
// Un id ya recortado no resuelve a índice vigente.
assert_eq!(s.index_of_id(0), None);
// El id de la primera línea vigente resuelve a índice 0.
let first_id = s.line_id(0);
assert_eq!(first_id, dropped);
assert_eq!(s.index_of_id(first_id), Some(0));
// Un id futuro tampoco resuelve.
assert_eq!(s.index_of_id(s.total_pushed() + 5), None);
}
#[test]
fn id_survives_front_drop() {
// Un id apuntado antes de un recorte sigue apuntando a la MISMA línea
// (mientras siga vigente), aunque su índice cambie.
let mut s = Scrollback::new(40);
for i in 0..10 {
s.push_line(&format!("L{i:04}"));
}
// Tomamos el id de una línea concreta por su texto.
let idx = (0..s.len()).find(|&i| s.line(i) == Some("L0007")).unwrap();
let id = s.line_id(idx);
// Llega más output → se recorta más frente.
for i in 10..20 {
s.push_line(&format!("L{i:04}"));
}
// El id sigue resolviendo a la línea "L0007" si no fue recortada.
if let Some(now) = s.index_of_id(id) {
assert_eq!(s.line(now), Some("L0007"), "el id debe seguir apuntando a la misma línea");
}
// (Si "L0007" ya se recortó, index_of_id devuelve None — también válido.)
}
#[test]
fn slice_text_joins_with_newlines() {
let mut s = Scrollback::new(1024);
for l in ["alfa", "beta", "gamma", "delta"] {
s.push_line(l);
}
assert_eq!(s.slice_text(1, 3), "beta\ngamma");
assert_eq!(s.slice_text(0, 4), "alfa\nbeta\ngamma\ndelta");
// Rango clampeado y vacío.
assert_eq!(s.slice_text(2, 999), "gamma\ndelta");
assert_eq!(s.slice_text(3, 3), "");
assert_eq!(s.slice_text(5, 2), "");
}
#[test]
fn clear_resets_buffer_and_numbering() {
let mut s = Scrollback::new(20);
for i in 0..50 {
s.push_line(&format!("L{i:04}"));
}
assert!(s.dropped() > 0);
s.clear();
assert!(s.is_empty());
assert_eq!(s.dropped(), 0);
assert_eq!(s.byte_len(), 0);
// Tras clear la numeración arranca de nuevo en 1.
s.push_line("nuevo");
assert_eq!(s.line_number(0), 1);
assert_eq!(s.line(0), Some("nuevo"));
}
#[test]
fn zero_limit_means_no_cap() {
let mut s = Scrollback::new(0);
for i in 0..1000 {
s.push_line(&format!("linea {i}"));
}
assert_eq!(s.len(), 1000);
assert_eq!(s.dropped(), 0);
assert_eq!(s.line(999), Some("linea 999"));
}
#[test]
fn unicode_lines_are_sliced_on_char_boundaries() {
// El índice usa offsets de byte; appendeamos líneas completas, así que
// los cortes caen siempre en frontera de carácter (inicio de línea).
let mut s = Scrollback::new(1024);
s.push_line("café ☕");
s.push_line("niño ñ");
assert_eq!(s.line(0), Some("café ☕"));
assert_eq!(s.line(1), Some("niño ñ"));
assert_eq!(s.slice_text(0, 2), "café ☕\nniño ñ");
}
#[test]
fn spill_archiva_lineas_recortadas_y_las_lee_random() {
// Setup: store con cap chico + spill a un archivo temporal. Tras
// muchas appends, las líneas viejas viven en el spill y se leen
// de vuelta por su id global.
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("spill.log");
let spill = SpillStore::create(&path).expect("spill create");
let mut s = Scrollback::new(20);
s.enable_spill(spill);
assert!(s.has_spill());
for i in 0..50 {
s.push_line(&format!("L{i:04}"));
}
// Hubo recorte → spill tiene entries.
assert!(s.dropped() > 0);
assert_eq!(s.spilled_count() as u64, s.dropped(), "todas las dropped van al spill");
// Una línea concreta del spill — la 5ª (id=5) → "L0005".
let read = s.read_spilled(5).expect("read").expect("entry");
assert_eq!(read, "L0005");
// La primera (id=0).
let first = s.read_spilled(0).expect("read").expect("entry");
assert_eq!(first, "L0000");
// Una línea fuera de rango (un id futuro).
let none = s.read_spilled(99999).expect("read");
assert!(none.is_none());
}
#[test]
fn sin_spill_read_spilled_es_none() {
let mut s = Scrollback::new(20);
for i in 0..50 {
s.push_line(&format!("L{i:04}"));
}
// Hubo recorte pero no hay spill → no se puede recuperar.
assert!(s.dropped() > 0);
assert_eq!(s.spilled_count(), 0);
assert!(s.read_spilled(0).expect("read").is_none());
}
#[test]
fn spill_sobrevive_a_clones_del_scrollback() {
// `Scrollback` es Clone (Pata clona el state del shell); el spill
// se comparte por Arc, así las dos instancias appendean al MISMO
// archivo. Acá comprobamos que el spilled_count se ve desde ambos.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("spill.log");
let spill = SpillStore::create(&path).unwrap();
let mut a = Scrollback::new(20);
a.enable_spill(spill);
for i in 0..30 {
a.push_line(&format!("L{i:04}"));
}
let b = a.clone();
// El clon ve el mismo spilled_count.
assert_eq!(a.spilled_count(), b.spilled_count());
// Y puede leer las mismas líneas.
let from_a = a.read_spilled(2).unwrap().unwrap();
let from_b = b.read_spilled(2).unwrap().unwrap();
assert_eq!(from_a, from_b);
}
#[test]
fn spill_almacena_utf8_intacto() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("spill.log");
let spill = SpillStore::create(&path).unwrap();
let mut s = Scrollback::new(15);
s.enable_spill(spill);
s.push_line("café ☕");
s.push_line("niño ñ");
s.push_line("hello world"); // empuja las anteriores a spill
assert!(s.dropped() > 0);
let cafe = s.read_spilled(0).unwrap().unwrap();
assert_eq!(cafe, "café ☕");
let nino = s.read_spilled(1).unwrap().unwrap();
assert_eq!(nino, "niño ñ");
}
#[test]
fn large_append_stays_under_cap_and_indexes_correctly() {
// Muchas líneas, tope moderado: el store se mantiene acotado y el acceso
// sigue correcto en todo el rango vigente.
let mut s = Scrollback::new(4096);
for i in 0..100_000 {
s.push_line(&format!("fila numero {i}"));
}
assert!(s.byte_len() <= 4096);
assert!(s.dropped() > 0);
// Todas las líneas vigentes son accesibles y coherentes con su número.
for idx in 0..s.len() {
let n = s.line_number(idx); // 1-based global
let expected = format!("fila numero {}", n - 1);
assert_eq!(s.line(idx), Some(expected.as_str()));
}
// La última empujada fue la 100000ª.
assert_eq!(s.total_pushed(), 100_000);
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Tipos de la superficie + render virtualizado **modo línea** (Capas 12).
//!
//! Acá viven los tipos compartidos ([`TermMetrics`], [`TermPalette`],
//! [`LineStyle`]) y la matemática pura de la ventana visible de **filas
//! uniformes** ([`visible_window`]). El render modo línea, [`line_surface`], es
//! el caso particular —un solo bloque de líneas que cubre todo el store— del
//! modelo de bloques general de [`crate::blocks`] (Fase 2): delega en
//! [`crate::blocks::block_surface`] para no duplicar la maquinaria de
//! virtualización ni los builders de fila.
//!
//! La apuesta del SDD: scrollback **ilimitado** a costo de render
//! **constante** — sólo se materializan las filas que caen en el viewport. El
//! scroll vive en el **propio widget** (un `scroll_y` en px que el caller
//! guarda en su Model), NO en un `transform` del panel sobre contenido alto
//! (esa fue la fuente del bug clip+transform que ya costó — SDD §"Anti-features").
//!
//! El widget es agnóstico de shuma: el color/tinte de cada renglón los **inyecta
//! el caller** vía `line_style` (Regla 2). La numeración del gutter es la global
//! 1-based del store, estable aunque el frente se recorte.
use std::sync::{Arc, Mutex};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_widget_scroll::max_offset;
use crate::blocks::{block_surface, Item};
use crate::store::Scrollback;
/// Métricas de la superficie — todo derivado del `font_size`. Asume fuente
/// monoespaciada (la mono embebida de `llimphi-text`): `char_width` es el
/// avance fijo de un carácter, base para columnar y para ubicar la selección
/// (Fase 3).
#[derive(Debug, Clone, Copy)]
pub struct TermMetrics {
pub font_size: f32,
/// Alto de cada renglón, en px (`font_size * 1.4`).
pub line_height: f32,
/// Avance de un carácter mono, en px (`font_size * 0.6`).
pub char_width: f32,
}
impl Default for TermMetrics {
fn default() -> Self {
Self::for_font_size(13.0)
}
}
impl TermMetrics {
pub const fn for_font_size(font_size: f32) -> Self {
Self {
font_size,
line_height: font_size * 1.4,
char_width: font_size * 0.6,
}
}
}
/// Paleta de la superficie. Defaults dark, derivables del [`Theme`].
///
/// [`Theme`]: llimphi_theme::Theme
#[derive(Debug, Clone, Copy)]
pub struct TermPalette {
/// Fondo del área de texto.
pub bg: Color,
/// Fondo del gutter (columna de números).
pub bg_gutter: Color,
/// Color del texto por defecto (cuando `LineStyle::fg` no se pisa).
pub fg_text: Color,
/// Color de los números de línea del gutter.
pub fg_line_number: Color,
/// Track de la barra de scroll.
pub bar_track: Color,
/// Thumb de la barra en reposo.
pub bar_thumb: Color,
/// Thumb de la barra al pasar el cursor.
pub bar_thumb_hover: Color,
/// Fondo translúcido del highlight de selección (overlay por renglón
/// sobre los rangos seleccionados). Caller elige el alpha; el widget lo
/// pinta literal sobre el texto sin tintarlo.
pub bg_selection: Color,
}
impl Default for TermPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TermPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// Selección: accent del tema con alpha bajo (~30%) — el texto se lee
// y el rango queda evidente sin tapar el color del texto.
let acc = t.accent.to_rgba8();
let bg_selection = Color::from_rgba8(acc.r, acc.g, acc.b, 80);
Self {
bg: t.bg_input,
bg_gutter: t.bg_panel,
fg_text: t.fg_text,
fg_line_number: t.fg_muted,
bar_track: t.bg_panel_alt,
bar_thumb: t.border,
bar_thumb_hover: t.accent,
bg_selection,
}
}
}
/// Estilo de un renglón concreto, provisto por el caller. El widget no decide
/// color: shuma tinta `ls`/paths/urls (vía `runs`), marca `stderr` con un
/// `bg`, etc., sin que la superficie sepa de comandos.
#[derive(Debug, Clone, Default)]
pub struct LineStyle {
/// Color base del texto del renglón. `None` → `palette.fg_text`.
pub fg: Option<Color>,
/// Overrides de color por rango de **bytes** del renglón
/// (`(start, end, color)`) — coloreo semántico (ls, syntax). Se clampean
/// al largo real del texto.
pub runs: Vec<(usize, usize, Color)>,
/// Tinte de fondo del renglón completo (p. ej. `stderr` en rojo tenue).
/// El caller elige el alpha; el widget lo pinta literal.
pub bg: Option<Color>,
}
impl LineStyle {
/// Renglón con un color base y sin runs ni tinte — el caso común.
pub fn fg(color: Color) -> Self {
Self {
fg: Some(color),
..Default::default()
}
}
}
/// La ventana de filas visibles dado el scroll y el viewport. Resultado puro y
/// testeable: el corazón de la virtualización de **filas uniformes**.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisibleWindow {
/// Primera fila a materializar (índice 0-based **vigente** en el store).
pub first: usize,
/// Una más allá de la última fila a materializar (exclusivo).
pub last: usize,
/// Píxeles del scroll que caen **dentro** de la primera fila — el desfase
/// con el que la columna de filas se sube para que el scroll sea suave por
/// sub-renglón (no salta de fila en fila).
pub partial_px: i64,
}
impl VisibleWindow {
/// Cantidad de filas que esta ventana materializa.
pub fn count(&self) -> usize {
self.last.saturating_sub(self.first)
}
}
/// Alto total del contenido (px) si se pintara entero — `filas * alto_fila`.
/// El scrollbar lo usa para dimensionar el thumb; nunca se materializa así.
pub fn content_height(total_rows: usize, row_h: f32) -> f32 {
total_rows as f32 * row_h
}
/// El `scroll_y` que ancla el contenido **al fondo** (estilo terminal): el
/// máximo offset posible. El caller lo fija mientras el usuario no scrollee
/// arriba, así el append mantiene el fondo pegado.
pub fn scroll_to_bottom(total_rows: usize, viewport_h: f32, row_h: f32) -> f32 {
max_offset(content_height(total_rows, row_h), viewport_h)
}
/// Calcula la ventana de filas a materializar para un stream **de filas
/// uniformes** (sin bloques). **Puro** — sin GPU, sin Views.
///
/// `scroll_y` se clampea a `[0, max_offset]` acá mismo (defensa en
/// profundidad). La ventana incluye una fila de guarda extra al fondo para
/// cubrir el renglón parcialmente visible del borde inferior.
pub fn visible_window(
total_rows: usize,
scroll_y: f32,
viewport_h: f32,
row_h: f32,
) -> VisibleWindow {
if total_rows == 0 || row_h <= 0.0 || viewport_h <= 0.0 {
return VisibleWindow {
first: 0,
last: 0,
partial_px: 0,
};
}
let content_h = content_height(total_rows, row_h);
let max_off = (content_h - viewport_h).max(0.0);
let off = scroll_y.clamp(0.0, max_off);
let first = ((off / row_h).floor() as usize).min(total_rows.saturating_sub(1));
// Desfase sub-renglón: cuánto del primer renglón ya pasó por arriba.
let partial_px = (off - first as f32 * row_h).round() as i64;
// Filas que entran en el viewport + el desfase + una de guarda al fondo.
let rows_in_view = ((viewport_h + partial_px as f32) / row_h).ceil() as usize + 1;
let last = (first + rows_in_view).min(total_rows);
VisibleWindow {
first,
last,
partial_px,
}
}
/// Superficie de terminal **modo línea, virtualizada** — el caso de **un solo
/// bloque** de líneas que cubre todo el store. Delega en
/// [`crate::blocks::block_surface`].
///
/// `on_scroll(delta_px)` se invoca con el delta a sumar a `scroll_y` (rueda y
/// arrastre de la barra); el caller acumula y clampea con
/// [`llimphi_widget_scroll::clamp_offset`] en su `update`. `line_style(idx,
/// texto)` da el color/tinte de cada renglón visible. `measure`, si se provee,
/// recibe el alto real del viewport en cada paint (patrón de medición del shell).
#[allow(clippy::too_many_arguments)]
pub fn line_surface<Msg, S, F>(
store: &Scrollback,
scroll_y: f32,
viewport_h: f32,
metrics: TermMetrics,
palette: &TermPalette,
line_style: S,
on_scroll: F,
measure: Option<Arc<Mutex<f32>>>,
) -> View<Msg>
where
Msg: Clone + 'static,
S: Fn(usize, &str) -> LineStyle,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
block_surface(
store,
vec![Item::lines(0, store.len())],
scroll_y,
viewport_h,
metrics,
palette,
line_style,
on_scroll,
measure,
)
}
#[cfg(test)]
mod tests {
use super::*;
const ROW: f32 = 18.0;
#[test]
fn empty_store_no_window() {
let w = visible_window(0, 0.0, 600.0, ROW);
assert_eq!(w.count(), 0);
assert_eq!(w, VisibleWindow { first: 0, last: 0, partial_px: 0 });
}
#[test]
fn window_at_top_is_constant_cost() {
// 1 M de filas, viewport de 600 px → ~34 filas + guarda, NO un millón.
let total = 1_000_000;
let w = visible_window(total, 0.0, 600.0, ROW);
assert_eq!(w.first, 0);
assert_eq!(w.partial_px, 0);
// ceil(600/18)+1 = 34+1 = 35.
assert_eq!(w.count(), 35);
assert!(w.count() < 50, "el costo debe ser constante, no {total}");
}
#[test]
fn window_in_the_middle_has_partial_offset() {
// Scroll a 1000 px: la primera fila visible es floor(1000/18)=55,
// y el desfase sub-renglón es 1000 - 55*18 = 1000 - 990 = 10.
let w = visible_window(1_000_000, 1000.0, 600.0, ROW);
assert_eq!(w.first, 55);
assert_eq!(w.partial_px, 10);
assert!(w.count() < 50);
assert!(w.last <= 1_000_000);
}
#[test]
fn scroll_clamps_to_bottom() {
// Scroll exagerado → se clampa al máximo; la última fila visible es la
// última del store, sin pasarse.
let total = 500;
let w = visible_window(total, 1e9, 600.0, ROW);
assert_eq!(w.last, total);
let bottom = scroll_to_bottom(total, 600.0, ROW);
let w2 = visible_window(total, bottom, 600.0, ROW);
assert_eq!(w2.last, total);
}
#[test]
fn content_smaller_than_viewport_shows_all() {
// 10 filas en 600 px de viewport: entran todas, sin scroll.
let w = visible_window(10, 0.0, 600.0, ROW);
assert_eq!(w.first, 0);
assert_eq!(w.last, 10);
assert_eq!(scroll_to_bottom(10, 600.0, ROW), 0.0);
}
#[test]
fn window_count_independent_of_scrollback_size() {
// La invariante central del SDD: el costo no depende del total.
let a = visible_window(1_000, 9000.0, 600.0, ROW).count();
let b = visible_window(10_000_000, 9000.0, 600.0, ROW).count();
assert_eq!(a, b);
}
}
@@ -0,0 +1,178 @@
//! Smoke test del `CellPipeline` (Fase 4.2 del SDD-TERMINAL).
//!
//! No verifica píxeles — eso requiere conocer la fuente exacta, font hinting
//! del rasterizer y un pipeline de comparación. Sí verifica:
//!
//! - `CellPipeline::new` compila el shader WGSL sin errores de naga.
//! - `create_atlas_texture` sube bytes a una `R8Unorm` sin pánico.
//! - `draw` ejecuta sin errores wgpu con un atlas vivo y N instancias —
//! por debajo y por arriba del cap del adapter, sin reasignar buffers.
//!
//! Corre en cualquier adapter wgpu disponible (en CI sin GPU = llvmpipe).
use llimphi_hal::{wgpu, Hal};
use llimphi_widget_terminal::cell_pipeline::{
pack_rgba, CellInstance, CellPipeline, CellUniforms,
};
use llimphi_widget_terminal::glyph_atlas::GlyphAtlas;
const W: u32 = 256;
const H: u32 = 256;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("cell-smoke-target"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}
#[test]
fn pipeline_compila_y_dibuja_sin_panico() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let pipeline = CellPipeline::new(&hal.device, FMT);
let (_tex, view) = make_target(&hal.device);
// Atlas con un par de glifos.
let mut atlas = GlyphAtlas::new(
llimphi_ui::llimphi_text::MONO_FONT_BYTES,
14.0,
16,
4,
)
.expect("atlas");
let slot_a = atlas.glyph_for('A').unwrap();
let slot_b = atlas.glyph_for('B').unwrap();
let (atlas_w, atlas_h) = atlas.size();
let (cell_w, cell_h) = atlas.cell_size();
let (_atlas_tex, atlas_view) =
CellPipeline::create_atlas_texture(&hal.device, &hal.queue, atlas.pixels(), atlas.size());
// Dos celdas, A y B en (0,0) y (cell_w,0).
let cells = vec![
CellInstance {
cell_x: 0.0,
cell_y: 0.0,
uv_x: slot_a.px as f32,
uv_y: slot_a.py as f32,
uv_w: cell_w as f32,
uv_h: cell_h as f32,
fg_rgba: pack_rgba(255, 255, 255, 255),
bg_rgba: pack_rgba(20, 20, 20, 255),
},
CellInstance {
cell_x: cell_w as f32,
cell_y: 0.0,
uv_x: slot_b.px as f32,
uv_y: slot_b.py as f32,
uv_w: cell_w as f32,
uv_h: cell_h as f32,
fg_rgba: pack_rgba(100, 255, 100, 255),
bg_rgba: pack_rgba(0, 0, 0, 255),
},
];
let uniforms = CellUniforms {
viewport_w: W as f32,
viewport_h: H as f32,
cell_w: cell_w as f32,
cell_h: cell_h as f32,
atlas_w: atlas_w as f32,
atlas_h: atlas_h as f32,
_pad0: 0.0,
_pad1: 0.0,
};
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("cell-smoke-encoder"),
});
// Clear primero para tener un load:Load coherente.
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("cell-smoke-clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
pipeline.draw(
&hal.device,
&hal.queue,
&mut encoder,
&view,
&atlas_view,
&cells,
uniforms,
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::PollType::wait_indefinitely());
}
#[test]
fn draw_con_cero_instancias_es_no_op() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let pipeline = CellPipeline::new(&hal.device, FMT);
let (_tex, view) = make_target(&hal.device);
let mut atlas = GlyphAtlas::new(
llimphi_ui::llimphi_text::MONO_FONT_BYTES,
14.0,
16,
4,
)
.unwrap();
let _ = atlas.glyph_for('A'); // tener algo en el atlas
let (_atlas_tex, atlas_view) =
CellPipeline::create_atlas_texture(&hal.device, &hal.queue, atlas.pixels(), atlas.size());
let (atlas_w, atlas_h) = atlas.size();
let (cell_w, cell_h) = atlas.cell_size();
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("cell-smoke-empty-encoder"),
});
pipeline.draw(
&hal.device,
&hal.queue,
&mut encoder,
&view,
&atlas_view,
&[],
CellUniforms {
viewport_w: W as f32,
viewport_h: H as f32,
cell_w: cell_w as f32,
cell_h: cell_h as f32,
atlas_w: atlas_w as f32,
atlas_h: atlas_h as f32,
_pad0: 0.0,
_pad1: 0.0,
},
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::PollType::wait_indefinitely());
}
+2 -2
View File
@@ -14,5 +14,5 @@ tree-sitter-rust = { workspace = true }
tree-sitter-python = { workspace = true }
# peniko sólo aporta el tipo de color (peniko::Color) para SyntaxPalette;
# es un crate de tipos sin GPU — no arrastra wgpu/vello. Versión alineada
# con la que expone vello 0.5 (ver workspace root).
peniko = "0.4"
# con la que expone vello 0.7 (ver workspace root).
peniko = "0.6"
-2
View File
@@ -9,8 +9,6 @@ description = "llimphi-widget-text-editor-lsp — trait LspClient + NoopLspClien
[dependencies]
llimphi-widget-text-editor = { workspace = true }
lsp-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
+3 -3
View File
@@ -34,11 +34,11 @@ pub use diagnostics::{Diagnostic, DiagnosticRange, Severity};
pub use find::{all_matches, find_next, find_prev, FindState};
pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind};
pub use ops::{indent_str, EditDelta};
pub use state::{ApplyResult, EditorOptions, EditorState};
pub use state::{ApplyResult, EditorOptions, EditorState, Preedit};
pub use undo::UndoStack;
pub use view::{
text_editor_view, text_editor_view_full, text_editor_view_highlighted, EditorMetrics,
EditorPalette, GutterStyle, PointerEvent,
text_editor_view, text_editor_view_colored, text_editor_view_full,
text_editor_view_highlighted, EditorMetrics, EditorPalette, GutterStyle, PointerEvent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
+133 -1
View File
@@ -5,7 +5,7 @@
use std::cell::RefCell;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey};
use llimphi_ui::{ImeEvent, Key, KeyEvent, KeyState, NamedKey};
use crate::buffer::Buffer;
use crate::clipboard::{Clipboard, NullClipboard};
@@ -75,6 +75,12 @@ pub struct EditorState {
/// ~40/255 sobre el bg).
pub line_tints: Vec<Option<llimphi_ui::llimphi_raster::peniko::Color>>,
pub undo: UndoStack,
/// Texto en composición del IME (dead keys de acentos, CJK, emoji
/// picker). No vive en el buffer hasta el `Commit`: el view lo pinta
/// subrayado en el caret y el caret se corre detrás. `None` = sin
/// composición activa (el caso normal). Lo administra
/// [`Self::apply_ime_event`].
pub preedit: Option<Preedit>,
/// Línea inicial visible — el viewport renderiza
/// `[scroll_offset, scroll_offset + visible)`. El caller llama a
/// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear.
@@ -95,6 +101,22 @@ pub struct EditorState {
pub highlight_cache: RefCell<Option<HighlightCache>>,
}
/// Texto en composición del IME — el que el método de entrada está
/// armando antes de confirmarlo (un acento muerto a medio componer, una
/// sílaba CJK con su ventana de candidatos, un emoji del picker). No
/// pertenece al buffer todavía; el view lo dibuja en el caret con
/// subrayado para que el usuario vea lo que está tecleando.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Preedit {
/// Texto provisional a pintar en el caret.
pub text: String,
/// Rango `(inicio, fin)` en **bytes** dentro de `text` que el IME
/// quiere resaltar (la "clause" activa), si lo reporta. Hoy el view
/// subraya todo el preedit por igual; el campo se conserva para un
/// resaltado más fino cuando haga falta.
pub cursor: Option<(usize, usize)>,
}
/// Entrada del cache: spans por línea + clave que la generó.
#[derive(Debug, Clone)]
pub struct HighlightCache {
@@ -120,6 +142,7 @@ impl EditorState {
guard_lines: Vec::new(),
line_tints: Vec::new(),
undo: UndoStack::new(),
preedit: None,
scroll_offset: 0,
edit_seq: 0,
pending_input_edits: RefCell::new(Vec::new()),
@@ -377,6 +400,60 @@ impl EditorState {
self.apply_key_with_clipboard(event, &mut NullClipboard)
}
/// Aplica un evento de IME al editor — el camino por el que llegan los
/// acentos compuestos (dead keys), CJK y el emoji picker cuando la app
/// habilita `App::ime_allowed`. El flujo del IME es
/// `Enabled → Preedit* → Commit | Disabled`:
///
/// - `Enabled`: el IME tomó el foco; no hay nada que insertar aún.
/// - `Preedit{text,..}`: texto en composición. Lo guardamos en
/// [`Self::preedit`] para que el view lo pinte subrayado en el caret
/// **sin** tocar el buffer. Un `Preedit` con `text` vacío cierra la
/// composición sin confirmar.
/// - `Commit(text)`: texto confirmado. Limpiamos el preedit e
/// insertamos `text` en todos los cursores, exactamente como si se
/// hubiera tecleado (mismo camino de undo + parseo incremental).
/// - `Disabled`: el IME soltó el foco; descartamos cualquier
/// composición pendiente.
///
/// Devuelve [`ApplyResult::Changed`] sólo en el `Commit` no-vacío (lo
/// único que persiste); el resto es `CursorMoved` (hubo que
/// redibujar el preedit) o `Ignored`.
pub fn apply_ime_event(&mut self, event: &ImeEvent) -> ApplyResult {
match event {
ImeEvent::Enabled => ApplyResult::Ignored,
ImeEvent::Preedit { text, cursor } => {
let had = self.preedit.is_some();
if text.is_empty() {
self.preedit = None;
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
} else {
self.preedit = Some(Preedit { text: text.clone(), cursor: *cursor });
ApplyResult::CursorMoved
}
}
ImeEvent::Commit(text) => {
let had = self.preedit.take().is_some();
if text.is_empty() {
return if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored };
}
let text = text.clone();
let changed =
self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text)));
if changed {
self.bump_edit_seq();
ApplyResult::Changed
} else {
ApplyResult::Ignored
}
}
ImeEvent::Disabled => {
let had = self.preedit.take().is_some();
if had { ApplyResult::CursorMoved } else { ApplyResult::Ignored }
}
}
}
/// Como [`Self::apply_key`] pero con backend de clipboard activo:
/// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que
/// haya en el clipboard.
@@ -1244,6 +1321,61 @@ mod tests {
assert!(s.cursor.caret.line != 1);
}
#[test]
fn ime_commit_inserta_como_tecleo() {
// El caso español: el dead-key compone "á" y llega como Commit.
let mut s = EditorState::new();
s.apply_key(&evtext("c", false, false));
let r = s.apply_ime_event(&ImeEvent::Commit("á".into()));
assert_eq!(r, ApplyResult::Changed);
assert_eq!(s.text(), "");
assert!(s.preedit.is_none());
}
#[test]
fn ime_preedit_no_toca_el_buffer() {
let mut s = EditorState::new();
s.set_text("ab");
s.cursor = Cursor::at(0, 2);
let r = s.apply_ime_event(&ImeEvent::Preedit { text: "´".into(), cursor: None });
assert_eq!(r, ApplyResult::CursorMoved);
// El buffer sigue intacto; el preedit vive aparte.
assert_eq!(s.text(), "ab");
assert_eq!(s.preedit.as_ref().map(|p| p.text.as_str()), Some("´"));
// El Commit reemplaza la composición e inserta el char final.
s.apply_ime_event(&ImeEvent::Commit("é".into()));
assert_eq!(s.text(), "abé");
assert!(s.preedit.is_none());
}
#[test]
fn ime_preedit_vacio_y_disabled_cierran_la_composicion() {
let mut s = EditorState::new();
s.apply_ime_event(&ImeEvent::Preedit { text: "".into(), cursor: None });
assert!(s.preedit.is_some());
// Un Preedit vacío cancela sin confirmar.
let r = s.apply_ime_event(&ImeEvent::Preedit { text: String::new(), cursor: None });
assert_eq!(r, ApplyResult::CursorMoved);
assert!(s.preedit.is_none());
// Disabled sobre algo en composición también limpia.
s.apply_ime_event(&ImeEvent::Preedit { text: "".into(), cursor: None });
s.apply_ime_event(&ImeEvent::Disabled);
assert!(s.preedit.is_none());
}
#[test]
fn ime_commit_reemplaza_seleccion() {
let mut s = EditorState::new();
s.set_text("abc");
s.cursor = Cursor {
anchor: Some(Pos::new(0, 0)),
caret: Pos::new(0, 2),
desired_col: 2,
};
s.apply_ime_event(&ImeEvent::Commit("ñ".into()));
assert_eq!(s.text(), "ñc");
}
#[test]
fn ctrl_c_sin_seleccion_es_ignorado() {
use crate::clipboard::MemClipboard;
+168 -4
View File
@@ -26,6 +26,16 @@ use crate::diagnostics::{Diagnostic, Severity};
use crate::highlight::{Language, Span, SyntaxPalette, TokenKind};
use crate::state::EditorState;
/// Tope de líneas que la variante embebida (`text_editor_view_colored`)
/// renderiza de una. La virtualización del editor-de-archivos capa a 200 para
/// no generar miles de Views (wgpu rechaza el bind group); pero la variante
/// embebida deja el scroll al contenedor de afuera y necesita pintar TODAS sus
/// líneas (si no, la mitad de abajo queda sin pintar = negro al anclar el panel
/// al fondo). Este tope es sólo la red de seguridad de wgpu — el caller acota el
/// total real (el shell, por su `MAX_VISIBLE = 400`). Probado: ~400 líneas
/// renderizan sin que wgpu rechace nada (el render plano viejo ya lo hacía).
pub const EMBEDDED_LINE_CAP: usize = 512;
/// Paleta del editor. Defaults dark.
#[derive(Debug, Clone, Copy)]
pub struct EditorPalette {
@@ -278,6 +288,7 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
spans,
&syntax,
match_ranges,
None,
on_pointer,
);
@@ -291,6 +302,53 @@ pub fn text_editor_view_full<Msg: Clone + 'static>(
.children(vec![gutter, content])
}
/// Como [`text_editor_view`] pero el caller provee el color de cada tramo de
/// cada línea (`line_color_runs[n]` = `(byte_start, byte_end, Color)` de la
/// línea `n`), en vez de derivarlo de un `Language`. Para outputs con coloreo
/// semántico propio (un shell que tinta `ls`, paths, urls, números…) sobre el
/// mismo editor read-only (numeración + selección + copiar).
pub fn text_editor_view_colored<Msg: Clone + 'static>(
state: &EditorState,
palette: &EditorPalette,
metrics: EditorMetrics,
visible_lines: usize,
line_color_runs: &[Vec<(usize, usize, Color)>],
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
) -> View<Msg> {
let caret = state.cursor.caret;
let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark());
// Variante embebida: el contenedor de afuera (el panel de output del shell)
// hace el scroll y reserva alto para TODAS las líneas, así que las pintamos
// completas (cap alto = red de seguridad de wgpu, ver `EMBEDDED_LINE_CAP`).
let visible = visible_lines.max(1).min(EMBEDDED_LINE_CAP);
let line_count = state.line_count();
let scroll = state.scroll_offset.min(line_count.saturating_sub(1));
let end_line = (scroll + visible).min(line_count);
let height = (end_line - scroll) as f32 * metrics.line_height;
let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette);
let content = build_content(
state,
palette,
metrics,
height,
scroll,
end_line,
Vec::new(),
&syntax,
&[],
Some(line_color_runs),
on_pointer,
);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(height) },
..Default::default()
})
.fill(palette.bg)
.clip(true)
.children(vec![gutter, content])
}
fn build_gutter<Msg: Clone + 'static>(
state: &EditorState,
scroll: usize,
@@ -336,7 +394,8 @@ fn build_gutter<Msg: Clone + 'static>(
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End),
.text_aligned(label, metrics.font_size * 0.85, color, Alignment::End)
.mono(),
);
}
GutterStyle::Phantom => {
@@ -394,6 +453,7 @@ fn with_alpha(c: Color, alpha: f32) -> Color {
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
#[allow(clippy::too_many_arguments)]
fn build_content<Msg: Clone + 'static>(
state: &EditorState,
palette: &EditorPalette,
@@ -404,6 +464,11 @@ fn build_content<Msg: Clone + 'static>(
spans_per_line: Vec<Vec<Span>>,
syntax: &SyntaxPalette,
match_ranges: &[(usize, usize)],
// Override de color por línea: `color_runs[n]` son `(byte_start, byte_end,
// Color)` para la línea `n` del buffer (índice absoluto). Cuando es `Some`,
// gana sobre el syntax highlight — para callers que colorean por semántica
// propia (un shell que tinta `ls`, paths, urls…). `None` = highlight normal.
color_runs: Option<&[Vec<(usize, usize, Color)>]>,
on_pointer: impl Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
) -> View<Msg> {
let caret = state.cursor.caret;
@@ -461,7 +526,9 @@ fn build_content<Msg: Clone + 'static>(
children.push(phantom_guard_divider(local_line, metrics, palette));
continue;
}
if let Some(line_spans) = spans_per_line.get(n) {
if let Some(runs) = color_runs.and_then(|cr| cr.get(n)) {
children.push(line_text_color_runs(local_line, &text, runs, metrics, palette));
} else if let Some(line_spans) = spans_per_line.get(n) {
children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax));
} else {
children.push(line_text_plain(local_line, text, metrics, palette));
@@ -473,15 +540,33 @@ fn build_content<Msg: Clone + 'static>(
children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette));
}
// 4) Caret — uno por cursor, sólo si visible.
// 4) Caret — uno por cursor, sólo si visible. El caret del cursor
// primario se corre detrás del preedit del IME en composición (el
// texto compuesto se pinta desde `p.col`), para que quede al final
// de lo que el usuario está tecleando.
let preedit_cols = state.preedit.as_ref().map_or(0, |p| p.text.chars().count());
for c in state.all_cursors() {
let p = c.caret;
if p.line >= scroll && p.line < end_line {
let local = crate::cursor::Pos::new(p.line - scroll, p.col);
let is_primary = std::ptr::eq(c, &state.cursor);
let col = if is_primary { p.col + preedit_cols } else { p.col };
let local = crate::cursor::Pos::new(p.line - scroll, col);
children.push(caret_rect(local, metrics, palette));
}
}
// 5) Preedit del IME — texto en composición pintado en el caret con
// subrayado, todavía fuera del buffer. Sólo en el cursor primario y
// si su línea está en viewport. (En mono el ancho es exacto; el
// texto que sigue al caret puede solaparse mientras se compone —
// transitorio y, en el caso típico de acentos, de un solo char.)
if let Some(pre) = state.preedit.as_ref() {
let p = state.cursor.caret;
if p.line >= scroll && p.line < end_line {
children.extend(preedit_views(p.line - scroll, p.col, &pre.text, metrics, palette));
}
}
let click_cb = on_pointer.clone();
let drag_cb = on_pointer;
View::new(Style {
@@ -603,6 +688,7 @@ fn line_text_plain<Msg: Clone + 'static>(
..Default::default()
})
.text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start)
.mono()
}
/// Renderiza una línea como secuencia de Views absolutos posicionados,
@@ -660,6 +746,38 @@ fn line_text_tokens<Msg: Clone + 'static>(
runs,
Alignment::Start,
)
.mono()
}
/// Como [`line_text_tokens`] pero con `(byte_start, byte_end, Color)`
/// explícitos provistos por el caller (coloreo semántico propio, p. ej. un
/// shell que tinta `ls`/paths/urls). El resto del texto va en `fg_text`.
fn line_text_color_runs<Msg: Clone + 'static>(
line: usize,
text: &str,
runs: &[(usize, usize, Color)],
metrics: EditorMetrics,
palette: &EditorPalette,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(4.0_f32),
top: length(line as f32 * metrics.line_height),
right: auto(),
bottom: auto(),
},
size: Size { width: length(2000.0_f32), height: length(metrics.line_height) },
..Default::default()
})
.text_runs(
text.to_string(),
metrics.font_size,
palette.fg_text,
runs.to_vec(),
Alignment::Start,
)
.mono()
}
fn caret_rect<Msg: Clone + 'static>(
@@ -683,6 +801,52 @@ fn caret_rect<Msg: Clone + 'static>(
.fill(palette.caret)
}
/// Pinta el texto en composición del IME en `(local_line, col)`: el texto
/// provisional + un subrayado debajo que lo marca como no-confirmado.
/// Devuelve los dos Views (texto y subrayado). Posición en coords del
/// área de contenido (mismo origen que [`line_text_plain`]).
fn preedit_views<Msg: Clone + 'static>(
local_line: usize,
col: usize,
text: &str,
metrics: EditorMetrics,
palette: &EditorPalette,
) -> Vec<View<Msg>> {
let x = 4.0 + col as f32 * metrics.char_width;
let y = local_line as f32 * metrics.line_height;
let w = (text.chars().count() as f32 * metrics.char_width).max(metrics.char_width);
vec![
// Texto provisional, en el color de texto normal.
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size { width: length(w), height: length(metrics.line_height) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text.to_string(), metrics.font_size, palette.fg_text, Alignment::Start)
.mono(),
// Subrayado: una línea fina en el color del caret bajo el texto.
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y + metrics.line_height - 2.0),
right: auto(),
bottom: auto(),
},
size: Size { width: length(w), height: length(1.5_f32) },
..Default::default()
})
.fill(palette.caret),
]
}
fn bracket_highlight<Msg: Clone + 'static>(
pos: Pos,
metrics: EditorMetrics,
+149 -9
View File
@@ -15,7 +15,7 @@
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
prelude::{auto, length, percent, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
@@ -33,6 +33,10 @@ pub struct TextInputPalette {
pub border_focus: Color,
pub fg_text: Color,
pub fg_placeholder: Color,
/// Color del caret (cursor de inserción) que se pinta cuando el input
/// está focado. Default = `fg_text` (sigue al texto, como `caret-color:
/// auto` en CSS).
pub caret: Color,
}
impl Default for TextInputPalette {
@@ -51,6 +55,7 @@ impl TextInputPalette {
border_focus: t.border_focus,
fg_text: t.fg_text,
fg_placeholder: t.fg_placeholder,
caret: t.fg_text,
}
}
}
@@ -133,7 +138,11 @@ impl TextInputState {
}
/// Compone el input box: borde de 1 px (rect padre coloreado), relleno
/// interno, texto o placeholder, caret simulado al final si está focado.
/// interno, texto o placeholder, y el caret (cursor de inserción) sobre el
/// texto si está focado. Caret v3 (Fase 7.1255): cuando está focado la hoja
/// pinta texto+caret en un `paint_over` con **scroll horizontal** — el texto
/// se desplaza para mantener el caret a la vista cuando desborda la caja, y se
/// recorta al área de contenido. Sin foco usa un nodo-hijo de texto (sin caret).
/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`).
pub fn text_input_view<Msg: Clone + 'static>(
state: &TextInputState,
@@ -151,9 +160,18 @@ pub fn text_input_view<Msg: Clone + 'static>(
} else {
raw
};
// El cambio de bg al focus ya transmite "este es el activo"; sin
// caret glyph (la fuente default rendea cuadrados de fallback).
let display = shown;
// Prefijo del texto visible hasta el caret (cursor de inserción), para
// medir su ancho y posicionar la barra del caret. La columna es índice de
// carácter (single-line ⇒ `line == 0`); `take(col)` sobre el texto MOSTRADO
// (placeholder/`•`/crudo) alinea el caret con lo que se ve. Cuando el input
// está vacío el `col` es 0 ⇒ prefijo vacío ⇒ caret al inicio (no se mide el
// placeholder).
let caret_prefix: String = if focused {
display.chars().take(state.editor().cursor.caret.col).collect()
} else {
String::new()
};
let text_color = if is_empty {
palette.fg_placeholder
} else {
@@ -165,7 +183,7 @@ pub fn text_input_view<Msg: Clone + 'static>(
(palette.bg, palette.border)
};
let inner = View::new(Style {
let mut inner = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
@@ -173,15 +191,90 @@ pub fn text_input_view<Msg: Clone + 'static>(
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(bg)
.radius(3.0)
.text_aligned(display, 13.0, text_color, Alignment::Start);
.radius(3.0);
let inner = if focused {
// Caret v3 — scroll horizontal (Fase 7.1255). Cuando el input está
// focado, la propia hoja pinta el texto Y el caret en un solo `paint_over`
// (pasada vello FINAL): así puede DESPLAZAR el texto a la izquierda cuando
// el cursor se saldría por el borde derecho, manteniéndolo visible — el
// clásico scroll del caret de los `<input>`. El offset (`scroll`) depende
// del ancho de layout y de la posición del caret, ambos conocidos sólo en
// tiempo de pintado (acá `rect.w` ya está resuelto y `ts` puede medir), no
// en `view()` — por eso no se hace con un nodo hijo + `transform` estático.
// Sin foco se usa el camino de nodo-hijo de abajo (sin caret, sin scroll).
let caret_color = palette.caret;
let display_c = display;
let caret_prefix_c = caret_prefix;
let tcolor = text_color;
inner.paint_over(move |scene, ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect};
use llimphi_ui::llimphi_raster::peniko::{BlendMode, Fill};
use llimphi_ui::llimphi_text::{draw_layout, measurement, Alignment};
let pad = 10.0_f64;
// Ancho visible interno (entre los dos paddings de 10 px).
let vis_w = (rect.w as f64 - 2.0 * pad).max(0.0);
// Layout del texto completo en una sola línea (sin wrap).
let layout = ts.layout(
&display_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false,
0.0, 0.0,
);
let th = measurement(&layout).height as f64;
// Ancho del prefijo hasta el caret = posición x del caret en el texto.
let caret_w = if caret_prefix_c.is_empty() {
0.0
} else {
let lp = ts.layout(
&caret_prefix_c, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
false, 0.0, 0.0,
);
measurement(&lp).width as f64
};
// Scroll: si el caret cae más allá del ancho visible, corre el texto a
// la izquierda lo justo para que el caret quede al borde (con 2 px de
// aire). Texto que entra ⇒ scroll 0 (anclado al padding-left).
let scroll = (caret_w - vis_w + 2.0).max(0.0);
let cx0 = rect.x as f64 + pad;
// Recorte al área de contenido para que el texto desplazado no se
// derrame sobre el padding ni fuera de la caja.
let clip = KRect::new(
cx0,
rect.y as f64,
rect.x as f64 + rect.w as f64 - pad,
rect.y as f64 + rect.h as f64,
);
scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &clip);
let oy = rect.y as f64 + (rect.h as f64 - th) * 0.5;
draw_layout(scene, &layout, tcolor, (cx0 - scroll, oy));
scene.pop_layer();
// Caret: barra vertical en la posición del caret, desplazada por el
// mismo scroll. Fuera del clip para que nunca se recorte en el borde.
let x = cx0 + caret_w - scroll;
let h = 16.0_f64;
let cy = rect.y as f64 + rect.h as f64 * 0.5;
let bar = KRect::new(x, cy - h * 0.5, x + 1.5, cy + h * 0.5);
scene.fill(Fill::NonZero, Affine::IDENTITY, caret_color, None, &bar);
})
} else {
// Sin foco: el texto va en un nodo HIJO de alto automático, centrado
// verticalmente por el contenedor (`align_items: Center`). (`align_items`
// no centra el texto PROPIO de un nodo — por eso el hijo.)
let texto = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
..Default::default()
})
.text_aligned(display, 13.0, text_color, Alignment::Start);
inner.children(vec![texto])
};
View::new(Style {
size: Size {
@@ -198,7 +291,17 @@ pub fn text_input_view<Msg: Clone + 'static>(
})
.fill(border)
.radius(4.0)
// Semántica: input de texto + el valor crudo como `value` (no el "•"
// del modo masked — los lectores no deben dictar la contraseña en
// voz alta; AccessKit ya marca el control como TextInput y el lector
// sustituye por "punto" cuando el contexto lo requiere). El
// placeholder va como `description` cuando el campo está vacío para
// que el lector lo enuncie como pista. `value` queda vacío en masked.
.role(llimphi_ui::Role::TextInput)
.aria_value(if state.masked { String::new() } else { state.text() })
.aria_description(if is_empty { placeholder.to_string() } else { String::new() })
.on_click(on_focus)
.cursor(llimphi_ui::Cursor::Text)
.children(vec![inner])
}
@@ -217,6 +320,43 @@ mod tests {
}
}
#[test]
fn palette_caret_default_sigue_al_texto() {
// El caret por default sigue al color del texto (`caret-color: auto`):
// `from_theme` y `Default` lo igualan a `fg_text`.
let t = llimphi_theme::Theme::dark();
let pal = TextInputPalette::from_theme(&t);
assert_eq!(pal.caret, pal.fg_text);
assert_eq!(pal.caret, t.fg_text);
assert_eq!(TextInputPalette::default().caret, TextInputPalette::default().fg_text);
}
#[test]
fn caret_se_registra_como_over_painter_solo_focado() {
// Caret v2 (Fase 7.1249): el caret se pinta con `paint_over` (pasada
// FINAL sobre el glifo). Verificamos el wiring montando la vista: con
// foco hay un over-painter registrado; sin foco no hay ninguno.
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::{has_over_painter, mount};
let mut st = TextInputState::new();
st.set_text("hola");
let pal = TextInputPalette::default();
let mut lt = LayoutTree::new();
let focado = mount(&mut lt, text_input_view(&st, "ph", true, &pal, ()));
assert!(
has_over_painter(&focado),
"input focado debe registrar el caret como over-painter"
);
let mut lt2 = LayoutTree::new();
let sin_foco = mount(&mut lt2, text_input_view(&st, "ph", false, &pal, ()));
assert!(
!has_over_painter(&sin_foco),
"input sin foco no pinta caret"
);
}
#[test]
fn apply_key_inserts_printable_chars() {
let mut s = TextInputState::new();
+41
View File
@@ -41,6 +41,8 @@ pub struct TimelinePalette {
pub fill: Color,
/// Color del playhead (la barrita vertical en la posición actual).
pub knob: Color,
/// Color de las marcas (bookmarks) pintadas sobre la barra.
pub mark: Color,
/// Alto total del widget en pixels.
pub height: f32,
/// Radio de las esquinas del track.
@@ -60,6 +62,9 @@ impl TimelinePalette {
track: t.bg_button,
fill: t.accent,
knob: t.fg_text,
// Ámbar cálido para que las marcas resalten tanto sobre la pista
// de fondo como sobre el tramo recorrido (accent).
mark: Color::from_rgba8(255, 196, 84, 255),
height: 14.0,
radius: 7.0,
}
@@ -74,6 +79,24 @@ impl TimelinePalette {
/// para ignorar el click). El widget es stateless: redibujá pasando un
/// `progress` nuevo en cada frame y el playhead avanza solo.
pub fn timeline_view<Msg, F>(progress: f32, palette: &TimelinePalette, on_seek: F) -> View<Msg>
where
Msg: 'static,
F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
{
timeline_view_marked(progress, &[], palette, on_seek)
}
/// Igual que [`timeline_view`] pero pinta además unas **marcas** (bookmarks)
/// en las fracciones `marks` (`0.0..=1.0`): finas barras verticales con el
/// color `palette.mark`, bajo el playhead. Las fracciones fuera de rango se
/// ignoran. El resto del comportamiento (recorrido, playhead, click-to-seek)
/// es idéntico — `timeline_view` es este con `marks` vacío.
pub fn timeline_view_marked<Msg, F>(
progress: f32,
marks: &[f32],
palette: &TimelinePalette,
on_seek: F,
) -> View<Msg>
where
Msg: 'static,
F: Fn(f32) -> Option<Msg> + Send + Sync + 'static,
@@ -81,6 +104,12 @@ where
let p = progress.clamp(0.0, 1.0);
let fill_color = palette.fill;
let knob_color = palette.knob;
let mark_color = palette.mark;
let marks: Vec<f32> = marks
.iter()
.copied()
.filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
.collect();
View::new(Style {
size: Size {
width: percent(1.0_f32),
@@ -105,6 +134,18 @@ where
let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill);
}
// Marcas — finas barras verticales en cada fracción (bajo el playhead).
let mw: f32 = 2.0;
for f in &marks {
let mx = x0 + w * f;
let mark = Rect::new(
(mx - mw * 0.5) as f64,
y0 as f64,
(mx + mw * 0.5) as f64,
(y0 + h) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, mark_color, None, &mark);
}
// Playhead — fina barra vertical en la posición actual.
let kx = x0 + fw;
let kw: f32 = 3.0;
+17 -2
View File
@@ -27,9 +27,9 @@ use llimphi_ui::llimphi_layout::taffy::{
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_ui::{Shadow, View};
use llimphi_icons::{icon_view, Icon};
use llimphi_theme::radius;
use llimphi_theme::{elevation, motion, radius};
/// Severidad del toast — define color e icono.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -187,6 +187,19 @@ fn single_toast_view<Msg: Clone + 'static>(toast: &Toast, on_dismiss: Msg) -> Vi
})
.text_aligned(toast.text.clone(), 12.0, fg, Alignment::Start);
// Sombra E3 + entrada/salida animada (key estable = id del toast):
// el toast aparece con fade-in suave y, al expirar/dismiss, su
// subescena se reproduce con fade-out — sin necesidad de tween
// manual en la app.
let (alpha, blur, dy) = elevation::E3;
let shadow = Shadow {
color: Color::from_rgba8(0, 0, 0, alpha),
blur,
dx: 0.0,
dy,
spread: 0.0,
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
@@ -211,6 +224,8 @@ fn single_toast_view<Msg: Clone + 'static>(toast: &Toast, on_dismiss: Msg) -> Vi
})
.fill(bg)
.radius(radius::MD)
.shadow(shadow)
.animated_inout(toast.id, motion::NORMAL)
.clip(true)
.on_click(on_dismiss)
.children(vec![rail, icon_cell, text])
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "llimphi-widget-toolbar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-toolbar — barra de herramientas moderna: grupos de botones-ícono planos con hover redondeado, estado activo con acento y separadores sutiles. Los grupos son datos (Vec<ToolbarGroup>) → componibles/configurables por el caller; los íconos los dibuja el caller (closure), igual que dock-rail."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[dev-dependencies]
llimphi-icons = { workspace = true }
+117
View File
@@ -0,0 +1,117 @@
//! Demo interactiva del toolbar: grupo de navegación + toggles de vista +
//! acciones (una deshabilitada). El texto de abajo refleja el último click.
//!
//! `cargo run -p llimphi-widget-toolbar --example toolbar_demo --release`
use llimphi_icons::{icon_view, Icon};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems,
};
use llimphi_ui::{run, App, Handle, View};
use llimphi_widget_toolbar::{toolbar_view, ToolbarGroup, ToolbarItem, ToolbarPalette};
struct Demo;
struct Model {
vista: usize,
dual: bool,
ultimo: String,
}
#[derive(Clone)]
enum Msg {
Subir,
Vista(usize),
Dual,
Nueva,
}
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"toolbar · demo"
}
fn init(_handle: &Handle<Self::Msg>) -> Self::Model {
Model { vista: 0, dual: false, ultimo: "(sin acciones)".into() }
}
fn update(model: Self::Model, msg: Self::Msg, _h: &Handle<Self::Msg>) -> Self::Model {
let mut m = model;
match msg {
Msg::Subir => m.ultimo = "subir".into(),
Msg::Vista(v) => {
m.vista = v;
m.ultimo = format!("vista {v}");
}
Msg::Dual => {
m.dual = !m.dual;
m.ultimo = format!("dual: {}", m.dual);
}
Msg::Nueva => m.ultimo = "nueva carpeta".into(),
}
m
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let theme = Theme::dark();
let pal = ToolbarPalette::from_theme(&theme);
let vistas = [Icon::Rows, Icon::Table, Icon::Grid, Icon::Image];
let barra = toolbar_view(
vec![
ToolbarGroup::new(vec![ToolbarItem::new(
|_s, c| icon_view(Icon::ChevronUp, c, 1.7),
Msg::Subir,
)
.with_label("subir")]),
ToolbarGroup::new(
vistas
.iter()
.enumerate()
.map(|(i, ic)| {
let ic = *ic;
ToolbarItem::new(move |_s, c| icon_view(ic, c, 1.7), Msg::Vista(i))
.active(model.vista == i)
})
.collect(),
),
ToolbarGroup::new(vec![
ToolbarItem::new(|_s, c| icon_view(Icon::Columns, c, 1.7), Msg::Dual)
.active(model.dual),
ToolbarItem::new(|_s, c| icon_view(Icon::Plus, c, 1.7), Msg::Nueva)
.with_label("carpeta")
.enabled(false),
]),
],
36.0,
&pal,
);
let estado = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
align_items: Some(AlignItems::Center),
padding: llimphi_ui::llimphi_layout::taffy::Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text(format!("último: {}", model.ultimo), 13.0, theme.fg_text);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(theme.bg_app)
.children(vec![barra, estado])
}
}
fn main() {
run::<Demo>();
}
+257
View File
@@ -0,0 +1,257 @@
//! `llimphi-widget-toolbar` — barra de herramientas moderna.
//!
//! Grupos de botones-ícono planos: hover con fondo redondeado, estado
//! **activo** con fondo de selección + ícono en acento, **deshabilitado**
//! atenuado y sin click. Entre grupos, un separador vertical sutil.
//!
//! El widget es render-only y agnóstico del `Msg`:
//! - los **íconos los dibuja el caller** vía closure `Fn(size, color) ->
//! View` (mismo contrato que `dock-rail::make_icon`) — el widget resuelve
//! el color según el estado (activo/normal/deshabilitado);
//! - los **grupos son datos** (`Vec<ToolbarGroup>`): el caller los arma,
//! reordena o filtra — la barra es componible/configurable por
//! construcción, sin sistema de config propio.
//!
//! ```ignore
//! let barra = toolbar_view(
//! vec![
//! ToolbarGroup::new(vec![
//! ToolbarItem::new(|s, c| icon_view(Icon::ChevronUp, c, 1.7), Msg::Subir)
//! .with_label("subir"),
//! ]),
//! ToolbarGroup::new(vec![
//! ToolbarItem::new(|s, c| icon_view(Icon::Rows, c, 1.7), Msg::Vista(0)).active(lista),
//! ToolbarItem::new(|s, c| icon_view(Icon::Grid, c, 1.7), Msg::Vista(2)).active(iconos),
//! ]),
//! ],
//! 36.0,
//! &ToolbarPalette::from_theme(&theme),
//! );
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Tamaño del ícono que se le pide al caller (px).
const ICON_PX: f32 = 16.0;
/// Paleta de la barra — subset del `Theme` semántico.
#[derive(Debug, Clone, Copy)]
pub struct ToolbarPalette {
/// Fondo de la franja.
pub bg_bar: Color,
/// Fondo del botón al hover.
pub bg_hover: Color,
/// Fondo del botón activo (toggle prendido).
pub bg_active: Color,
/// Ícono/label normal.
pub fg: Color,
/// Ícono del botón activo.
pub fg_active: Color,
/// Ícono/label deshabilitado.
pub fg_disabled: Color,
/// Separador vertical entre grupos.
pub separator: Color,
}
impl ToolbarPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_bar: t.bg_panel_alt,
bg_hover: t.bg_row_hover,
bg_active: t.bg_selected,
fg: t.fg_muted,
fg_active: t.accent,
fg_disabled: t.fg_placeholder,
separator: t.border,
}
}
}
impl Default for ToolbarPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
/// Un botón de la barra. El ícono es una closure `(size_px, color) -> View`
/// — el widget la invoca con el color ya resuelto por estado.
pub struct ToolbarItem<Msg> {
pub icon: Box<dyn Fn(f32, Color) -> View<Msg>>,
/// Texto corto opcional a la derecha del ícono (la barra es icon-first).
pub label: Option<String>,
/// Toggle prendido (fondo de selección + acento).
pub active: bool,
/// `false` = atenuado y sin click.
pub enabled: bool,
pub on_click: Msg,
}
impl<Msg> ToolbarItem<Msg> {
pub fn new(
icon: impl Fn(f32, Color) -> View<Msg> + 'static,
on_click: Msg,
) -> Self {
Self {
icon: Box::new(icon),
label: None,
active: false,
enabled: true,
on_click,
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
/// Un grupo de botones contiguos; entre grupos va un separador.
pub struct ToolbarGroup<Msg> {
pub items: Vec<ToolbarItem<Msg>>,
}
impl<Msg> ToolbarGroup<Msg> {
pub fn new(items: Vec<ToolbarItem<Msg>>) -> Self {
Self { items }
}
}
/// Compone la barra: franja horizontal de alto `height` con los grupos
/// alineados a la izquierda.
pub fn toolbar_view<Msg: Clone + 'static>(
groups: Vec<ToolbarGroup<Msg>>,
height: f32,
palette: &ToolbarPalette,
) -> View<Msg> {
let mut kids: Vec<View<Msg>> = Vec::new();
let n = groups.len();
for (gi, group) in groups.into_iter().enumerate() {
for item in group.items {
kids.push(button(item, height, palette));
}
if gi + 1 < n {
kids.push(separator(height, palette));
}
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(height),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg_bar)
.children(kids)
}
fn button<Msg: Clone + 'static>(
item: ToolbarItem<Msg>,
bar_h: f32,
palette: &ToolbarPalette,
) -> View<Msg> {
let fg = if !item.enabled {
palette.fg_disabled
} else if item.active {
palette.fg_active
} else {
palette.fg
};
let mut inner: Vec<View<Msg>> = vec![View::new(Style {
size: Size {
width: length(ICON_PX),
height: length(ICON_PX),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![(item.icon)(ICON_PX, fg)])];
if let Some(label) = item.label {
inner.push(
View::new(Style {
size: Size { width: auto(), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
..Default::default()
})
.text(label, 11.5, fg),
);
}
let mut btn = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: auto(),
height: length(bar_h - 8.0),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(7.0_f32),
right: length(7.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(5.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.radius(6.0)
.children(inner);
if item.active {
btn = btn.fill(palette.bg_active);
}
if item.enabled {
btn = btn.hover_fill(palette.bg_hover).on_click(item.on_click);
}
btn
}
fn separator<Msg: Clone + 'static>(bar_h: f32, palette: &ToolbarPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: length(1.0_f32),
height: length(bar_h - 16.0),
},
flex_shrink: 0.0,
margin: Rect {
left: length(5.0_f32),
right: length(5.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.separator)
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-transport — botones de transporte de reproductor (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/snapshot/record/eq). Stateless: el caller pasa el estado por botón + un handler TransportAction → Msg."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
+402
View File
@@ -0,0 +1,402 @@
//! `llimphi-widget-transport` — botones de **transporte** para reproductores
//! de medios (play/pause/prev/next/seek/volume/mute/repeat/shuffle/speed/
//! snapshot/record/eq).
//!
//! Pattern análogo a `llimphi-widget-timeline`/`-waveform`: el widget **no
//! mantiene estado** del reproductor. El caller arma un
//! [`TransportButton`] por cada botón visible (con el flag que define su
//! estado activo, como `playing` o `muted`), lo pasa a
//! [`transport_button_view`], y recibe el [`TransportAction`] semántico en
//! un closure cuando el usuario clickea. Quien mapea esas acciones al
//! `MediaCommand` propio del dominio es la app.
//!
//! ```text
//! [⏮ ] [⏯ ] [⏭ ] [⏪ ] [⏩ ] [🔊]
//! ```
//!
//! Uso típico (media-app):
//!
//! ```ignore
//! use llimphi_widget_transport::{transport_button_view, TransportAction as Ta,
//! TransportButton as Tb, TransportPalette};
//! let pal = TransportPalette::from_theme(&theme);
//! transport_button_view(
//! Tb::PlayPause { playing: !pause().is_paused() },
//! &pal,
//! |action| match action {
//! Ta::TogglePlay => Msg::Command(MediaCommand::TogglePause),
//! Ta::SeekBy(secs) => Msg::Command(MediaCommand::SeekBy { secs }),
//! /* … */
//! },
//! )
//! ```
//!
//! Para una fila completa el caller mapea su `Vec<TransportButton>` con
//! `.into_iter().map(|b| transport_button_view(b, &pal, on_action.clone()))`
//! y lo pone como `children` de una `View` con `flex_direction: Row` y
//! `gap: TransportPalette::gap`.
#![forbid(unsafe_code)]
use llimphi_icons::{icon_view, Icon};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Acción semántica que el widget reporta cuando el usuario clickea un
/// botón. El caller la traduce al comando propio de su dominio
/// (ej. `MediaCommand::TogglePause`, `MediaCommand::SeekBy { secs }`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportAction {
/// Alterna play/pause.
TogglePlay,
/// Detener — usualmente "seek a 0 + pause", pero el widget no opina:
/// el caller decide.
Stop,
/// Pista previa.
Prev,
/// Pista siguiente.
Next,
/// Salto relativo en segundos (signed: negativo = atrás). Entero
/// porque la mayoría de keymaps de transport usan pasos enteros
/// (5/10/30/60 s, paridad VLC); si una app necesita fracciones,
/// puede pasar `secs * k` y dividir al recibir.
SeekBy(i64),
/// Cambio relativo de volumen (signed; unidades de fracción 0..1
/// típicamente).
VolumeBy(f32),
/// Mute on/off.
ToggleMute,
/// Ciclar modo de repetición (Off → One → All → Off…).
CycleRepeat,
/// Shuffle on/off.
ToggleShuffle,
/// Paso de velocidad (±1) — el caller decide la escala.
SpeedStep(i32),
/// Restablecer velocidad a 1.0×.
SpeedReset,
/// Capturar snapshot (frame del video, p.ej.).
Snapshot,
/// Toggle de grabación.
ToggleRecord,
/// Toggle del ecualizador.
ToggleEqualizer,
}
/// Botón de transporte concreto + su estado de pintura. El widget elige
/// el icono y la `TransportAction` a partir de esto.
///
/// Para los pares simétricos (SeekBack/SeekForward, VolumeDown/VolumeUp)
/// el caller pasa el **valor absoluto** del paso; el widget se ocupa del
/// signo cuando arma la acción (`SeekBy(-secs)` / `VolumeBy(-step)`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportButton {
/// Play/Pause — el icono refleja `playing`. `active` cuando NO está
/// pausado (paridad VLC: el botón "iluminado" indica reproducción).
PlayPause { playing: bool },
/// Detener (seek a 0). Nunca active.
Stop,
/// Pista previa.
Prev,
/// Pista siguiente.
Next,
/// Saltar atrás `secs` segundos (positivo; el widget niega para
/// armar la acción).
SeekBack { secs: i64 },
/// Saltar adelante `secs` segundos.
SeekForward { secs: i64 },
/// Bajar volumen en `step` (positivo).
VolumeDown { step: f32 },
/// Subir volumen en `step`.
VolumeUp { step: f32 },
/// Toggle mute — `active` cuando está muteado.
Mute { muted: bool },
/// Ciclar repeat — `active` cuando no es "Off".
Repeat { active: bool },
/// Toggle shuffle — `active` cuando está prendido.
Shuffle { active: bool },
/// Bajar velocidad (chevron ↓). Nunca active.
SpeedDown,
/// Subir velocidad (chevron ↑). Nunca active.
SpeedUp,
/// Restablecer velocidad — `active` cuando ya está en 1.0×
/// (paridad VLC: indica "estado nominal").
SpeedReset { is_default: bool },
/// Snapshot (cámara).
Snapshot,
/// Grabar — `active` cuando está grabando. El widget colorea el
/// icono con `palette.fg_record` para señalizar "rec on".
Record { recording: bool },
/// Toggle del EQ — `active` cuando está prendido.
Equalizer { enabled: bool },
}
impl TransportButton {
fn icon(&self) -> Icon {
match self {
Self::PlayPause { playing } => {
if *playing {
Icon::Pause
} else {
Icon::Play
}
}
Self::Stop => Icon::Stop,
Self::Prev => Icon::SkipBack,
Self::Next => Icon::SkipForward,
Self::SeekBack { .. } => Icon::Rewind,
Self::SeekForward { .. } => Icon::FastForward,
Self::VolumeDown { .. } => Icon::Minus,
Self::VolumeUp { .. } => Icon::Plus,
Self::Mute { .. } => Icon::VolumeMute,
Self::Repeat { .. } => Icon::Repeat,
Self::Shuffle { .. } => Icon::Shuffle,
Self::SpeedDown => Icon::ChevronDown,
Self::SpeedUp => Icon::ChevronUp,
Self::SpeedReset { .. } => Icon::Gauge,
Self::Snapshot => Icon::Camera,
Self::Record { .. } => Icon::Record,
Self::Equalizer { .. } => Icon::Equalizer,
}
}
fn action(&self) -> TransportAction {
match self {
Self::PlayPause { .. } => TransportAction::TogglePlay,
Self::Stop => TransportAction::Stop,
Self::Prev => TransportAction::Prev,
Self::Next => TransportAction::Next,
Self::SeekBack { secs } => TransportAction::SeekBy(-*secs),
Self::SeekForward { secs } => TransportAction::SeekBy(*secs),
Self::VolumeDown { step } => TransportAction::VolumeBy(-step),
Self::VolumeUp { step } => TransportAction::VolumeBy(*step),
Self::Mute { .. } => TransportAction::ToggleMute,
Self::Repeat { .. } => TransportAction::CycleRepeat,
Self::Shuffle { .. } => TransportAction::ToggleShuffle,
Self::SpeedDown => TransportAction::SpeedStep(-1),
Self::SpeedUp => TransportAction::SpeedStep(1),
Self::SpeedReset { .. } => TransportAction::SpeedReset,
Self::Snapshot => TransportAction::Snapshot,
Self::Record { .. } => TransportAction::ToggleRecord,
Self::Equalizer { .. } => TransportAction::ToggleEqualizer,
}
}
fn is_active(&self) -> bool {
match self {
Self::PlayPause { playing } => *playing,
Self::Mute { muted } => *muted,
Self::Repeat { active } | Self::Shuffle { active } => *active,
Self::SpeedReset { is_default } => *is_default,
Self::Record { recording } => *recording,
Self::Equalizer { enabled } => *enabled,
_ => false,
}
}
fn is_record(&self) -> bool {
matches!(self, Self::Record { .. })
}
}
/// Paleta + dimensiones de los botones del transport. Las medidas viven
/// acá porque definen la silueta de la barra; el caller no toca el
/// `Style` directamente.
#[derive(Debug, Clone, Copy)]
pub struct TransportPalette {
/// Fondo del botón inactivo.
pub bg: Color,
/// Fondo del botón activo (PlayPause cuando reproduce, Mute cuando
/// muteado, etc.).
pub bg_active: Color,
/// Fondo en hover.
pub bg_hover: Color,
/// Color del icono inactivo.
pub fg: Color,
/// Color del icono activo.
pub fg_active: Color,
/// Color especial del icono Record (siempre rojo, prendido o no).
pub fg_record: Color,
/// Ancho del botón (px).
pub btn_w: f32,
/// Alto del botón (px).
pub btn_h: f32,
/// Radio de las esquinas.
pub radius: f64,
/// Grosor del stroke del icono (unidades llimphi-icons; típico 1.62.0).
pub icon_stroke: f32,
/// Separación recomendada entre botones cuando el caller los pone
/// en una fila (el widget no la aplica — sólo expone el sugerido).
pub gap: f32,
}
impl Default for TransportPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl TransportPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_button,
bg_active: t.bg_selected,
bg_hover: t.bg_button_hover,
fg: t.fg_text,
fg_active: t.accent,
// Rojo "REC" universal — no viaja por el theme.
fg_record: Color::from_rgba8(232, 86, 86, 255),
btn_w: 40.0,
btn_h: 34.0,
radius: 8.0,
icon_stroke: 2.0,
gap: 6.0,
}
}
}
/// Compone **un** botón de transporte. El handler `on_action` recibe la
/// [`TransportAction`] semántica cuando el usuario clickea — el caller
/// la traduce al `MediaCommand` (o equivalente) de su dominio.
///
/// El widget no mantiene estado: pasale el `TransportButton` con su
/// estado vigente cada frame y el icono / color / bg-activo se ajusta
/// solo. Para una **fila** de botones, mapeá tu `Vec<TransportButton>`
/// con `.iter().map(|b| transport_button_view(*b, &pal, on_action.clone()))`
/// como children de una `View` con `flex_direction: Row` y
/// `gap: palette.gap`.
pub fn transport_button_view<Msg, F>(
button: TransportButton,
palette: &TransportPalette,
on_action: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(TransportAction) -> Msg + Send + Sync + 'static,
{
let active = button.is_active();
let bg = if active { palette.bg_active } else { palette.bg };
let fg = if button.is_record() {
palette.fg_record
} else if active {
palette.fg_active
} else {
palette.fg
};
let action = button.action();
let icon = button.icon();
View::new(Style {
size: Size {
width: length(palette.btn_w),
height: length(palette.btn_h),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(bg)
.hover_fill(palette.bg_hover)
.radius(palette.radius)
.on_click(on_action(action))
.children(vec![icon_view::<Msg>(icon, fg, palette.icon_stroke)])
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq)]
struct Cmd(TransportAction);
#[test]
fn from_theme_usa_colores_semanticos() {
let t = llimphi_theme::Theme::dark();
let p = TransportPalette::from_theme(&t);
assert_eq!(p.bg, t.bg_button);
assert_eq!(p.bg_active, t.bg_selected);
assert_eq!(p.bg_hover, t.bg_button_hover);
assert_eq!(p.fg, t.fg_text);
assert_eq!(p.fg_active, t.accent);
// El rojo de REC no debe venir del theme.
let [r, _, _, _] = p.fg_record.components;
assert!(r > 0.8, "fg_record debe ser rojo dominante");
}
#[test]
fn play_pause_alterna_icono_y_activa_al_reproducir() {
let on = TransportButton::PlayPause { playing: true };
let off = TransportButton::PlayPause { playing: false };
assert!(matches!(on.icon(), Icon::Pause));
assert!(matches!(off.icon(), Icon::Play));
assert!(on.is_active());
assert!(!off.is_active());
assert_eq!(on.action(), TransportAction::TogglePlay);
assert_eq!(off.action(), TransportAction::TogglePlay);
}
#[test]
fn seek_simetrico_niega_signo_atras() {
let back = TransportButton::SeekBack { secs: 5 };
let fwd = TransportButton::SeekForward { secs: 5 };
assert_eq!(back.action(), TransportAction::SeekBy(-5));
assert_eq!(fwd.action(), TransportAction::SeekBy(5));
}
#[test]
fn volumen_simetrico_niega_signo_abajo() {
let down = TransportButton::VolumeDown { step: 0.1 };
let up = TransportButton::VolumeUp { step: 0.1 };
assert_eq!(down.action(), TransportAction::VolumeBy(-0.1));
assert_eq!(up.action(), TransportAction::VolumeBy(0.1));
}
#[test]
fn record_activo_y_record_es_caso_especial() {
let on = TransportButton::Record { recording: true };
let off = TransportButton::Record { recording: false };
assert!(on.is_active());
assert!(!off.is_active());
// Pero ambos son "record" → ambos usan fg_record.
assert!(on.is_record());
assert!(off.is_record());
}
#[test]
fn speed_reset_active_cuando_default() {
let nominal = TransportButton::SpeedReset { is_default: true };
let off = TransportButton::SpeedReset { is_default: false };
assert!(nominal.is_active());
assert!(!off.is_active());
}
#[test]
fn construye_sin_panic_todos_los_botones() {
let pal = TransportPalette::default();
let buttons = [
TransportButton::PlayPause { playing: false },
TransportButton::Stop,
TransportButton::Prev,
TransportButton::Next,
TransportButton::SeekBack { secs: 5 },
TransportButton::SeekForward { secs: 5 },
TransportButton::VolumeDown { step: 0.1 },
TransportButton::VolumeUp { step: 0.1 },
TransportButton::Mute { muted: false },
TransportButton::Repeat { active: false },
TransportButton::Shuffle { active: false },
TransportButton::SpeedDown,
TransportButton::SpeedUp,
TransportButton::SpeedReset { is_default: true },
TransportButton::Snapshot,
TransportButton::Record { recording: false },
TransportButton::Equalizer { enabled: false },
];
for b in buttons {
let _ = transport_button_view::<Cmd, _>(b, &pal, Cmd);
}
}
}
+3
View File
@@ -300,6 +300,9 @@ fn tree_row_view<Msg: Clone + 'static>(
..Default::default()
})
.text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
// Nombres largos (archivos, nodos) terminan en `…` en vez de cortarse
// a media letra contra el `clip` del contenedor.
.ellipsis(1)
.on_click(row.on_select)
};
row_children.push(label);
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-waveform"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-waveform — visor de forma de onda en vivo (envelope min/max relleno + línea central + contorno). Stateless y agnóstico: el caller pasa un closure que rellena un buffer de samples y devuelve la cantidad de canales (fold a mono interno)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+298
View File
@@ -0,0 +1,298 @@
//! `llimphi-widget-waveform` — visor de **forma de onda en vivo**.
//!
//! Pattern análogo a [`llimphi-widget-timeline`](
//! https://docs.rs/llimphi-widget-timeline): el widget **no mantiene
//! estado** del audio (ni cpal, ni `AudioProbe`, ni ringbuffer). El caller
//! le pasa un closure `Fn(&mut Vec<f32>) -> u16` que rellena un buffer con
//! los últimos samples y devuelve cuántos **canales** intercalados trae;
//! el widget hace el fold a mono y dibuja un **envelope min/max por
//! columna** (polígono cerrado con relleno tenue + stroke por arriba y
//! por abajo) sobre una **línea central** que siempre está presente como
//! "ground" del visor. Sin handlers de mouse — paint-only.
//!
//! ```text
//! ┌─────────────────────────────────────────────────┐
//! │ ▄▄▄ ▄ ▄▄▄ ▄▄ ▄▄ ▄ │
//! │ ▄███▄ ▄█▄▄███▄▄██▄▄▄▄██▄▄█▄ │
//! │──█████──███████████████████████─── centro ──────│
//! │ ▀███▀ ▀█▀▀███▀▀██▀▀▀▀██▀▀█▀ │
//! │ ▀▀▀ ▀ ▀▀▀ ▀▀ ▀▀ ▀ │
//! └─────────────────────────────────────────────────┘
//! ```
//!
//! Uso típico (reproductor con audio probe):
//!
//! ```ignore
//! use std::sync::Arc;
//! let probe = audio_probe(); // Arc<AudioProbe> propia de la app
//! let palette = WaveformPalette::default();
//! waveform_view(
//! move |out| {
//! let (_sr, ch) = probe.snapshot(out);
//! ch // canales intercalados
//! },
//! &palette,
//! )
//! ```
//!
//! Si el closure devuelve `0` canales (o no llena el buffer), el widget
//! pinta sólo la línea central — útil para mostrar "visor vivo, sin
//! señal" cuando el dispositivo de captura todavía no levantó.
#![forbid(unsafe_code)]
use std::sync::{Arc, Mutex};
use llimphi_ui::llimphi_layout::taffy::prelude::{auto, percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::View;
/// Paleta + dimensiones del visor de waveform.
///
/// El `bg`/`radius` se usan como `fill`/`radius` del nodo contenedor; el
/// `center`/`stroke`/`fill` se pintan dentro de `paint_with`. Los paddings
/// definen el margen interior — la onda no toca los bordes redondeados.
#[derive(Debug, Clone, Copy)]
pub struct WaveformPalette {
/// Fondo del recuadro (se pinta como `fill` del nodo).
pub bg: Color,
/// Color de la línea central (ground del visor).
pub center: Color,
/// Color del contorno top/bot del envelope.
pub stroke: Color,
/// Color del relleno del envelope (típicamente `stroke` con alfa bajo).
pub fill: Color,
/// Radio de las esquinas del recuadro.
pub radius: f64,
/// Padding horizontal interior (px) — margen entre el borde y la onda.
pub pad_x: f32,
/// Padding vertical interior (px).
pub pad_y: f32,
/// Grosor del stroke del envelope (px).
pub stroke_w: f32,
}
impl Default for WaveformPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl WaveformPalette {
/// Construye la paleta desde un `Theme` semántico. El relleno del
/// envelope se deriva del `accent` con alfa bajo para que se vea como
/// "halo" sin pelear con el stroke.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
let accent = t.accent;
let [r, g, b, _] = accent.components;
Self {
bg: t.bg_panel_alt,
center: t.fg_muted,
stroke: accent,
// Mismo color que el stroke pero con alfa bajo (≈0.27) para
// que el envelope se vea como un halo sin pelear con el contorno.
fill: Color::from_rgba8(
(r * 255.0) as u8,
(g * 255.0) as u8,
(b * 255.0) as u8,
70,
),
radius: 8.0,
pad_x: 12.0,
pad_y: 8.0,
stroke_w: 1.2,
}
}
}
/// Compone el visor de waveform.
///
/// `source` se invoca **una vez por frame** dentro del `paint_with`: el
/// caller lo usa para rellenar el buffer con los últimos samples
/// intercalados y devolver cuántos canales trae. Si devuelve `0` (o el
/// buffer queda vacío) el widget pinta sólo la línea central. El widget
/// es stateless: redibujá pasando el mismo closure cada frame y la onda
/// avanza sola con cada snapshot nuevo.
///
/// El widget ocupa el espacio que le dé el padre (`width: auto`, `height:
/// 100%`); para que crezca dentro de una fila/columna del padre, el
/// caller lo envuelve con un `flex_grow: 1.0`.
pub fn waveform_view<Msg, F>(source: F, palette: &WaveformPalette) -> View<Msg>
where
Msg: 'static,
F: Fn(&mut Vec<f32>) -> u16 + Send + Sync + 'static,
{
let pal = *palette;
// Buffer scratch: se reusa entre frames para no realocar. Es seguro
// tenerlo en un `Arc<Mutex>` porque `paint_with` corre en el hilo de
// UI (un sólo painter activo por frame).
let scratch: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
View::new(Style {
size: Size {
width: auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.fill(pal.bg)
.radius(pal.radius)
.paint_with(move |scene, _ts, rect| {
if rect.w <= 4.0 || rect.h <= 4.0 {
return;
}
let inner_x = rect.x + pal.pad_x;
let inner_y = rect.y + pal.pad_y;
let inner_w = (rect.w - 2.0 * pal.pad_x).max(1.0);
let inner_h = (rect.h - 2.0 * pal.pad_y).max(1.0);
let mid_y = inner_y + inner_h * 0.5;
// Línea central — siempre presente, hace de "ground" del visor.
let mut center = BezPath::new();
center.move_to((inner_x as f64, mid_y as f64));
center.line_to(((inner_x + inner_w) as f64, mid_y as f64));
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
pal.center,
None,
&center,
);
let mut snap = scratch.lock().unwrap_or_else(|p| p.into_inner());
let channels = source(&mut snap).max(1) as usize;
let total_frames = snap.len() / channels;
if total_frames < 2 {
return;
}
// Envelope min/max por columna: para cada bucket de frames
// guardamos el mínimo y el máximo del mono fold y dibujamos la
// forma como un polígono cerrado (relleno tenue + stroke top/bot).
// Da mucho más "cuerpo" que la línea pico-sólo.
let cols = (inner_w.max(2.0) as usize).min(total_frames);
let frames_per_col = total_frames / cols.max(1);
if frames_per_col == 0 {
return;
}
let amp = inner_h * 0.5;
let denom = (cols as f32 - 1.0).max(1.0);
let mut top = BezPath::new();
let mut bot = BezPath::new();
let mut envelope = BezPath::new();
// Pasada hacia adelante: top + arranca envelope por el borde
// superior. Cacheamos los mínimos para no recorrer el buffer dos
// veces.
let mut mins = Vec::with_capacity(cols);
for col in 0..cols {
let f0 = col * frames_per_col;
let f1 = ((col + 1) * frames_per_col).min(total_frames);
let mut vmin = f32::INFINITY;
let mut vmax = f32::NEG_INFINITY;
for f in f0..f1 {
let mut acc = 0.0_f32;
for ch in 0..channels {
acc += snap[f * channels + ch];
}
let v = (acc / channels as f32).clamp(-1.0, 1.0);
if v < vmin {
vmin = v;
}
if v > vmax {
vmax = v;
}
}
mins.push(vmin);
let x = inner_x + (col as f32 / denom) * inner_w;
let y_top = mid_y - vmax * amp;
let y_bot = mid_y - vmin * amp;
if col == 0 {
top.move_to((x as f64, y_top as f64));
bot.move_to((x as f64, y_bot as f64));
envelope.move_to((x as f64, y_top as f64));
} else {
top.line_to((x as f64, y_top as f64));
bot.line_to((x as f64, y_bot as f64));
envelope.line_to((x as f64, y_top as f64));
}
}
// Cierre del envelope: volvé por la línea de mínimos en sentido
// inverso (sin recorrer samples otra vez — los mins ya están).
for col in (0..cols).rev() {
let x = inner_x + (col as f32 / denom) * inner_w;
let y_bot = mid_y - mins[col] * amp;
envelope.line_to((x as f64, y_bot as f64));
}
envelope.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fill, None, &envelope);
let stroke = Stroke::new(pal.stroke_w as f64);
scene.stroke(&stroke, Affine::IDENTITY, pal.stroke, None, &top);
scene.stroke(&stroke, Affine::IDENTITY, pal.stroke, None, &bot);
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_theme_usa_colores_semanticos() {
let t = llimphi_theme::Theme::dark();
let p = WaveformPalette::from_theme(&t);
assert_eq!(p.bg, t.bg_panel_alt);
assert_eq!(p.center, t.fg_muted);
assert_eq!(p.stroke, t.accent);
// fill = accent con alfa bajo (~0.27).
let [r0, g0, b0, _] = t.accent.components;
let [r1, g1, b1, a1] = p.fill.components;
// Componentes RGB iguales módulo el roundtrip f32→u8→f32.
assert!((r0 - r1).abs() < 0.01);
assert!((g0 - g1).abs() < 0.01);
assert!((b0 - b1).abs() < 0.01);
// Alfa ≈ 70/255 ≈ 0.274.
assert!((a1 - 70.0 / 255.0).abs() < 0.005);
}
#[test]
fn construye_sin_panic_sin_senal() {
// Closure que reporta 0 canales => sólo pinta la línea central.
let pal = WaveformPalette::default();
let _ = waveform_view::<(), _>(|_| 0, &pal);
}
#[test]
fn construye_con_senal_mono() {
let pal = WaveformPalette::default();
let _ = waveform_view::<(), _>(
|out| {
out.clear();
for i in 0..1024 {
out.push(((i as f32) * 0.01).sin());
}
1
},
&pal,
);
}
#[test]
fn construye_con_senal_estereo() {
let pal = WaveformPalette::default();
let _ = waveform_view::<(), _>(
|out| {
out.clear();
for i in 0..512 {
let t = i as f32 * 0.02;
out.push(t.sin());
out.push((t * 1.5).sin());
}
2
},
&pal,
);
}
}
-1
View File
@@ -9,4 +9,3 @@ description = "llimphi-widget-wawa-mark — sello vectorial de wawa: rombo con d
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn paint_mark(
let core_r = (side * 0.018).max(1.2);
let halo_r = core_r * 2.6;
let halo_color = with_alpha(palette.core, 0.30);
scene.push_layer(Mix::Normal, 1.0, Affine::IDENTITY, &Circle::new(p2, halo_r));
scene.push_layer(Fill::NonZero, Mix::Normal, 1.0, Affine::IDENTITY, &Circle::new(p2, halo_r));
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "llimphi-widget-wrap"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-wrap — contenedor flex que envuelve a la siguiente línea (FlexWrap::Wrap) con gap horizontal+vertical. Para chips, tags, galerías fluidas, toolbars que respiran."
[dependencies]
llimphi-ui = { workspace = true }
+59
View File
@@ -0,0 +1,59 @@
//! `llimphi-widget-wrap` — contenedor con `flex-wrap: wrap` y gap.
//!
//! Lo que en Flutter es `Wrap` y en Compose `FlowRow`: items que fluyen
//! horizontalmente y saltan a la siguiente línea cuando se acaba el
//! ancho. Usalo para chips, tags, galerías fluidas, toolbars que
//! respiran. taffy ya soporta `flex-wrap`; este crate es el azúcar
//! mínima para no repetir el `Style` boilerplate.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, FlexWrap, Size, Style},
AlignItems,
};
use llimphi_ui::View;
/// Eje principal del wrap.
#[derive(Debug, Clone, Copy)]
pub enum WrapAxis {
Row,
Column,
}
/// Wrap container.
///
/// - `axis` decide eje principal (row = horizontal, column = vertical).
/// - `h_gap` es el gap **entre items en la misma línea**.
/// - `v_gap` es el gap **entre líneas**.
pub fn wrap_view<Msg: Clone + 'static>(
children: Vec<View<Msg>>,
axis: WrapAxis,
h_gap: f32,
v_gap: f32,
) -> View<Msg> {
let dir = match axis {
WrapAxis::Row => FlexDirection::Row,
WrapAxis::Column => FlexDirection::Column,
};
View::new(Style {
flex_direction: dir,
flex_wrap: FlexWrap::Wrap,
size: Size {
width: percent(1.0_f32),
height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
},
gap: Size {
width: length(h_gap),
height: length(v_gap),
},
align_items: Some(AlignItems::FlexStart),
..Default::default()
})
.children(children)
}
/// Atajo: row de chips con gap parejo 6px.
pub fn chip_row<Msg: Clone + 'static>(children: Vec<View<Msg>>) -> View<Msg> {
wrap_view(children, WrapAxis::Row, 6.0, 6.0)
}