feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
);
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-widget-list
|
||||
|
||||
> Virtualized list for [llimphi](../../README.md).
|
||||
|
||||
Renders only visible items. Single/multi selection, programmatic scroll, keyboard nav.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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`.
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.0–1.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.0–1.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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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
Reference in New Issue
Block a user