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:
@@ -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,
|
||||
|
||||
@@ -9,4 +9,3 @@ description = "llimphi-widget-avatar — círculo de identidad con inicial sobre
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -9,4 +9,3 @@ description = "llimphi-widget-spinner — spinner circular animado por reloj abs
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 1–2).
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Tipos de la superficie + render virtualizado **modo línea** (Capas 1–2).
|
||||
//!
|
||||
//! 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());
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(), "cá");
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.6–2.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
¢er,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user