feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-app-header"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-app-header — tira superior estándar para apps Llimphi: label dinámico a la izquierda + slot de acciones opcional a la derecha. Análogo Llimphi al `nahual-widget-app-header` GPUI."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-app-header
> Header común de app para [llimphi](../../README.md).
Barra superior estándar: logo/icono · título · acciones a la derecha · breadcrumb opcional. Cualquier app del monorepo lo usa para coherencia visual.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-app-header
> Common app header for [llimphi](../../README.md).
Standard top bar: logo/icon · title · right-side actions · optional breadcrumb. Any monorepo app uses it for visual coherence.
+145
View File
@@ -0,0 +1,145 @@
//! `llimphi-widget-app-header` — tira superior estándar de las apps.
//!
//! Reproduce el contrato del `nahual-widget-app-header` GPUI: label
//! dinámico a la izquierda con `flex_grow`, slot a la derecha para
//! acciones (theme switcher, botones de toolbar, etc.). bg = `bg_panel`,
//! line-bottom como `border` del theme.
//!
//! Uso típico:
//!
//! ```ignore
//! app_header(format!("Log: {} · {} entries", path, n), vec![], &palette)
//! ```
#![forbid(unsafe_code)]
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_widget_panel::{panel_signature_painter, PanelStyle};
/// Paleta del header. Defaults desde el theme global.
#[derive(Debug, Clone, Copy)]
pub struct AppHeaderPalette {
pub bg: Color,
pub border_bottom: Color,
pub fg_text: Color,
pub height: f32,
/// Firma visual: gradient sutil + hairline accent en el top edge. Se
/// activa por defecto al construir desde theme. `None` cae al fill
/// plano de `bg` (modo back-compat para sitios que arman la palette
/// a mano sin theme).
pub signature: Option<PanelStyle>,
}
impl Default for AppHeaderPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl AppHeaderPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_panel,
border_bottom: t.border,
fg_text: t.fg_text,
height: 40.0,
signature: Some(PanelStyle {
radius: 0.0,
..PanelStyle::from_theme(t)
}),
}
}
}
/// Header con `label` a la izquierda y `actions` a la derecha. `actions`
/// es vacío para apps sin toolbar; viene como Vec para que la app meta
/// botones / switcher / status pill / lo que necesite.
pub fn app_header<Msg: Clone + 'static>(
label: impl Into<String>,
actions: Vec<View<Msg>>,
palette: &AppHeaderPalette,
) -> View<Msg> {
let label_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(palette.height),
},
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.into(), 14.0, palette.fg_text, Alignment::Start);
let actions_view = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
height: length(palette.height),
},
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),
},
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(actions);
// Bottom border: el header rellena `bg` (o aplica la firma si está
// habilitada), y debajo va una línea 1px de `border_bottom`. Lo
// metemos como un wrapper column.
let bar_style = Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(palette.height),
},
align_items: Some(AlignItems::Center),
..Default::default()
};
let bar = match palette.signature {
Some(style) => View::new(bar_style)
.paint_with(panel_signature_painter(style))
.children(vec![label_view, actions_view]),
None => View::new(bar_style)
.fill(palette.bg)
.children(vec![label_view, actions_view]),
};
let underline = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
..Default::default()
})
.fill(palette.border_bottom);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: length(palette.height + 1.0),
},
..Default::default()
})
.children(vec![bar, underline])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-avatar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-avatar — círculo de identidad con inicial sobre color generado del hash del nombre. Determinista (mismo nombre → mismo color) y tonal (paleta limitada para que no choque)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+116
View File
@@ -0,0 +1,116 @@
//! `llimphi-widget-avatar` — círculo de identidad con inicial.
//!
//! Genera un avatar **determinista** de un nombre: el color de fondo
//! viene de un hash del nombre, mapeado a una paleta limitada de 8
//! tonos (para que dos usuarios distintos no acaben con colores que
//! se confundan). La inicial es la primera letra del nombre (uppercase),
//! pintada centrada en blanco-cálido.
//!
//! Útil para chats (ayni), authorship en pluma, presencia en
//! herramientas colaborativas. Una sola función — sin state, sin
//! animación, sin paleta configurable (la consistencia importa más
//! que la personalización).
#![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;
/// Construye el avatar de `name` con diámetro `size_px`.
pub fn avatar_view<Msg: Clone + 'static>(name: &str, size_px: f32) -> View<Msg> {
let bg = color_for(name);
let initial = name
.chars()
.next()
.map(|c| c.to_uppercase().next().unwrap_or(c))
.unwrap_or('·');
let fg = Color::from_rgba8(248, 248, 250, 255);
let font = (size_px * 0.42).max(8.0);
View::new(Style {
size: Size {
width: length(size_px),
height: length(size_px),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.radius((size_px * 0.5) as f64)
.paint_with(move |scene, _ts, rect| {
// Highlight radial en el cuadrante superior — el avatar se lee
// como esfera. paint_with corre entre el fill y la inicial, así
// que la luz se suma al color del nombre sin tapar el texto.
// Mismo patrón dot-badge / switch-thumb (P6/P7).
use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle};
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.30) as f64;
let r = (rect.w as f64 * 0.18).max(1.0);
let highlight = Color::from_rgba8(255, 255, 255, 60);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
highlight,
None,
&Circle::new((cx, cy), r),
);
})
.text_aligned(initial.to_string(), font, fg, Alignment::Center)
}
/// Paleta tonal limitada — 8 colores HSL-ish elegidos para destacar
/// sobre fondos oscuros sin ser estridentes.
const PALETTE: &[Color] = &[
Color::from_rgba8(96, 130, 220, 255), // azul
Color::from_rgba8(110, 180, 130, 255), // verde aurora
Color::from_rgba8(220, 140, 80, 255), // naranja sunset
Color::from_rgba8(160, 110, 220, 255), // púrpura
Color::from_rgba8(80, 180, 180, 255), // aqua
Color::from_rgba8(220, 120, 160, 255), // rosa
Color::from_rgba8(180, 170, 90, 255), // mostaza
Color::from_rgba8(130, 150, 175, 255), // gris-azul
];
/// Hash FNV-1a simple sobre los bytes del nombre, mod paleta. No
/// requiere crypto — sólo necesitamos que mismo input dé mismo color.
fn color_for(name: &str) -> Color {
let mut h: u32 = 0x811c9dc5;
for b in name.bytes() {
h ^= b as u32;
h = h.wrapping_mul(0x01000193);
}
PALETTE[(h as usize) % PALETTE.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_for_is_deterministic() {
assert_eq!(color_for("sergio").components, color_for("sergio").components);
assert_eq!(color_for("calcetin").components, color_for("calcetin").components);
}
#[test]
fn different_names_can_have_different_colors() {
let names = ["a", "b", "c", "d", "e", "f", "g", "h"];
let colors: Vec<_> = names.iter().map(|n| color_for(n)).collect();
// Al menos 2 colores distintos en 8 nombres — el hash es trivial,
// colisiones esperadas, no garantizamos 8 distintos.
let unique: std::collections::HashSet<_> =
colors.iter().map(|c| c.components.map(|x| (x * 255.0) as u8)).collect();
assert!(unique.len() >= 2);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-badge"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-badge — chip pequeño (count o dot) para notificaciones, contadores, estado de conexión. Cuatro variants semánticas."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+136
View File
@@ -0,0 +1,136 @@
//! `llimphi-widget-badge` — chip pequeño para conteo o estado.
//!
//! Dos formas:
//! - `count_badge_view(n, kind)` — chip ovalado con número adentro
//! ("3", "12", "99+"). Para notificaciones, items sin leer, etc.
//! - `dot_badge_view(kind)` — círculo de 8px sin contenido. Para
//! estado de conexión (online/offline/idle) o "hay algo nuevo".
//!
//! Cuatro `BadgeKind` con paleta semántica (Info / Success / Warning
//! / Error / Neutral) — los colores no cambian con el theme para
//! mantener la consistencia semántica.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BadgeKind {
Info,
Success,
Warning,
Error,
Neutral,
}
impl BadgeKind {
pub fn bg(self) -> Color {
match self {
BadgeKind::Info => Color::from_rgba8(60, 130, 220, 255),
BadgeKind::Success => Color::from_rgba8(70, 180, 110, 255),
BadgeKind::Warning => Color::from_rgba8(220, 160, 40, 255),
BadgeKind::Error => Color::from_rgba8(220, 80, 80, 255),
BadgeKind::Neutral => Color::from_rgba8(120, 130, 150, 255),
}
}
pub fn fg(self) -> Color {
// Texto siempre blanco-cálido sobre los colores sólidos del bg.
Color::from_rgba8(248, 248, 250, 255)
}
}
const BADGE_H: f32 = 16.0;
const FONT: f32 = 10.0;
const DOT_R: f32 = 4.0; // dot diameter = 8
/// Chip con número. Si `count >= 100`, muestra "99+".
pub fn count_badge_view<Msg: Clone + 'static>(count: u32, kind: BadgeKind) -> View<Msg> {
let text = if count >= 100 { "99+".to_string() } else { count.to_string() };
// Ancho proporcional al texto, con padding generoso.
let w = (text.chars().count() as f32 * 6.5 + 10.0).max(BADGE_H);
let badge_radius = (BADGE_H * 0.5) as f64;
View::new(Style {
size: Size {
width: length(w),
height: length(BADGE_H),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(5.0_f32),
right: length(5.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(kind.bg())
.radius(badge_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior: blanco alpha 35 → 0 sobre la mitad de arriba.
// Da volumen de pill — el chip se lee como una superficie con
// luz cayendo, no como un rect plano. Match: button/splash —
// misma firma vertical descendente.
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, badge_radius);
let top = Color::from_rgba8(255, 255, 255, 35);
let bot = Color::from_rgba8(255, 255, 255, 0);
let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
})
.text_aligned(text, FONT, kind.fg(), Alignment::Center)
}
/// Dot sin contenido — sólo color.
pub fn dot_badge_view<Msg: Clone + 'static>(kind: BadgeKind) -> View<Msg> {
let dot_radius = DOT_R as f64;
View::new(Style {
size: Size {
width: length(DOT_R * 2.0),
height: length(DOT_R * 2.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(kind.bg())
.radius(dot_radius)
.paint_with(move |scene, _ts, rect| {
// Highlight radial chiquito en el cuadrante superior — lectura
// de esfera, no de círculo plano. El dot es 8px; el highlight
// ocupa ~3px centrado a 1/3 del top.
use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle};
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.33) as f64;
let r = (rect.w as f64 * 0.18).max(1.0);
let highlight = Color::from_rgba8(255, 255, 255, 90);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
highlight,
None,
&Circle::new((cx, cy), r),
);
})
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "llimphi-widget-banner"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-banner — tiras horizontales de status (Info/Success/Warning/Error). Colores semánticos hardcoded por severidad — no dependen del theme. Análogo Llimphi al `nahual-widget-banner` GPUI."
[dependencies]
llimphi-ui = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-banner
> Banner / alerts para [llimphi](../../README.md).
Mensaje destacado al tope de la vista: info / warning / error / success. Auto-dismiss configurable.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-banner
> Banner / alerts for [llimphi](../../README.md).
Prominent message at the top of the view: info / warning / error / success. Configurable auto-dismiss.
+109
View File
@@ -0,0 +1,109 @@
//! `llimphi-widget-banner` — tiras horizontales de status.
//!
//! Cuatro variants con paleta consistente entre apps:
//!
//! - [`BannerKind::Info`] — azul tenue, mensajes neutros.
//! - [`BannerKind::Success`] — verde, confirmaciones de op exitosa.
//! - [`BannerKind::Warning`] — amber, llamadas de atención.
//! - [`BannerKind::Error`] — rojo, errores fatales o de carga.
//!
//! Análogo Llimphi al `nahual-widget-banner` GPUI. Los colores son
//! **semánticos** y no cambian con el theme (un Error en dark y en
//! light tiene que seguir leyéndose como rojo).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Severidad / tono del banner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BannerKind {
Info,
Success,
Warning,
Error,
}
impl BannerKind {
pub fn bg(self) -> Color {
match self {
BannerKind::Info => Color::from_rgba8(0x1d, 0x2a, 0x3a, 0xff),
BannerKind::Success => Color::from_rgba8(0x2d, 0x3a, 0x2a, 0xff),
BannerKind::Warning => Color::from_rgba8(0x4a, 0x3a, 0x1a, 0xff),
BannerKind::Error => Color::from_rgba8(0x4a, 0x20, 0x20, 0xff),
}
}
pub fn fg(self) -> Color {
match self {
BannerKind::Info => Color::from_rgba8(0xc0, 0xd0, 0xe0, 0xff),
BannerKind::Success => Color::from_rgba8(0xc0, 0xe0, 0xa0, 0xff),
BannerKind::Warning => Color::from_rgba8(0xf0, 0xe0, 0xa0, 0xff),
BannerKind::Error => Color::from_rgba8(0xff, 0xd0, 0xd0, 0xff),
}
}
}
/// Ancho del rail de severidad en el edge izquierdo. Mismo valor que
/// `llimphi-widget-toast` — banner y toast son las versiones persistente
/// y efímera del mismo lenguaje (P5 → P8).
const RAIL_W: f32 = 3.0;
/// Banner simple: una fila con `message` centrado verticalmente y
/// alineado a la izquierda. bg/fg vienen del `kind`.
pub fn banner_view<Msg: Clone + 'static>(
kind: BannerKind,
message: impl Into<String>,
) -> View<Msg> {
use llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection;
// Rail de severidad en el edge izquierdo — stripe del color fg
// semántico, visible al pasar el ojo. Mismo patrón que toast P5.
let rail = View::new(Style {
size: Size {
width: length(RAIL_W),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(kind.fg());
// Contenedor del mensaje: padding original ahora vive acá para que
// el rail pegue al borde sin offset y el texto arranque después.
let body = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(message.into(), 11.0, kind.fg(), Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(kind.bg())
.radius(3.0)
.clip(true)
.children(vec![rail, body])
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-breadcrumb"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-breadcrumb — ruta navegable con separadores chevron. Cada segmento clicable salta a su nivel."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
+128
View File
@@ -0,0 +1,128 @@
//! `llimphi-widget-breadcrumb` — ruta navegable con separadores chevron.
//!
//! Patrón clásico: `home docs 2026 nota.md`. Cada segmento es
//! clicable y emite un Msg con su índice. El último segmento (la
//! "página actual") se renderiza con énfasis y sin click handler.
#![forbid(unsafe_code)]
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_icons::{icon_view, Icon};
use llimphi_theme::Theme;
/// Paleta del breadcrumb.
#[derive(Debug, Clone, Copy)]
pub struct BreadcrumbPalette {
pub fg_link: Color,
pub fg_current: Color,
pub fg_separator: Color,
pub bg_hover: Color,
}
impl BreadcrumbPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
fg_link: t.fg_muted,
fg_current: t.fg_text,
fg_separator: t.fg_placeholder,
bg_hover: t.bg_row_hover,
}
}
}
const SEG_H: f32 = 22.0;
const SEG_PAD: f32 = 6.0;
const FONT: f32 = 11.5;
const SEP_BOX: f32 = 12.0;
/// Construye el breadcrumb. `segments` son los labels visibles, en
/// orden de raíz a hoja. `make_msg(i)` se llama al click en el
/// segmento `i` (no se llama para el último — la "página actual").
pub fn breadcrumb_view<Msg, F>(
segments: &[&str],
make_msg: F,
palette: &BreadcrumbPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize) -> Msg,
{
let last = segments.len().saturating_sub(1);
let mut children: Vec<View<Msg>> = Vec::with_capacity(segments.len() * 2);
for (i, &label) in segments.iter().enumerate() {
let is_current = i == last;
children.push(segment_view(
label,
is_current,
if is_current { None } else { Some(make_msg(i)) },
palette,
));
if !is_current {
children.push(separator_view(palette));
}
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(SEG_H),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(children)
}
fn segment_view<Msg: Clone + 'static>(
label: &str,
is_current: bool,
msg: Option<Msg>,
palette: &BreadcrumbPalette,
) -> View<Msg> {
let fg = if is_current { palette.fg_current } else { palette.fg_link };
let approx_w = label.chars().count() as f32 * 6.5 + SEG_PAD * 2.0;
let mut node = View::new(Style {
size: Size {
width: length(approx_w),
height: length(SEG_H),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(SEG_PAD),
right: length(SEG_PAD),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), FONT, fg, Alignment::Center)
.radius(llimphi_theme::radius::XS);
if let Some(m) = msg {
node = node.hover_fill(palette.bg_hover).on_click(m);
}
node
}
fn separator_view<Msg: Clone + 'static>(palette: &BreadcrumbPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: length(SEP_BOX),
height: length(SEP_BOX),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![icon_view(Icon::ChevronRight, palette.fg_separator, 1.6)])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-button"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-button — botón clicable con estado hover. Reusable entre apps Llimphi; cambia el bg cuando el cursor pasa por encima. Compuesto de `View::fill().hover_fill().on_click()` con una paleta tematizable."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-button
> Botón con variantes para [llimphi](../../README.md).
Variantes: `primary`, `secondary`, `ghost`, `danger`. Estado hover/active/disabled. Soporta icono + label, o sólo icono.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-button
> Button with variants for [llimphi](../../README.md).
Variants: `primary`, `secondary`, `ghost`, `danger`. Hover/active/disabled states. Supports icon + label, or icon-only.
+136
View File
@@ -0,0 +1,136 @@
//! Showcase de `llimphi-widget-button`: tres botones con hover.
//!
//! Corré con: `cargo run -p llimphi-widget-button --example showcase --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_button::{button_styled, button_view, ButtonPalette};
#[derive(Clone, Debug)]
enum Msg {
A,
B,
C,
}
struct Model {
last: Option<Msg>,
counter: u32,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · button showcase"
}
fn init(_: &Handle<Msg>) -> Model {
Model {
last: None,
counter: 0,
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
m.counter += 1;
m.last = Some(msg);
m
}
fn view(model: &Model) -> View<Msg> {
let palette = ButtonPalette::default();
let warning = ButtonPalette {
bg: Color::from_rgba8(140, 70, 30, 255),
bg_hover: Color::from_rgba8(200, 100, 40, 255),
..palette
};
let danger = ButtonPalette {
bg: Color::from_rgba8(150, 40, 40, 255),
bg_hover: Color::from_rgba8(220, 70, 70, 255),
..palette
};
let a = button_view("acción A", &palette, Msg::A);
let b = button_view("acción B (warning)", &warning, Msg::B);
let c = button_styled(
"borrar (left-aligned, fixed width)",
Style {
size: Size {
width: length(320.0_f32),
height: length(34.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
},
Alignment::Start,
&danger,
Msg::C,
);
let status = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
..Default::default()
})
.text_aligned(
format!(
"clicks: {} · último: {}",
model.counter,
match model.last {
Some(Msg::A) => "A",
Some(Msg::B) => "B",
Some(Msg::C) => "C",
None => "",
}
),
14.0,
Color::from_rgba8(180, 190, 205, 255),
Alignment::Start,
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(14.0_f32),
},
padding: Rect {
left: length(32.0_f32),
right: length(32.0_f32),
top: length(32.0_f32),
bottom: length(32.0_f32),
},
align_items: Some(AlignItems::Start),
justify_content: Some(JustifyContent::Start),
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(vec![a, b, c, status])
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+124
View File
@@ -0,0 +1,124 @@
//! `llimphi-widget-button` — botón clicable con estado hover.
//!
//! Reusable entre apps Llimphi: `button_view(label, palette, on_click)`
//! devuelve una vista que cambia de color cuando el cursor pasa por
//! encima y emite `on_click` al ser apretada. El caller controla las
//! dimensiones envolviendo el `View` retornado en un contenedor flex
//! con el tamaño que necesite (botón ancho completo, chip 80×30, etc).
//!
//! No expone estado interno — todo el estado vive en el `Model` del App
//! (el hover lo trackea llimphi-ui automáticamente vía `hover_fill`).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Paleta del botón. Por default un chip dark con highlight tenue al
/// hover — similar al patrón `bg_panel_alt` + `bg_row_hover` de
/// `nahual-theme`.
#[derive(Debug, Clone, Copy)]
pub struct ButtonPalette {
pub bg: Color,
pub bg_hover: Color,
pub fg: Color,
pub radius: f64,
}
impl Default for ButtonPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ButtonPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_button,
bg_hover: t.bg_button_hover,
fg: t.fg_text,
radius: 5.0,
}
}
}
/// Compone un botón rectangular: bg + texto + on_click + hover. Por
/// default ocupa ancho 100% del padre y alto 30 px; sobre-escribir
/// pasando un `Style` propio vía [`button_styled`].
pub fn button_view<Msg: Clone + 'static>(
label: impl Into<String>,
palette: &ButtonPalette,
on_click: Msg,
) -> View<Msg> {
button_styled(
label,
Style {
size: Size {
width: percent(1.0_f32),
height: length(30.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),
justify_content: Some(JustifyContent::Center),
..Default::default()
},
Alignment::Center,
palette,
on_click,
)
}
/// 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.
pub fn button_styled<Msg: Clone + 'static>(
label: impl Into<String>,
style: Style,
text_alignment: Alignment,
palette: &ButtonPalette,
on_click: Msg,
) -> View<Msg> {
// 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
// — el hover sigue funcionando idéntico. El RoundedRect cubre el
// botón completo y `Extend::Pad` (default de peniko) deja la mitad
// inferior en alpha 0. Match: chrome/splash — superficie con luz
// descendente desde el edge superior.
let radius = palette.radius;
View::new(style)
.fill(palette.bg)
.hover_fill(palette.bg_hover)
.radius(radius)
.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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, radius);
let top = Color::from_rgba8(255, 255, 255, 28);
let bot = Color::from_rgba8(255, 255, 255, 0);
let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.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)
.on_click(on_click)
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-card"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-card — container card-shape con padding consistente, esquinas redondeadas y opcional accent border a la izquierda. Análogo Llimphi al `nahual-widget-card` GPUI."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-card
> Card base para [llimphi](../../README.md).
Contenedor con borde + radius + padding consistente con el theme. Slot de contenido libre. Base de `stat-card` y otras especializadas.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-card
> Base card for [llimphi](../../README.md).
Container with border + radius + theme-consistent padding. Free content slot. Base for `stat-card` and other specialized variants.
+146
View File
@@ -0,0 +1,146 @@
//! `llimphi-widget-card` — container card-shape para entries de
//! timeline, info cards, dashboards, etc.
//!
//! Aporta la **forma**: padding consistente (12/8), `radius` 4, gap
//! pequeño entre children, y opcionalmente un accent vertical
//! (4 px) pegado a la izquierda para entries semánticas (verde =
//! OK, rojo = error, ámbar = warning, etc).
//!
//! Análogo Llimphi al `nahual-widget-card` GPUI.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
#[derive(Debug, Clone, Copy)]
pub struct CardPalette {
pub bg: Color,
}
impl Default for CardPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl CardPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self { bg: t.bg_panel }
}
}
/// Opciones del card.
#[derive(Debug, Clone, Copy)]
pub struct CardOptions {
/// Accent vertical a la izquierda (4 px). `None` = sin accent.
pub accent: Option<Color>,
pub padding: f32,
pub gap: f32,
pub radius: f64,
/// Firma visual del panel (gradient sutil + hairline accent en el
/// top). `Some(style)` reemplaza el fill plano del body por el
/// painter de la firma — usar para cards prominentes (dashboards,
/// timeline entries grandes) donde se nota el "tallado". `None`
/// mantiene el fill sólido del `CardPalette` (default).
pub signature: Option<PanelStyle>,
}
impl Default for CardOptions {
fn default() -> Self {
Self {
accent: None,
padding: 12.0,
gap: 4.0,
radius: 4.0,
signature: None,
}
}
}
impl CardOptions {
/// Variante con firma visual derivada del theme. El `radius` del
/// card se alinea al del `PanelStyle` para que la silueta del
/// gradiente coincida con las esquinas del nodo.
pub fn with_signature(t: &llimphi_theme::Theme) -> Self {
let style = PanelStyle::from_theme(t);
Self {
accent: None,
padding: 12.0,
gap: 4.0,
radius: style.radius,
signature: Some(style),
}
}
}
/// Compone un card: bg + radius + padding + flex-column con gap entre
/// children. Si `opts.accent` está presente, hay una franja vertical
/// de 4 px del color del accent pegada al borde izquierdo.
pub fn card_view<Msg: Clone + 'static>(
children: Vec<View<Msg>>,
opts: CardOptions,
palette: &CardPalette,
) -> View<Msg> {
let pad = opts.padding;
let body_style = Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(pad),
right: length(pad),
top: length(pad * 0.66),
bottom: length(pad * 0.66),
},
gap: Size {
width: length(0.0_f32),
height: length(opts.gap),
},
..Default::default()
};
let body = if let Some(style) = opts.signature {
View::new(body_style)
.paint_with(panel_signature_painter(style))
.radius(opts.radius)
.clip(true)
.children(children)
} else {
View::new(body_style)
.fill(palette.bg)
.radius(opts.radius)
.children(children)
};
let Some(accent) = opts.accent else {
return body;
};
let accent_strip = View::new(Style {
size: Size {
width: length(4.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(accent)
.radius(opts.radius);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(),
},
..Default::default()
})
.children(vec![accent_strip, body])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-clipboard"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-clipboard — backend de portapapeles del sistema (vía arboard) que implementa el trait Clipboard del text-editor. Una línea para que el menú de edición y los atajos Ctrl+C/X/V de cualquier app Llimphi usen el clipboard real del SO, con degradación silenciosa a no-op si no hay display."
[dependencies]
llimphi-widget-text-editor = { workspace = true }
arboard = { workspace = true }
+55
View File
@@ -0,0 +1,55 @@
//! `llimphi-clipboard` — el portapapeles del sistema para apps Llimphi.
//!
//! El `text-editor` define el trait [`Clipboard`] pero deja el backend al
//! caller (no quiere acoplarse a X11/Wayland/macOS/Windows). Este crate
//! aporta el backend obvio — [`arboard`] — para que cualquier app lo
//! enchufe en una línea:
//!
//! ```ignore
//! let mut clip = llimphi_clipboard::SystemClipboard::new();
//! editor.apply_key_with_clipboard(&ev, &mut clip);
//! ```
//!
//! Si no hay display (CI headless, sesión sin servidor gráfico) degrada
//! a no-op silencioso: `get` devuelve `None`, `set` descarta. Nunca
//! panica.
#![forbid(unsafe_code)]
use llimphi_widget_text_editor::Clipboard;
/// Portapapeles del sistema vía `arboard`. `None` interno = no se pudo
/// abrir (sin display); en ese caso opera como [`llimphi_widget_text_editor::NullClipboard`].
pub struct SystemClipboard {
inner: Option<arboard::Clipboard>,
}
impl SystemClipboard {
pub fn new() -> Self {
Self {
inner: arboard::Clipboard::new().ok(),
}
}
/// `true` si el backend del SO está disponible.
pub fn is_available(&self) -> bool {
self.inner.is_some()
}
}
impl Default for SystemClipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard for SystemClipboard {
fn get(&mut self) -> Option<String> {
self.inner.as_mut()?.get_text().ok()
}
fn set(&mut self, s: &str) {
if let Some(c) = self.inner.as_mut() {
let _ = c.set_text(s.to_owned());
}
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-context-menu"
version.workspace = true
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."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-context-menu
> Menú contextual para [llimphi](../../README.md).
Look distintivo: barra accent vertical 3px, hard edges, header tiny. API: `View::on_right_click[_at]` + `App::view_overlay`.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-context-menu
> Context menu for [llimphi](../../README.md).
Distinctive look: 3px vertical accent bar, hard edges, tiny header. API: `View::on_right_click[_at]` + `App::view_overlay`.
+761
View File
@@ -0,0 +1,761 @@
//! `llimphi-widget-context-menu` — menú contextual con look gioser.
//!
//! Distintivo y minimalista:
//!
//! ```text
//! ┃ B5 ← header (uppercase tiny)
//! ┃ ✂ Cortar Ctrl+X
//! ┃ ⧉ Copiar Ctrl+C ← gutter de íconos + barra accent (3px)
//! ┃ ⎘ Pegar Ctrl+V
//! ┃ ─────────────────────
//! ┃ ◐ Tema ▸ ← submenú (flyout a la derecha)
//! ```
//!
//! Cada fila: barra accent vertical (firma) · gutter de ícono · label
//! (centrado vertical) · atajo o chevron de submenú. Sin radios, sin
//! sombras: color sólido + tipografía + la barra accent.
//!
//! Se monta como `View<Msg>` que se devuelve desde
//! [`llimphi_ui::App::view_overlay`]. Internamente arma:
//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra
//! el menú al click-fuera.
//! 2. Un **panel** absoluto (clampeado al viewport).
//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un
//! segundo panel-flyout a la derecha del item padre.
//!
//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un
//! leve desplazamiento vertical de entrada. La app que quiera animarlo
//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, 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_panel::{panel_signature_painter, PanelStyle};
/// Paleta del menú — estilo "webpage" elegante derivado del theme:
/// panel redondeado con borde hairline, filas como píldoras con hover
/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un
/// indicador accent a la izquierda). Defaults dark; override por la app.
#[derive(Debug, Clone, Copy)]
pub struct ContextMenuPalette {
pub bg_panel: Color,
/// Fila bajo el cursor (hover) — tinte suave.
pub bg_hover: Color,
/// Fila activa por teclado (flechas) — algo más marcado que el hover.
pub bg_active: Color,
pub fg_text: Color,
/// Texto de la fila activa/hover (legible sobre el tinte suave).
pub fg_active: Color,
pub fg_shortcut: Color,
pub fg_disabled: Color,
pub fg_destructive: Color,
pub fg_header: Color,
/// Ícono en gutter (estado normal) — algo más apagado que el texto.
pub fg_icon: Color,
pub accent: Color,
pub border: Color,
pub separator: Color,
pub scrim: Color,
/// Radio de las esquinas del panel.
pub radius: f64,
pub panel: PanelStyle,
}
impl ContextMenuPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`)
// con su gradiente sutil + esquinas redondeadas.
let mut panel = PanelStyle::neutral(t);
panel.bg_base = t.bg_panel;
panel.radius = PANEL_RADIUS as f64;
Self {
bg_panel: t.bg_panel,
bg_hover: t.bg_row_hover,
bg_active: t.bg_selected,
fg_text: t.fg_text,
fg_active: t.fg_text,
fg_shortcut: t.fg_muted,
fg_disabled: t.fg_muted,
fg_destructive: t.fg_destructive,
fg_header: t.fg_muted,
fg_icon: t.fg_muted,
accent: t.accent,
border: t.border,
separator: t.border,
scrim: Color::from_rgba8(0, 0, 0, 64),
radius: PANEL_RADIUS as f64,
panel,
}
}
}
/// Un item del menú. `separator = true` ignora el resto y pinta una
/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si
/// está abierto, despliega un flyout). `icon` es un glifo opcional que
/// se pinta en el gutter izquierdo.
#[derive(Debug, Clone)]
pub struct ContextMenuItem {
pub label: String,
pub shortcut: Option<String>,
pub icon: Option<String>,
pub enabled: bool,
pub separator: bool,
pub destructive: bool,
/// Items del submenú. Vacío = acción simple.
pub children: Vec<ContextMenuItem>,
}
impl ContextMenuItem {
pub fn action(label: impl Into<String>) -> Self {
Self {
label: label.into(),
shortcut: None,
icon: None,
enabled: true,
separator: false,
destructive: false,
children: Vec::new(),
}
}
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
/// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`).
pub fn icon(mut self, glyph: impl Into<String>) -> Self {
self.icon = Some(glyph.into());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
/// Convierte el item en submenú con estos hijos.
pub fn submenu(mut self, children: Vec<ContextMenuItem>) -> Self {
self.children = children;
self
}
pub fn has_submenu(&self) -> bool {
!self.children.is_empty()
}
pub fn separator() -> Self {
Self {
label: String::new(),
shortcut: None,
icon: None,
enabled: false,
separator: true,
destructive: false,
children: Vec::new(),
}
}
}
/// Especificación del menú. Mantiene los 8 campos clásicos para no
/// romper los call-sites por literal; las capacidades nuevas (submenús,
/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía
/// [`context_menu_view_ex`].
pub struct ContextMenuSpec<Msg: Clone + 'static> {
pub anchor: (f32, f32),
pub viewport: (f32, f32),
pub header: Option<String>,
pub items: Vec<ContextMenuItem>,
/// Índice resaltado por keyboard. `usize::MAX` = ninguno.
pub active: usize,
/// Click en un item de nivel raíz (índice).
pub on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync>,
/// Msg al click-fuera (scrim) o Esc.
pub on_dismiss: Msg,
pub palette: ContextMenuPalette,
}
/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús
/// (flyout), animación de aparición y hover. Su `Default` reproduce el
/// menú clásico (sin animación ni submenús).
pub struct ContextMenuExtras<Msg: Clone + 'static> {
/// Índice del item-submenú desplegado (flyout). La app lo guarda y lo
/// actualiza vía `on_hover`.
pub open_sub: Option<usize>,
/// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo.
pub appear: f32,
/// Click en un item de submenú: `(parent_idx, child_idx)`.
pub on_pick_sub: Option<Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>>,
/// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout),
/// `None` si es item normal (cerrar). La app guarda el resultado en
/// `open_sub`.
pub on_hover: Option<Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
}
impl<Msg: Clone + 'static> Default for ContextMenuExtras<Msg> {
fn default() -> Self {
Self {
open_sub: None,
appear: 1.0,
on_pick_sub: None,
on_hover: None,
}
}
}
const PANEL_W: f32 = 252.0;
/// Altura de cada item (no-separator).
const ITEM_H: f32 = 32.0;
const SEP_H: f32 = 11.0;
const HEADER_H: f32 = 26.0;
/// Gutter del ícono a la izquierda del label.
const ICON_W: f32 = 24.0;
const ITEM_PAD_LEFT: f32 = 10.0;
const ITEM_PAD_RIGHT: f32 = 12.0;
/// Radio de las esquinas del panel (estilo webpage).
const PANEL_RADIUS: f32 = 10.0;
/// Radio de la píldora de hover/activo de cada fila.
const ITEM_RADIUS: f32 = 6.0;
/// Padding interno del panel (entre el borde y la columna de píldoras).
const PANEL_PAD: f32 = 6.0;
/// Ancho del indicador accent vertical de la fila activa.
const INDICATOR_W: f32 = 3.0;
/// Desplazamiento vertical de entrada (px) cuando `appear` = 0.
const APPEAR_SLIDE: f32 = 8.0;
/// Compone el menú clásico (sin submenús ni animación) como `View<Msg>`
/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya
/// vienen incluidos.
pub fn context_menu_view<Msg: Clone + 'static>(spec: ContextMenuSpec<Msg>) -> View<Msg> {
context_menu_view_ex(spec, ContextMenuExtras::default())
}
/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús
/// (flyout en hover), animación de aparición y hover.
pub fn context_menu_view_ex<Msg: Clone + 'static>(
spec: ContextMenuSpec<Msg>,
extras: ContextMenuExtras<Msg>,
) -> View<Msg> {
let ContextMenuSpec {
anchor,
viewport,
header,
items,
active,
on_pick,
on_dismiss,
palette,
} = spec;
let ContextMenuExtras {
open_sub,
appear,
on_pick_sub,
on_hover,
} = extras;
let appear = appear.clamp(0.0, 1.0);
let slide = (1.0 - appear) * APPEAR_SLIDE;
let (panel, panel_x, panel_y) = panel_view(
anchor,
viewport,
&header,
&items,
active,
slide,
&on_pick,
on_hover.as_ref(),
&palette,
);
let mut layers: Vec<View<Msg>> = vec![panel];
// Flyout del submenú abierto (sólo si la app provee `on_pick_sub`).
if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) {
if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) {
let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx);
let flyout = submenu_view(
sub_anchor,
viewport,
pidx,
&parent.children,
slide,
on_pick_sub,
&palette,
);
layers.push(flyout);
}
}
// Scrim full-screen: cualquier click "fuera" dismissa.
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.scrim)
.alpha(appear)
.on_click(on_dismiss)
.children(layers)
}
/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados.
#[allow(clippy::too_many_arguments)]
fn panel_view<Msg: Clone + 'static>(
anchor: (f32, f32),
viewport: (f32, f32),
header: &Option<String>,
items: &[ContextMenuItem],
active: usize,
slide: f32,
on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
palette: &ContextMenuPalette,
) -> (View<Msg>, f32, f32) {
let header_h = if header.is_some() { HEADER_H } else { 0.0 };
let items_h: f32 = items
.iter()
.map(|it| if it.separator { SEP_H } else { ITEM_H })
.sum();
// borde (1+1) + padding interno (PANEL_PAD ×2) + header + items.
let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h;
let margin = 4.0;
let x = anchor
.0
.min((viewport.0 - PANEL_W - margin).max(margin))
.max(margin);
let y = anchor
.1
.min((viewport.1 - panel_h - margin).max(margin))
.max(margin);
let mut children: Vec<View<Msg>> = Vec::with_capacity(items.len() + 1);
if let Some(text) = header {
children.push(header_view(text.clone(), palette));
}
for (i, item) in items.iter().enumerate() {
children.push(item_view(
i,
None,
item,
i == active,
on_pick,
on_hover,
palette,
));
}
let panel = panel_container(x, y + slide, panel_h, children, palette);
(panel, x, y)
}
/// Flyout del submenú: mismo look, posicionado a la derecha del padre.
#[allow(clippy::too_many_arguments)]
fn submenu_view<Msg: Clone + 'static>(
anchor: (f32, f32),
viewport: (f32, f32),
parent_idx: usize,
children_items: &[ContextMenuItem],
slide: f32,
on_pick_sub: &Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>,
palette: &ContextMenuPalette,
) -> View<Msg> {
let panel_h: f32 = children_items
.iter()
.map(|it| if it.separator { SEP_H } else { ITEM_H })
.sum::<f32>()
+ 2.0
+ 2.0 * PANEL_PAD;
let margin = 4.0;
let x = anchor
.0
.min((viewport.0 - PANEL_W - margin).max(margin))
.max(margin);
let y = anchor
.1
.min((viewport.1 - panel_h - margin).max(margin))
.max(margin);
let mut children: Vec<View<Msg>> = Vec::with_capacity(children_items.len());
for (j, item) in children_items.iter().enumerate() {
children.push(item_view(
j,
Some((parent_idx, on_pick_sub.clone())),
item,
false,
// on_pick raíz no se usa cuando hay parent; pasamos un dummy.
&dummy_pick(),
None,
palette,
));
}
panel_container(x, y + slide, panel_h, children, palette)
}
/// El contenedor visual: panel redondeado con borde hairline (un nodo
/// exterior del color del borde + uno interior con el gradiente del
/// PanelStyle) y padding interno para que las píldoras de cada fila
/// queden inset — el look de menú de webpage.
fn panel_container<Msg: Clone + 'static>(
x: f32,
y: f32,
panel_h: f32,
children: Vec<View<Msg>>,
palette: &ContextMenuPalette,
) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(PANEL_W),
height: length(panel_h),
},
padding: Rect {
left: length(1.0_f32),
right: length(1.0_f32),
top: length(1.0_f32),
bottom: length(1.0_f32),
},
..Default::default()
})
.fill(palette.border)
.radius(palette.radius as f64)
.children(vec![View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(PANEL_PAD),
right: length(PANEL_PAD),
top: length(PANEL_PAD),
bottom: length(PANEL_PAD),
},
..Default::default()
})
.radius((palette.radius - 1.0) as f64)
.paint_with(panel_signature_painter(palette.panel))
.children(children)])
}
/// Ancla del flyout: a la derecha del panel padre, alineado al item.
fn submenu_anchor(
panel_x: f32,
panel_y: f32,
header: &Option<String>,
items: &[ContextMenuItem],
parent_idx: usize,
) -> (f32, f32) {
let mut off = if header.is_some() { HEADER_H } else { 0.0 };
off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor
for it in items.iter().take(parent_idx) {
off += if it.separator { SEP_H } else { ITEM_H };
}
// pequeño solape para que el flyout se lea continuo con el padre.
(panel_x + PANEL_W - PANEL_PAD, panel_y + off)
}
fn header_view<Msg: Clone + 'static>(text: String, palette: &ContextMenuPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(HEADER_H),
},
padding: Rect {
left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0),
right: length(ITEM_PAD_RIGHT),
top: length(2.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start)
}
/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de
/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa
/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout.
#[allow(clippy::too_many_arguments)]
fn item_view<Msg: Clone + 'static>(
idx: usize,
parent: Option<(usize, Arc<dyn Fn(usize, usize) -> Msg + Send + Sync>)>,
item: &ContextMenuItem,
is_active: bool,
on_pick: &Arc<dyn Fn(usize) -> Msg + Send + Sync>,
on_hover: Option<&Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
palette: &ContextMenuPalette,
) -> View<Msg> {
if item.separator {
return separator_view(palette);
}
// Color del texto y del atajo según estado.
let (fg, fg_dim): (Color, Color) = if !item.enabled {
(palette.fg_disabled, palette.fg_disabled)
} else if item.destructive {
(palette.fg_destructive, palette.fg_shortcut)
} else if is_active {
(palette.fg_active, palette.fg_active)
} else {
(palette.fg_text, palette.fg_shortcut)
};
// Ícono: accent cuando la fila está activa (cue del menú), si no
// apagado.
let icon_fg = if !item.enabled {
palette.fg_disabled
} else if is_active {
palette.accent
} else {
palette.fg_icon
};
// Indicador accent vertical a la izquierda — visible sólo en la fila
// activa; reserva su ancho siempre para que el texto no salte.
let indicator = View::new(Style {
size: Size {
width: length(INDICATOR_W),
height: percent(0.55_f32),
},
flex_shrink: 0.0,
..Default::default()
});
let indicator = if is_active && item.enabled {
indicator.fill(palette.accent).radius(2.0)
} else {
indicator
};
// Gutter de ícono — auto height para que el row lo centre vertical.
let icon_cell = View::new(Style {
size: Size {
width: length(ICON_W),
height: auto(),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center);
// Label — auto height (lo centra el align_items Center del row).
let label = View::new(Style {
size: Size {
width: auto(),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(item.label.clone(), 12.5, fg, Alignment::Start);
// Cola: chevron de submenú o atajo de teclado.
let trailing_text = if item.has_submenu() {
Some(("\u{203A}".to_string(), fg)) //
} else {
item.shortcut.clone().map(|s| (s, fg_dim))
};
let mut row_children: Vec<View<Msg>> = vec![indicator, icon_cell, label];
if let Some((txt, color)) = trailing_text {
row_children.push(
View::new(Style {
size: Size {
width: length(64.0_f32),
height: auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(txt, 11.0, color, Alignment::End),
);
}
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(ITEM_H),
},
flex_direction: FlexDirection::Row,
padding: Rect {
left: length(ITEM_PAD_LEFT),
right: length(ITEM_PAD_RIGHT),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.radius(ITEM_RADIUS as f64)
.children(row_children);
// Fondo: píldora suave en activo (teclado). El hover lo aporta
// `hover_fill` (tinte aún más suave) para no competir con el activo.
if is_active && item.enabled {
row = row.fill(palette.bg_active);
}
if item.enabled {
row = row.hover_fill(palette.bg_hover);
match &parent {
Some((pidx, cb)) => {
let cb = cb.clone();
let pidx = *pidx;
row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx)));
}
None => {
let on_pick = on_pick.clone();
row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx)));
// Hover abre/cierra el flyout según sea submenú o no.
if let Some(on_hover) = on_hover {
let on_hover = on_hover.clone();
let target = if item.has_submenu() { Some(idx) } else { None };
row = row.on_pointer_enter(on_hover(target));
}
}
}
}
row
}
fn separator_view<Msg: Clone + 'static>(palette: &ContextMenuPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(SEP_H),
},
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(ITEM_PAD_LEFT),
right: length(ITEM_PAD_RIGHT),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
..Default::default()
})
.fill(palette.separator)])
}
/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`).
/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`.
fn dummy_pick<Msg: Clone + 'static>() -> Arc<dyn Fn(usize) -> Msg + Send + Sync> {
Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick"))
}
/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el
/// siguiente índice válido (saltea separators y disabled). `usize::MAX`
/// si no hay elegibles.
pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize {
if items.is_empty() {
return usize::MAX;
}
let n = items.len() as i32;
let start = if current == usize::MAX {
if direction >= 0 {
-1
} else {
n
}
} else {
current as i32
};
let mut i = start;
for _ in 0..n {
i += direction;
if i < 0 {
i = n - 1;
} else if i >= n {
i = 0;
}
let item = &items[i as usize];
if !item.separator && item.enabled {
return i as usize;
}
}
usize::MAX
}
#[cfg(test)]
mod tests {
use super::*;
fn it(label: &str) -> ContextMenuItem {
ContextMenuItem::action(label)
}
#[test]
fn step_active_skips_separators() {
let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")];
assert_eq!(step_active(&items, 0, 1), 2);
assert_eq!(step_active(&items, 2, -1), 0);
}
#[test]
fn step_active_skips_disabled() {
let items = vec![it("A"), it("B").disabled(), it("C")];
assert_eq!(step_active(&items, 0, 1), 2);
assert_eq!(step_active(&items, 2, -1), 0);
}
#[test]
fn step_active_wraps_around() {
let items = vec![it("A"), it("B"), it("C")];
assert_eq!(step_active(&items, 2, 1), 0);
assert_eq!(step_active(&items, 0, -1), 2);
}
#[test]
fn submenu_y_icono_se_setean() {
let item = it("Tema")
.icon("")
.submenu(vec![it("Oscuro"), it("Claro")]);
assert!(item.has_submenu());
assert_eq!(item.children.len(), 2);
assert_eq!(item.icon.as_deref(), Some(""));
}
#[test]
fn extras_default_es_menu_clasico() {
let extras: ContextMenuExtras<u8> = ContextMenuExtras::default();
assert_eq!(extras.appear, 1.0);
assert!(extras.open_sub.is_none());
assert!(extras.on_hover.is_none());
assert!(extras.on_pick_sub.is_none());
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-dock-rail"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-dock-rail — rail vertical de dientes (pestañas con barra de acento + icono) para sidebars acoplables; clic activa, arrastre mueve entre rails."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+184
View File
@@ -0,0 +1,184 @@
//! `llimphi-widget-dock-rail` — rail vertical de **dientes** para
//! sidebars acoplables.
//!
//! Cada diente es una pestaña vertical: una **barra de acento** de 3px
//! pegada al borde interno (encendida cuando el item está activo) + un
//! **icono** centrado. Los dientes apilan en una columna redondeada que
//! se pinta como una franja flotante al borde del centro — el patrón del
//! dock de cosmos, ahora reutilizable.
//!
//! Render-only y agnóstico del `Msg`: el item se identifica por un `u64`
//! opaco. El clic (en el *press*, para no pelear con el arrastre) activa
//! vía `on_activate(id)`; el diente es **arrastrable** con su `id` como
//! payload, y el rail entero es **drop target** (`on_drop(payload)`) —
//! así una app puede mover un diente de un sidebar a otro soltándolo
//! sobre el rail opuesto. El icono lo dibuja el caller (`make_icon`), que
//! recibe el color ya resuelto según el estado activo/inactivo.
//!
//! ```ignore
//! let items = [DockRailItem { id: 0, active: true }, DockRailItem { id: 1, active: false }];
//! dock_rail_view(
//! &items,
//! 44.0,
//! &DockRailPalette::from_theme(&theme),
//! |id, size, color| my_icon_view(id, size, color),
//! |id| Msg::DockActivate(side, id),
//! move |payload| Some(Msg::DockDrop(side, payload)),
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
use llimphi_theme::Theme;
/// Alto de cada diente (px).
const TOOTH_H: f32 = 42.0;
/// Alto de la barra de acento (px) — un poco menor que el diente para
/// dejar aire arriba y abajo.
const BAR_H: f32 = 40.0;
/// Tamaño del icono que se le pide al caller (px).
const ICON_PX: f32 = 20.0;
/// Paleta del rail.
#[derive(Debug, Clone, Copy)]
pub struct DockRailPalette {
/// Fondo de la franja del rail.
pub bg_rail: Color,
/// Fondo del diente activo.
pub bg_active: Color,
/// Fondo al hover (diente) y al sobrevolar un drop válido (rail).
pub bg_hover: Color,
/// Color del acento: barra del diente activo + su icono.
pub accent: Color,
/// Color del icono de un diente inactivo.
pub icon_inactive: Color,
}
impl DockRailPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
bg_rail: t.bg_panel_alt,
bg_active: t.bg_selected,
bg_hover: t.bg_row_hover,
accent: t.accent,
icon_inactive: t.fg_muted,
}
}
}
/// Un diente del rail: su id opaco y si está activo.
#[derive(Debug, Clone, Copy)]
pub struct DockRailItem {
pub id: u64,
pub active: bool,
}
/// Construye el rail de dientes.
///
/// - `items`: en el orden en que se quieren mostrar (el widget no
/// reordena).
/// - `width`: ancho de la franja del rail (px).
/// - `make_icon(id, size, color)`: dibuja el icono del item con el color
/// ya resuelto (acento si activo, atenuado si no).
/// - `on_activate(id)`: `Msg` al clickear (en el press).
/// - `on_drop(payload)`: `Msg` opcional cuando se suelta un diente
/// (cualquier `id`) sobre este rail.
pub fn dock_rail_view<Msg, FIcon, FAct, FDrop>(
items: &[DockRailItem],
width: f32,
palette: &DockRailPalette,
make_icon: FIcon,
on_activate: FAct,
on_drop: FDrop,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FIcon: Fn(u64, f32, Color) -> View<Msg>,
FAct: Fn(u64) -> Msg,
FDrop: Fn(u64) -> Option<Msg> + Send + Sync + 'static,
{
let mut teeth: Vec<View<Msg>> = Vec::with_capacity(items.len());
for item in items {
let fg = if item.active {
palette.accent
} else {
palette.icon_inactive
};
// Barra de acento, pegada al borde interno.
let accent_bar = {
let b = View::new(Style {
size: Size {
width: length(3.0_f32),
height: length(BAR_H),
},
flex_shrink: 0.0,
..Default::default()
});
if item.active {
b.fill(palette.accent).radius(2.0)
} else {
b
}
};
let icon_box = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(0.0_f32),
height: length(TOOTH_H),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![make_icon(item.id, ICON_PX, fg)]);
let id = item.id;
let msg = on_activate(id);
let mut tooth = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(TOOTH_H),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.hover_fill(palette.bg_hover)
// Click en el press activa; arrastrar mueve de rail (payload=id).
.on_click_at(move |_, _, _, _| Some(msg.clone()))
.draggable_at(|phase, _, _, _, _| match phase {
DragPhase::Move | DragPhase::End => None,
})
.drag_payload(id)
.children(vec![accent_bar, icon_box]);
if item.active {
tooth = tooth.fill(palette.bg_active);
}
teeth.push(tooth);
}
// La franja: sólo del alto de los dientes (el hueco de abajo lo deja
// libre para que el área central lo aproveche si el rail flota como
// overlay). Es además el drop target del lado.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(width),
height: auto(),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_rail)
.radius(5.0)
.on_drop(on_drop)
.drop_hover_fill(palette.bg_hover)
.children(teeth)
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-widget-edit-menu"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-edit-menu — el menú de edición estándar (Deshacer/Rehacer/Cortar/Copiar/Pegar/Eliminar/Seleccionar todo) para cualquier campo que use EditorState (input single-line e IDE enriquecido). Arma el ContextMenuSpec desde flags derivados del estado y aplica las acciones reutilizando apply_key_with_clipboard."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
+361
View File
@@ -0,0 +1,361 @@
//! `llimphi-widget-edit-menu` — el menú de edición estándar para
//! cualquier campo de texto Llimphi.
//!
//! Tanto el input single-line ([`llimphi_widget_text_input`]) como el
//! editor IDE enriquecido ([`llimphi_widget_text_editor`]) se apoyan en
//! el mismo [`EditorState`]. Este widget arma, a partir de ese estado,
//! el menú contextual canónico:
//!
//! ```text
//! ┃ EDICIÓN
//! ┃ Deshacer Ctrl+Z
//! ┃ Rehacer Ctrl+Y
//! ┃ ─────────────────────
//! ┃ Cortar Ctrl+X
//! ┃ Copiar Ctrl+C
//! ┃ Pegar Ctrl+V
//! ┃ Eliminar Supr
//! ┃ ─────────────────────
//! ┃ Seleccionar todo Ctrl+A
//! ```
//!
//! Cada ítem se habilita o no según el estado real (sin selección →
//! Cortar/Copiar/Eliminar grises; sin historial → Deshacer gris; etc).
//!
//! Uso típico, en tres pasos por app:
//! 1. El campo emite la posición del click derecho — `View::on_right_click_at`
//! → `Msg::AbrirMenuEdicion(x, y)`. El `update` guarda el ancla.
//! 2. `App::view_overlay` devuelve
//! `Some(context_menu_view(edit_menu::edit_context_menu(...)))` cuando el
//! ancla está presente.
//! 3. El pick produce `Msg::Edicion(EditAction)`; el `update` llama a
//! [`apply`] con el `EditorState` del campo focuseado y el clipboard.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
use llimphi_widget_context_menu::{step_active, ContextMenuItem, ContextMenuPalette, ContextMenuSpec};
use llimphi_widget_text_editor::{ApplyResult, Clipboard, EditorState};
/// Una acción de edición del menú estándar. Es `Copy` para que el
/// `on_pick` la capture sin clonar y la app la rebote en un `Msg`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditAction {
Undo,
Redo,
Cut,
Copy,
Paste,
/// Borra la selección (Supr/Delete sin mover el resto).
Delete,
SelectAll,
}
/// Banderas que deciden qué ítems van habilitados. Derivalas del estado
/// del campo focuseado con [`EditFlags::from_editor`].
#[derive(Debug, Clone, Copy)]
pub struct EditFlags {
/// Hay selección no-vacía → Cortar/Copiar/Eliminar habilitados.
pub has_selection: bool,
/// Hay algo que deshacer.
pub can_undo: bool,
/// Hay algo que rehacer.
pub can_redo: bool,
/// El clipboard tiene contenido pegable → Pegar habilitado. Si no se
/// puede saber barato, pasá `true` (Pegar no-opea si está vacío).
pub can_paste: bool,
/// El buffer no está vacío → Seleccionar todo habilitado.
pub has_text: bool,
/// Campo enmascarado (password): Cortar/Copiar se deshabilitan para
/// no filtrar el secreto al clipboard.
pub masked: bool,
}
impl Default for EditFlags {
fn default() -> Self {
Self {
has_selection: false,
can_undo: false,
can_redo: false,
can_paste: true,
has_text: false,
masked: false,
}
}
}
impl EditFlags {
/// Deriva las banderas del estado del editor. `can_paste` se deja en
/// `true` (consultar el clipboard real requiere `&mut`; pegar vacío
/// es no-op). `masked` lo decide el caller (el input lo sabe vía
/// `TextInputState::is_masked`).
pub fn from_editor(state: &EditorState, masked: bool) -> Self {
Self {
has_selection: state.has_selection(),
can_undo: state.can_undo(),
can_redo: state.can_redo(),
can_paste: true,
has_text: !state.is_empty(),
masked,
}
}
/// Igual que [`Self::from_editor`] pero fijando `can_paste`
/// explícitamente (cuando el caller ya sabe si el clipboard tiene
/// algo, p.ej. consultándolo una vez por frame).
pub fn from_editor_with_paste(state: &EditorState, masked: bool, can_paste: bool) -> Self {
Self {
can_paste,
..Self::from_editor(state, masked)
}
}
}
/// Los ítems del menú + la acción de cada uno alineadas por índice. Las
/// filas separador llevan una acción de relleno (`SelectAll`) que **nunca
/// se dispara**: el `context-menu` no engancha `on_click` en separadores
/// ni en ítems deshabilitados, así que `on_pick(i)` sólo recibe índices
/// de ítems-acción habilitados. Mantener un `EditAction` por fila (en vez
/// de `Option`) permite que el closure de `on_pick` capture sólo `Arc`s
/// y no un `Msg` crudo — clave para satisfacer `Send + Sync` sin exigirle
/// esos bounds al `Msg` de la app.
fn entries(flags: EditFlags) -> (Vec<ContextMenuItem>, Vec<EditAction>) {
let mut items: Vec<ContextMenuItem> = Vec::with_capacity(9);
let mut actions: Vec<EditAction> = Vec::with_capacity(9);
const FILL: EditAction = EditAction::SelectAll;
let mut push = |item: ContextMenuItem, action: EditAction| {
items.push(item);
actions.push(action);
};
let undo = ContextMenuItem::action("Deshacer").icon("\u{21A9}").with_shortcut("Ctrl+Z");
push(
if flags.can_undo { undo } else { undo.disabled() },
EditAction::Undo,
);
let redo = ContextMenuItem::action("Rehacer").icon("\u{21AA}").with_shortcut("Ctrl+Y");
push(
if flags.can_redo { redo } else { redo.disabled() },
EditAction::Redo,
);
push(ContextMenuItem::separator(), FILL);
let can_copy = flags.has_selection && !flags.masked;
let cut = ContextMenuItem::action("Cortar").icon("\u{2702}").with_shortcut("Ctrl+X");
push(if can_copy { cut } else { cut.disabled() }, EditAction::Cut);
let copy = ContextMenuItem::action("Copiar").icon("\u{29C9}").with_shortcut("Ctrl+C");
push(if can_copy { copy } else { copy.disabled() }, EditAction::Copy);
let paste = ContextMenuItem::action("Pegar").icon("\u{2398}").with_shortcut("Ctrl+V");
push(
if flags.can_paste { paste } else { paste.disabled() },
EditAction::Paste,
);
let del = ContextMenuItem::action("Eliminar")
.icon("\u{2717}")
.with_shortcut("Supr")
.destructive();
push(
if flags.has_selection { del } else { del.disabled() },
EditAction::Delete,
);
push(ContextMenuItem::separator(), FILL);
let sel = ContextMenuItem::action("Seleccionar todo").icon("\u{2750}").with_shortcut("Ctrl+A");
push(
if flags.has_text { sel } else { sel.disabled() },
EditAction::SelectAll,
);
(items, actions)
}
/// Sólo los ítems (para componer un menú custom que incluya el bloque de
/// edición seguido de acciones propias de la app).
pub fn edit_menu_items(flags: EditFlags) -> Vec<ContextMenuItem> {
entries(flags).0
}
/// Mueve el resaltado de teclado por las filas del menú de edición, saltando
/// separadores y filas deshabilitadas. `active == usize::MAX` significa "ninguna
/// fila"; `direction` +1 baja, 1 sube. Pensado para enganchar flechas
/// arriba/abajo sobre el menú de edición abierto (paralelo a [`step_active`]).
pub fn edit_menu_step(flags: EditFlags, active: usize, direction: i32) -> usize {
let items = entries(flags).0;
step_active(&items, active, direction)
}
/// La [`EditAction`] de la fila `active`, o `None` si esa fila es un separador,
/// está deshabilitada o fuera de rango. Pensado para resolver la tecla Enter
/// sobre la fila resaltada por [`edit_menu_step`].
pub fn edit_menu_action_at(flags: EditFlags, active: usize) -> Option<EditAction> {
let (items, actions) = entries(flags);
let item = items.get(active)?;
if item.separator || !item.enabled {
return None;
}
actions.get(active).copied()
}
/// Arma el [`ContextMenuSpec`] del menú de edición listo para
/// `context_menu_view`. `on_action` rebota cada pick en un `Msg` de la
/// app; `on_dismiss` cierra al click-fuera o Esc.
pub fn edit_context_menu<Msg, F>(
anchor: (f32, f32),
viewport: (f32, f32),
theme: &Theme,
flags: EditFlags,
on_action: F,
on_dismiss: Msg,
) -> ContextMenuSpec<Msg>
where
Msg: Clone + 'static,
F: Fn(EditAction) -> Msg + Send + Sync + 'static,
{
let (items, actions) = entries(flags);
let actions = Arc::new(actions);
let on_action = Arc::new(on_action);
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
// `i` siempre cae en un ítem-acción habilitado (los separadores y
// deshabilitados no enganchan click). El `SelectAll` de relleno de
// los separadores nunca se alcanza.
let a = actions.get(i).copied().unwrap_or(EditAction::SelectAll);
(on_action)(a)
});
ContextMenuSpec {
anchor,
viewport,
header: Some("Edición".to_string()),
items,
active: usize::MAX,
on_pick,
on_dismiss,
palette: ContextMenuPalette::from_theme(theme),
}
}
/// Aplica una [`EditAction`] al `EditorState`. Reutiliza
/// `apply_key_with_clipboard` (sintetizando la tecla equivalente) para
/// heredar exactamente el mismo comportamiento — incluido el bookkeeping
/// de parseo incremental — que el atajo de teclado. Devuelve el
/// [`ApplyResult`] para que el caller decida si persistir el cambio.
pub fn apply(state: &mut EditorState, action: EditAction, clipboard: &mut dyn Clipboard) -> ApplyResult {
match action {
EditAction::SelectAll => {
state.select_all();
ApplyResult::CursorMoved
}
EditAction::Undo => state.apply_key_with_clipboard(&ctrl_char("z"), clipboard),
EditAction::Redo => state.apply_key_with_clipboard(&ctrl_char("y"), clipboard),
EditAction::Cut => state.apply_key_with_clipboard(&ctrl_char("x"), clipboard),
EditAction::Copy => state.apply_key_with_clipboard(&ctrl_char("c"), clipboard),
EditAction::Paste => state.apply_key_with_clipboard(&ctrl_char("v"), clipboard),
EditAction::Delete => state.apply_key_with_clipboard(&named(NamedKey::Delete), clipboard),
}
}
fn ctrl_char(s: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(s.into()),
state: KeyState::Pressed,
text: Some(s.to_string()),
modifiers: Modifiers {
ctrl: true,
..Modifiers::default()
},
repeat: false,
}
}
fn named(k: NamedKey) -> KeyEvent {
KeyEvent {
key: Key::Named(k),
state: KeyState::Pressed,
text: None,
modifiers: Modifiers::default(),
repeat: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_widget_text_editor::MemClipboard;
fn lleno() -> EditorState {
let mut s = EditorState::new();
s.set_text("hola mundo");
s
}
#[test]
fn select_all_y_copy_llevan_todo_al_clipboard() {
let mut s = lleno();
let r = apply(&mut s, EditAction::SelectAll, &mut MemClipboard::new());
assert_eq!(r, ApplyResult::CursorMoved);
assert!(s.has_selection());
let mut clip = MemClipboard::new();
apply(&mut s, EditAction::Copy, &mut clip);
assert_eq!(clip.get().as_deref(), Some("hola mundo"));
}
#[test]
fn cut_borra_y_copia() {
let mut s = lleno();
s.select_all();
let mut clip = MemClipboard::new();
let r = apply(&mut s, EditAction::Cut, &mut clip);
assert_eq!(r, ApplyResult::Changed);
assert!(s.is_empty());
assert_eq!(clip.get().as_deref(), Some("hola mundo"));
}
#[test]
fn paste_inserta_del_clipboard() {
let mut s = EditorState::new();
let mut clip = MemClipboard::with("XYZ");
apply(&mut s, EditAction::Paste, &mut clip);
assert_eq!(s.text(), "XYZ");
}
#[test]
fn flags_sin_seleccion_deshabilitan_copiar() {
let s = lleno();
let flags = EditFlags::from_editor(&s, false);
assert!(!flags.has_selection);
let items = edit_menu_items(flags);
// "Cortar" es el primer ítem tras el separador (índice 3).
assert!(!items[3].enabled, "Cortar debería estar deshabilitado sin selección");
}
#[test]
fn step_y_action_at_saltan_separadores_y_deshabilitados() {
let mut s = lleno();
s.select_all();
let flags = EditFlags::from_editor(&s, false);
// Desde "ninguna fila", bajar cae en la primera seleccionable (Deshacer
// está gris sin historial; Cortar=3 es el primer habilitado real).
let first = edit_menu_step(flags, usize::MAX, 1);
assert!(edit_menu_action_at(flags, first).is_some());
// El separador (índice 2) nunca da acción.
assert_eq!(edit_menu_action_at(flags, 2), None);
// Avanzar y retroceder vuelve a una fila con acción válida.
let next = edit_menu_step(flags, first, 1);
assert!(edit_menu_action_at(flags, next).is_some());
}
#[test]
fn masked_deshabilita_copiar_aun_con_seleccion() {
let mut s = lleno();
s.select_all();
let flags = EditFlags::from_editor(&s, true);
let items = edit_menu_items(flags);
assert!(!items[4].enabled, "Copiar debería estar gris en campo enmascarado");
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-empty"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-empty — empty-state con icono grande, título y descripción opcional. Reemplaza pantallas en blanco crudas con orientación al usuario."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
+112
View File
@@ -0,0 +1,112 @@
//! `llimphi-widget-empty` — empty state con icono, título y descripción.
//!
//! Patrón para reemplazar pantallas en blanco con orientación: cuando
//! una lista no tiene items, un editor no tiene archivo abierto, una
//! búsqueda no arrojó resultados — en vez de fondo plano, mostrar
//! un icono grande apagado + título + descripción corta + (opcional)
//! botón de acción primaria.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_icons::{icon_view, Icon};
use llimphi_theme::{alpha, Theme};
/// Paleta del empty state — colores apagados para no competir con la
/// UI principal.
#[derive(Debug, Clone, Copy)]
pub struct EmptyPalette {
pub fg_icon: Color,
pub fg_title: Color,
pub fg_desc: Color,
}
impl EmptyPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
fg_icon: with_alpha8(t.fg_muted, alpha::HINT),
fg_title: t.fg_muted,
fg_desc: with_alpha8(t.fg_muted, alpha::DISABLED),
}
}
}
fn with_alpha8(c: Color, a: u8) -> Color {
let [r, g, b, _] = c.components;
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
AlphaColor::new([r, g, b, a as f32 / 255.0])
}
/// Construye el empty state. La app llama desde su `view()` cuando
/// detecta el caso vacío:
///
/// ```ignore
/// if model.items.is_empty() {
/// return empty_view(Icon::File, "Sin archivos abiertos",
/// Some("Abrí uno con Ctrl+O para empezar."),
/// &palette);
/// }
/// ```
pub fn empty_view<Msg: Clone + 'static>(
icon: Icon,
title: impl Into<String>,
description: Option<&str>,
palette: &EmptyPalette,
) -> View<Msg> {
let icon_cell = View::new(Style {
size: Size {
width: length(72.0_f32),
height: length(72.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![icon_view(icon, palette.fg_icon, 1.4)]);
let title_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(title.into(), 15.5, palette.fg_title, Alignment::Center);
let mut children = vec![icon_cell, title_view];
if let Some(desc) = description {
children.push(
View::new(Style {
size: Size {
width: length(360.0_f32),
height: length(40.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(desc.to_string(), 12.0, palette.fg_desc, Alignment::Center),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(0.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.children(children)
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-field"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-field — wrapper de formulario: label arriba + slot del input + descripción/error abajo. Patrón estándar para formularios accesibles."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+127
View File
@@ -0,0 +1,127 @@
//! `llimphi-widget-field` — wrapper de formulario para inputs.
//!
//! Patrón estándar de campo:
//! ```text
//! Nombre del campo (label — bold, fg_text)
//! [ input control aquí ] (slot — viene como View<Msg>)
//! Descripción o error abajo (helper — fg_muted o fg_destructive)
//! ```
//!
//! El widget no implementa el input — lo recibe como `View<Msg>` y lo
//! envuelve. Esto permite usarlo con `text-input`, `text-area`, `switch`,
//! `segmented` o cualquier otro control.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
};
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 FieldPalette {
pub fg_label: Color,
pub fg_helper: Color,
pub fg_error: Color,
pub fg_required: Color,
}
impl FieldPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
fg_label: t.fg_text,
fg_helper: t.fg_muted,
fg_error: t.fg_destructive,
fg_required: t.fg_destructive,
}
}
}
/// Spec del field. `helper` y `error` son mutuamente excluyentes —
/// si hay error, se renderiza el error (rojo); si no, el helper.
pub struct FieldSpec<Msg: Clone + 'static> {
pub label: String,
/// El input/control concreto (text-input, switch, segmented, etc).
pub control: View<Msg>,
/// Marca el field como requerido — agrega un asterisco al label.
pub required: bool,
/// Texto explicativo debajo del control. `None` para omitirlo.
pub helper: Option<String>,
/// Mensaje de error — gana sobre `helper` cuando está presente.
pub error: Option<String>,
pub palette: FieldPalette,
}
const LABEL_H: f32 = 16.0;
const HELPER_H: f32 = 16.0;
const GAP_LABEL: f32 = 4.0;
const GAP_HELPER: f32 = 4.0;
const LABEL_FONT: f32 = 11.5;
const HELPER_FONT: f32 = 10.5;
pub fn field_view<Msg: Clone + 'static>(spec: FieldSpec<Msg>) -> View<Msg> {
let FieldSpec {
label,
control,
required,
helper,
error,
palette,
} = spec;
// Label: nombre + (asterisco si required).
let label_text = if required { format!("{label} *") } else { label };
let label_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(LABEL_H),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label_text, LABEL_FONT, palette.fg_label, Alignment::Start);
// Helper / error — error gana si presente.
let helper_text = error.clone().or(helper.clone());
let helper_color = if error.is_some() { palette.fg_error } else { palette.fg_helper };
let mut children: Vec<View<Msg>> = vec![label_view, spacer(GAP_LABEL), control];
if let Some(t) = helper_text {
children.push(spacer(GAP_HELPER));
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(HELPER_H),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(t, HELPER_FONT, helper_color, Alignment::Start),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
},
..Default::default()
})
.children(children)
}
fn spacer<Msg: Clone + 'static>(h: f32) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(h),
},
flex_shrink: 0.0,
..Default::default()
})
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "llimphi-widget-gallery"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-gallery — app demo que pinta todos los widgets de llimphi en una sola ventana. Pensado como referencia visual y como smoke test al introducir cambios al theme o a los widgets."
[[bin]]
name = "llimphi-widget-gallery"
path = "src/main.rs"
[dependencies]
llimphi-ui = { workspace = true }
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 }
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 }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-gallery
> Grid virtualizada de cards para [llimphi](../../README.md).
Layout responsive con columnas auto-fit; cada card es `View<Msg>` libre. Reutiliza [`card`](../card/README.md).
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-gallery
> Virtualized card grid for [llimphi](../../README.md).
Responsive layout with auto-fit columns; each card is a free `View<Msg>`. Reuses [`card`](../card/README.md).
+569
View File
@@ -0,0 +1,569 @@
//! `llimphi-widget-gallery` — todos los widgets de Llimphi en una sola
//! ventana. Útil como referencia visual y smoke test al cambiar el
//! theme o cualquier widget.
//!
//! Corré con: `cargo run -p llimphi-widget-gallery --release`.
use std::sync::Arc;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_widget_app_header::{app_header, AppHeaderPalette};
use llimphi_widget_banner::{banner_view, BannerKind};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
use llimphi_widget_theme_switcher::theme_switcher_view;
use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use app_bus::{AppMenu, Menu, MenuItem};
#[derive(Clone)]
enum Msg {
EditKey(llimphi_ui::KeyEvent),
SelectRow(usize),
SelectTab(usize),
ClickAction(u32),
ResizeOuter(f32),
SwapTile { from: usize, to: usize },
ChangeTheme(Theme),
CycleTheme,
// --- Barra de menú + contextual ---
MenuOpen(Option<usize>),
MenuCommand(String),
CloseMenus,
ContextMenuOpen(f32, f32),
}
struct Model {
text: TextInputState,
list_sel: usize,
tab: usize,
last_action: Option<u32>,
left_w: f32,
tile_order: Vec<usize>,
theme: Theme,
/// Índice del menú raíz abierto en la barra (None = ninguno).
menu_open: Option<usize>,
/// Posición (en coords de ventana) del menú contextual, si está abierto.
context_menu: Option<(f32, f32)>,
}
struct Gallery;
impl App for Gallery {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · widget gallery"
}
fn initial_size() -> (u32, u32) {
(1280, 820)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
text: TextInputState::new(),
list_sel: 0,
tab: 0,
last_action: None,
left_w: 380.0,
tile_order: vec![0, 1, 2, 3],
theme: Theme::dark(),
menu_open: None,
context_menu: None,
}
}
fn on_key(_: &Model, e: &llimphi_ui::KeyEvent) -> Option<Msg> {
Some(Msg::EditKey(e.clone()))
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::EditKey(ev) => {
m.text.apply_key(&ev);
}
Msg::SelectRow(i) => m.list_sel = i,
Msg::SelectTab(i) => m.tab = i,
Msg::ClickAction(id) => m.last_action = Some(id),
Msg::ResizeOuter(dx) => m.left_w = (m.left_w + dx).clamp(220.0, 800.0),
Msg::SwapTile { from, to } => {
if from != to && from < m.tile_order.len() && to < m.tile_order.len() {
m.tile_order.swap(from, to);
}
}
Msg::ChangeTheme(t) => m.theme = t,
Msg::CycleTheme => m.theme = Theme::next_after(m.theme.name),
Msg::MenuOpen(which) => {
m.menu_open = which;
// Abrir un menú raíz cierra cualquier contextual.
m.context_menu = None;
}
Msg::CloseMenus => {
m.menu_open = None;
m.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
m.menu_open = None;
m.context_menu = Some((x, y));
}
Msg::MenuCommand(cmd) => {
m.menu_open = None;
m.context_menu = None;
handle_menu_command(&cmd, &mut m, handle);
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model));
let header_palette = AppHeaderPalette::from_theme(&theme);
let btn_palette = ButtonPalette::from_theme(&theme);
let list_palette = ListPalette::from_theme(&theme);
let splitter_palette = SplitterPalette::from_theme(&theme);
let tabs_palette = TabsPalette::from_theme(&theme);
let stat_palette = StatCardPalette::from_theme(&theme);
let input_palette = TextInputPalette::from_theme(&theme);
// --- Header con acción a la derecha ---
let header = app_header(
format!(
"llimphi widget gallery · última acción: {}",
match model.last_action {
Some(i) => format!("button #{i}"),
None => "ninguna".to_string(),
}
),
vec![
{
let mut btn = button_view("acción A", &btn_palette, Msg::ClickAction(1));
btn.style.size = Size {
width: length(110.0_f32),
height: length(28.0_f32),
};
btn
},
{
let mut btn = button_view("acción B", &btn_palette, Msg::ClickAction(2));
btn.style.size = Size {
width: length(110.0_f32),
height: length(28.0_f32),
};
btn
},
theme_switcher_view::<Msg>(&theme, Msg::ChangeTheme),
],
&header_palette,
);
// --- Panel izquierdo: lista virtualizada ---
let entries = (0..40)
.map(|i| format!("entry {:02}", i))
.collect::<Vec<_>>();
let visible_rows: Vec<ListRow<Msg>> = entries
.iter()
.enumerate()
.take(20)
.map(|(i, label)| ListRow {
label: label.clone(),
selected: i == model.list_sel,
on_click: Msg::SelectRow(i),
})
.collect();
let list = list_view(ListSpec {
rows: visible_rows,
total: entries.len(),
caption: Some(format!("{} entradas", entries.len())),
truncated_hint: Some(format!("… y {} más", entries.len() - 20)),
row_height: 22.0,
palette: list_palette,
});
// --- Panel derecho: tabs con stat cards + banners + input + tiled ---
let tiled_palette = TiledPalette::from_theme(&theme);
let tab_content = match model.tab {
0 => stats_pane(&theme, &stat_palette),
1 => alerts_pane(),
2 => input_pane(&model.text, &input_palette, &theme),
_ => tiled_pane(&theme, &tiled_palette, &model.tile_order),
};
let tabs = tabs_view(TabsSpec {
labels: vec!["Stats".into(), "Banners".into(), "Input".into(), "Tiled".into()],
active: model.tab,
on_select: Msg::SelectTab,
content: tab_content,
tab_height: 32.0,
palette: tabs_palette,
tab_width: Some(120.0),
});
let body = splitter_two(
Direction::Row,
list,
PaneSize::Fixed(model.left_w),
tabs,
PaneSize::Flex,
|phase, dx| match phase {
DragPhase::Move => Some(Msg::ResizeOuter(dx)),
DragPhase::End => None,
},
&splitter_palette,
);
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)
// Origen (0,0) ⇒ las coords locales del right-click son coords de
// ventana, listas para anclar el contextual.
.on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, header, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
// Prioridad: contextual sobre el dropdown del menú principal.
if let Some((x, y)) = model.context_menu {
return Some(context_menu_for_gallery(model, x, y));
}
let menu = app_menu();
menubar_overlay(&menubar_spec(&menu, model))
}
}
// ---------------------------------------------------------------------
// Menú principal (barra) + contextual
// ---------------------------------------------------------------------
/// Viewport para clampear overlays. La gallery no trackea el tamaño real
/// de ventana, así que usamos las constantes de `initial_size()`.
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = Gallery::initial_size();
(w as f32, h as f32)
}
/// `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// Menú principal de la vitrina. Archivo / Ver / Ayuda — sólo comandos que
/// mapean a `Msg` reales. No hay "Editar": el único campo de texto es el
/// `text_input` del tab Input, que ya recibe teclas por `on_key`.
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(
Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Cambiar tema", "view.theme"))
.item(MenuItem::new("Pestaña: Stats", "view.tab.0").separated())
.item(MenuItem::new("Pestaña: Banners", "view.tab.1"))
.item(MenuItem::new("Pestaña: Input", "view.tab.2"))
.item(MenuItem::new("Pestaña: Tiled", "view.tab.3")),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
/// Traduce un command id (de la barra o del contextual) al `Msg` real y lo
/// aplica. `file.quit` cierra el proceso directo (no hay diálogo).
fn handle_menu_command(cmd: &str, m: &mut Model, handle: &Handle<Msg>) {
match cmd {
"file.quit" => std::process::exit(0),
// Reusa el Msg de ciclo de tema en vez de duplicar la lógica.
"view.theme" => handle.dispatch(Msg::CycleTheme),
"view.tab.0" => m.tab = 0,
"view.tab.1" => m.tab = 1,
"view.tab.2" => m.tab = 2,
"view.tab.3" => m.tab = 3,
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => {}
}
}
/// Menú contextual de la vitrina. No hay objeto seleccionable en un canvas:
/// el "ítem seleccionado" es la entrada resaltada de la lista izquierda, así
/// que el contextual la nombra y ofrece navegar las pestañas + cambiar tema.
fn context_menu_for_gallery(model: &Model, x: f32, y: f32) -> View<Msg> {
let header = format!("entrada seleccionada: {:02}", model.list_sel);
let items = vec![
ContextMenuItem::action("Cambiar tema"),
ContextMenuItem::separator(),
ContextMenuItem::action("Pestaña: Stats"),
ContextMenuItem::action("Pestaña: Banners"),
ContextMenuItem::action("Pestaña: Input"),
ContextMenuItem::action("Pestaña: Tiled"),
];
// Mapeo índice de item → command id de `handle_menu_command`.
let cmds: Vec<&'static str> = vec![
"view.theme",
"",
"view.tab.0",
"view.tab.1",
"view.tab.2",
"view.tab.3",
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string())
});
context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport: viewport_of(model),
header: Some(header),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
})
}
// ---------------------------------------------------------------------
// Paneles de tabs
// ---------------------------------------------------------------------
fn stats_pane(theme: &Theme, palette: &StatCardPalette) -> View<Msg> {
let valid = Color::from_rgba8(94, 184, 124, 255);
let warn = Color::from_rgba8(238, 178, 53, 255);
let danger = Color::from_rgba8(225, 84, 75, 255);
let row = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(160.0_f32),
},
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
wrap_card_cell(stat_card_view::<Msg>(
"Coherencia",
"247",
"átomos válidos",
valid,
&[],
palette,
)),
wrap_card_cell(stat_card_view::<Msg>(
"Por evaluar",
"12",
"esperando re-cómputo",
warn,
&[],
palette,
)),
wrap_card_cell(stat_card_view::<Msg>(
"En conflicto",
"3",
"contradicen su origen",
danger,
&[
"puerta_amanecer".into(),
"muelle_soledad".into(),
"viento_nuevo".into(),
],
palette,
)),
]);
let _ = theme;
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.children(vec![row])
}
fn wrap_card_cell(view: View<Msg>) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![view])
}
fn alerts_pane() -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(10.0_f32),
},
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.children(vec![
banner_view(BannerKind::Info, "info: gallery iniciada con widgets verdes"),
banner_view(BannerKind::Success, "success: 12 widgets cargados ok"),
banner_view(BannerKind::Warning, "warning: el tema light aún tiene contraste subóptimo"),
banner_view(BannerKind::Error, "error: ningún error real — sólo un demo"),
])
}
fn input_pane(state: &TextInputState, palette: &TextInputPalette, theme: &Theme) -> View<Msg> {
let label = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
..Default::default()
})
.text_aligned(
"Probá tipear acá:".to_string(),
12.0,
theme.fg_muted,
Alignment::Start,
);
let input = text_input_view(
state,
"lo que sea",
true, // siempre focado en este demo
palette,
Msg::ClickAction(0),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.children(vec![label, input])
}
fn tiled_pane(theme: &Theme, palette: &TiledPalette, order: &[usize]) -> View<Msg> {
let body = |text: &str, size: f32, color: Color, align: Alignment| -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), size, color, align)
};
let make_tile = |id: usize| -> TileSpec<Msg> {
match id {
0 => TileSpec {
label: "logs".into(),
content: body(
"[12:01] boot\n[12:02] config ok\n[12:03] esperando…",
12.0,
theme.fg_text,
Alignment::Start,
),
},
1 => TileSpec {
label: "métricas".into(),
content: body("cpu 37%\nram 1.2 G\nnet 12 kB/s", 12.0, theme.fg_text, Alignment::Start),
},
2 => TileSpec {
label: "uptime".into(),
content: body("4d 12h", 26.0, theme.accent, Alignment::Center),
},
_ => TileSpec {
label: "queue".into(),
content: body(
"pending 7\nin-flight 2\ndone 1842",
12.0,
theme.fg_text,
Alignment::Start,
),
},
}
};
let tiles: Vec<TileSpec<Msg>> = order.iter().map(|&id| make_tile(id)).collect();
tiled_view_reorderable(
tiles,
|from, to| Some(Msg::SwapTile { from, to }),
palette,
)
}
fn main() {
llimphi_ui::run::<Gallery>();
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-grid"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-grid — grilla virtualizada 2D para Llimphi: celdas clicables en mosaico, selección, caption/hint opcionales, recorte de overflow. El caller hace la virtualización (calcula la ventana visible con `ventana_visible` y pasa sólo las celdas visibles); el widget las compone en filas. Base para galerías de miniaturas tipo gThumb/FastStone — agnóstico del contenido de la celda (el caller arma cada `View`: thumb, placeholder, lo que sea)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+467
View File
@@ -0,0 +1,467 @@
//! `llimphi-widget-grid` — grilla virtualizada 2D para Llimphi.
//!
//! Hermano de [`llimphi-widget-list`], pero en mosaico: celdas clicables
//! dispuestas en `cols` columnas × filas, con selección, caption/hint
//! opcionales y recorte de overflow. Pensado como base para galerías de
//! miniaturas tipo gThumb / FastStone — capaz de listar miles de archivos
//! sin montar todo: el caller renderea **sólo la ventana visible**.
//!
//! Como `list`, el widget **no** scrollea por sí mismo. La virtualización
//! es del caller, que mantiene `scroll_fila` (primera fila de celdas
//! visible) en su estado y lo actualiza con la rueda (calco de
//! `nahual-file-explorer`). La diferencia con `list` es que en 2D el
//! cálculo de la ventana no es trivial — cuántas columnas caben depende
//! del ancho del viewport — así que este crate lo provee como función
//! pura testeable: [`ventana_visible`].
//!
//! El widget es **agnóstico del contenido**: cada [`GridCell`] lleva un
//! `View<Msg>` que el caller arma (un thumb `peniko::Image`, un skeleton
//! mientras decodifica, un ícono…). Así el pipeline de miniaturas (cola
//! async + cache) vive afuera y sólo llena la celda con imagen o
//! placeholder.
//!
//! Flujo típico del caller:
//!
//! ```ignore
//! let v = ventana_visible(total, viewport_w, viewport_h, scroll_fila, &metrics);
//! let cells: Vec<GridCell<Msg>> = (v.first..v.first + v.count)
//! .map(|i| GridCell {
//! content: thumb_o_placeholder(i), // el caller decide
//! label: Some(nombre(i)),
//! selected: i == seleccionado,
//! on_click: Msg::Seleccionar(i),
//! })
//! .collect();
//! let grid = grid_view(GridSpec {
//! cells, cols: v.cols, metrics,
//! caption: Some(format!("{total} imágenes")),
//! truncated_hint: (v.first + v.count < total)
//! .then(|| format!("… y {} más", total - (v.first + v.count))),
//! palette: GridPalette::default(),
//! });
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
/// Geometría de la grilla en pixels. `tile_h` debe incluir el alto del
/// label si el caller lo usa — el widget reserva una franja inferior para
/// él dentro de la celda. `gap` es el espacio entre celdas (y entre filas);
/// `pad` el margen interno del contenedor (cada lado).
#[derive(Debug, Clone, Copy)]
pub struct GridMetrics {
pub tile_w: f32,
pub tile_h: f32,
pub gap: f32,
pub pad: f32,
}
impl Default for GridMetrics {
fn default() -> Self {
// Default ~thumb mediano estilo gThumb.
Self {
tile_w: 128.0,
tile_h: 148.0, // 128 imagen + ~20 label
gap: 8.0,
pad: 8.0,
}
}
}
/// Resultado del cálculo de virtualización: qué celdas montar. `first` y
/// `count` delimitan el rango de índices `[first, first + count)` que el
/// caller debe renderear; `cols` cuántas columnas caben (para `grid_view`).
/// Los demás campos son informativos (scrollbars, "fila X de Y", clamping).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisibleWindow {
/// Columnas que caben en el ancho del viewport (≥ 1).
pub cols: usize,
/// Índice del primer item a renderear.
pub first: usize,
/// Cantidad de items a renderear desde `first`.
pub count: usize,
/// Fila (0-based) del primer item visible — ya clampeada al rango.
pub first_row: usize,
/// Total de filas que ocupa la colección completa.
pub total_rows: usize,
/// Filas que entran en el alto del viewport (incluye 1 de margen).
pub filas_visibles: usize,
}
/// Calcula la ventana visible de una grilla virtualizada. **Función pura.**
///
/// - `total`: cantidad total de items.
/// - `viewport_w` / `viewport_h`: dimensiones del área de la grilla en px.
/// - `scroll_fila`: primera fila que el caller quiere ver arriba (se
/// clampa al rango válido; el caller no necesita pre-clampar).
/// - `m`: geometría (tile + gap + pad).
///
/// El número de columnas se deriva del ancho: `cols = ⌊(ancho_útil + gap)
/// / (tile_w + gap)⌋`, mínimo 1. Las filas visibles incluyen una extra de
/// margen para que una fila parcial al borde no aparezca en blanco al
/// scrollear.
pub fn ventana_visible(
total: usize,
viewport_w: f32,
viewport_h: f32,
scroll_fila: usize,
m: &GridMetrics,
) -> VisibleWindow {
let paso_w = (m.tile_w + m.gap).max(1.0);
let paso_h = (m.tile_h + m.gap).max(1.0);
let util_w = (viewport_w - 2.0 * m.pad + m.gap).max(0.0);
let cols = ((util_w / paso_w).floor() as usize).max(1);
let total_rows = total.div_ceil(cols);
let util_h = (viewport_h - 2.0 * m.pad + m.gap).max(0.0);
let filas_visibles = (util_h / paso_h).ceil() as usize + 1;
let max_first_row = total_rows.saturating_sub(1);
let first_row = scroll_fila.min(max_first_row);
let first = first_row * cols;
let last_row = (first_row + filas_visibles).min(total_rows);
let count = (last_row * cols).min(total).saturating_sub(first);
VisibleWindow {
cols,
first,
count,
first_row,
total_rows,
filas_visibles,
}
}
/// Paleta de la grilla. Defaults dark con selección azulada (calco de
/// `ListPalette`).
#[derive(Debug, Clone, Copy)]
pub struct GridPalette {
pub bg_panel: Color,
pub bg_cell: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
}
impl Default for GridPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl GridPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_cell: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
}
}
}
/// Una celda de la grilla. `content` es el `View` que el caller arma para
/// el cuerpo (thumb/placeholder/ícono); el widget lo centra y, debajo,
/// pinta `label` truncable si está. `on_click` se emite al clickear la
/// celda completa.
pub struct GridCell<Msg> {
pub content: View<Msg>,
pub label: Option<String>,
pub selected: bool,
pub on_click: Msg,
}
/// Especificación completa de la grilla a renderear. `cells` ya viene
/// recortada a la ventana visible (ver [`ventana_visible`]); `cols` es el
/// número de columnas de esa ventana.
pub struct GridSpec<Msg> {
pub cells: Vec<GridCell<Msg>>,
pub cols: usize,
pub metrics: GridMetrics,
pub caption: Option<String>,
pub truncated_hint: Option<String>,
pub palette: GridPalette,
}
/// Compone la grilla como un `View<Msg>`. Agrupa `cells` en filas de
/// `cols` celdas y las apila. El contenedor recorta (`clip`) para que las
/// celdas no sangren a vecinos cuando el caller subestima el área.
pub fn grid_view<Msg: Clone + 'static>(spec: GridSpec<Msg>) -> View<Msg> {
let GridSpec {
cells,
cols,
metrics,
caption,
truncated_hint,
palette,
} = spec;
let cols = cols.max(1);
let mut children: Vec<View<Msg>> = Vec::new();
if let Some(text) = caption {
children.push(barra_texto(text, 11.0, palette.fg_muted, 20.0));
}
// Agrupar en filas de `cols`. La última fila puede quedar incompleta.
let mut iter = cells.into_iter();
loop {
let fila: Vec<GridCell<Msg>> = iter.by_ref().take(cols).collect();
if fila.is_empty() {
break;
}
children.push(fila_view(fila, &metrics, &palette));
}
if let Some(text) = truncated_hint {
children.push(barra_texto(text, 10.0, palette.fg_muted, 16.0));
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(metrics.pad),
right: length(metrics.pad),
top: length(metrics.pad),
bottom: length(metrics.pad),
},
gap: Size {
width: length(0.0_f32),
height: length(metrics.gap),
},
..Default::default()
})
.fill(palette.bg_panel)
.clip(true)
.children(children)
}
fn fila_view<Msg: Clone + 'static>(
fila: Vec<GridCell<Msg>>,
m: &GridMetrics,
palette: &GridPalette,
) -> View<Msg> {
let celdas: Vec<View<Msg>> = fila
.into_iter()
.map(|c| celda_view(c, m, palette))
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(m.tile_h),
},
gap: Size {
width: length(m.gap),
height: length(0.0_f32),
},
..Default::default()
})
.children(celdas)
}
fn celda_view<Msg: Clone + 'static>(
cell: GridCell<Msg>,
m: &GridMetrics,
palette: &GridPalette,
) -> View<Msg> {
let bg = if cell.selected {
palette.bg_selected
} else {
palette.bg_cell
};
let mut hijos: Vec<View<Msg>> = Vec::with_capacity(2);
// Cuerpo de la celda: el content del caller, centrado, ocupa el alto
// restante (tile_h menos la franja de label).
hijos.push(
View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.clip(true)
.children(vec![cell.content]),
);
if let Some(label) = cell.label {
hijos.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.clip(true)
.text_aligned(label, 10.0, palette.fg_text, Alignment::Center),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(m.tile_w),
height: length(m.tile_h),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(bg)
.clip(true)
.children(hijos)
.on_click(cell.on_click)
}
fn barra_texto<Msg: Clone + 'static>(
text: String,
size: f32,
color: Color,
height: f32,
) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(height),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(text, size, color, Alignment::Start)
}
#[cfg(test)]
mod pruebas {
use super::*;
fn metrics() -> GridMetrics {
GridMetrics {
tile_w: 80.0,
tile_h: 96.0,
gap: 8.0,
pad: 8.0,
}
}
#[test]
fn cols_se_deriva_del_ancho() {
let m = metrics();
// util_w = 400 - 16 + 8 = 392; paso = 88; 392/88 = 4.45 → 4.
let v = ventana_visible(100, 400.0, 300.0, 0, &m);
assert_eq!(v.cols, 4);
assert_eq!(v.total_rows, 25);
}
#[test]
fn ancho_minimo_da_al_menos_una_columna() {
let m = metrics();
let v = ventana_visible(10, 50.0, 300.0, 0, &m);
assert_eq!(v.cols, 1, "nunca menos de 1 columna");
}
#[test]
fn ventana_arriba_monta_filas_visibles_mas_margen() {
let m = metrics();
// util_h = 300 - 16 + 8 = 292; paso_h = 104; ceil(292/104)=3; +1 = 4.
let v = ventana_visible(100, 400.0, 300.0, 0, &m);
assert_eq!(v.filas_visibles, 4);
assert_eq!(v.first, 0);
// 4 filas × 4 cols = 16 items.
assert_eq!(v.count, 16);
}
#[test]
fn ventana_al_fondo_clampa_y_recorta_la_cola() {
let m = metrics();
// 100 items, 4 cols → 25 filas. Pedir fila 22 (cerca del fondo).
let v = ventana_visible(100, 400.0, 300.0, 22, &m);
assert_eq!(v.first_row, 22);
assert_eq!(v.first, 88);
// last_row = min(22+4, 25) = 25 → count = min(100,100) - 88 = 12.
assert_eq!(v.count, 12);
}
#[test]
fn scroll_mas_alla_del_fondo_se_clampa() {
let m = metrics();
let v = ventana_visible(100, 400.0, 300.0, 999, &m);
// total_rows 25 → max_first_row 24.
assert_eq!(v.first_row, 24);
assert_eq!(v.first, 96);
// Sólo la última fila: 100 - 96 = 4 items.
assert_eq!(v.count, 4);
}
#[test]
fn coleccion_vacia_no_monta_nada() {
let m = metrics();
let v = ventana_visible(0, 400.0, 300.0, 0, &m);
assert!(v.cols >= 1);
assert_eq!(v.total_rows, 0);
assert_eq!(v.count, 0);
assert_eq!(v.first, 0);
}
#[test]
fn ultima_fila_parcial_cuenta_completa_en_total_rows() {
let m = metrics();
// 10 items, 4 cols → 3 filas (la última con 2).
let v = ventana_visible(10, 400.0, 1000.0, 0, &m);
assert_eq!(v.cols, 4);
assert_eq!(v.total_rows, 3);
// Viewport alto: entran todas.
assert_eq!(v.count, 10);
}
#[test]
fn grid_view_agrupa_en_filas_sin_panicar() {
// Smoke: 7 celdas en 3 columnas → 3 filas (3+3+1). Sólo verifica
// que compone sin panicar y devuelve un View.
let cells: Vec<GridCell<i32>> = (0..7)
.map(|i| GridCell {
content: View::new(Style::default()),
label: Some(format!("img{i}")),
selected: i == 2,
on_click: i,
})
.collect();
let _v = grid_view(GridSpec {
cells,
cols: 3,
metrics: metrics(),
caption: Some("7 imágenes".into()),
truncated_hint: None,
palette: GridPalette::default(),
});
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-list"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-list — lista vertical virtualizada para Llimphi: filas clicables, selección, caption opcional, recorte de overflow. El caller hace la virtualización (pasa sólo las filas visibles) y el widget las compone."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-list
> Lista virtualizada para [llimphi](../../README.md).
Renderiza sólo los items visibles. Selección single/multi, scroll programático, keyboard nav.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-list
> Virtualized list for [llimphi](../../README.md).
Renders only visible items. Single/multi selection, programmatic scroll, keyboard nav.
+201
View File
@@ -0,0 +1,201 @@
//! `llimphi-widget-list` — lista vertical virtualizada.
//!
//! Compone una pila de filas con foco visual en la seleccionada y un Msg
//! por click. Pensado como bloque reusable para file explorers, árboles
//! lineales, paneles de log, listados de items, etc.
//!
//! El widget **no** maneja virtualización por sí mismo: el caller pasa
//! únicamente las filas que deberían renderearse (las visibles según su
//! propio `offset`/`scroll`). El widget se ocupa del resto: caption
//! opcional con el conteo, fondo de selección, hint "… y N más" cuando
//! `total > rows.len()`, y `clip` en el contenedor para que las filas no
//! sangren a vecinos.
//!
//! Ejemplo:
//!
//! ```ignore
//! let rows: Vec<ListRow<Msg>> = entries[offset..(offset + visible).min(entries.len())]
//! .iter()
//! .enumerate()
//! .map(|(i, e)| ListRow {
//! label: e.name.clone(),
//! selected: offset + i == selected,
//! on_click: Msg::Select(offset + i),
//! })
//! .collect();
//!
//! let panel = list_view(ListSpec {
//! rows,
//! total: entries.len(),
//! caption: Some(format!("{} entradas", entries.len())),
//! truncated_hint: (entries.len() > offset + rows.len())
//! .then(|| format!("… y {} más", entries.len() - offset - rows.len())),
//! row_height: 22.0,
//! palette: ListPalette::default(),
//! });
//! ```
#![forbid(unsafe_code)]
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;
/// Paleta de la lista. Los defaults son una variante dark con selección
/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro.
#[derive(Debug, Clone, Copy)]
pub struct ListPalette {
pub bg_panel: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
}
impl Default for ListPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ListPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
}
}
}
/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al
/// hacer click sobre cualquier parte de la fila.
pub struct ListRow<Msg> {
pub label: String,
pub selected: bool,
pub on_click: Msg,
}
/// Especificación completa de la lista a renderear.
pub struct ListSpec<Msg> {
/// Filas a renderear, ya filtradas a la ventana visible.
pub rows: Vec<ListRow<Msg>>,
/// Total de items del modelo (usado para el caption — la lista
/// mostrada puede ser un subconjunto virtualizado).
pub total: usize,
/// Caption opcional arriba de las filas (p. ej. "120 entradas").
pub caption: Option<String>,
/// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de
/// la ventana visible. El caller decide qué texto usar.
pub truncated_hint: Option<String>,
/// Altura de cada fila en pixels.
pub row_height: f32,
pub palette: ListPalette,
}
/// Compone la lista como un `View<Msg>`. El contenedor tiene `clip = true`
/// para evitar overflow visual cuando el llamador subestima el tamaño
/// disponible — las filas que excedan el área del panel se recortan.
pub fn list_view<Msg: Clone + 'static>(spec: ListSpec<Msg>) -> View<Msg> {
let ListSpec {
rows,
total: _,
caption,
truncated_hint,
row_height,
palette,
} = spec;
let mut children: Vec<View<Msg>> = Vec::with_capacity(rows.len() + 2);
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 row in rows {
children.push(row_view(row, row_height, &palette));
}
if let Some(text) = truncated_hint {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(text, 10.0, palette.fg_muted, Alignment::Start),
);
}
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 row_view<Msg: Clone + 'static>(row: ListRow<Msg>, height: f32, palette: &ListPalette) -> View<Msg> {
let bg = if row.selected {
palette.bg_selected
} else {
palette.bg_panel
};
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(height),
},
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()
})
.fill(bg)
.text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start)
.on_click(row.on_click)
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "llimphi-widget-menubar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
app-bus = { path = "../../../../shared/app-bus" }
+334
View File
@@ -0,0 +1,334 @@
//! `llimphi-widget-menubar` — la barra de menú principal de una app.
//!
//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar /
//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de
//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la
//! ventana de la app — para las apps que corren standalone y no bajo el
//! shell del launcher.
//!
//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz
//! está abierto (`Option<usize>`); el widget aplana el `AppMenu` y emite
//! `Msg` en cada interacción.
//!
//! Dos entradas:
//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`.
//! - [`menubar_overlay`] → el dropdown del menú abierto, para
//! `App::view_overlay` (devolvé `None` si no hay nada abierto).
//!
//! El `command` de cada ítem es el id que la app entiende (convención
//! `menu.<verbo>`, ver [`app_bus::AppMenu::standard`]); el widget lo
//! rebota por `on_command`.
#![forbid(unsafe_code)]
use std::sync::Arc;
use app_bus::{AppMenu, Menu};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_button::{button_styled, ButtonPalette};
use llimphi_widget_context_menu::{
context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette,
ContextMenuSpec,
};
type MsgFromMenu<Msg> = Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>;
type MsgFromStr<Msg> = Arc<dyn Fn(&str) -> Msg + Send + Sync>;
/// Todo lo que el render necesita. El host lo arma en cada `view()`.
pub struct MenuBarSpec<'a, Msg: Clone + 'static> {
/// El menú a pintar (típicamente `AppMenu::standard()` + menús propios).
pub menu: &'a AppMenu,
/// Índice del menú raíz abierto (estado del host). `None` = ninguno.
pub open: Option<usize>,
pub theme: &'a Theme,
/// Tamaño de la ventana — para clampear el dropdown.
pub viewport: (f32, f32),
/// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón.
pub height: f32,
/// Abrir/cerrar un menú raíz por índice (`None` = cerrar).
pub on_open: MsgFromMenu<Msg>,
/// command id → Msg, al elegir un ítem.
pub on_command: MsgFromStr<Msg>,
}
/// Alto recomendado de la barra de menú.
pub const DEFAULT_HEIGHT: f32 = 30.0;
fn title_palette(theme: &Theme) -> ButtonPalette {
ButtonPalette::from_theme(theme)
}
fn title_palette_active(theme: &Theme) -> ButtonPalette {
let base = ButtonPalette::from_theme(theme);
ButtonPalette {
bg: theme.accent,
bg_hover: theme.accent,
fg: theme.bg_panel,
radius: base.radius,
}
}
/// La fila de títulos (Archivo / Editar / …). Click sobre un título
/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent.
/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que,
/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú
/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay,
/// donde los títulos quedan por encima del scrim y son hovereables.
fn titles_row<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>, hover_switch: bool) -> View<Msg> {
let pal = title_palette(spec.theme);
let pal_on = title_palette_active(spec.theme);
let mut titles: Vec<View<Msg>> = Vec::with_capacity(spec.menu.menus.len());
for (i, root) in spec.menu.menus.iter().enumerate() {
let open = spec.open == Some(i);
let target = if open { None } else { Some(i) };
let mut title = button_styled(
root.label.clone(),
title_style(),
Alignment::Center,
if open { &pal_on } else { &pal },
(spec.on_open)(target),
);
// Con un menú abierto, hover sobre otro título lo abre.
if hover_switch && !open {
title = title.on_pointer_enter((spec.on_open)(Some(i)));
}
titles.push(title);
}
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(spec.height),
},
flex_shrink: 0.0,
flex_direction: FlexDirection::Row,
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(spec.theme.bg_panel_alt)
.children(titles)
}
/// La barra de menú principal — primer hijo del column raíz de `view()`.
pub fn menubar_view<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> View<Msg> {
titles_row(spec, false)
}
/// Aplana un menú raíz al par alineado `(items, commands)` que consume el
/// context-menu (los separadores `separator_before` se insertan como
/// filas y llevan `command = None`). Es la única fuente de verdad del
/// orden de filas — la navegación por teclado y el render comparten esto.
fn dropdown_items(root: &Menu) -> (Vec<ContextMenuItem>, Vec<Option<String>>) {
let mut items: Vec<ContextMenuItem> = Vec::new();
let mut commands: Vec<Option<String>> = Vec::new();
for (k, src) in root.items.iter().enumerate() {
if src.separator_before && k != 0 {
items.push(ContextMenuItem::separator());
commands.push(None);
}
let mut cm = ContextMenuItem::action(src.label.clone());
if let Some(s) = &src.shortcut {
cm = cm.with_shortcut(s.clone());
}
if let Some(ic) = &src.icon {
cm = cm.icon(ic.clone());
}
if !src.enabled {
cm = cm.disabled();
}
items.push(cm);
commands.push(Some(src.command.clone()));
}
(items, commands)
}
/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no
/// hay menú abierto. Hospeda además una copia de la fila de títulos por
/// encima del scrim: así, con el menú abierto, mover el mouse a otro
/// título cambia de menú (hover-switch).
pub fn menubar_overlay<Msg: Clone + 'static>(spec: &MenuBarSpec<Msg>) -> Option<View<Msg>> {
menubar_overlay_core(spec, usize::MAX, 1.0)
}
/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado;
/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil
/// para que el dropdown se deslice/funda al cambiar de menú por hover o
/// flechas). La app guarda el `active` y un `Tween` para el `appear`.
pub fn menubar_overlay_animated<Msg: Clone + 'static>(
spec: &MenuBarSpec<Msg>,
active: usize,
appear: f32,
) -> Option<View<Msg>> {
menubar_overlay_core(spec, active, appear)
}
fn menubar_overlay_core<Msg: Clone + 'static>(
spec: &MenuBarSpec<Msg>,
active: usize,
appear: f32,
) -> Option<View<Msg>> {
let idx = spec.open?;
let root = spec.menu.menus.get(idx)?;
let mut x = 6.0_f32;
for prev in spec.menu.menus.iter().take(idx) {
x += approx_title_width(&prev.label);
}
let (items, commands) = dropdown_items(root);
let on_command = spec.on_command.clone();
let on_open = spec.on_open.clone();
let commands = Arc::new(commands);
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| {
match commands.get(i).and_then(|c| c.clone()) {
Some(cmd) => (on_command)(&cmd),
None => (on_open)(None),
}
});
let dropdown = context_menu_view_ex(
ContextMenuSpec {
anchor: (x, spec.height),
viewport: spec.viewport,
header: Some(root.label.clone()),
items,
active,
on_pick,
on_dismiss: (spec.on_open)(None),
palette: ContextMenuPalette::from_theme(spec.theme),
},
ContextMenuExtras {
appear,
..ContextMenuExtras::default()
},
);
// Fila de títulos por encima del scrim del dropdown: queda hovereable
// para cambiar de menú con el mouse. Absoluta al tope para no consumir
// el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana
// el hit-test.
let titles = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(0.0_f32),
right: auto(),
bottom: auto(),
},
size: Size {
width: percent(1.0_f32),
height: length(spec.height),
},
..Default::default()
})
.children(vec![titles_row(spec, true)]);
Some(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![dropdown, titles]),
)
}
/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el
/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el
/// próximo índice de fila válido (saltea separadores y deshabilitados).
/// `usize::MAX` si no hay menú abierto o sin filas elegibles.
pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize {
let Some(root) = menu.menus.get(menu_idx) else {
return usize::MAX;
};
let (items, _) = dropdown_items(root);
step_active(&items, active, dir)
}
/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con
/// Enter). `None` si el índice no es una fila-acción.
pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option<String> {
let root = menu.menus.get(menu_idx)?;
let (_, commands) = dropdown_items(root);
commands.get(active).cloned().flatten()
}
fn title_style() -> Style {
Style {
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
height: length(24.0_f32),
},
flex_shrink: 0.0,
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),
justify_content: Some(JustifyContent::Center),
..Default::default()
}
}
/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi`
/// para anclar el dropdown sin medir la fuente.
fn approx_title_width(label: &str) -> f32 {
label.chars().count() as f32 * 8.0 + 22.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlay_none_si_no_hay_abierto() {
let menu = AppMenu::standard();
let spec = MenuBarSpec {
menu: &menu,
open: None,
theme: &Theme::dark(),
viewport: (800.0, 600.0),
height: DEFAULT_HEIGHT,
on_open: Arc::new(|_| 0u8),
on_command: Arc::new(|_| 1u8),
};
assert!(menubar_overlay(&spec).is_none());
}
#[test]
fn overlay_some_si_hay_abierto() {
let menu = AppMenu::standard();
let spec = MenuBarSpec {
menu: &menu,
open: Some(0),
theme: &Theme::dark(),
viewport: (800.0, 600.0),
height: DEFAULT_HEIGHT,
on_open: Arc::new(|_| 0u8),
on_command: Arc::new(|_| 1u8),
};
assert!(menubar_overlay(&spec).is_some());
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-modal"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-modal — diálogo genérico (título + body arbitrario + botones primary/cancel/destructive) con scrim y centrado. Para menús contextuales usar llimphi-widget-context-menu."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+319
View File
@@ -0,0 +1,319 @@
//! `llimphi-widget-modal` — diálogo genérico centrado con scrim.
//!
//! Distinto del `context-menu` (chico, anclado a un click): el modal
//! ocupa una región central de tamaño configurable, presenta un título,
//! un cuerpo arbitrario (lo arma la app) y una barra de botones.
//!
//! Uso típico:
//! 1. La app guarda `Option<ModalState>` en su modelo.
//! 2. `view_overlay` devuelve `Some(modal_view(spec))` cuando hay
//! state, `None` cuando se cerró.
//! 3. La app captura `Esc` en `on_key` → cierra; `Enter` → primary.
//!
//! Tres severidades de botón:
//! - `Primary` — verde/accent, acción principal.
//! - `Cancel` — neutral, descarta.
//! - `Destructive` — rojo, acción irreversible (eliminar, etc).
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, 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::{alpha, radius, Theme};
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
/// Paleta del modal.
#[derive(Debug, Clone, Copy)]
pub struct ModalPalette {
/// Color del scrim. El alpha se usa como **promedio** del vignette
/// radial: el centro (debajo del panel) queda ~25% más claro y las
/// esquinas ~40% más oscuras, manteniendo la densidad media igual a
/// 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
/// los modales y overlays.
pub panel: PanelStyle,
pub border: Color,
pub fg_title: Color,
pub fg_text: Color,
pub bg_btn: Color,
pub bg_btn_hover: Color,
pub fg_btn: Color,
pub bg_primary: Color,
pub fg_primary: Color,
pub bg_destructive: Color,
pub fg_destructive: Color,
}
impl ModalPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM),
panel: PanelStyle::from_theme_large(t),
border: t.border,
fg_title: t.fg_text,
fg_text: t.fg_muted,
bg_btn: t.bg_button,
bg_btn_hover: t.bg_button_hover,
fg_btn: t.fg_text,
bg_primary: t.accent,
fg_primary: t.bg_app,
bg_destructive: t.fg_destructive,
fg_destructive: t.bg_app,
}
}
}
/// Severidad de un botón del modal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonKind {
Primary,
Cancel,
Destructive,
}
/// Spec de un botón. `label` se renderiza; `msg` se dispatcha al click.
#[derive(Clone)]
pub struct ModalButton<Msg> {
pub label: String,
pub kind: ButtonKind,
pub msg: Msg,
}
impl<Msg> ModalButton<Msg> {
pub fn primary(label: impl Into<String>, msg: Msg) -> Self {
Self { label: label.into(), kind: ButtonKind::Primary, msg }
}
pub fn cancel(label: impl Into<String>, msg: Msg) -> Self {
Self { label: label.into(), kind: ButtonKind::Cancel, msg }
}
pub fn destructive(label: impl Into<String>, msg: Msg) -> Self {
Self { label: label.into(), kind: ButtonKind::Destructive, msg }
}
}
/// Spec completo del modal.
pub struct ModalSpec<Msg: Clone + 'static> {
pub title: String,
/// Cuerpo libre — la app construye un `View` con lo que quiera
/// mostrar (texto, form, lista). Se pinta entre título y botones.
pub body: View<Msg>,
pub buttons: Vec<ModalButton<Msg>>,
/// Tamaño del panel (clampea al viewport con margen).
pub size: (f32, f32),
pub viewport: (f32, f32),
/// Msg al hacer click en el scrim o presionar Esc (la app maneja
/// Esc en su `on_key`; este Msg es el del click).
pub on_dismiss: Msg,
pub palette: ModalPalette,
}
const TITLE_H: f32 = 40.0;
const BUTTONS_H: f32 = 56.0;
const TITLE_FONT: f32 = 14.0;
const BTN_FONT: f32 = 12.5;
const PAD: f32 = 16.0;
pub fn modal_view<Msg: Clone + 'static>(spec: ModalSpec<Msg>) -> View<Msg> {
let ModalSpec {
title,
body,
buttons,
size,
viewport,
on_dismiss,
palette,
} = spec;
let (w, h) = (
size.0.min(viewport.0 - 32.0).max(200.0),
size.1.min(viewport.1 - 32.0).max(140.0),
);
let x = ((viewport.0 - w) * 0.5).max(0.0);
let y = ((viewport.1 - h) * 0.5).max(0.0);
// Header — título a la izquierda; al borde inferior, una línea
// separadora se logra con un nodo de 1px.
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(TITLE_H),
},
padding: Rect {
left: length(PAD),
right: length(PAD),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start);
let separator = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.border);
// Body — flex_grow para ocupar todo el espacio sobrante.
let body_wrap = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(PAD),
right: length(PAD),
top: length(PAD),
bottom: length(PAD),
},
..Default::default()
})
.children(vec![body]);
// Botones — flex-row justify-end con gap.
let btn_children: Vec<View<Msg>> = buttons
.into_iter()
.map(|b| button_view(b, &palette))
.collect();
let buttons_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(BUTTONS_H),
},
flex_direction: FlexDirection::Row,
justify_content: Some(JustifyContent::FlexEnd),
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(PAD),
right: length(PAD),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(btn_children);
let panel = 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(h),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.paint_with(panel_signature_painter(palette.panel))
.radius(palette.panel.radius)
.clip(true)
.children(vec![header, separator, body_wrap, buttons_row]);
let scrim_base = palette.scrim;
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect};
use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Fill, Gradient};
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// Vignette: el centro toma alpha = base * 0.75 (más translúcido,
// deja ver lo que hay detrás del modal); las esquinas alpha =
// base * 1.4 (más sólido, oscurece los bordes). El promedio
// visual queda cerca de `base` original, así la densidad pedida
// por el caller se preserva.
let [r, g, b, base_a] = scrim_base.components;
let inner: Color =
AlphaColor::new([r, g, b, (base_a * 0.75).clamp(0.0, 1.0)]);
let outer: Color =
AlphaColor::new([r, g, b, (base_a * 1.4).clamp(0.0, 1.0)]);
let cx = rect.x as f64 + rect.w as f64 * 0.5;
let cy = rect.y as f64 + rect.h as f64 * 0.5;
let diag_half = (((rect.w as f64).powi(2) + (rect.h as f64).powi(2)).sqrt() * 0.5) as f32;
let gradient = Gradient::new_radial(Point::new(cx, cy), diag_half)
.with_stops([inner, outer].as_slice());
let full = KurboRect::new(
rect.x as f64,
rect.y as f64,
(rect.x + rect.w) as f64,
(rect.y + rect.h) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &full);
})
.on_click(on_dismiss)
.children(vec![panel])
}
fn button_view<Msg: Clone + 'static>(btn: ModalButton<Msg>, palette: &ModalPalette) -> View<Msg> {
let (bg, fg, hover) = match btn.kind {
ButtonKind::Primary => (palette.bg_primary, palette.fg_primary, brighten(palette.bg_primary, 0.15)),
ButtonKind::Cancel => (palette.bg_btn, palette.fg_btn, palette.bg_btn_hover),
ButtonKind::Destructive => (palette.bg_destructive, palette.fg_destructive, brighten(palette.bg_destructive, 0.15)),
};
let label = btn.label.clone();
View::new(Style {
size: Size {
width: length(label.chars().count() as f32 * 7.5 + 28.0),
height: length(32.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.hover_fill(hover)
.radius(radius::SM)
.text_aligned(label, BTN_FONT, fg, Alignment::Center)
.on_click(btn.msg)
}
/// Aclara un color sumando `delta` a cada componente RGB. Útil para
/// derivar un hover state del color base sin tener que definirlo aparte.
fn brighten(c: Color, delta: f32) -> Color {
let [r, g, b, a] = c.components;
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
AlphaColor::new([
(r + delta).clamp(0.0, 1.0),
(g + delta).clamp(0.0, 1.0),
(b + delta).clamp(0.0, 1.0),
a,
])
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "llimphi-widget-navigator"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-navigator — navegador data-agnóstico de nodos (Mónada/Dir/Archivo) en dos modos conmutables: árbol y grafo; click selecciona, right-click abre."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-tree = { workspace = true }
llimphi-widget-nodegraph = { workspace = true }
[dev-dependencies]
llimphi-widget-segmented = { workspace = true }
@@ -0,0 +1,222 @@
//! Showcase de `llimphi-widget-navigator`: un bosque de "Mónadas" con sus
//! archivos, conmutable entre **árbol** y **grafo** con un control
//! segmentado. Click selecciona; click en el chevron expande/colapsa;
//! right-click "abre" (acá sólo registra el id en el header).
//!
//! Corré con:
//! `cargo run -p llimphi-widget-navigator --example navigator_demo --release`.
use std::collections::HashSet;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_theme::Theme;
use llimphi_widget_navigator::{
navigator_view, NavId, NavKind, NavMode, NavNode, NavPalette, NavSpec,
};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
#[derive(Clone)]
enum Msg {
Toggle(NavId),
Select(NavId),
Open(NavId),
SetMode(usize),
}
struct Model {
expanded: HashSet<NavId>,
selected: Option<NavId>,
mode: NavMode,
last_open: Option<NavId>,
}
struct Showcase;
/// Bosque de demo: tres Mónadas (clusters de nouser), cada una con sus
/// archivos miembros.
fn forest() -> Vec<NavNode> {
vec![
NavNode::branch(
1,
"src · código rust",
NavKind::Monad,
vec![
NavNode::leaf(11, "lib.rs", NavKind::File),
NavNode::leaf(12, "config.rs", NavKind::File),
NavNode::branch(
13,
"widgets/",
NavKind::Dir,
vec![
NavNode::leaf(131, "tree.rs", NavKind::File),
NavNode::leaf(132, "navigator.rs", NavKind::File),
],
),
],
),
NavNode::branch(
2,
"docs · markdown",
NavKind::Monad,
vec![
NavNode::leaf(21, "README.md", NavKind::File),
NavNode::leaf(22, "SDD.md", NavKind::File),
],
),
NavNode::branch(
3,
"assets · imágenes",
NavKind::Monad,
vec![
NavNode::leaf(31, "logo.png", NavKind::File),
NavNode::leaf(32, "icon.svg", NavKind::File),
],
),
]
}
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · navigator showcase"
}
fn initial_size() -> (u32, u32) {
(520, 680)
}
fn init(_: &Handle<Msg>) -> Model {
let mut expanded = HashSet::new();
expanded.insert(1);
expanded.insert(13);
Model {
expanded,
selected: None,
mode: NavMode::Tree,
last_open: None,
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::Toggle(id) => {
if !m.expanded.remove(&id) {
m.expanded.insert(id);
}
}
Msg::Select(id) => m.selected = Some(id),
Msg::Open(id) => m.last_open = Some(id),
Msg::SetMode(i) => m.mode = NavMode::from_index(i),
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = NavPalette::from_theme(&theme);
// Toggle de modo.
let toggle = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(36.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(vec![segmented_view(
&NavMode::LABELS,
model.mode.index(),
Msg::SetMode,
&SegmentedPalette::from_theme(&theme),
)]);
let roots = forest();
let nav = navigator_view(
NavSpec {
roots: &roots,
mode: model.mode,
selected: model.selected,
palette,
guides: true,
},
{
let expanded = model.expanded.clone();
move |id| expanded.contains(&id)
},
Msg::Toggle,
Msg::Select,
Some(Msg::Open),
);
let nav_pane = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.children(vec![nav]);
let status = format!(
"modo: {} · sel: {} · abrir (right-click): {}",
match model.mode {
NavMode::Tree => "árbol",
NavMode::Graph => "grafo",
},
model
.selected
.map(|i| i.to_string())
.unwrap_or_else(|| "".into()),
model
.last_open
.map(|i| i.to_string())
.unwrap_or_else(|| "".into()),
);
let footer = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(26.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(theme.bg_panel_alt)
.text_aligned(status, 12.0, theme.fg_muted, Alignment::Start);
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![toggle, nav_pane, footer])
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+626
View File
@@ -0,0 +1,626 @@
//! `llimphi-widget-navigator` — navegador **data-agnóstico** de nodos en
//! dos modos conmutables: **árbol** (`tree`) y **grafo** (`nodegraph`).
//!
//! Nació para que `pata` muestre las **Mónadas** de nouser y sus archivos
//! en un sidebar, pero el widget no sabe de nouser: recibe un bosque de
//! [`NavNode`]s (id opaco + label + [`NavKind`] + hijos) y emite `Msg`s al
//! interactuar. El caller mapea cada `id` a lo suyo (un `MonadId`, un path)
//! y decide qué hacer al seleccionar/abrir.
//!
//! Igual que el resto de widgets Llimphi, es **render-only y stateless**:
//! el estado (qué nodos están expandidos, cuál está seleccionado, en qué
//! modo está) vive en el `Model` del App; el widget sólo pinta y avisa.
//!
//! - **Árbol** — reusa [`llimphi_widget_tree`]. El navegador aplana el
//! bosque respetando `is_expanded`, dibuja un icono por [`NavKind`] entre
//! el chevron y el label, y cablea toggle / select / context por fila.
//! - **Grafo** — reusa [`llimphi_widget_nodegraph`]. Coloca los nodos
//! visibles en columnas por profundidad, con cables de **contención**
//! (padre→hijo). El nodo seleccionado se resalta; arrastrar un nodo lo
//! selecciona; el right-click abre el menú contextual.
//!
//! ```ignore
//! navigator_view(
//! NavSpec { roots: &model.nodes, mode: model.mode,
//! selected: model.selected, palette, guides: true },
//! |id| model.expanded.contains(&id),
//! Msg::Toggle, Msg::Select, Some(Msg::Open),
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
use llimphi_theme::Theme;
use llimphi_widget_nodegraph::{
nodegraph_view_styled, NodeId, NodeSpec, NodeTint, NodegraphMetrics, NodegraphPalette, Wire,
};
use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec};
/// Identificador opaco de un nodo. El caller lo asigna y lo recibe de vuelta
/// sin que el widget lo interprete (típicamente un índice a su propio mapa
/// `id → MonadId | PathBuf`).
pub type NavId = u64;
/// Naturaleza de un nodo — sólo para elegir su icono y tinte. El widget no
/// asume semántica de dominio más allá de esto.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavKind {
/// Una Mónada (cluster semántico de nouser). Diamante de acento.
Monad,
/// Una agrupación intermedia (carpeta lógica, categoría). Cuadrado.
Group,
/// Un directorio del filesystem. Cuadrado tenue.
Dir,
/// Un archivo hoja. Punto.
File,
/// Cualquier otra cosa. Punto tenue.
Other,
}
/// Un nodo del bosque que el navegador pinta. La jerarquía es explícita
/// (`children`); el navegador la aplana según el estado de expansión.
#[derive(Debug, Clone)]
pub struct NavNode {
pub id: NavId,
pub label: String,
pub kind: NavKind,
pub children: Vec<NavNode>,
}
impl NavNode {
/// Un nodo hoja (sin hijos).
pub fn leaf(id: NavId, label: impl Into<String>, kind: NavKind) -> Self {
Self {
id,
label: label.into(),
kind,
children: Vec::new(),
}
}
/// Un nodo con hijos.
pub fn branch(
id: NavId,
label: impl Into<String>,
kind: NavKind,
children: Vec<NavNode>,
) -> Self {
Self {
id,
label: label.into(),
kind,
children,
}
}
/// `true` si tiene al menos un hijo.
pub fn has_children(&self) -> bool {
!self.children.is_empty()
}
}
/// Modo de visualización del navegador.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavMode {
/// Árbol indentado con expand/collapse.
Tree,
/// Grafo de nodos con cables de contención.
Graph,
}
impl NavMode {
/// Etiquetas para un control segmentado (en el mismo orden que
/// [`NavMode::index`] / [`NavMode::from_index`]).
pub const LABELS: [&'static str; 2] = ["Árbol", "Grafo"];
/// El otro modo (para un botón de toggle simple).
pub fn toggled(self) -> Self {
match self {
NavMode::Tree => NavMode::Graph,
NavMode::Graph => NavMode::Tree,
}
}
/// Índice 0/1 — para alimentar un control segmentado.
pub fn index(self) -> usize {
match self {
NavMode::Tree => 0,
NavMode::Graph => 1,
}
}
/// Recupera el modo desde un índice de control segmentado (≥1 = grafo).
pub fn from_index(i: usize) -> Self {
if i == 0 {
NavMode::Tree
} else {
NavMode::Graph
}
}
}
/// Paleta del navegador: hereda las de tree y nodegraph del [`Theme`]
/// semántico, más los tintes por [`NavKind`] para los iconos.
#[derive(Debug, Clone, Copy)]
pub struct NavPalette {
pub tree: TreePalette,
pub graph: NodegraphPalette,
pub accent: Color,
pub monad: Color,
pub group: Color,
pub dir: Color,
pub file: Color,
pub other: Color,
}
impl Default for NavPalette {
fn default() -> Self {
Self::from_theme(&Theme::dark())
}
}
impl NavPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
tree: TreePalette::from_theme(t),
graph: NodegraphPalette::from_theme(t),
accent: t.accent,
monad: t.accent,
group: t.fg_text,
dir: t.fg_muted,
file: t.fg_text,
other: t.fg_muted,
}
}
/// El color del icono de un nodo según su clase.
pub fn kind_color(&self, kind: NavKind) -> Color {
match kind {
NavKind::Monad => self.monad,
NavKind::Group => self.group,
NavKind::Dir => self.dir,
NavKind::File => self.file,
NavKind::Other => self.other,
}
}
}
/// Lo que el navegador necesita saber para pintar, sin los callbacks.
pub struct NavSpec<'a> {
/// Las raíces del bosque a mostrar.
pub roots: &'a [NavNode],
/// Modo activo.
pub mode: NavMode,
/// Nodo seleccionado (resaltado en ambos modos). `None` = ninguno.
pub selected: Option<NavId>,
/// Paleta.
pub palette: NavPalette,
/// Dibujar líneas guía de indentación en el árbol.
pub guides: bool,
}
/// Alto de fila del árbol / paso vertical del grafo.
const ROW_H: f32 = 24.0;
/// Tamaño del icono de clase (px).
const ICON_PX: f32 = 14.0;
/// Compone el navegador. Los callbacks se identifican por [`NavId`]:
/// - `is_expanded(id)` → si un nodo rama está abierto (sólo árbol);
/// - `on_toggle(id)` → al click en el chevron (sólo árbol);
/// - `on_select(id)` → al click en la fila (árbol) o al arrastrar el nodo
/// (grafo);
/// - `on_context(id)` → al right-click (ambos modos); `None` = sin menú.
pub fn navigator_view<Msg, FExp, FTog, FSel, FCtx>(
spec: NavSpec,
is_expanded: FExp,
on_toggle: FTog,
on_select: FSel,
on_context: Option<FCtx>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FExp: Fn(NavId) -> bool,
FTog: Fn(NavId) -> Msg,
FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
FCtx: Fn(NavId) -> Msg,
{
match spec.mode {
NavMode::Tree => tree_mode(spec, is_expanded, on_toggle, on_select, on_context),
NavMode::Graph => graph_mode(spec, is_expanded, on_select, on_context),
}
}
// =====================================================================
// Árbol
// =====================================================================
fn tree_mode<Msg, FExp, FTog, FSel, FCtx>(
spec: NavSpec,
is_expanded: FExp,
on_toggle: FTog,
on_select: FSel,
on_context: Option<FCtx>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FExp: Fn(NavId) -> bool,
FTog: Fn(NavId) -> Msg,
FSel: Fn(NavId) -> Msg,
FCtx: Fn(NavId) -> Msg,
{
let mut rows: Vec<TreeRow<Msg>> = Vec::new();
for root in spec.roots {
push_rows(
root,
0,
&spec,
&is_expanded,
&on_toggle,
&on_select,
&on_context,
&mut rows,
);
}
tree_view(TreeSpec {
rows,
row_height: ROW_H,
indent_px: 14.0,
palette: spec.palette.tree,
guides: spec.guides,
})
}
#[allow(clippy::too_many_arguments)]
fn push_rows<Msg, FExp, FTog, FSel, FCtx>(
node: &NavNode,
depth: usize,
spec: &NavSpec,
is_expanded: &FExp,
on_toggle: &FTog,
on_select: &FSel,
on_context: &Option<FCtx>,
out: &mut Vec<TreeRow<Msg>>,
) where
Msg: Clone + Send + Sync + 'static,
FExp: Fn(NavId) -> bool,
FTog: Fn(NavId) -> Msg,
FSel: Fn(NavId) -> Msg,
FCtx: Fn(NavId) -> Msg,
{
let has_children = node.has_children();
let expanded = has_children && is_expanded(node.id);
let icon = kind_icon_view::<Msg>(node.kind, spec.palette.kind_color(node.kind));
let mut row = TreeRow::new(
node.label.clone(),
depth,
has_children,
expanded,
spec.selected == Some(node.id),
on_toggle(node.id),
on_select(node.id),
)
.with_icon(icon);
if let Some(ctx) = on_context.as_ref().map(|f| f(node.id)) {
row = row.with_context(ctx);
}
out.push(row);
if expanded {
for child in &node.children {
push_rows(
child, depth + 1, spec, is_expanded, on_toggle, on_select, on_context, out,
);
}
}
}
// =====================================================================
// Grafo
// =====================================================================
/// Un nodo visible aplanado para el grafo: su id, su label/kind y la posición
/// (índice) de su padre en la lista (`None` = raíz).
struct FlatNode {
id: NavId,
label: String,
kind: NavKind,
depth: usize,
parent: Option<usize>,
has_children: bool,
}
fn flatten_for_graph<FExp: Fn(NavId) -> bool>(
roots: &[NavNode],
is_expanded: &FExp,
) -> Vec<FlatNode> {
let mut out = Vec::new();
for root in roots {
walk_graph(root, 0, None, is_expanded, &mut out);
}
out
}
fn walk_graph<FExp: Fn(NavId) -> bool>(
node: &NavNode,
depth: usize,
parent: Option<usize>,
is_expanded: &FExp,
out: &mut Vec<FlatNode>,
) {
let has_children = node.has_children();
let me = out.len();
out.push(FlatNode {
id: node.id,
label: node.label.clone(),
kind: node.kind,
depth,
parent,
has_children,
});
if has_children && is_expanded(node.id) {
for child in &node.children {
walk_graph(child, depth + 1, Some(me), is_expanded, out);
}
}
}
fn graph_mode<Msg, FExp, FSel, FCtx>(
spec: NavSpec,
is_expanded: FExp,
on_select: FSel,
on_context: Option<FCtx>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FExp: Fn(NavId) -> bool,
FSel: Fn(NavId) -> Msg + Send + Sync + 'static,
FCtx: Fn(NavId) -> Msg,
{
let flat = flatten_for_graph(spec.roots, &is_expanded);
let metrics = NodegraphMetrics {
node_width: 150.0,
..NodegraphMetrics::default()
};
// Layout: columna por profundidad, una fila por nodo visible.
const MARGIN: f32 = 24.0;
const COL_GAP: f32 = 36.0;
const ROW_GAP: f32 = 12.0;
let node_h = metrics.node_height(1, 1);
let col_w = metrics.node_width + COL_GAP;
let mut nodes: Vec<NodeSpec> = Vec::with_capacity(flat.len());
let mut wires: Vec<Wire> = Vec::new();
let ids: Vec<NavId> = flat.iter().map(|f| f.id).collect();
for (i, f) in flat.iter().enumerate() {
let inputs = if f.parent.is_some() {
vec![String::new()]
} else {
Vec::new()
};
let outputs = if f.has_children {
vec![String::new()]
} else {
Vec::new()
};
// Prefijo del icono en el label (el nodegraph no tiene slot de icono;
// un glifo simple por clase basta para distinguirlos de un vistazo).
let label = format!("{} {}", kind_glyph(f.kind), f.label);
nodes.push(NodeSpec {
id: i as NodeId,
label,
x: MARGIN + f.depth as f32 * col_w,
y: MARGIN + i as f32 * (node_h + ROW_GAP),
inputs,
outputs,
});
if let Some(p) = f.parent {
wires.push(Wire {
from_node: p as NodeId,
from_output: 0,
to_node: i as NodeId,
to_input: 0,
});
}
}
// Arrastrar un nodo lo selecciona (al soltar). El grafo no reposiciona
// por arrastre — el layout es derivado, no editable.
let drag_ids = ids.clone();
let on_drag = move |id: NodeId, phase: DragPhase, _dx: f32, _dy: f32| match phase {
DragPhase::End => drag_ids
.get(id as usize)
.map(|nav_id| on_select(*nav_id)),
DragPhase::Move => None,
};
// Sin conexiones: la contención es fija.
let on_connect = |_: NodeId, _: u16, _: NodeId, _: u16| None;
// Right-click → menú contextual (evaluado en build, por nodo).
let ctx_ids = &ids;
let on_right: Option<Box<dyn Fn(NodeId) -> Option<Msg>>> = on_context.map(|f| {
let f = move |id: NodeId| ctx_ids.get(id as usize).map(|nav_id| f(*nav_id));
Box::new(f) as Box<dyn Fn(NodeId) -> Option<Msg>>
});
// Resaltado del nodo seleccionado.
let sel_idx = spec
.selected
.and_then(|sid| ids.iter().position(|id| *id == sid));
let accent = spec.palette.accent;
let tint = move |id: NodeId| -> Option<NodeTint> {
if Some(id as usize) == sel_idx {
Some(NodeTint {
bg_title: Some(accent),
..NodeTint::default()
})
} else {
None
}
};
nodegraph_view_styled(
&nodes,
&wires,
&spec.palette.graph,
&metrics,
on_drag,
on_connect,
on_right,
Some(&tint as &dyn Fn(NodeId) -> Option<NodeTint>),
None,
)
}
/// Glifo ASCII-ish por clase para el label del grafo.
fn kind_glyph(kind: NavKind) -> &'static str {
match kind {
NavKind::Monad => "",
NavKind::Group => "",
NavKind::Dir => "",
NavKind::File => "·",
NavKind::Other => "·",
}
}
// =====================================================================
// Icono vectorial por clase (para el árbol)
// =====================================================================
/// Un mini-canvas con el icono de la clase, tinte `color`. Diamante para
/// Mónada, cuadrado para grupo/dir, círculo para archivo.
fn kind_icon_view<Msg: Clone + 'static>(kind: NavKind, color: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX),
height: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, RoundedRect};
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 = (rect.w.min(rect.h) as f64 * 0.34).max(1.5);
match kind {
NavKind::Monad => {
// Diamante (cuadrado a 45°).
let mut p = BezPath::new();
p.move_to(Point::new(cx, cy - r));
p.line_to(Point::new(cx + r, cy));
p.line_to(Point::new(cx, cy + r));
p.line_to(Point::new(cx - r, cy));
p.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p);
}
NavKind::Group | NavKind::Dir => {
let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0);
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq);
}
NavKind::File | NavKind::Other => {
let dot = (rect.w.min(rect.h) as f64 * 0.22).max(1.0);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
color,
None,
&Circle::new((cx, cy), dot),
);
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Toggle(NavId),
Select(NavId),
Open(NavId),
}
fn forest() -> Vec<NavNode> {
vec![NavNode::branch(
1,
"Mónada src",
NavKind::Monad,
vec![
NavNode::leaf(11, "lib.rs", NavKind::File),
NavNode::leaf(12, "main.rs", NavKind::File),
],
)]
}
#[test]
fn navmode_toggle_e_indices() {
assert_eq!(NavMode::Tree.toggled(), NavMode::Graph);
assert_eq!(NavMode::Graph.toggled(), NavMode::Tree);
assert_eq!(NavMode::Tree.index(), 0);
assert_eq!(NavMode::from_index(1), NavMode::Graph);
assert_eq!(NavMode::from_index(0), NavMode::Tree);
}
#[test]
fn navnode_constructores() {
let n = NavNode::leaf(1, "x", NavKind::File);
assert!(!n.has_children());
let b = NavNode::branch(2, "y", NavKind::Monad, vec![n]);
assert!(b.has_children());
assert_eq!(b.children.len(), 1);
}
#[test]
fn flatten_grafo_respeta_expansion() {
let roots = forest();
// Colapsado: sólo la raíz.
let collapsed = flatten_for_graph(&roots, &|_| false);
assert_eq!(collapsed.len(), 1);
assert_eq!(collapsed[0].id, 1);
assert!(collapsed[0].parent.is_none());
assert!(collapsed[0].has_children);
// Expandido: raíz + 2 hijos, con parent = índice 0.
let expanded = flatten_for_graph(&roots, &|id| id == 1);
assert_eq!(expanded.len(), 3);
assert_eq!(expanded[1].parent, Some(0));
assert_eq!(expanded[2].parent, Some(0));
assert_eq!(expanded[1].depth, 1);
}
#[test]
fn navigator_view_construye_en_ambos_modos() {
// No paniquea construyendo el View en cada modo (smoke).
let roots = forest();
let palette = NavPalette::default();
for mode in [NavMode::Tree, NavMode::Graph] {
let _v: View<Msg> = navigator_view(
NavSpec {
roots: &roots,
mode,
selected: Some(1),
palette,
guides: true,
},
|id| id == 1,
Msg::Toggle,
Msg::Select,
Some(Msg::Open),
);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-widget-nodegraph"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-nodegraph — lienzo de nodos con pins y cables Bezier. Reusable por pluma (DAG), nakui (fórmulas yupay), tullpu (ajustes no destructivos), dominium (sistemas), takiy (cadena de audio)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "nodegraph_demo"
path = "examples/nodegraph_demo.rs"
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-nodegraph
> Lienzo de nodos + cables Bezier para [llimphi](../../README.md).
Cada nodo es `View<Msg>` libre con puertos in/out. Aristas curvas Bezier. Drag de nodos, pan/zoom del canvas, conectar puertos. Usado por `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-nodegraph
> Node canvas + Bezier wires for [llimphi](../../README.md).
Each node is a free `View<Msg>` with in/out ports. Bezier curve edges. Node drag, canvas pan/zoom, port connect. Used by `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`.
@@ -0,0 +1,197 @@
//! Showcase de `llimphi-widget-nodegraph`. Cuatro nodos pre-conectados
//! representando una cadena de audio (`Source → Filter → Mixer →
//! Output`) y un `LFO` huérfano para que el usuario lo conecte
//! arrastrando desde su pin de salida hasta el `mod` del filtro.
//!
//! - Arrastrá la title bar de cualquier nodo para moverlo.
//! - Arrastrá desde un pin de salida (lado derecho) y soltá sobre un
//! pin de entrada (lado izquierdo) de otro nodo para conectar.
//!
//! Corré con: `cargo run -p llimphi-widget-nodegraph --example
//! nodegraph_demo --release`.
use llimphi_theme::Theme;
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_widget_nodegraph::{
nodegraph_view, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, PinIdx, Wire,
};
#[derive(Clone)]
enum Msg {
DragNode {
id: NodeId,
// El demo no diferencia Move/End; lo dejamos en el Msg por si
// un caller real quiere persistir layout solo en End.
#[allow(dead_code)]
phase: DragPhase,
dx: f32,
dy: f32,
},
Connect {
from_node: NodeId,
from_pin: PinIdx,
to_node: NodeId,
to_pin: PinIdx,
},
}
struct Model {
nodes: Vec<NodeSpec>,
wires: Vec<Wire>,
}
const ID_SOURCE: NodeId = 1;
const ID_FILTER: NodeId = 2;
const ID_MIXER: NodeId = 3;
const ID_OUTPUT: NodeId = 4;
const ID_LFO: NodeId = 5;
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · nodegraph showcase (drag títulos, arrastrá pin → pin)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
nodes: vec![
NodeSpec {
id: ID_SOURCE,
label: "Source".into(),
x: 60.0,
y: 80.0,
inputs: vec![],
outputs: vec!["out".into()],
},
NodeSpec {
id: ID_FILTER,
label: "Filter".into(),
x: 290.0,
y: 80.0,
inputs: vec!["in".into(), "mod".into()],
outputs: vec!["out".into()],
},
NodeSpec {
id: ID_MIXER,
label: "Mixer".into(),
x: 520.0,
y: 80.0,
inputs: vec!["a".into(), "b".into()],
outputs: vec!["out".into()],
},
NodeSpec {
id: ID_OUTPUT,
label: "Output".into(),
x: 750.0,
y: 80.0,
inputs: vec!["in".into()],
outputs: vec![],
},
NodeSpec {
id: ID_LFO,
label: "LFO".into(),
x: 290.0,
y: 260.0,
inputs: vec![],
outputs: vec!["out".into()],
},
],
wires: vec![
Wire {
from_node: ID_SOURCE,
from_output: 0,
to_node: ID_FILTER,
to_input: 0,
},
Wire {
from_node: ID_FILTER,
from_output: 0,
to_node: ID_MIXER,
to_input: 0,
},
Wire {
from_node: ID_MIXER,
from_output: 0,
to_node: ID_OUTPUT,
to_input: 0,
},
],
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::DragNode { id, phase: _, dx, dy } => {
if let Some(n) = m.nodes.iter_mut().find(|n| n.id == id) {
n.x += dx;
n.y += dy;
if n.x < 0.0 {
n.x = 0.0;
}
if n.y < 0.0 {
n.y = 0.0;
}
}
}
Msg::Connect {
from_node,
from_pin,
to_node,
to_pin,
} => {
if from_node == to_node {
return m;
}
let exists = m.wires.iter().any(|w| {
w.from_node == from_node
&& w.from_output == from_pin
&& w.to_node == to_node
&& w.to_input == to_pin
});
if !exists {
m.wires.push(Wire {
from_node,
from_output: from_pin,
to_node,
to_input: to_pin,
});
}
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = NodegraphPalette::from_theme(&theme);
let metrics = NodegraphMetrics::default();
nodegraph_view(
&model.nodes,
&model.wires,
&palette,
&metrics,
|id, phase, dx, dy| Some(Msg::DragNode { id, phase, dx, dy }),
|from_node, from_pin, to_node, to_pin| {
Some(Msg::Connect {
from_node,
from_pin,
to_node,
to_pin,
})
},
)
}
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+718
View File
@@ -0,0 +1,718 @@
//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables
//! Bezier sobre Llimphi.
//!
//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa
//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta:
//!
//! - el lienzo (fondo lleno);
//! - cada nodo como un rect con título arriba y pins a los lados
//! (entradas a la izquierda, salidas a la derecha);
//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)`
//! como Bezier cúbicas con tangentes horizontales (mismo look que
//! `pluma-editor-llimphi::multilienzo_editor::carril_editor`).
//!
//! El widget no mantiene estado: el caller acumula posición de nodos +
//! cables en su `Model` y le pasa handlers para los dos eventos
//! interactivos:
//!
//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se
//! invoca al arrastrar la title bar de un nodo. El handler suma el
//! delta a la posición persistida.
//! - **conectar dos pins** — al arrastrar desde un pin de salida y
//! soltar sobre un pin de entrada, `on_connect(from_node, from_out,
//! to_node, to_in)` se invoca para que el caller materialice el
//! `Wire` en su modelo.
//!
//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay),
//! tullpu (ajustes no destructivos), dominium (sistemas), takiy
//! (cadena de audio), pluma-notebook (kernel-DAG visual).
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, View};
/// Identificador opaco de un nodo. El caller asigna estos valores; el
/// widget los pasa de vuelta sin interpretarlos.
pub type NodeId = u32;
/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo.
pub type PinIdx = u16;
/// Especificación de un nodo del grafo. El caller construye uno por
/// nodo en cada `view`. Las posiciones son en pixels relativas al rect
/// del lienzo.
#[derive(Debug, Clone)]
pub struct NodeSpec {
pub id: NodeId,
pub label: String,
/// Esquina superior-izquierda del nodo, en coordenadas del lienzo.
pub x: f32,
pub y: f32,
/// Labels de los pins de entrada. Cantidad = altura mínima del nodo.
pub inputs: Vec<String>,
/// Labels de los pins de salida.
pub outputs: Vec<String>,
}
/// Cable entre el pin de salida de un nodo y el pin de entrada de otro.
/// El widget no valida ciclos ni direcciones — esa política vive en el
/// caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Wire {
pub from_node: NodeId,
pub from_output: PinIdx,
pub to_node: NodeId,
pub to_input: PinIdx,
}
/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe
/// el color correspondiente de la paleta global para *ese* nodo; los
/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo
/// (p.ej. el cono afectado por un morfismo) sin tocar el resto.
#[derive(Debug, Clone, Copy, Default)]
pub struct NodeTint {
pub bg_node: Option<Color>,
pub bg_title: Option<Color>,
pub fg_title: Option<Color>,
}
/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico.
#[derive(Debug, Clone, Copy)]
pub struct NodegraphPalette {
pub bg_canvas: Color,
pub bg_node: Color,
pub bg_title: Color,
pub fg_title: Color,
pub fg_pin_label: Color,
pub pin_input: Color,
pub pin_output: Color,
pub pin_drop_hover: Color,
pub wire: Color,
pub border: Color,
}
impl Default for NodegraphPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl NodegraphPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_canvas: t.bg_app,
bg_node: t.bg_panel,
bg_title: t.bg_panel_alt,
fg_title: t.fg_text,
fg_pin_label: t.fg_muted,
pin_input: t.accent,
pin_output: t.accent,
pin_drop_hover: t.bg_selected,
wire: t.accent,
border: t.border,
}
}
}
/// Geometría del nodo y de los pins.
#[derive(Debug, Clone, Copy)]
pub struct NodegraphMetrics {
pub node_width: f32,
pub title_height: f32,
pub pin_row_height: f32,
pub pin_radius: f32,
pub pin_label_size: f32,
pub title_text_size: f32,
pub wire_stroke: f32,
pub node_radius: f64,
}
impl Default for NodegraphMetrics {
fn default() -> Self {
Self {
node_width: 160.0,
title_height: 22.0,
pin_row_height: 18.0,
pin_radius: 5.0,
pin_label_size: 10.0,
title_text_size: 11.0,
wire_stroke: 1.6,
node_radius: 4.0,
}
}
}
impl NodegraphMetrics {
/// Alto total del rect que ocupa un nodo con `n_in` entradas y
/// `n_out` salidas. El cuerpo crece con el lado que tenga más pins.
pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 {
let rows = n_in.max(n_out).max(1) as f32;
self.title_height + rows * self.pin_row_height + 6.0
}
/// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es
/// `(_x, node_y)`. Sirve también para outputs (misma alineación).
pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
node_y
+ self.title_height
+ 3.0
+ (pin as f32 + 0.5) * self.pin_row_height
}
pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 {
self.input_pin_y(node_y, pin)
}
}
type DragNodeFn<Msg> =
Arc<dyn Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync>;
type ConnectFn<Msg> = Arc<
dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync,
>;
/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del
/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores =
/// pin_idx.
#[inline]
fn encode_payload(node: NodeId, pin: PinIdx) -> u64 {
((node as u64) << 32) | (pin as u64)
}
#[inline]
fn decode_payload(payload: u64) -> (NodeId, PinIdx) {
let node = (payload >> 32) as NodeId;
let pin = (payload & 0xFFFF) as PinIdx;
(node, pin)
}
/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta
/// del cursor cuando el usuario arrastra la title bar de un nodo (las
/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca
/// cuando el usuario suelta un cable iniciado en un pin de salida
/// sobre un pin de entrada de otro nodo.
pub fn nodegraph_view<Msg, FDrag, FConnect>(
nodes: &[NodeSpec],
wires: &[Wire],
palette: &NodegraphPalette,
metrics: &NodegraphMetrics,
on_drag_node: FDrag,
on_connect: FConnect,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
FConnect:
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
{
nodegraph_view_ex::<Msg, FDrag, FConnect, fn(NodeId) -> Option<Msg>>(
nodes,
wires,
palette,
metrics,
on_drag_node,
on_connect,
None,
)
}
/// Variante extendida con un handler opcional de click derecho sobre
/// la title bar de cada nodo. Permite a la app montar acciones por-nodo
/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar
/// este nodo" en un editor de cadena de audio) sin esperar a que el
/// widget tenga un menú contextual propio.
///
/// `on_right_click_node` se evalúa una vez por nodo al construir la
/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer
/// right-click sobre la title bar; `None` deja al nodo sin acción
/// contextual.
pub fn nodegraph_view_ex<Msg, FDrag, FConnect, FRight>(
nodes: &[NodeSpec],
wires: &[Wire],
palette: &NodegraphPalette,
metrics: &NodegraphMetrics,
on_drag_node: FDrag,
on_connect: FConnect,
on_right_click_node: Option<FRight>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
FConnect:
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
FRight: Fn(NodeId) -> Option<Msg>,
{
nodegraph_view_styled(
nodes,
wires,
palette,
metrics,
on_drag_node,
on_connect,
on_right_click_node,
None,
None,
)
}
/// Variante con realce: además de los handlers, acepta dos closures de
/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales
/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un
/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la
/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`].
#[allow(clippy::too_many_arguments)]
pub fn nodegraph_view_styled<Msg, FDrag, FConnect, FRight>(
nodes: &[NodeSpec],
wires: &[Wire],
palette: &NodegraphPalette,
metrics: &NodegraphMetrics,
on_drag_node: FDrag,
on_connect: FConnect,
on_right_click_node: Option<FRight>,
node_tint: Option<&dyn Fn(NodeId) -> Option<NodeTint>>,
wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
FConnect:
Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option<Msg> + Send + Sync + 'static,
FRight: Fn(NodeId) -> Option<Msg>,
{
let on_drag: DragNodeFn<Msg> = Arc::new(on_drag_node);
let on_connect: ConnectFn<Msg> = Arc::new(on_connect);
let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint);
let stroke_px = metrics.wire_stroke;
let mut children: Vec<View<Msg>> = Vec::with_capacity(nodes.len() + 1);
// Capa 0 — cables (van detrás de los nodos).
children.push(wires_layer(painted, stroke_px));
// Capa 1..N — nodos.
for node in nodes {
let right_click_msg = on_right_click_node
.as_ref()
.and_then(|f| f(node.id));
let tint = node_tint.and_then(|f| f(node.id));
children.push(node_view(
node,
palette,
metrics,
&on_drag,
&on_connect,
right_click_msg,
tint,
));
}
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.bg_canvas)
.clip(true)
.children(children)
}
#[derive(Debug, Clone, Copy)]
struct WirePainted {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
}
fn precompute_wires(
nodes: &[NodeSpec],
wires: &[Wire],
metrics: &NodegraphMetrics,
default_color: Color,
wire_tint: Option<&dyn Fn(&Wire) -> Option<Color>>,
) -> Vec<WirePainted> {
let mut out = Vec::with_capacity(wires.len());
for w in wires {
let from = nodes.iter().find(|n| n.id == w.from_node);
let to = nodes.iter().find(|n| n.id == w.to_node);
if let (Some(a), Some(b)) = (from, to) {
let x1 = a.x + metrics.node_width;
let y1 = metrics.output_pin_y(a.y, w.from_output);
let x2 = b.x;
let y2 = metrics.input_pin_y(b.y, w.to_input);
let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color);
out.push(WirePainted {
x1,
y1,
x2,
y2,
color,
});
}
}
out
}
fn wires_layer<Msg>(wires: Vec<WirePainted>, stroke_px: f32) -> View<Msg>
where
Msg: Clone + 'static,
{
let nodo = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(0.0_f32),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
});
if wires.is_empty() {
return nodo;
}
nodo.paint_with(move |scene, _ts, rect| {
let stroke = Stroke::new(stroke_px as f64);
for w in &wires {
// Bezier cúbica con tangentes horizontales — mismo patrón
// que las hebras de pluma-editor-llimphi.
let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64;
let x1 = (rect.x + w.x1) as f64;
let y1 = (rect.y + w.y1) as f64;
let x2 = (rect.x + w.x2) as f64;
let y2 = (rect.y + w.y2) as f64;
let mut path = BezPath::new();
path.move_to((x1, y1));
path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2));
scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path);
}
})
}
fn node_view<Msg>(
node: &NodeSpec,
palette: &NodegraphPalette,
metrics: &NodegraphMetrics,
on_drag: &DragNodeFn<Msg>,
on_connect: &ConnectFn<Msg>,
on_right_click_msg: Option<Msg>,
tint: Option<NodeTint>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let n_in = node.inputs.len();
let n_out = node.outputs.len();
let height = metrics.node_height(n_in, n_out);
// Colores efectivos: el tinte sobrescribe la paleta por-campo.
let tint = tint.unwrap_or_default();
let bg_node = tint.bg_node.unwrap_or(palette.bg_node);
let bg_title = tint.bg_title.unwrap_or(palette.bg_title);
let fg_title = tint.fg_title.unwrap_or(palette.fg_title);
let node_id = node.id;
let drag = on_drag.clone();
let mut title_bar = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(metrics.title_height),
},
flex_shrink: 0.0,
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(bg_title)
.text_aligned(
node.label.clone(),
metrics.title_text_size,
fg_title,
Alignment::Start,
)
.draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy));
if let Some(msg) = on_right_click_msg {
title_bar = title_bar.on_right_click(msg);
}
let mut pin_layer_children: Vec<View<Msg>> = Vec::with_capacity(n_in + n_out);
for (i, label) in node.inputs.iter().enumerate() {
pin_layer_children.push(pin_view(
node_id,
i as PinIdx,
PinKind::Input,
label,
palette,
metrics,
on_connect.clone(),
));
}
for (i, label) in node.outputs.iter().enumerate() {
pin_layer_children.push(pin_view(
node_id,
i as PinIdx,
PinKind::Output,
label,
palette,
metrics,
on_connect.clone(),
));
}
let pin_layer = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(metrics.title_height),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.children(pin_layer_children);
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(node.x),
top: length(node.y),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: length(metrics.node_width),
height: length(height),
},
..Default::default()
})
.fill(bg_node)
.radius(metrics.node_radius)
.children(vec![title_bar, pin_layer])
}
#[derive(Debug, Clone, Copy)]
enum PinKind {
Input,
Output,
}
fn pin_view<Msg>(
node_id: NodeId,
pin_idx: PinIdx,
kind: PinKind,
label: &str,
palette: &NodegraphPalette,
metrics: &NodegraphMetrics,
on_connect: ConnectFn<Msg>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let y_top = pin_idx as f32 * metrics.pin_row_height;
let row_h = metrics.pin_row_height;
let r = metrics.pin_radius;
let diam = r * 2.0;
let (pin_left, pin_right, dot_color, label_align) = match kind {
PinKind::Input => (
Some(length(-r)),
None,
palette.pin_input,
Alignment::Start,
),
PinKind::Output => (
None,
Some(length(-r)),
palette.pin_output,
Alignment::End,
),
};
let mut dot = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: pin_left.unwrap_or_else(|| length(0.0_f32)),
top: length((row_h - diam) * 0.5),
right: pin_right.unwrap_or_else(|| length(0.0_f32)),
bottom: length(0.0_f32),
},
size: Size {
width: length(diam),
height: length(diam),
},
..Default::default()
})
.fill(dot_color)
.radius(r as f64);
match kind {
PinKind::Output => {
dot = dot
.draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None)
.drag_payload(encode_payload(node_id, pin_idx));
}
PinKind::Input => {
let to_node = node_id;
let to_pin = pin_idx;
let cb = on_connect.clone();
dot = dot
.on_drop(move |payload: u64| {
let (from_node, from_pin) = decode_payload(payload);
(cb)(from_node, from_pin, to_node, to_pin)
})
.drop_hover_fill(palette.pin_drop_hover);
}
}
let label_view = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(diam + 4.0),
top: length(0.0),
right: length(diam + 4.0),
bottom: length(0.0),
},
size: Size {
width: Dimension::auto(),
height: length(row_h),
},
..Default::default()
})
.text_aligned(
label.to_string(),
metrics.pin_label_size,
palette.fg_pin_label,
label_align,
);
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(y_top),
right: length(0.0),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: length(row_h),
},
..Default::default()
})
.children(vec![label_view, dot])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_roundtrip() {
for (n, p) in [
(0u32, 0u16),
(1, 0),
(0, 1),
(42, 7),
(u32::MAX, u16::MAX),
(123_456, 65_535),
] {
let enc = encode_payload(n, p);
let (n2, p2) = decode_payload(enc);
assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})");
}
}
#[test]
fn metrics_node_height_grows_with_max_side() {
let m = NodegraphMetrics::default();
assert_eq!(m.node_height(3, 1), m.node_height(1, 3));
let min = m.title_height + m.pin_row_height + 6.0;
assert_eq!(m.node_height(0, 0), min);
}
#[test]
fn pin_y_progression() {
let m = NodegraphMetrics::default();
let y0 = m.input_pin_y(100.0, 0);
let y1 = m.input_pin_y(100.0, 1);
let y2 = m.input_pin_y(100.0, 2);
assert!(y1 - y0 > 0.0, "pins crecen hacia abajo");
assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme");
}
#[test]
fn precompute_skips_dangling_wires() {
let nodes = vec![NodeSpec {
id: 1,
label: "solo".into(),
x: 0.0,
y: 0.0,
inputs: vec!["in".into()],
outputs: vec!["out".into()],
}];
let wires = vec![Wire {
from_node: 99,
from_output: 0,
to_node: 1,
to_input: 0,
}];
let m = NodegraphMetrics::default();
let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
assert!(pre.is_empty());
}
#[test]
fn precompute_resolves_existing_wires() {
let nodes = vec![
NodeSpec {
id: 1,
label: "a".into(),
x: 0.0,
y: 0.0,
inputs: vec![],
outputs: vec!["out".into()],
},
NodeSpec {
id: 2,
label: "b".into(),
x: 200.0,
y: 50.0,
inputs: vec!["in".into()],
outputs: vec![],
},
];
let wires = vec![Wire {
from_node: 1,
from_output: 0,
to_node: 2,
to_input: 0,
}];
let m = NodegraphMetrics::default();
let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None);
assert_eq!(pre.len(), 1);
assert!((pre[0].x1 - m.node_width).abs() < 1e-3);
assert!((pre[0].x2 - 200.0).abs() < 1e-3);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-panel"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-panel — firma visual transversal: gradiente vertical casi imperceptible + hairline accent en el top edge. Helper paint_with + wrapper panel_view. La capa que vuelve reconocible al sistema sin cargar."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+239
View File
@@ -0,0 +1,239 @@
//! `llimphi-widget-panel` — firma visual transversal de los paneles gioser.
//!
//! Aporta dos detalles que aplicados consistentemente vuelven al sistema
//! reconocible sin que se note "diseñado":
//!
//! 1. **Gradiente vertical casi imperceptible** — el fondo del panel no
//! es un color sólido sino una interpolación lineal entre una versión
//! ligeramente más clara (top) y una ligeramente más oscura (bot) del
//! color base. La diferencia es ~4% en valor — invisible al primer
//! vistazo pero el ojo lo registra como "tallado" en vez de "pintado".
//!
//! 2. **Hairline accent en el top edge** — una línea horizontal de 1px
//! en el color accent del theme, al ~30% de alpha, justo en el borde
//! superior del panel. Funciona como "hilo de identidad" que cose
//! todos los paneles del sistema: aparece en modales, dropdowns,
//! cards, sidebars; siempre el mismo grosor, siempre el mismo color.
//!
//! ## API
//!
//! - [`PanelStyle`] — bundle de tokens (color base, accent, radio,
//! alpha del hairline, fuerza del gradiente).
//! - [`panel_signature_painter`] — `Fn` para `View::paint_with`. Útil si
//! ya tenés un View configurado y querés sumarle la firma sin envolver.
//! - [`panel_view`] — convenience: arma el View completo con la firma
//! aplicada, recibe los hijos como `Vec<View<Msg>>`.
//!
//! ## Cuándo usarlo
//!
//! - SÍ: modales, dropdowns, cards prominentes, columnas de layout,
//! shortcuts-help, paneles flotantes.
//! - NO: chips, badges, toasts, items de lista (la firma es para
//! superficies grandes; en piezas chiquitas es ruido).
#![forbid(unsafe_code)]
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};
/// Token bundle de la firma visual.
#[derive(Debug, Clone, Copy)]
pub struct PanelStyle {
/// Color base del panel (típico: `theme.bg_panel`).
pub bg_base: Color,
/// Color del hairline (típico: `theme.accent`).
pub accent: Color,
/// Radio de las esquinas (típico: `radius::MD` para cards, `radius::LG`
/// para modales/overlays).
pub radius: f64,
/// Alpha del hairline (0.01.0). Por debajo de 0.20 se pierde; por
/// encima de 0.45 se vuelve dominante. Default 0.30.
pub hairline_alpha: f32,
/// Fuerza del gradiente — cada componente RGB se desplaza ±gradient
/// (en escala 0.01.0). 0.04 = 4% = imperceptible-pero-presente.
/// Subir más sólo si el theme es muy claro y el efecto no llega.
pub gradient_strength: f32,
}
impl PanelStyle {
/// Estilo estándar para cards / sidebars / paneles medianos.
pub fn from_theme(t: &Theme) -> Self {
Self {
bg_base: t.bg_panel,
accent: t.accent,
radius: radius::MD,
hairline_alpha: alpha::SCRIM as f32 / 255.0 * 1.2, // ~0.30
gradient_strength: 0.04,
}
}
/// Variante para superficies grandes — modales, splash, overlays.
/// Esquinas más generosas, gradiente y hairline un toque más marcados.
pub fn from_theme_large(t: &Theme) -> Self {
Self {
bg_base: t.bg_panel,
accent: t.accent,
radius: radius::LG,
hairline_alpha: 0.35,
gradient_strength: 0.05,
}
}
/// Variante neutra — sin hairline (panels que no deben llevar la
/// "firma" porque son piezas auxiliares). Mantiene el gradiente.
pub fn neutral(t: &Theme) -> Self {
Self {
bg_base: t.bg_panel,
accent: t.accent,
radius: radius::MD,
hairline_alpha: 0.0,
gradient_strength: 0.03,
}
}
/// Color del top del gradiente: base aclarada.
pub fn bg_top(&self) -> Color {
shift(self.bg_base, self.gradient_strength)
}
/// Color del bottom del gradiente: base oscurecida.
pub fn bg_bot(&self) -> Color {
shift(self.bg_base, -self.gradient_strength)
}
}
/// Devuelve la closure de pintura que aplica la firma sobre el rect del
/// nodo. Pasarla a `View::paint_with` para sumar la firma a un View
/// existente. El View NO debe tener `.fill(...)` setteado — el gradient
/// reemplaza el fill sólido.
///
/// Nota: el View debe llamar `.radius(style.radius)` en sí mismo si quiere
/// que clip/hit-test/borders respeten las esquinas. La firma pinta el
/// gradiente como `RoundedRect` con el mismo `radius`, así que la
/// silueta visual es consistente independientemente del clipping.
pub fn panel_signature_painter(
style: PanelStyle,
) -> impl Fn(&mut llimphi_ui::llimphi_raster::vello::Scene, &mut llimphi_ui::llimphi_text::Typesetter, PaintRect)
+ Send
+ Sync
+ 'static {
move |scene, _ts, rect| {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
// === 1) Gradiente vertical en RoundedRect ===
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, style.radius);
let gradient = Gradient::new_linear(
Point::new(x0, y0),
Point::new(x0, y1),
)
.with_stops([style.bg_top(), style.bg_bot()].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
// === 2) Hairline accent en el top edge ===
// Se acorta horizontalmente para no chocar con las esquinas
// redondeadas — queda inscrito en el "techo recto" del panel.
if style.hairline_alpha > 0.0 && rect.w > style.radius as f32 * 2.0 + 4.0 {
let hairline_color = with_alpha_mul(style.accent, style.hairline_alpha);
let hairline = KurboRect::new(
x0 + style.radius,
y0,
x1 - style.radius,
y0 + 1.0,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, hairline_color, None, &hairline);
}
}
}
/// Convenience: arma un `View` con la firma aplicada y los `children`
/// adentro. Equivalente a:
///
/// ```ignore
/// View::new(Style { size: full, ..Default::default() })
/// .paint_with(panel_signature_painter(style))
/// .radius(style.radius)
/// .clip(true)
/// .children(children)
/// ```
///
/// Para layouts custom (size específico, padding, flex direction), usar
/// `panel_signature_painter` directamente y construir el View a mano.
pub fn panel_view<Msg: Clone + 'static>(
children: Vec<View<Msg>>,
style: PanelStyle,
) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(panel_signature_painter(style))
.radius(style.radius)
.clip(true)
.children(children)
}
// =====================================================================
// Helpers internos
// =====================================================================
/// Desplaza cada componente RGB de `c` por `delta` (positivo aclara,
/// negativo oscurece). Clampea en [0,1]. El alpha queda intacto.
fn shift(c: Color, delta: f32) -> Color {
let [r, g, b, a] = c.components;
AlphaColor::new([
(r + delta).clamp(0.0, 1.0),
(g + delta).clamp(0.0, 1.0),
(b + delta).clamp(0.0, 1.0),
a,
])
}
fn with_alpha_mul(c: Color, mult: f32) -> Color {
let [r, g, b, a] = c.components;
AlphaColor::new([r, g, b, a * mult])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bg_top_is_brighter_than_bg_bot() {
let t = Theme::dark();
let s = PanelStyle::from_theme(&t);
let top = s.bg_top();
let bot = s.bg_bot();
// El top debe tener cada canal RGB ≥ al del bot (es más claro).
for i in 0..3 {
assert!(top.components[i] >= bot.components[i],
"canal {i}: top {} < bot {}", top.components[i], bot.components[i]);
}
}
#[test]
fn neutral_style_has_no_hairline() {
let t = Theme::dark();
let s = PanelStyle::neutral(&t);
assert_eq!(s.hairline_alpha, 0.0);
}
#[test]
fn shift_clamps_to_unit() {
let c = Color::from_rgba8(250, 250, 250, 255);
let bright = shift(c, 0.5);
assert!(bright.components[0] <= 1.0);
assert!(bright.components[1] <= 1.0);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-panes"
version.workspace = true
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."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+319
View File
@@ -0,0 +1,319 @@
//! Demo de `llimphi-widget-panes` — "tmux de componentes gioser".
//!
//! Dos tipos de panel heterogéneos (Contador y Notas) conviviendo en un
//! mismo árbol BSP que se parte horizontal/vertical, se cierra, se enfoca
//! (click) y se redimensiona (arrastrando los divisores). Prueba de punta
//! a punta de que componentes distintos se montan en un layout
//! intercambiable con splits resizables.
//!
//! Correr: `cargo run -p llimphi-widget-panes --example panes_demo --release`
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_theme::Theme;
use llimphi_widget_panes::{panes_view, Axis, Layout, PaneId, PanesPalette, Side};
struct Demo;
#[derive(Clone)]
enum Msg {
Focus(PaneId),
Split(Axis),
Close,
Resize(Vec<Side>, f32),
Inc(PaneId),
Dec(PaneId),
AddNote(PaneId),
}
enum Kind {
Counter(i64),
Notes(Vec<String>),
}
struct Pane {
title: String,
kind: Kind,
}
struct Model {
layout: Layout,
panes: HashMap<PaneId, Pane>,
focused: PaneId,
next_id: PaneId,
theme: Theme,
}
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"panes — tmux de componentes gioser"
}
fn init(_: &Handle<Msg>) -> Model {
let mut panes = HashMap::new();
panes.insert(
1,
Pane {
title: "Contador".into(),
kind: Kind::Counter(0),
},
);
panes.insert(
2,
Pane {
title: "Notas".into(),
kind: Kind::Notes(vec!["arrastrá el divisor del medio →".into()]),
},
);
let mut layout = Layout::single(1);
layout.split(1, 2, Axis::Horizontal);
Model {
layout,
panes,
focused: 1,
next_id: 3,
theme: Theme::dark(),
}
}
fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::Focus(id) => model.focused = id,
Msg::Split(axis) => {
let id = model.next_id;
model.next_id += 1;
let kind = if id % 2 == 0 {
Kind::Counter(0)
} else {
Kind::Notes(vec![])
};
let title = match &kind {
Kind::Counter(_) => "Contador".to_string(),
Kind::Notes(_) => "Notas".to_string(),
};
model.panes.insert(id, Pane { title, kind });
model.layout.split(model.focused, id, axis);
model.focused = id;
}
Msg::Close => {
if model.layout.count() > 1 {
let target = model.focused;
let (nl, removed) = model.layout.clone().without(target);
if removed {
model.layout = nl;
model.panes.remove(&target);
model.focused = model.layout.first_leaf();
}
}
}
Msg::Resize(path, d) => model.layout.resize(&path, d),
Msg::Inc(id) => {
if let Some(Pane {
kind: Kind::Counter(n),
..
}) = model.panes.get_mut(&id)
{
*n += 1;
}
}
Msg::Dec(id) => {
if let Some(Pane {
kind: Kind::Counter(n),
..
}) = model.panes.get_mut(&id)
{
*n -= 1;
}
}
Msg::AddNote(id) => {
if let Some(Pane {
kind: Kind::Notes(v),
..
}) = model.panes.get_mut(&id)
{
let n = v.len() + 1;
v.push(format!("nota #{n}"));
}
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let t = &model.theme;
let toolbar = View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(8.0),
height: length(8.0),
},
padding: uniform(8.0),
flex_shrink: 0.0,
..Default::default()
})
.fill(t.bg_panel)
.children(vec![
button("Split →", Msg::Split(Axis::Horizontal), t),
button("Split ↓", Msg::Split(Axis::Vertical), t),
button("Cerrar", Msg::Close, t),
View::new(Style {
flex_grow: 1.0,
..Default::default()
}),
label(
format!("foco #{} · {} paneles", model.focused, model.layout.count()),
13.0,
t.fg_muted,
),
]);
let palette = PanesPalette::from_theme(t);
let panes = &model.panes;
let theme = t;
let area = panes_view(
&model.layout,
model.focused,
move |id| render_pane(panes, theme, id),
|path, phase, d| {
let _ = phase;
Some(Msg::Resize(path, d))
},
Msg::Focus,
&palette,
);
let area_wrap = View::new(Style {
flex_grow: 1.0,
size: Size {
width: percent(1.0),
height: percent(1.0),
},
min_size: Size {
width: length(0.0),
height: length(0.0),
},
..Default::default()
})
.children(vec![area]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
})
.fill(t.bg_app)
.children(vec![toolbar, area_wrap])
}
}
fn render_pane(panes: &HashMap<PaneId, Pane>, t: &Theme, id: PaneId) -> View<Msg> {
let Some(pane) = panes.get(&id) else {
return label("(panel vacío)".to_string(), 14.0, t.fg_muted);
};
let header = label(format!("{} #{id}", pane.title), 13.0, t.fg_text);
let body = match &pane.kind {
Kind::Counter(n) => View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(8.0),
height: length(8.0),
},
..Default::default()
})
.children(vec![
label(format!("{n}"), 44.0, t.accent),
View::new(Style {
flex_direction: FlexDirection::Row,
gap: Size {
width: length(8.0),
height: length(8.0),
},
..Default::default()
})
.children(vec![
button("", Msg::Dec(id), t),
button("+", Msg::Inc(id), t),
]),
]),
Kind::Notes(v) => {
let mut lines: Vec<View<Msg>> = v
.iter()
.map(|s| label(format!("{s}"), 14.0, t.fg_text))
.collect();
lines.push(button("+ nota", Msg::AddNote(id), t));
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(6.0),
height: length(6.0),
},
..Default::default()
})
.children(lines)
}
};
View::new(Style {
flex_direction: FlexDirection::Column,
gap: Size {
width: length(10.0),
height: length(10.0),
},
padding: uniform(12.0),
flex_grow: 1.0,
..Default::default()
})
.children(vec![header, body])
}
fn button(text: &str, msg: Msg, t: &Theme) -> View<Msg> {
View::new(Style {
padding: Rect {
left: length(12.0),
right: length(12.0),
top: length(6.0),
bottom: length(6.0),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(t.bg_button)
.hover_fill(t.bg_button_hover)
.radius(6.0)
.on_click(msg)
.children(vec![label(text.to_string(), 14.0, t.fg_text)])
}
fn label(
text: String,
size: f32,
color: llimphi_ui::llimphi_raster::peniko::Color,
) -> View<Msg> {
View::new(Style::default()).text(text, size, color)
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+505
View File
@@ -0,0 +1,505 @@
//! `llimphi-widget-panes` — árbol de paneles BSP estilo tmux.
//!
//! La pieza que faltaba para "montar cualquier componente de gioser 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
//! redimensiona (arrastrando los divisores). tmux, pero in-process y sobre
//! el bucle Elm de Llimphi.
//!
//! No confundir con `llimphi-widget-panel` (el chrome de UN panel con
//! título): esto es el árbol de N panes.
//!
//! ## Modelo
//!
//! - [`Layout`] es la **estructura** del árbol (qué hoja vive dónde, con
//! qué ratio cada split). Vive en el `Model` del host y se manipula con
//! [`Layout::split`], [`Layout::without`] y [`Layout::resize`].
//! - El **contenido** de cada hoja lo provee el host vía un closure
//! `FnMut(PaneId) -> View<Msg>` que se invoca al construir la vista —
//! por eso puede tomar prestado el `Model` (no necesita ser `'static`).
//! - El handler de resize sí se guarda en el árbol de vistas (lo agarra el
//! divisor draggable), así que ése debe ser `'static + Send + Sync`. El
//! de focus se evalúa al construir (porque `on_click` toma el `Msg` por
//! valor), así que no tiene esa restricción.
//!
//! ## Por qué no `Box<dyn Any>`
//!
//! Igual que el resto del repo: el host mantiene un `enum` de sus tipos de
//! panel y hace dispatch estático. El widget es genérico sobre `Msg`; el
//! host decide cómo materializar cada hoja. Cero downcasting.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
/// Identificador estable de un panel. El host lo asigna (un contador
/// monótono basta) y lo usa como llave hacia su propio estado.
pub type PaneId = u64;
/// Eje del split. `Horizontal` pone los panes lado a lado (divisor
/// vertical, se arrastra en X); `Vertical` los apila (divisor horizontal,
/// se arrastra en Y).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
Horizontal,
Vertical,
}
/// Rama de un split, usada para direccionar un nodo dentro del árbol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
First,
Second,
}
/// Árbol binario de paneles. `Leaf` es un panel; `Split` divide el espacio
/// entre dos subárboles con un `ratio` (fracción que ocupa el primero).
#[derive(Debug, Clone, PartialEq)]
pub enum Layout {
Leaf(PaneId),
Split {
axis: Axis,
/// Fracción del eje que ocupa el subárbol `first` (0..1).
ratio: f32,
first: Box<Layout>,
second: Box<Layout>,
},
}
impl Layout {
/// Árbol de un solo panel.
pub fn single(id: PaneId) -> Self {
Layout::Leaf(id)
}
/// Cantidad de hojas (paneles) en el árbol.
pub fn count(&self) -> usize {
match self {
Layout::Leaf(_) => 1,
Layout::Split { first, second, .. } => first.count() + second.count(),
}
}
/// Lista de todas las hojas, en orden de aparición (izq→der / arr→ab).
pub fn leaves(&self) -> Vec<PaneId> {
let mut out = Vec::new();
self.collect_leaves(&mut out);
out
}
fn collect_leaves(&self, out: &mut Vec<PaneId>) {
match self {
Layout::Leaf(id) => out.push(*id),
Layout::Split { first, second, .. } => {
first.collect_leaves(out);
second.collect_leaves(out);
}
}
}
/// `true` si la hoja existe en el árbol.
pub fn contains(&self, id: PaneId) -> bool {
match self {
Layout::Leaf(x) => *x == id,
Layout::Split { first, second, .. } => first.contains(id) || second.contains(id),
}
}
/// Primera hoja (la de más arriba/izquierda). Útil para reenfocar tras
/// cerrar un panel.
pub fn first_leaf(&self) -> PaneId {
match self {
Layout::Leaf(id) => *id,
Layout::Split { first, .. } => first.first_leaf(),
}
}
/// Parte la hoja `target` en dos: `target` queda en `Side::First` y la
/// nueva hoja `new` en `Side::Second`, con ratio 0.5. Devuelve `true`
/// si encontró el target.
pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool {
match self {
Layout::Leaf(id) if *id == target => {
*self = Layout::Split {
axis,
ratio: 0.5,
first: Box::new(Layout::Leaf(target)),
second: Box::new(Layout::Leaf(new)),
};
true
}
Layout::Leaf(_) => false,
Layout::Split { first, second, .. } => {
first.split(target, new, axis) || second.split(target, new, axis)
}
}
}
/// Devuelve el árbol sin la hoja `target`, colapsando el split padre en
/// el hermano sobreviviente. El `bool` indica si removió algo. Quitar la
/// única hoja raíz es no-op (devuelve el árbol intacto, `false`).
pub fn without(self, target: PaneId) -> (Layout, bool) {
match self {
Layout::Leaf(id) => (Layout::Leaf(id), false),
Layout::Split {
axis,
ratio,
first,
second,
} => {
if matches!(*first, Layout::Leaf(t) if t == target) {
return (*second, true);
}
if matches!(*second, Layout::Leaf(t) if t == target) {
return (*first, true);
}
let (nf, rf) = first.without(target);
if rf {
return (
Layout::Split {
axis,
ratio,
first: Box::new(nf),
second,
},
true,
);
}
let (ns, rs) = second.without(target);
(
Layout::Split {
axis,
ratio,
first: Box::new(nf),
second: Box::new(ns),
},
rs,
)
}
}
}
/// Ajusta el ratio del split direccionado por `path` (camino de raíz a
/// ese nodo). `delta` se suma al ratio, clamp a [0.05, 0.95].
pub fn resize(&mut self, path: &[Side], delta: f32) {
match self {
Layout::Split {
ratio,
first,
second,
..
} => match path.split_first() {
None => *ratio = (*ratio + delta).clamp(0.05, 0.95),
Some((Side::First, rest)) => first.resize(rest, delta),
Some((Side::Second, rest)) => second.resize(rest, delta),
},
Layout::Leaf(_) => {}
}
}
}
/// Ratio movido por píxel arrastrado. No conocemos el tamaño en px del
/// contenedor en tiempo de `view` (limitación conocida de Llimphi, la
/// misma raíz por la que no hay `View::map`), así que aproximamos con una
/// sensibilidad fija. El clamp en [`Layout::resize`] evita degenerar.
const RESIZE_SENSITIVITY: f32 = 1.0 / 600.0;
/// Paleta del árbol de paneles.
#[derive(Debug, Clone, Copy)]
pub struct PanesPalette {
pub bg: Color,
pub border: Color,
pub focus_border: Color,
pub divider: Color,
pub divider_hover: Color,
pub thickness: f32,
}
impl Default for PanesPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl PanesPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_app,
border: t.border,
focus_border: t.accent,
divider: t.border,
divider_hover: t.accent,
thickness: 6.0,
}
}
}
/// Renderiza el árbol de paneles.
///
/// - `leaf` materializa el contenido de cada hoja; se llama una vez por
/// panel mientras se construye la vista (puede tomar prestado el host).
/// - `on_resize` recibe el camino al split, la fase del drag y el delta de
/// ratio; devolver `Some(msg)` dispara el `update` (el host llama
/// [`Layout::resize`]).
/// - `on_focus` produce el msg al hacer click en un panel.
pub fn panes_view<Msg>(
layout: &Layout,
focused: PaneId,
mut leaf: impl FnMut(PaneId) -> View<Msg>,
on_resize: impl Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
on_focus: impl Fn(PaneId) -> Msg,
palette: &PanesPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let on_resize: Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync> =
Arc::new(on_resize);
render(
layout,
focused,
&mut leaf,
&on_resize,
&on_focus,
Vec::new(),
palette,
)
}
#[allow(clippy::too_many_arguments)]
fn render<Msg>(
layout: &Layout,
focused: PaneId,
leaf: &mut dyn FnMut(PaneId) -> View<Msg>,
on_resize: &Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync>,
on_focus: &dyn Fn(PaneId) -> Msg,
path: Vec<Side>,
palette: &PanesPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
match layout {
Layout::Leaf(id) => {
let id = *id;
let content = leaf(id);
let is_focused = id == focused;
let border_col = if is_focused {
palette.focus_border
} else {
palette.border
};
let border_w = if is_focused { 2.0 } else { 1.0 };
// Caja interior (fondo del panel) con el contenido del host.
let inner = View::new(Style {
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
size: full(),
min_size: zero(),
..Default::default()
})
.fill(palette.bg)
.children(vec![content]);
// Marco: no hay `stroke`, así que el borde es un contenedor
// relleno con un padding del grosor → simula el trazo.
View::new(Style {
flex_direction: FlexDirection::Column,
size: full(),
min_size: zero(),
padding: uniform(border_w),
..Default::default()
})
.fill(border_col)
.on_click(on_focus(id))
.children(vec![inner])
}
Layout::Split {
axis,
ratio,
first,
second,
} => {
let flex_dir = match axis {
Axis::Horizontal => FlexDirection::Row,
Axis::Vertical => FlexDirection::Column,
};
let mut p1 = path.clone();
p1.push(Side::First);
let mut p2 = path.clone();
p2.push(Side::Second);
let a = render(first, focused, leaf, on_resize, on_focus, p1, palette);
let b = render(second, focused, leaf, on_resize, on_focus, p2, palette);
let pane_a = grow_pane(a, *ratio);
let pane_b = grow_pane(b, 1.0 - *ratio);
let divider = divider_view(*axis, palette, on_resize.clone(), path.clone());
View::new(Style {
flex_direction: flex_dir,
size: full(),
min_size: zero(),
..Default::default()
})
.children(vec![pane_a, divider, pane_b])
}
}
}
fn grow_pane<Msg>(view: View<Msg>, grow: f32) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
View::new(Style {
flex_grow: grow.max(0.01),
flex_shrink: 1.0,
flex_basis: length(0.0),
size: full(),
min_size: zero(),
..Default::default()
})
.children(vec![view])
}
fn divider_view<Msg>(
axis: Axis,
palette: &PanesPalette,
on_resize: Arc<dyn Fn(Vec<Side>, DragPhase, f32) -> Option<Msg> + Send + Sync>,
path: Vec<Side>,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let (width, height) = match axis {
Axis::Horizontal => (length(palette.thickness), percent(1.0_f32)),
Axis::Vertical => (percent(1.0_f32), length(palette.thickness)),
};
View::new(Style {
size: Size { width, height },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.divider)
.hover_fill(palette.divider_hover)
.draggable(move |phase, dx, dy| {
let main = match axis {
Axis::Horizontal => dx,
Axis::Vertical => dy,
};
(on_resize)(path.clone(), phase, main * RESIZE_SENSITIVITY)
})
}
fn full() -> Size<Dimension> {
Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
}
}
fn zero() -> Size<Dimension> {
Size {
width: length(0.0_f32),
height: length(0.0_f32),
}
}
fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
Rect {
left: length(px),
right: length(px),
top: length(px),
bottom: length(px),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_has_one_leaf() {
let l = Layout::single(1);
assert_eq!(l.count(), 1);
assert_eq!(l.leaves(), vec![1]);
assert_eq!(l.first_leaf(), 1);
}
#[test]
fn split_creates_two_leaves() {
let mut l = Layout::single(1);
assert!(l.split(1, 2, Axis::Horizontal));
assert_eq!(l.count(), 2);
assert_eq!(l.leaves(), vec![1, 2]);
assert!(l.contains(2));
}
#[test]
fn split_missing_target_is_noop() {
let mut l = Layout::single(1);
assert!(!l.split(99, 2, Axis::Vertical));
assert_eq!(l.count(), 1);
}
#[test]
fn nested_split_then_close_collapses() {
let mut l = Layout::single(1);
l.split(1, 2, Axis::Horizontal);
l.split(2, 3, Axis::Vertical); // 2 se parte en [2 / 3]
assert_eq!(l.leaves(), vec![1, 2, 3]);
let (l, removed) = l.without(3);
assert!(removed);
assert_eq!(l.leaves(), vec![1, 2]);
let (l, removed) = l.without(1);
assert!(removed);
assert_eq!(l.leaves(), vec![2]);
let (l, removed) = l.without(2);
assert!(!removed);
assert_eq!(l.leaves(), vec![2]);
}
#[test]
fn resize_adjusts_ratio_with_clamp() {
let mut l = Layout::single(1);
l.split(1, 2, Axis::Horizontal);
l.resize(&[], 0.2);
if let Layout::Split { ratio, .. } = &l {
assert!((ratio - 0.7).abs() < 1e-6);
} else {
panic!("esperaba split");
}
l.resize(&[], -10.0);
if let Layout::Split { ratio, .. } = &l {
assert!((ratio - 0.05).abs() < 1e-6);
}
}
#[test]
fn resize_nested_path() {
let mut l = Layout::single(1);
l.split(1, 2, Axis::Horizontal);
l.split(2, 3, Axis::Vertical);
l.resize(&[Side::Second], 0.1);
if let Layout::Split { second, .. } = &l {
if let Layout::Split { ratio, .. } = second.as_ref() {
assert!((ratio - 0.6).abs() < 1e-6);
return;
}
}
panic!("estructura inesperada");
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-progress"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-progress — barras de progreso lineales y radiales determinadas (0.0-1.0). Para indeterminadas usar llimphi-widget-spinner."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+126
View File
@@ -0,0 +1,126 @@
//! `llimphi-widget-progress` — progreso determinado, lineal o radial.
//!
//! Determinado = la app conoce el porcentaje (`0.0..=1.0`). Para
//! progreso indeterminado (la op está corriendo, no sé cuánto falta),
//! usar `llimphi-widget-spinner`.
//!
//! Dos formas:
//! - [`linear_progress_view`] — barra horizontal con relleno proporcional.
//! - [`radial_progress_view`] — anillo cuya porción llena indica el avance.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::radius;
/// Barra horizontal: una pista (`track`) con un fill proporcional al
/// `progress` (0.0..=1.0) pintado encima.
pub fn linear_progress_view<Msg: Clone + 'static>(
progress: f32,
track_color: Color,
fill_color: Color,
height_px: f32,
) -> View<Msg> {
let p = progress.clamp(0.0, 1.0);
let fill_radius = radius::XS;
let fill = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(0.0_f32),
right: llimphi_ui::llimphi_layout::taffy::prelude::auto(),
bottom: length(0.0_f32),
},
size: Size {
width: percent(p),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(fill_color)
.radius(fill_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior sobre la porción rellena — la barra deja de
// leerse como un rect plano y se siente como una luz que avanza.
// Mismo patrón que button/badge (P6).
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, fill_radius);
let top = Color::from_rgba8(255, 255, 255, 50);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
});
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(height_px),
},
..Default::default()
})
.fill(track_color)
.radius(radius::XS)
.children(vec![fill])
}
/// Anillo cuya porción angular llena indica el avance. Empieza desde
/// arriba (12 en punto) y gira en sentido horario, igual que la
/// convención de relojes y muchos progress radiales.
pub fn radial_progress_view<Msg: Clone + 'static>(
progress: f32,
track_color: Color,
fill_color: Color,
stroke_width_ratio: f32,
) -> View<Msg> {
let p = progress.clamp(0.0, 1.0);
let sw = stroke_width_ratio;
View::new(Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
let side = rect.w.min(rect.h) as f64;
if side <= 0.0 {
return;
}
let cx = rect.x as f64 + rect.w as f64 * 0.5;
let cy = rect.y as f64 + rect.h as f64 * 0.5;
let stroke_w = (side * sw as f64).max(1.0);
let radius = (side - stroke_w) * 0.5;
let stroke = Stroke::new(stroke_w).with_caps(Cap::Round);
// Track completo (anillo gris).
let track = Arc::new((cx, cy), (radius, radius), 0.0, std::f64::consts::TAU, 0.0);
scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &track);
// Arco lleno — arranca en -π/2 (12 en punto) y barre `p * 2π`
// en sentido horario (positivo en el sistema y-down de vello).
if p > 0.0 {
let theta0 = -std::f64::consts::FRAC_PI_2;
let sweep = std::f64::consts::TAU * p as f64;
let fill_arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0);
scene.stroke(&stroke, Affine::IDENTITY, fill_color, None, &fill_arc);
}
})
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-scroll"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-scroll — área de scroll vertical reutilizable: viewport clipeado + contenido desplazado + barra arrastrable. Stateless (el offset vive en el Model); rueda autocontenida vía View::on_scroll. Helpers puros: clamp_offset, ensure_visible, approach (scroll suave)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+326
View File
@@ -0,0 +1,326 @@
//! `llimphi-widget-scroll` — área de scroll vertical reutilizable.
//!
//! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un
//! offset en el `Model` + `clip` + virtualización. Este widget empaqueta
//! ese patrón en un solo builder, **sin estado propio** (el offset sigue
//! viviendo en el `Model`, fiel al bucle Elm):
//!
//! - **viewport clipeado** de alto fijo (`viewport_len`),
//! - **contenido desplazado** `-offset` px (overflow recortado),
//! - **barra de scroll arrastrable** a la derecha (sólo si el contenido
//! excede el viewport),
//! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con
//! el cursor sobre el área emite un `Msg` sin que la app rutee nada por
//! su `on_wheel` global.
//!
//! El caller debe conocer el **alto total del contenido** (`content_len`)
//! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya
//! piden la ventana visible. Para contenido de filas uniformes es
//! `n_filas * alto_fila`.
//!
//! ## Convención del callback `on_scroll`
//!
//! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset
//! absoluto): tanto la rueda como el arrastre de la barra emiten deltas,
//! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es
//! la misma idea que el `splitter` (el handler de drag se reusa durante
//! todo el arrastre, así que un offset absoluto capturado se quedaría
//! viejo; el delta-por-evento siempre es correcto).
//!
//! ```ignore
//! // view:
//! scroll_y(
//! model.offset,
//! model.rows.len() as f32 * ROW_H,
//! panel_h,
//! lista_view,
//! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px
//! &ScrollPalette::default(),
//! )
//! // update:
//! Msg::ScrollBy(d) => {
//! m.offset = clamp_offset(m.offset + d, content_len, viewport_len);
//! }
//! ```
//!
//! Para llevar una selección a la vista (teclado), ver [`ensure_visible`];
//! para scroll suave/inercia, ver [`approach`].
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, Position, Rect, Size, Style},
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
/// Alto mínimo del thumb en px — para que no desaparezca con contenido
/// muy largo.
const MIN_THUMB: f32 = 28.0;
/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll
/// de un editor (≈3 líneas de texto).
pub const DEFAULT_LINE_PX: f32 = 48.0;
/// Ancho de la barra de scroll en px.
pub const DEFAULT_BAR_WIDTH: f32 = 10.0;
/// Colores de la barra de scroll.
#[derive(Debug, Clone, Copy)]
pub struct ScrollPalette {
/// Canal de fondo (track).
pub track: Color,
/// Pulgar (thumb) en reposo.
pub thumb: Color,
/// Pulgar al pasar el cursor.
pub thumb_hover: Color,
/// Ancho de la barra y px por línea de rueda.
pub bar_width: f32,
pub line_px: f32,
}
impl Default for ScrollPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl ScrollPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
track: t.bg_panel_alt,
thumb: t.border,
thumb_hover: t.accent,
bar_width: DEFAULT_BAR_WIDTH,
line_px: DEFAULT_LINE_PX,
}
}
}
/// Máximo offset posible: cuánto se puede desplazar antes de que el final
/// del contenido toque el borde inferior del viewport. `0` si el contenido
/// entra entero.
pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 {
(content_len - viewport_len).max(0.0)
}
/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update`
/// tras sumar el delta de [`scroll_y`].
pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 {
offset.clamp(0.0, max_offset(content_len, viewport_len))
}
/// Devuelve el offset que deja **visible** el intervalo vertical
/// `[item_top, item_top + item_h]` dentro de un viewport de alto
/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve
/// sin cambios. Pensado para "llevar la selección a la vista" al navegar
/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el
/// caller puede clampear arriba con [`clamp_offset`] si lo necesita.
pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 {
if item_top < offset {
// El item arranca por encima del viewport: subí hasta su tope.
item_top.max(0.0)
} else if item_top + item_h > offset + viewport_len {
// El item termina por debajo: bajá hasta que su fondo toque el borde.
(item_top + item_h - viewport_len).max(0.0)
} else {
offset
}
}
/// Un paso de aproximación exponencial de `current` hacia `target`
/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2
/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza
/// exacto en `target` (evita el "casi-llega" infinito). El caller lo
/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en
/// su `Model`.
pub fn approach(current: f32, target: f32, factor: f32) -> f32 {
let f = factor.clamp(0.0, 1.0);
let next = current + (target - current) * f;
if (target - next).abs() < 0.5 {
target
} else {
next
}
}
/// 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
/// quieran pintar su propia barra.
pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) {
let max_off = max_offset(content_len, viewport_len);
if max_off <= 0.0 || content_len <= 0.0 {
return (viewport_len, 0.0, 0.0);
}
let ratio = (viewport_len / content_len).clamp(0.0, 1.0);
let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len);
let travel = (viewport_len - thumb_h).max(0.0);
let thumb_y = if max_off > 0.0 {
(offset / max_off).clamp(0.0, 1.0) * travel
} else {
0.0
};
let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 };
(thumb_h, thumb_y, offset_per_px)
}
/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya
/// clampeado por el caller). `content_len`/`viewport_len` el alto total y
/// visible. `content` se desplaza `-offset` y se recorta al viewport.
/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda
/// y arrastre de barra); el caller acumula con [`clamp_offset`].
pub fn scroll_y<Msg, F>(
offset: f32,
content_len: f32,
viewport_len: f32,
content: View<Msg>,
on_scroll: F,
palette: &ScrollPalette,
) -> View<Msg>
where
// `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre
// capturan el `Arc<dyn Fn + Send + Sync>`, no un `Msg`. Sólo se exige
// `Clone` (para montar el `View`) y `'static`.
Msg: Clone + 'static,
F: Fn(f32) -> Msg + Send + Sync + 'static,
{
let on_scroll = Arc::new(on_scroll);
// Contenido desplazado: nodo absoluto anclado a left/right (toma el
// ancho del viewport) con top = -offset y alto natural. El overflow se
// recorta por el `clip` del viewport.
let content_wrap = View::new(Style {
position: Position::Absolute,
inset: Rect {
top: length(-offset),
left: length(0.0),
right: length(0.0),
bottom: auto(),
},
..Default::default()
})
.children(vec![content]);
let mut children = vec![content_wrap];
// Barra: sólo si hay overflow.
if max_offset(content_len, viewport_len) > 0.0 {
let (thumb_h, thumb_y, offset_per_px) =
thumb_geometry(offset, content_len, viewport_len);
let on_thumb = 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)
.hover_fill(palette.thumb_hover)
.radius((palette.bar_width * 0.5) as f64)
.draggable(move |phase, _dx, dy| match phase {
// Cada Move trae el delta de px de pantalla del thumb; lo
// convertimos a delta de offset de contenido.
DragPhase::Move => Some((on_thumb)(dy * offset_per_px)),
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);
}
// Viewport: alto fijo, ancho del padre, contenido recortado, rueda
// local. Position::Relative para ser el bloque contenedor de los
// hijos absolutos.
let line_px = palette.line_px;
let on_wheel = on_scroll;
View::new(Style {
position: Position::Relative,
size: Size {
width: percent(1.0),
height: length(viewport_len),
},
..Default::default()
})
.clip(true)
.on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px)))
.children(children)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_y_clamp() {
assert_eq!(max_offset(1000.0, 300.0), 700.0);
assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero
assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0);
assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0);
assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0);
}
#[test]
fn ensure_visible_arriba_abajo_y_sin_cambio() {
let vp = 300.0;
// Item por encima del offset → subir hasta su tope.
assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0);
// Item por debajo del fondo visible → bajar lo justo.
assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300
// Item ya visible → sin cambios.
assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0);
// Nunca negativo.
assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0);
}
#[test]
fn approach_aterriza_exacto() {
// Se acerca pero no salta.
let a = approach(0.0, 100.0, 0.25);
assert!(a > 0.0 && a < 100.0);
// Diferencia < 0.5 px → aterriza exacto.
assert_eq!(approach(99.8, 100.0, 0.25), 100.0);
// factor 1.0 salta de una.
assert_eq!(approach(0.0, 100.0, 1.0), 100.0);
}
#[test]
fn thumb_proporcional_y_topes() {
// Contenido entra entero → thumb cubre todo, sin travel.
let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0);
assert_eq!((h, y, opp), (300.0, 0.0, 0.0));
// Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB).
let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0);
assert!((h - 100.0).abs() < 0.01);
assert_eq!(y, 0.0);
// En el máximo offset, el thumb toca el fondo del track.
let max = max_offset(900.0, 300.0);
let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0);
assert!((y2 + h2 - 300.0).abs() < 0.01);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-segmented"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-segmented — control de opciones mutuamente exclusivas (radio horizontal). Para 2-5 opciones en línea."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+143
View File
@@ -0,0 +1,143 @@
//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas.
//!
//! N opciones horizontales con UNA activa. Patrón iOS/macOS para
//! alternativas radio-style cuando son pocas (2-5) y caben en línea.
//! Si son más, usar un `tabs` o un dropdown.
//!
//! Render-only: la app guarda `selected: usize` en el modelo y
//! dispatcha `Msg::SelectSegment(usize)` al click.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, 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::{radius, Theme};
/// Paleta del control.
#[derive(Debug, Clone, Copy)]
pub struct SegmentedPalette {
pub bg_track: Color,
pub bg_active: Color,
pub fg_active: Color,
pub fg_inactive: Color,
pub fg_hover: Color,
}
impl SegmentedPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
bg_track: t.bg_button,
bg_active: t.bg_panel,
fg_active: t.fg_text,
fg_inactive: t.fg_muted,
fg_hover: t.fg_text,
}
}
}
/// Construye el control. `labels` son los textos visibles; `selected`
/// es el índice activo (0-based). `make_msg(i)` se llama al click.
pub fn segmented_view<Msg, F>(
labels: &[&str],
selected: usize,
make_msg: F,
palette: &SegmentedPalette,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize) -> Msg,
{
let children: Vec<View<Msg>> = labels
.iter()
.enumerate()
.map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette))
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
padding: Rect {
left: length(2.0_f32),
right: length(2.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
gap: Size {
width: length(2.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.fill(palette.bg_track)
.radius(radius::SM)
.children(children)
}
fn segment_view<Msg: Clone + 'static>(
_idx: usize,
label: &str,
is_active: bool,
msg: Msg,
palette: &SegmentedPalette,
) -> View<Msg> {
let (bg, fg) = if is_active {
(Some(palette.bg_active), palette.fg_active)
} else {
(None, palette.fg_inactive)
};
let seg_radius = radius::XS;
let mut node = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::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()
})
.radius(seg_radius)
.text_aligned(label.to_string(), 11.5, fg, Alignment::Center)
.on_click(msg);
if let Some(c) = bg {
node = node.fill(c).paint_with(move |scene, _ts, rect| {
// Gloss superior sólo en el segmento activo — refuerza
// "esto está seleccionado" con la misma firma de button (P6).
// Los segmentos inactivos quedan planos para que el contraste
// sea inequívoco.
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius);
let top = Color::from_rgba8(255, 255, 255, 28);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
});
}
node
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-shortcuts-help"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-shortcuts-help — overlay '?' que muestra los atajos de teclado del contexto actual, agrupados por categoría."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+282
View File
@@ -0,0 +1,282 @@
//! `llimphi-widget-shortcuts-help` — overlay de atajos de teclado.
//!
//! Convención "press ? for help": cuando el usuario aprieta `?`,
//! aparece un panel centrado con todos los atajos del contexto actual
//! agrupados por categoría. Cualquier tecla cierra (la app maneja eso).
//!
//! La app construye un `ShortcutsHelpSpec` con grupos y entries, lo
//! guarda en su modelo cuando se abre, y lo devuelve desde
//! `view_overlay`.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Position, 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::{alpha, radius, Theme};
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
/// Paleta del overlay.
#[derive(Debug, Clone, Copy)]
pub struct ShortcutsHelpPalette {
pub scrim: Color,
/// Firma del panel (gradient + hairline accent en top edge).
pub panel: PanelStyle,
pub border: Color,
pub fg_title: Color,
pub fg_group: Color,
pub fg_desc: Color,
pub fg_key: Color,
pub bg_key: Color,
}
impl ShortcutsHelpPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM),
panel: PanelStyle::from_theme_large(t),
border: t.border,
fg_title: t.fg_text,
fg_group: t.accent,
fg_desc: t.fg_text,
fg_key: t.fg_text,
bg_key: t.bg_button,
}
}
}
/// Una entrada de atajo: combinación de teclas + descripción de qué hace.
#[derive(Debug, Clone)]
pub struct ShortcutEntry {
/// La combinación tal como aparece (ej. `"Ctrl+S"`, `"⌘K ⌘P"`, `"?"`).
pub keys: String,
pub description: String,
}
impl ShortcutEntry {
pub fn new(keys: impl Into<String>, description: impl Into<String>) -> Self {
Self { keys: keys.into(), description: description.into() }
}
}
/// Grupo de atajos con un título (ej. "Edición", "Navegación").
#[derive(Debug, Clone)]
pub struct ShortcutGroup {
pub title: String,
pub entries: Vec<ShortcutEntry>,
}
impl ShortcutGroup {
pub fn new(title: impl Into<String>, entries: Vec<ShortcutEntry>) -> Self {
Self { title: title.into(), entries }
}
}
/// Spec completo del overlay.
pub struct ShortcutsHelpSpec<Msg: Clone + 'static> {
pub title: String,
pub groups: Vec<ShortcutGroup>,
pub viewport: (f32, f32),
pub on_dismiss: Msg,
pub palette: ShortcutsHelpPalette,
}
const PANEL_W: f32 = 480.0;
const TITLE_FONT: f32 = 16.0;
const GROUP_FONT: f32 = 11.5;
const ENTRY_FONT: f32 = 12.0;
const ENTRY_H: f32 = 22.0;
const GROUP_H: f32 = 24.0;
const TITLE_H: f32 = 40.0;
const PAD: f32 = 20.0;
pub fn shortcuts_help_view<Msg: Clone + 'static>(spec: ShortcutsHelpSpec<Msg>) -> View<Msg> {
let ShortcutsHelpSpec { title, groups, viewport, on_dismiss, palette } = spec;
// Altura del panel — suma de header + grupos.
let body_h: f32 = groups
.iter()
.map(|g| GROUP_H + g.entries.len() as f32 * ENTRY_H + 8.0)
.sum();
let panel_h = (TITLE_H + body_h + PAD * 2.0).min(viewport.1 - 32.0);
let panel_w = PANEL_W.min(viewport.0 - 32.0);
let x = ((viewport.0 - panel_w) * 0.5).max(0.0);
let y = ((viewport.1 - panel_h) * 0.5).max(0.0);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(TITLE_H),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start);
let mut body_children: Vec<View<Msg>> = Vec::with_capacity(groups.len() * 6);
for group in &groups {
body_children.push(group_header_view(&group.title, &palette));
for entry in &group.entries {
body_children.push(entry_view(entry, &palette));
}
}
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.children(body_children);
let panel = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(panel_w),
height: length(panel_h),
},
flex_direction: FlexDirection::Column,
padding: Rect {
left: length(PAD),
right: length(PAD),
top: length(PAD),
bottom: length(PAD),
},
..Default::default()
})
.paint_with(panel_signature_painter(palette.panel))
.radius(palette.panel.radius)
.clip(true)
.children(vec![header, body]);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.scrim)
.on_click(on_dismiss)
.children(vec![panel])
}
fn group_header_view<Msg: Clone + 'static>(
title: &str,
palette: &ShortcutsHelpPalette,
) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(GROUP_H),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(8.0_f32),
bottom: length(2.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(
title.to_uppercase(),
GROUP_FONT,
palette.fg_group,
Alignment::Start,
)
}
fn entry_view<Msg: Clone + 'static>(
entry: &ShortcutEntry,
palette: &ShortcutsHelpPalette,
) -> View<Msg> {
let desc = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(
entry.description.clone(),
ENTRY_FONT,
palette.fg_desc,
Alignment::Start,
);
let key_radius = radius::XS;
let keys = View::new(Style {
size: Size {
width: length(140.0_f32),
height: length(ENTRY_H - 6.0),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexEnd),
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_key)
.radius(key_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior — el chip de teclado se lee como tecla con
// luz cayendo desde el top, no como rect plano. Mismo patrón
// que button (P6) — todo chip clicable o tipo-tecla comparte
// la firma.
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, key_radius);
let top = Color::from_rgba8(255, 255, 255, 28);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
})
.text_aligned(entry.keys.clone(), ENTRY_FONT - 1.0, palette.fg_key, Alignment::End);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(ENTRY_H),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(10.0_f32),
height: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![desc, keys])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-skeleton"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-skeleton — bloque animado con shimmer para placeholders de contenido en carga. Alternativa a spinner cuando se conoce la forma del contenido (lista de N items, card con título+texto+imagen)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+145
View File
@@ -0,0 +1,145 @@
//! `llimphi-widget-skeleton` — placeholder de carga con shimmer.
//!
//! Cuando una pantalla está cargando contenido cuya forma es predecible
//! (ej. una lista de 5 cards, un avatar+nombre+timestamp), un skeleton
//! es más informativo que un spinner: el usuario ya ve QUÉ vendrá,
//! sólo no tiene los valores reales todavía.
//!
//! El brillo (shimmer) viene de una **banda de gradiente que cruza** el
//! rect de izquierda a derecha cíclicamente. Los stops son
//! `[low, high, low]` sobre una franja del ~50% del ancho, con `Extend::Pad`
//! por default — fuera de la banda el rect queda en `low`, dentro el
//! `high` pinta el destello. Es el patrón canónico de Material/Apple/
//! sistemas modernos, más legible que la oscilación uniforme previa.
//!
//! Como `spinner`, requiere que la app fuerce redraws periódicos para
//! que la animación corra (típico: `Handle::spawn_periodic(50ms, …)`
//! mientras hay skeletons visibles).
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::{radius, Theme};
/// Paleta del skeleton — dos tonos entre los que oscila.
#[derive(Debug, Clone, Copy)]
pub struct SkeletonPalette {
pub low: Color,
pub high: Color,
}
impl SkeletonPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
low: t.bg_panel_alt,
high: t.bg_button_hover,
}
}
}
/// Período del shimmer en segundos — un ciclo completo de la banda
/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar
/// "esto se está cargando", lento para no marear.
const SHIMMER_CYCLE_SECS: f32 = 1.4;
/// Ancho de la banda como fracción del ancho del rect. 50% da una
/// transición suave; bajar a 30% da un destello más puntual.
const SHIMMER_BAND_FRAC: f64 = 0.5;
/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos
/// (avatares chicos, line skeletons de ~80px) el destello sea un
/// pixel apretado.
const SHIMMER_BAND_MIN_PX: f64 = 40.0;
/// Bloque rectangular animado. La altura y forma viene del `Style`
/// que pasa el caller — el skeleton sólo aporta el `fill` animado.
///
/// Devuelve un `View` con `paint_with` que pinta una banda de
/// gradiente atravesando el rect. Para usarlo dentro de un layout
/// con tamaño definido, envolvelo en un contenedor con el `Style`
/// adecuado.
pub fn skeleton_view<Msg: Clone + 'static>(palette: &SkeletonPalette) -> View<Msg> {
let started = Instant::now();
let p = *palette;
View::new(Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.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;
}
// Progress del ciclo en [0, 1).
let elapsed = started.elapsed().as_secs_f32();
let progress = (elapsed / SHIMMER_CYCLE_SECS).fract() as f64;
// Banda: ancho relativo al rect (con floor mínimo) que arranca
// a la izquierda del rect y termina a la derecha. Distancia
// total recorrida = rect.w + band_w, así el destello entra y
// sale por completo.
let rect_w = rect.w as f64;
let band_w = (rect_w * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX);
let travel = rect_w + band_w;
let band_left = rect.x as f64 - band_w + progress * travel;
let band_right = band_left + band_w;
let cy = (rect.y + rect.h * 0.5) as f64;
// Single fill: gradient lineal con stops [low, high, low]. Fuera
// de [band_left, band_right] el Extend::Pad (default de peniko)
// extiende los stops endpoint — ambos `low` — así el resto del
// rect queda en `low` sin necesidad de un fill base separado.
let rr = RoundedRect::new(
rect.x as f64,
rect.y as f64,
(rect.x + rect.w) as f64,
(rect.y + rect.h) as f64,
radius::SM,
);
let gradient = Gradient::new_linear(
Point::new(band_left, cy),
Point::new(band_right, cy),
)
.with_stops([p.low, p.high, p.low].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
})
}
/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro.
/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`).
pub fn skeleton_box_view<Msg: Clone + 'static>(
width_px: f32,
height_px: f32,
palette: &SkeletonPalette,
) -> View<Msg> {
View::new(Style {
size: Size {
width: length(width_px),
height: length(height_px),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![skeleton_view(palette)])
}
/// Línea horizontal típica para texto en carga (height fijo ~12px).
pub fn skeleton_line_view<Msg: Clone + 'static>(
width_px: f32,
palette: &SkeletonPalette,
) -> View<Msg> {
skeleton_box_view(width_px, 12.0, palette)
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-widget-slider"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-slider — slider horizontal con etiqueta + track draggable + valor numérico. El track es un fillbar (sin pulgar): cambia el ancho relleno según la fracción `(value-min)/(max-min)`. El drag emite el delta de valor (no pixels) en cada `Move`, listo para reentrar al update."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[[example]]
name = "slider_demo"
path = "examples/slider_demo.rs"
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-slider
> Slider con tick marks para [llimphi](../../README.md).
Horizontal/vertical. Range custom, snap-to-ticks opcional, label de valor en vivo. Continuous y stepped variants.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-slider
> Slider with tick marks for [llimphi](../../README.md).
Horizontal/vertical. Custom range, optional snap-to-ticks, live value label. Continuous and stepped variants.
+130
View File
@@ -0,0 +1,130 @@
//! Showcase de `llimphi-widget-slider`: tres sliders sobre un Model que
//! acumula deltas en vivo. Corré con:
//!
//! ```text
//! cargo run -p llimphi-widget-slider --example slider_demo
//! ```
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_widget_slider::{slider_view, SliderPalette};
#[derive(Clone, Debug)]
enum Msg {
EditPsique(f32),
EditMateria(f32),
EditPoder(f32),
}
struct Model {
psique: f32,
materia: f32,
poder: f32,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · slider demo"
}
fn initial_size() -> (u32, u32) {
(520, 280)
}
fn init(_: &Handle<Msg>) -> Model {
Model { psique: 0.0, materia: 0.5, poder: -0.25 }
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::EditPsique(dv) => m.psique = (m.psique + dv).clamp(-1.0, 1.0),
Msg::EditMateria(dv) => m.materia = (m.materia + dv).clamp(-1.0, 1.0),
Msg::EditPoder(dv) => m.poder = (m.poder + dv).clamp(-1.0, 1.0),
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette = SliderPalette::from_theme(&theme);
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"ajustá los sliders — el Model acumula deltas en vivo".to_string(),
13.0,
theme.fg_text,
Alignment::Start,
);
let psique = slider_view(
"psique",
model.psique,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditPsique(dv)),
DragPhase::End => None,
},
);
let materia = slider_view(
"materia",
model.materia,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditMateria(dv)),
DragPhase::End => None,
},
);
let poder = slider_view(
"poder",
model.poder,
-1.0,
1.0,
&palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditPoder(dv)),
DragPhase::End => None,
},
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
align_items: Some(AlignItems::Stretch),
..Default::default()
})
.fill(theme.bg_app)
.children(vec![header, psique, materia, poder])
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+254
View File
@@ -0,0 +1,254 @@
//! `llimphi-widget-slider` — slider horizontal con label + track + valor.
//!
//! Pattern análogo a `llimphi-widget-splitter`: el widget no mantiene
//! estado. El caller guarda el valor actual en su `Model` y le pasa un
//! handler `Fn(DragPhase, f32) -> Option<Msg>` que recibe **el delta de
//! valor** (no el delta de pixels) entre eventos consecutivos. El widget
//! traduce internamente `dx_pixels` a `dv` usando `track_width`.
//!
//! Visualmente es un *fillbar*: el track entero es draggable y se rellena
//! una fracción proporcional a `(value - min) / (max - min)`. No hay
//! pulgar separado — el límite entre relleno y vacío es el indicador.
//!
//! Layout fila:
//!
//! ```text
//! [ label_width ] [ ████░░░░░░ ] [ value_width ]
//! "psique" 0.4 / 1.0 " 0.40"
//! ```
//!
//! Uso típico (sliders sobre `LayerMods` de un Concepto):
//!
//! ```ignore
//! slider_view(
//! "psique",
//! model.selected.mods.psique,
//! -1.0, 1.0,
//! &palette,
//! |phase, dv| match phase {
//! DragPhase::Move => Some(Msg::EditMod(Layer::Psique, dv)),
//! DragPhase::End => None,
//! },
//! )
//! ```
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, View};
/// Paleta del slider. Las dimensiones también viajan acá porque definen
/// el layout fila — el caller no toca el `Style` del slider directamente.
#[derive(Debug, Clone, Copy)]
pub struct SliderPalette {
pub track: Color,
pub track_filled: Color,
pub track_hover: Color,
pub fg_label: Color,
pub fg_value: Color,
pub radius: f64,
/// Alto total del widget en pixels.
pub row_height: f32,
/// Ancho fijo del bloque del label (a la izquierda).
pub label_width: f32,
/// Ancho fijo del bloque del valor numérico (a la derecha).
pub value_width: f32,
/// Ancho fijo del track draggable (al medio). Único valor que el
/// widget usa para convertir dx_pixels → dv_value.
pub track_width: f32,
/// Grosor (alto) del track en pixels.
pub track_thickness: f32,
}
impl Default for SliderPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl SliderPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
track: t.bg_button,
track_filled: t.accent,
track_hover: t.bg_button_hover,
fg_label: t.fg_muted,
fg_value: t.fg_text,
radius: 3.0,
row_height: 22.0,
label_width: 80.0,
value_width: 56.0,
track_width: 120.0,
track_thickness: 6.0,
}
}
}
/// Compone un slider horizontal: label + track-fillbar draggable + valor.
///
/// `value`, `min`, `max` son sólo para presentación visual y conversión
/// `dx → dv`; el caller mantiene el estado y aplica el delta en su
/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver
/// `None` deja el drag activo sin emitir Msg.
pub fn slider_view<Msg, F>(
label: impl Into<String>,
value: f32,
min: f32,
max: f32,
palette: &SliderPalette,
on_change: F,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
{
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).
let span = max - min;
let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option<Msg> {
let dv = dx * span / track_width;
on_change(phase, dv)
};
// Bloque del label.
let label_view = View::new(Style {
size: Size {
width: length(palette.label_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start);
// Track draggable: fill = track bg, hijo = porción rellena (accent).
let filled_radius = palette.radius;
let filled = View::new(Style {
size: Size {
width: percent(ratio),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette.track_filled)
.radius(filled_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior sobre la stripe accent — la barra se lee como
// luz que avanza, no como rect plano. Mismo patrón button/progress
// (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px
// default) y un sheen fuerte le mete glitter.
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius);
let top = Color::from_rgba8(255, 255, 255, 40);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
});
let track = View::new(Style {
size: Size {
width: length(track_width),
height: length(palette.track_thickness),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.track)
.hover_fill(palette.track_hover)
.radius(palette.radius)
.draggable(handler)
.children(vec![filled]);
// Wrapper del track para centrarlo verticalmente sobre la fila.
let track_cell = View::new(Style {
size: Size {
width: length(track_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![track]);
// Bloque del valor.
let value_text = format_value(value);
let value_view = View::new(Style {
size: Size {
width: length(palette.value_width),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(value_text, 12.0, palette.fg_value, Alignment::End);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(palette.row_height),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![label_view, track_cell, value_view])
}
/// Formato uniforme para los valores: 2 decimales con signo explícito si
/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`.
fn format_value(v: f32) -> String {
let abs = v.abs();
if abs >= 1000.0 {
format!("{v:.0}")
} else if abs >= 10.0 {
format!("{v:.1}")
} else {
format!("{v:+.2}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_value_pretty_for_three_regimes() {
assert_eq!(format_value(0.34), "+0.34");
assert_eq!(format_value(-0.10), "-0.10");
assert_eq!(format_value(42.5), "42.5");
assert_eq!(format_value(1234.0), "1234");
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-spinner"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-spinner — spinner circular animado por reloj absoluto (no requiere ticks del modelo). Stroke gradient circular. Default 24×24 pero escalable."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+72
View File
@@ -0,0 +1,72 @@
//! `llimphi-widget-spinner` — spinner circular animado por reloj absoluto.
//!
//! El paint usa `Instant::now()` para calcular el ángulo de rotación,
//! así no hace falta que la app guarde un tween ni dispatchee ticks:
//! cuando llimphi-ui rasterize un frame (porque algo cambió en el
//! modelo o porque la app pidió un repaint), el spinner se ve girando.
//!
//! **Nota**: el spinner sólo se anima si HAY frames. Una app idle no
//! repintará por sí sola — usar `Handle::spawn_periodic(50ms, …)`
//! mientras el spinner esté visible para forzar redraw. O conectar
//! el spinner a un `Tween` y leer su `progress()` desde la `view`.
//!
//! Diseño visual: arco de 270° con stroke variable (más grueso al
//! frente del giro, más fino atrás) para dar sensación de aceleración.
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{percent, Size, Style},
Position,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Construye el `View` que pinta un spinner circular animado dentro
/// del rect del padre.
///
/// - `color`: tinte del arco (típico: `theme.accent`).
/// - `stroke_width_ratio`: grosor del arco como fracción del lado
/// menor (0.10 = 10%). Default razonable es `0.12`.
/// - `speed_rev_per_sec`: revoluciones por segundo. Default `1.0`.
pub fn spinner_view<Msg: Clone + 'static>(
color: Color,
stroke_width_ratio: f32,
speed_rev_per_sec: f32,
) -> View<Msg> {
// Anchor temporal: arrancamos el reloj al construir el View. Como
// la closure se evalúa por frame, cada repintado calcula `elapsed`
// contra este origen — sin tween, sin model state.
let started = Instant::now();
let sw = stroke_width_ratio;
let speed = speed_rev_per_sec;
View::new(Style {
position: Position::Absolute,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.paint_with(move |scene, _ts, rect| {
let side = rect.w.min(rect.h) as f64;
if side <= 0.0 {
return;
}
let cx = rect.x as f64 + rect.w as f64 * 0.5;
let cy = rect.y as f64 + rect.h as f64 * 0.5;
let stroke_w = (side * sw as f64).max(1.0);
let radius = (side - stroke_w) * 0.5;
let elapsed = started.elapsed().as_secs_f64();
// Ángulo de inicio del arco — gira completamente cada `1/speed` s.
let theta0 = elapsed * speed as f64 * std::f64::consts::TAU;
// Arco de 270° (= 3π/2 rad) — la "abertura" sugiere movimiento.
let sweep = std::f64::consts::PI * 1.5;
let arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0);
let stroke = Stroke::new(stroke_w).with_caps(Cap::Round);
scene.stroke(&stroke, Affine::IDENTITY, color, None, &arc);
})
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-splash"
version.workspace = true
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."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-motion = { workspace = true }
+283
View File
@@ -0,0 +1,283 @@
//! `llimphi-widget-splash` — splash de arranque gioser.
//!
//! Identidad visual del SO al boot: cuatro cuadrantes ordenados como
//! una cruz andina, cada uno con su nombre quechua y color simbólico,
//! que **entran en secuencia** con un tween de fade+escala.
//!
//! Los cuadrantes (en orden de entrada):
//! 1. `unanchay` — PERCIBIR — cyan (índigo claro)
//! 2. `yachay` — CONOCER — verde aurora
//! 3. `ruway` — HACER — naranja sunset
//! 4. `ukupacha` — RAÍZ — púrpura profundo
//!
//! Cada cuadrante hace fade-in + slight scale-up, con un offset de
//! `motion::NORMAL / 2` entre uno y el siguiente. La app pasa un
//! `Instant` de inicio y el splash calcula las fases relativas — no
//! requiere ningún tween del modelo.
//!
//! Cuando el splash termina (todos visibles), la app puede:
//! - mantenerlo unos segundos más como pantalla de carga,
//! - hacer un fade-out completo cuando el sistema esté listo,
//! - o reemplazarlo por la UI principal.
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, 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_motion::motion;
/// Datos de un cuadrante: nombre quechua, glosa breve y color.
#[derive(Debug, Clone, Copy)]
pub struct Quadrant {
pub name: &'static str,
pub gloss: &'static str,
pub color: Color,
}
/// Los cuatro cuadrantes canónicos, en orden de entrada al splash.
pub fn quadrants() -> [Quadrant; 4] {
[
Quadrant {
name: "unanchay",
gloss: "PERCIBIR",
color: Color::from_rgba8(110, 160, 230, 255),
},
Quadrant {
name: "yachay",
gloss: "CONOCER",
color: Color::from_rgba8(110, 220, 180, 255),
},
Quadrant {
name: "ruway",
gloss: "HACER",
color: Color::from_rgba8(232, 160, 90, 255),
},
Quadrant {
name: "ukupacha",
gloss: "RAÍZ",
color: Color::from_rgba8(160, 110, 220, 255),
},
]
}
/// Construye el splash. `started_at` es el `Instant` de origen — el
/// splash calcula las fases relativas. La app puede llamar `animate(handle,
/// motion::SLOW * 3, …)` para forzar repaints durante la animación.
///
/// `bg`: color de fondo (típico: `theme.bg_app`).
/// `fg_text`: color del título/glosa.
pub fn splash_view<Msg: Clone + 'static>(
started_at: Instant,
bg: Color,
fg_text: Color,
) -> View<Msg> {
let elapsed = started_at.elapsed().as_secs_f32();
let stagger = motion::NORMAL.as_secs_f32() * 0.45;
let per_quad = motion::NORMAL.as_secs_f32();
let quads = quadrants();
let cells: Vec<View<Msg>> = quads
.iter()
.enumerate()
.map(|(i, q)| {
let local_t = ((elapsed - i as f32 * stagger) / per_quad).clamp(0.0, 1.0);
let eased = motion::ease_out_cubic(local_t);
quadrant_cell(q, eased, fg_text)
})
.collect();
// 2×2 grid: row 0 = unanchay + yachay; row 1 = ruway + ukupacha.
let row = |a: View<Msg>, b: View<Msg>| -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(0.5_f32),
},
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![a, b])
};
let mut iter = cells.into_iter();
let r0 = row(iter.next().unwrap(), iter.next().unwrap());
let r1 = row(iter.next().unwrap(), iter.next().unwrap());
let grid = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(420.0_f32),
height: length(280.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(12.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![r0, r1]);
// Título "gioser" 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 {
size: Size {
width: length(420.0_f32),
height: length(32.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned("gioser", 22.0, fg_text, Alignment::Center)
.alpha(title_alpha);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(0.0_f32),
height: length(28.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(bg)
.children(vec![grid, title])
}
fn quadrant_cell<Msg: Clone + 'static>(
quad: &Quadrant,
progress: f32,
fg_text: Color,
) -> View<Msg> {
// El cuadrante "entra" con fade y un leve drift desde abajo (10px).
// El drift lo representamos con un padding-top que tiende a cero;
// como llimphi no expone translate por nodo (sólo position absolute),
// metemos el contenido en un wrapper con padding decreciente.
let drift = (1.0 - progress) * 10.0;
let name = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(quad.name, 16.0, fg_text, Alignment::Center);
let gloss = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(quad.gloss, 10.0, quad.color, Alignment::Center);
let inner = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(drift),
bottom: length(0.0_f32),
},
..Default::default()
})
.children(vec![name, gloss]);
// Fondo del cuadrante con gradient vertical en el color semántico:
// alpha 50 arriba → alpha 12 abajo. Da volumen al cuadrante (más
// intenso cerca del accent strip del top) y un efecto "halo descendente"
// que ayuda a leer la cruz andina como cuatro luces que emergen del
// centro. Antes: alpha 30 uniforme.
let border = with_alpha8(quad.color, 90);
let bg_top = with_alpha8(quad.color, 50);
let bg_bot = with_alpha8(quad.color, 12);
let cell_radius = llimphi_theme::radius::MD;
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.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, cell_radius);
let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1))
.with_stops([bg_top, bg_bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr);
})
.radius(cell_radius)
.clip(true)
.alpha(progress)
.children(vec![
// Línea accent superior — 2px del color del cuadrante a alta
// intensidad, ancla del gradiente que cae.
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(2.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(border),
inner,
])
}
fn with_alpha8(c: Color, a: u8) -> Color {
let [r, g, b, _] = c.components;
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
AlphaColor::new([r, g, b, a as f32 / 255.0])
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-splitter"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-splitter — split container con divisor draggable. Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes, divisor sólido del ancho del thickness configurable, drag emite Msg con el delta del eje principal."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-splitter
> Splitter horizontal/vertical para [llimphi](../../README.md).
Divide el espacio entre dos hijos con un handle arrastrable. Min/max sizes por hijo; doble-click para reset.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-splitter
> Horizontal/vertical splitter for [llimphi](../../README.md).
Divides space between two children with a draggable handle. Per-child min/max sizes; double-click to reset.
+126
View File
@@ -0,0 +1,126 @@
//! Showcase de `llimphi-widget-splitter`: dos splits anidados
//! draggables (Row con Column adentro).
//!
//! Corré con: `cargo run -p llimphi-widget-splitter --example showcase --release`.
//!
//! Probá: agarrá el divisor vertical y arrastralo izquierda/derecha
//! para resizar el pane izquierdo; agarrá el divisor horizontal de la
//! derecha para resizar el pane superior derecho.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{percent, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, DragPhase, Handle, View};
use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
#[derive(Clone)]
enum Msg {
ResizeOuter(f32),
ResizeInner(f32),
}
struct Model {
left_w: f32,
top_h: f32,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · splitter showcase"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(_: &Handle<Msg>) -> Model {
Model {
left_w: 320.0,
top_h: 240.0,
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::ResizeOuter(dx) => {
m.left_w = (m.left_w + dx).clamp(120.0, 800.0);
}
Msg::ResizeInner(dy) => {
m.top_h = (m.top_h + dy).clamp(80.0, 600.0);
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let palette = SplitterPalette::default();
let left = pane("izquierdo", Color::from_rgba8(28, 36, 50, 255));
let top_right = pane(
&format!("arriba · {:.0} px", model.top_h),
Color::from_rgba8(38, 50, 70, 255),
);
let bottom_right = pane(
"abajo · flex",
Color::from_rgba8(48, 36, 60, 255),
);
let right = splitter_two(
Direction::Column,
top_right,
PaneSize::Fixed(model.top_h),
bottom_right,
PaneSize::Flex,
|phase, dy| match phase {
DragPhase::Move => Some(Msg::ResizeInner(dy)),
DragPhase::End => None,
},
&palette,
);
splitter_two(
Direction::Row,
left,
PaneSize::Fixed(model.left_w),
right,
PaneSize::Flex,
|phase, dx| match phase {
DragPhase::Move => Some(Msg::ResizeOuter(dx)),
DragPhase::End => None,
},
&palette,
)
}
}
fn pane(label: &str, bg: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(bg)
.text_aligned(
label.to_string(),
18.0,
Color::from_rgba8(220, 230, 240, 255),
Alignment::Center,
)
}
fn main() {
llimphi_ui::run::<Showcase>();
}
+174
View File
@@ -0,0 +1,174 @@
//! `llimphi-widget-splitter` — split container con divisor draggable.
//!
//! Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes con un
//! divisor entre medio que el usuario arrastra para reasignar el tamaño.
//! El widget no mantiene estado: el caller acumula el tamaño de un pane
//! en su `Model` y le pasa el valor actual + un handler `Fn(DragPhase,
//! f32) -> Option<Msg>` que materializa el delta en un Msg de update.
//!
//! Uso típico (dos panes, izquierdo fijo y derecho flex):
//!
//! ```ignore
//! splitter_two(
//! Direction::Row,
//! left_view,
//! PaneSize::Fixed(model.left_size),
//! right_view,
//! PaneSize::Flex,
//! |phase, dx| match phase {
//! DragPhase::Move => Some(Msg::ResizeLeft(dx)),
//! DragPhase::End => Some(Msg::PersistLayout),
//! },
//! &SplitterPalette::default(),
//! )
//! ```
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{DragPhase, View};
/// Dirección del split. `Row` apila los panes horizontalmente
/// (divisor vertical, drag horizontal); `Column` los apila verticalmente.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Row,
Column,
}
/// Tamaño de un pane sobre el eje principal del split.
#[derive(Debug, Clone, Copy)]
pub enum PaneSize {
/// Ancho/alto fijo en pixels. El otro pane se ajusta con `flex_grow`.
Fixed(f32),
/// Toma todo el espacio sobrante (`flex_grow = 1`).
Flex,
}
/// Paleta del divisor. Cambia de color al hover para señalar
/// "agarrame y arrastrá".
#[derive(Debug, Clone, Copy)]
pub struct SplitterPalette {
pub divider: Color,
pub divider_hover: Color,
pub thickness: f32,
}
impl Default for SplitterPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl SplitterPalette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
divider: t.border,
divider_hover: t.accent,
thickness: 6.0,
}
}
}
/// Split de dos panes con divisor draggable entre medio. `on_resize`
/// se invoca con el delta del eje principal (positivo → divisor se
/// mueve a la derecha/abajo).
pub fn splitter_two<Msg, F>(
direction: Direction,
a: View<Msg>,
a_size: PaneSize,
b: View<Msg>,
b_size: PaneSize,
on_resize: F,
palette: &SplitterPalette,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
F: Fn(DragPhase, f32) -> Option<Msg> + Send + Sync + 'static,
{
let flex_dir = match direction {
Direction::Row => FlexDirection::Row,
Direction::Column => FlexDirection::Column,
};
// El divisor sólo necesita Msg en el eje principal — escondemos el
// otro detrás del closure.
let on_resize = Arc::new(on_resize);
let cb_dir = direction;
let cb = on_resize.clone();
let divider = divider_view::<Msg>(direction, palette, move |phase, dx, dy| {
let main = match cb_dir {
Direction::Row => dx,
Direction::Column => dy,
};
(cb)(phase, main)
});
let pane_a = wrap_pane(a, direction, a_size);
let pane_b = wrap_pane(b, direction, b_size);
View::new(Style {
flex_direction: flex_dir,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![pane_a, divider, pane_b])
}
fn wrap_pane<Msg>(view: View<Msg>, direction: Direction, size: PaneSize) -> View<Msg> {
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::Column, PaneSize::Fixed(px)) => (percent(1.0_f32), length(px), 0.0),
(Direction::Column, PaneSize::Flex) => (percent(1.0_f32), Dimension::auto(), 1.0),
};
View::new(Style {
size: Size { width, height },
flex_grow,
flex_shrink: 0.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![view])
}
fn divider_view<Msg>(
direction: Direction,
palette: &SplitterPalette,
handler: impl Fn(DragPhase, f32, f32) -> Option<Msg> + Send + Sync + 'static,
) -> View<Msg>
where
Msg: Clone + Send + Sync + 'static,
{
let (width, height) = match direction {
Direction::Row => (length(palette.thickness), percent(1.0_f32)),
Direction::Column => (percent(1.0_f32), length(palette.thickness)),
};
View::new(Style {
size: Size { width, height },
flex_shrink: 0.0,
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(palette.divider)
.hover_fill(palette.divider_hover)
.draggable(handler)
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-stat-card"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-stat-card — tarjeta de dashboard con label chico + valor grande + descripción + accent vertical. Análogo Llimphi al `nahual-widget-stat-card` GPUI."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-card = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-stat-card
> Card para métricas para [llimphi](../../README.md).
Label + valor grande + sub-label + sparkline opcional. Variante `compact` y `wide`. Usado por `cosmos-card`, `chasqui-card`, `arje-card`, etc.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-stat-card
> Card for metrics for [llimphi](../../README.md).
Label + large value + sub-label + optional sparkline. `compact` and `wide` variants. Used by `cosmos-card`, `chasqui-card`, `arje-card`, etc.
+144
View File
@@ -0,0 +1,144 @@
//! `llimphi-widget-stat-card` — tarjeta de dashboard con accent.
//!
//! Compone (sobre `llimphi-widget-card`):
//! - **Border-l-4** con un color de accent que el caller decide.
//! - **Label** chico arriba en el color del accent.
//! - **Value** grande (28 px) en el color principal del texto.
//! - **Description** chica en el color tenue.
//! - **Listing opcional** de items recientes con sub-header
//! `"recent (N):"`.
//!
//! Análogo Llimphi al `nahual-widget-stat-card` GPUI. Pensado para
//! dashboards estilo `minga-explorer`, `brahman-broker-explorer`.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_card::{card_view, CardOptions, CardPalette};
/// Paleta del stat-card. `accent` se setea por instancia (verde/rojo/
/// ámbar etc.), los otros vienen del theme.
#[derive(Debug, Clone, Copy)]
pub struct StatCardPalette {
pub bg: Color,
pub fg_text: Color,
pub fg_muted: Color,
}
impl Default for StatCardPalette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl StatCardPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg: t.bg_panel,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
}
}
}
/// Compone un stat-card.
///
/// - `label`: header chico en color `accent`.
/// - `value`: texto principal grande.
/// - `description`: línea chica tenue debajo del value.
/// - `accent`: color del border-l + del label.
/// - `recent_items`: si no vacío, agrega "recent (N):" + una fila por
/// item.
pub fn stat_card_view<Msg: Clone + 'static>(
label: &str,
value: impl Into<String>,
description: &str,
accent: Color,
recent_items: &[String],
palette: &StatCardPalette,
) -> View<Msg> {
let label_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(label.to_string(), 11.0, accent, Alignment::Start);
let value_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(36.0_f32),
},
..Default::default()
})
.text_aligned(value.into(), 28.0, palette.fg_text, Alignment::Start);
let desc_row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
description.to_string(),
11.0,
palette.fg_muted,
Alignment::Start,
);
let mut children: Vec<View<Msg>> = vec![label_row, value_row, desc_row];
if !recent_items.is_empty() {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(6.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(
format!("recent ({}):", recent_items.len()),
10.0,
palette.fg_muted,
Alignment::Start,
),
);
for it in recent_items {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(it.clone(), 11.0, palette.fg_text, Alignment::Start),
);
}
}
card_view(
children,
CardOptions {
accent: Some(accent),
..Default::default()
},
&CardPalette { bg: palette.bg },
)
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-widget-status-bar"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-status-bar — barra inferior con segmentos left/center/right configurables. Cada segmento puede llevar icono opcional y handler de click."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-icons = { workspace = true }
llimphi-widget-panel = { workspace = true }
+242
View File
@@ -0,0 +1,242 @@
//! `llimphi-widget-status-bar` — barra de estado inferior.
//!
//! Patrón clásico de IDEs/editores: barra delgada en el borde inferior
//! de la ventana con tres regiones (left/center/right). Cada región
//! tiene N segmentos, cada uno puede llevar icono + texto + handler de
//! click opcional.
//!
//! Útil para mostrar: rama git activa, posición del cursor, tipo de
//! archivo, modo (insert/normal), notificaciones pendientes, etc.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, 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_icons::{icon_view, Icon};
use llimphi_theme::Theme;
use llimphi_widget_panel::{panel_signature_painter, PanelStyle};
/// Paleta de la barra de estado.
#[derive(Debug, Clone, Copy)]
pub struct StatusBarPalette {
pub bg: Color,
pub fg: Color,
pub fg_muted: Color,
pub bg_hover: Color,
pub border: Color,
/// Firma visual de la barra: gradient sutil + hairline accent en su
/// top edge — el hairline funciona como "techo" que separa la barra
/// de la zona de contenido. `None` cae al fill plano + border top
/// del modo previo (back-compat).
pub signature: Option<PanelStyle>,
}
impl StatusBarPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
bg: t.bg_panel_alt,
fg: t.fg_text,
fg_muted: t.fg_muted,
bg_hover: t.bg_row_hover,
border: t.border,
signature: Some(PanelStyle {
radius: 0.0,
bg_base: t.bg_panel_alt,
..PanelStyle::from_theme(t)
}),
}
}
}
/// Un segmento de la barra. `icon` y `on_click` son opcionales.
#[derive(Clone)]
pub struct StatusSegment<Msg> {
pub text: String,
pub icon: Option<Icon>,
pub on_click: Option<Msg>,
/// Si `true`, usa `fg` en vez de `fg_muted` — útil para destacar
/// estados importantes (ej. "modificado").
pub emphasized: bool,
}
impl<Msg> StatusSegment<Msg> {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
icon: None,
on_click: None,
emphasized: false,
}
}
pub fn with_icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn clickable(mut self, msg: Msg) -> Self {
self.on_click = Some(msg);
self
}
pub fn emphasized(mut self) -> Self {
self.emphasized = true;
self
}
}
const BAR_H: f32 = 22.0;
const SEG_GAP: f32 = 14.0;
const FONT_SIZE: f32 = 11.0;
const ICON_SIZE: f32 = 12.0;
pub fn status_bar_view<Msg: Clone + 'static>(
left: Vec<StatusSegment<Msg>>,
center: Vec<StatusSegment<Msg>>,
right: Vec<StatusSegment<Msg>>,
palette: &StatusBarPalette,
) -> View<Msg> {
let make_region = |segs: Vec<StatusSegment<Msg>>, justify: JustifyContent| -> View<Msg> {
let children: Vec<View<Msg>> = segs
.into_iter()
.map(|s| segment_view(s, palette))
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
justify_content: Some(justify),
gap: Size {
width: length(SEG_GAP),
height: length(0.0_f32),
},
..Default::default()
})
.children(children)
};
let left_region = make_region(left, JustifyContent::FlexStart);
let center_region = make_region(center, JustifyContent::Center);
let right_region = make_region(right, JustifyContent::FlexEnd);
// Modo con firma: la barra trae su propio hairline accent en el top
// edge — reemplaza el border plano del modo previo.
let bar_style = Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(BAR_H),
},
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),
flex_shrink: 0.0,
..Default::default()
};
if let Some(style) = palette.signature {
return View::new(bar_style)
.paint_with(panel_signature_painter(style))
.children(vec![left_region, center_region, right_region]);
}
// Back-compat: fill plano + border top 1px en el wrapper column.
let border = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.border);
let bar = View::new(bar_style)
.fill(palette.bg)
.children(vec![left_region, center_region, right_region]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: length(BAR_H + 1.0),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![border, bar])
}
fn segment_view<Msg: Clone + 'static>(
seg: StatusSegment<Msg>,
palette: &StatusBarPalette,
) -> View<Msg> {
let fg = if seg.emphasized { palette.fg } else { palette.fg_muted };
let approx_w = seg.text.chars().count() as f32 * 6.0
+ if seg.icon.is_some() { ICON_SIZE + 4.0 } else { 0.0 }
+ 12.0;
let mut children: Vec<View<Msg>> = Vec::with_capacity(2);
if let Some(icon) = seg.icon {
children.push(
View::new(Style {
size: Size {
width: length(ICON_SIZE),
height: length(ICON_SIZE),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![icon_view(icon, fg, 1.4)]),
);
}
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
align_items: Some(AlignItems::Center),
..Default::default()
})
.text_aligned(seg.text.clone(), FONT_SIZE, fg, Alignment::Start),
);
let mut node = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(approx_w),
height: percent(1.0_f32),
},
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(4.0_f32),
height: length(0.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(children);
if let Some(msg) = seg.on_click {
node = node.hover_fill(palette.bg_hover).on_click(msg);
}
node
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-widget-switch"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-switch — toggle binario on/off (track + thumb) con paleta del theme. Para preferencias, modos y feature flags."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+152
View File
@@ -0,0 +1,152 @@
//! `llimphi-widget-switch` — toggle binario (track + thumb).
//!
//! Render-only: la app guarda el `bool` en su modelo y dispatcha el
//! Msg de toggle al click. Visualmente:
//! - Track horizontal (40×22 default) con color del estado activo.
//! - Thumb circular (18px) que se posiciona a la izquierda (off) o
//! derecha (on) del track.
//!
//! Para animar la transición, la app puede guardar un `Tween<f32>` con
//! el progreso 0→1 y leerlo desde `view` para interpolar la posición
//! del thumb. Sin tween la transición es instantánea — funcional pero
//! menos elegante.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, Position, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
use llimphi_theme::Theme;
/// Paleta del switch.
#[derive(Debug, Clone, Copy)]
pub struct SwitchPalette {
pub track_off: Color,
pub track_on: Color,
pub thumb: Color,
}
impl SwitchPalette {
pub fn from_theme(t: &Theme) -> Self {
Self {
track_off: t.bg_button,
track_on: t.accent,
thumb: t.fg_text,
}
}
}
const TRACK_W: f32 = 40.0;
const TRACK_H: f32 = 22.0;
const THUMB_R: f32 = 9.0; // radio en px → diámetro 18
const PAD: f32 = 2.0;
/// Construye un switch. `progress` en `[0.0, 1.0]` indica la
/// posición animada del thumb (0 = off, 1 = on). Para la transición
/// instantánea usar `if state { 1.0 } else { 0.0 }`.
///
/// `on_toggle` se dispatcha al click; la app actualiza su `bool` y
/// (opcionalmente) lanza un `Tween` que actualiza `progress` por frame.
pub fn switch_view<Msg: Clone + 'static>(
progress: f32,
on_toggle: Msg,
palette: &SwitchPalette,
) -> View<Msg> {
let p = progress.clamp(0.0, 1.0);
// Track color interpola entre off y on según progress.
let track_color = lerp_color(palette.track_off, palette.track_on, p);
// Thumb absolute dentro del track. Range del centro: PAD+THUMB_R a TRACK_W-PAD-THUMB_R.
let min_x = PAD;
let max_x = TRACK_W - PAD - THUMB_R * 2.0;
let thumb_x = min_x + (max_x - min_x) * p;
let thumb_y = (TRACK_H - THUMB_R * 2.0) * 0.5;
let thumb = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(thumb_x),
top: length(thumb_y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(THUMB_R * 2.0),
height: length(THUMB_R * 2.0),
},
..Default::default()
})
.fill(palette.thumb)
.radius(THUMB_R as f64)
.paint_with(move |scene, _ts, rect| {
// Highlight radial pequeño en cuadrante superior — el thumb se
// lee como esfera, no como círculo plano. Mismo patrón que el
// dot del badge (P6).
use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle};
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.32) as f64;
let r = (rect.w as f64 * 0.18).max(1.0);
let highlight = Color::from_rgba8(255, 255, 255, 70);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
highlight,
None,
&Circle::new((cx, cy), r),
);
});
let track_radius = (TRACK_H * 0.5) as f64;
View::new(Style {
size: Size {
width: length(TRACK_W),
height: length(TRACK_H),
},
..Default::default()
})
.fill(track_color)
.radius(track_radius)
.paint_with(move |scene, _ts, rect| {
// Gloss superior en el track — pill con luz cayendo desde arriba.
// El track interpola color (off/on) en el fill, el gloss queda
// estable encima en ambos estados.
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 y_mid = y0 + (y1 - y0) * 0.5;
let rr = RoundedRect::new(x0, y0, x1, y1, track_radius);
let top = Color::from_rgba8(255, 255, 255, 28);
let bot = Color::from_rgba8(255, 255, 255, 0);
let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
.with_stops([top, bot].as_slice());
scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
})
.on_click(on_toggle)
.children(vec![thumb])
}
fn lerp_color(a: Color, b: Color, t: f32) -> Color {
let [r0, g0, b0, a0] = a.components;
let [r1, g1, b1, a1] = b.components;
use llimphi_ui::llimphi_raster::peniko::color::AlphaColor;
AlphaColor::new([
r0 + (r1 - r0) * t,
g0 + (g1 - g0) * t,
b0 + (b1 - b0) * t,
a0 + (a1 - a0) * t,
])
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-widget-tabs"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-tabs — tira de tabs + área de contenido. Análogo Llimphi al `nahual-widget-tabs` GPUI. El caller mantiene el índice activo en el `Model` y le da al widget las labels + el view del tab activo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-panel = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-tabs
> Tabs con cierre para [llimphi](../../README.md).
Pestañas horizontales arrastrables, botón "+", close por pestaña. Activa por keyboard (Ctrl+Tab). Usado por `nada`, `pluma`, `puriy`.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-tabs
> Closeable tabs for [llimphi](../../README.md).
Draggable horizontal tabs, "+" button, per-tab close. Keyboard active (Ctrl+Tab). Used by `nada`, `pluma`, `puriy`.
+136
View File
@@ -0,0 +1,136 @@
//! Showcase de `llimphi-widget-tabs`: 3 tabs con contenido distinto
//! cada uno. Hover en los tabs inactivos cambia el bg.
//!
//! Corré con: `cargo run -p llimphi-widget-tabs --example showcase --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec};
#[derive(Clone)]
enum Msg {
SelectTab(usize),
}
struct Model {
active: usize,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · tabs showcase"
}
fn initial_size() -> (u32, u32) {
(900, 600)
}
fn init(_: &Handle<Msg>) -> Model {
Model { active: 0 }
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::SelectTab(i) => m.active = i,
}
m
}
fn view(model: &Model) -> View<Msg> {
let body = match model.active {
0 => content_pane(
"General",
"Acá vivirían los settings principales del módulo.\n\
El click cambia de tab; el hover sobre tabs inactivos\n\
ilumina el fondo levemente.",
Color::from_rgba8(220, 230, 245, 255),
),
1 => content_pane(
"Avanzado",
"Variables esotéricas, banderas experimentales.\n\
Probablemente no las toques.",
Color::from_rgba8(200, 220, 240, 255),
),
_ => content_pane(
"Logs",
"[12:01:33] arranqué\n[12:01:34] cargué config\n\
[12:01:35] esperando eventos…",
Color::from_rgba8(180, 195, 215, 255),
),
};
tabs_view(TabsSpec {
labels: vec!["General".into(), "Avanzado".into(), "Logs".into()],
active: model.active,
on_select: Msg::SelectTab,
content: body,
tab_height: 36.0,
palette: TabsPalette::default(),
tab_width: Some(160.0),
})
}
}
fn content_pane(title: &str, body: &str, fg: Color) -> View<Msg> {
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(36.0_f32),
},
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(8.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Start),
..Default::default()
})
.text_aligned(
format!("# {title}"),
18.0,
Color::from_rgba8(220, 230, 245, 255),
Alignment::Start,
);
let body_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
padding: Rect {
left: length(20.0_f32),
right: length(20.0_f32),
top: length(0.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.text_aligned(body.to_string(), 13.0, fg, Alignment::Start);
View::new(Style {
flex_direction: llimphi_ui::llimphi_layout::taffy::FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![header, body_view])
}
fn main() {
llimphi_ui::run::<Showcase>();
}

Some files were not shown because too many files have changed in this diff Show More