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-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>();
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `llimphi-widget-tabs` — tira de tabs + área de contenido.
|
||||
//!
|
||||
//! Análogo Llimphi al `nahual-widget-tabs` GPUI. El widget no mantiene
|
||||
//! estado interno: el `Model` del App lleva el índice activo, le pasa al
|
||||
//! widget las labels + el `View` del tab activo, y maneja el Msg de
|
||||
//! cambio de tab.
|
||||
//!
|
||||
//! Uso típico:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! tabs_view(
|
||||
//! TabsSpec {
|
||||
//! labels: vec!["General".into(), "Avanzado".into(), "Logs".into()],
|
||||
//! active: model.active_tab,
|
||||
//! on_select: |i| Msg::SelectTab(i),
|
||||
//! content: render_active_tab(model),
|
||||
//! palette: TabsPalette::default(),
|
||||
//! }
|
||||
//! )
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style},
|
||||
AlignItems, JustifyContent, Rect,
|
||||
};
|
||||
|
||||
/// Ancho mínimo de un tab cuando `tab_width` es `None` — evita que los
|
||||
/// tabs cortos (un nombre de 4 chars) se vean apretados contra los
|
||||
/// vecinos. Si se especifica `tab_width: Some(px)`, se ignora.
|
||||
const DEFAULT_MIN_TAB_WIDTH: f32 = 120.0;
|
||||
/// Separación horizontal entre tabs — deja ver el `bg_bar` como hilo
|
||||
/// fino, suaviza el bloque sólido de antes.
|
||||
const TAB_GAP: f32 = 2.0;
|
||||
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 tab bar.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TabsPalette {
|
||||
pub bg_bar: Color,
|
||||
pub bg_tab_inactive: Color,
|
||||
pub bg_tab_hover: Color,
|
||||
pub bg_tab_active: Color,
|
||||
pub fg_text: Color,
|
||||
pub fg_text_active: Color,
|
||||
/// Línea bajo el tab activo (acento). Si es `None` no se dibuja.
|
||||
pub accent: Option<Color>,
|
||||
/// Firma visual del área de contenido (sólo gradient — el accent
|
||||
/// del tab activo justo encima ya cumple el rol del hairline). `None`
|
||||
/// cae al fill plano de `bg_tab_active` (back-compat).
|
||||
pub content_signature: Option<PanelStyle>,
|
||||
}
|
||||
|
||||
impl Default for TabsPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&llimphi_theme::Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl TabsPalette {
|
||||
/// Construye la paleta desde un `Theme` semántico.
|
||||
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
|
||||
Self {
|
||||
bg_bar: t.bg_panel_alt,
|
||||
bg_tab_inactive: t.bg_panel,
|
||||
bg_tab_hover: t.bg_row_hover,
|
||||
bg_tab_active: t.bg_app,
|
||||
fg_text: t.fg_muted,
|
||||
fg_text_active: t.fg_text,
|
||||
accent: Some(t.accent),
|
||||
content_signature: Some(PanelStyle {
|
||||
radius: 0.0,
|
||||
bg_base: t.bg_app,
|
||||
..PanelStyle::neutral(t)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Especificación de los tabs. `labels.len()` define cuántos tabs; el
|
||||
/// `Msg` por click se construye con `on_select(idx)`.
|
||||
pub struct TabsSpec<Msg, F> {
|
||||
pub labels: Vec<String>,
|
||||
pub active: usize,
|
||||
/// Function from tab index to Msg. Se invoca una vez por tab en `view`.
|
||||
pub on_select: F,
|
||||
/// Contenido del tab activo. El widget lo coloca debajo de la barra.
|
||||
pub content: View<Msg>,
|
||||
pub tab_height: f32,
|
||||
pub palette: TabsPalette,
|
||||
/// Ancho de cada tab. `None` = tamaño según contenido (auto).
|
||||
pub tab_width: Option<f32>,
|
||||
}
|
||||
|
||||
/// Compone la barra de tabs + área de contenido. La función `on_select`
|
||||
/// se consume — se invoca una vez por tab para construir su Msg.
|
||||
pub fn tabs_view<Msg, F>(spec: TabsSpec<Msg, F>) -> View<Msg>
|
||||
where
|
||||
Msg: Clone + 'static,
|
||||
F: Fn(usize) -> Msg,
|
||||
{
|
||||
let TabsSpec {
|
||||
labels,
|
||||
active,
|
||||
on_select,
|
||||
content,
|
||||
tab_height,
|
||||
palette,
|
||||
tab_width,
|
||||
} = spec;
|
||||
|
||||
let mut bar_children: Vec<View<Msg>> = Vec::with_capacity(labels.len() + 1);
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
bar_children.push(tab_button(
|
||||
label,
|
||||
i == active,
|
||||
tab_height,
|
||||
tab_width,
|
||||
&palette,
|
||||
on_select(i),
|
||||
));
|
||||
}
|
||||
// Spacer al final: empuja los tabs al inicio y rellena el resto del
|
||||
// ancho con el bg_bar.
|
||||
bar_children.push(
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: Dimension::auto(),
|
||||
height: length(tab_height),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_bar),
|
||||
);
|
||||
|
||||
let bar = View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(tab_height + accent_thickness(&palette)),
|
||||
},
|
||||
// No comprimir verticalmente cuando el contenido del tab activo
|
||||
// pide percent(1.0): si no, el column padre reparte overflow y
|
||||
// come la altura del tab strip.
|
||||
flex_shrink: 0.0,
|
||||
gap: Size {
|
||||
width: length(TAB_GAP),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(palette.bg_bar)
|
||||
.children(bar_children);
|
||||
|
||||
let content_style = Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
let content_wrap = match palette.content_signature {
|
||||
Some(style) => View::new(content_style)
|
||||
.paint_with(panel_signature_painter(style))
|
||||
.children(vec![content]),
|
||||
None => View::new(content_style)
|
||||
.fill(palette.bg_tab_active)
|
||||
.children(vec![content]),
|
||||
};
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![bar, content_wrap])
|
||||
}
|
||||
|
||||
fn tab_button<Msg: Clone + 'static>(
|
||||
label: &str,
|
||||
active: bool,
|
||||
height: f32,
|
||||
width: Option<f32>,
|
||||
palette: &TabsPalette,
|
||||
on_click: Msg,
|
||||
) -> View<Msg> {
|
||||
let (bg, fg) = if active {
|
||||
(palette.bg_tab_active, palette.fg_text_active)
|
||||
} else {
|
||||
(palette.bg_tab_inactive, palette.fg_text)
|
||||
};
|
||||
let w = match width {
|
||||
Some(px) => length(px),
|
||||
None => Dimension::auto(),
|
||||
};
|
||||
// Cuando el tab es auto-width, garantizamos min para que un label
|
||||
// corto («main.rs», 7 chars) no apriete al vecino.
|
||||
let min_w = match width {
|
||||
Some(_) => auto(),
|
||||
None => length(DEFAULT_MIN_TAB_WIDTH),
|
||||
};
|
||||
|
||||
let label_view = View::new(Style {
|
||||
size: Size {
|
||||
width: w,
|
||||
height: length(height),
|
||||
},
|
||||
min_size: Size { width: min_w, height: auto() },
|
||||
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),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
.hover_fill(palette.bg_tab_hover)
|
||||
.text_aligned(label.to_string(), 13.0, fg, Alignment::Center)
|
||||
.on_click(on_click);
|
||||
|
||||
// Línea de acento bajo el tab activo. Para inactivos se dibuja con el
|
||||
// bg_bar (transparente al ojo).
|
||||
let accent_color = match (palette.accent, active) {
|
||||
(Some(c), true) => c,
|
||||
_ => palette.bg_bar,
|
||||
};
|
||||
let accent = View::new(Style {
|
||||
size: Size {
|
||||
width: w,
|
||||
height: length(accent_thickness(palette)),
|
||||
},
|
||||
min_size: Size { width: min_w, height: auto() },
|
||||
..Default::default()
|
||||
})
|
||||
.fill(accent_color);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: w,
|
||||
height: length(height + accent_thickness(palette)),
|
||||
},
|
||||
min_size: Size { width: min_w, height: auto() },
|
||||
// Cuando hay muchos tabs y el ancho total excede la bar, no
|
||||
// comprimir cada tab — preferimos overflow a verlos como una
|
||||
// lasca delgada. (Eventualmente: scroll horizontal.)
|
||||
flex_shrink: 0.0,
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![label_view, accent])
|
||||
}
|
||||
|
||||
fn accent_thickness(palette: &TabsPalette) -> f32 {
|
||||
if palette.accent.is_some() {
|
||||
2.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user