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,16 @@
|
||||
[package]
|
||||
name = "llimphi-widget-theme-switcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-theme-switcher — botón que cicla los presets de `llimphi_theme::Theme`. Análogo Llimphi del `nahual-widget-theme-switcher` GPUI: el caller lifta `Msg::ChangeTheme(Theme)` y reasigna el theme en su Model."
|
||||
|
||||
[dependencies]
|
||||
llimphi-ui = { workspace = true }
|
||||
llimphi-theme = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "theme_switcher_demo"
|
||||
path = "examples/theme_switcher_demo.rs"
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-widget-theme-switcher
|
||||
|
||||
> Selector de tema para [llimphi](../../README.md).
|
||||
|
||||
Botón que cicla Dark → Light → Aurora → Sunset, escribe la preferencia en `wawa-config`. Useful en el panel del escritorio.
|
||||
@@ -0,0 +1,5 @@
|
||||
# llimphi-widget-theme-switcher
|
||||
|
||||
> Theme selector for [llimphi](../../README.md).
|
||||
|
||||
Button that cycles Dark → Light → Aurora → Sunset, writes the preference to `wawa-config`. Useful in the desktop panel.
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Showcase de `llimphi-widget-theme-switcher`.
|
||||
//!
|
||||
//! Una ventana con el switcher en la cabecera + un sample de paneles
|
||||
//! que cambian de color al ciclar. Validación visual de que el theme
|
||||
//! propaga a la UI: al hacer click en el switcher, los paneles se
|
||||
//! repintan con el siguiente preset.
|
||||
//!
|
||||
//! Corré: `cargo run -p llimphi-widget-theme-switcher --example theme_switcher_demo --release`.
|
||||
|
||||
use llimphi_theme::Theme;
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, AlignItems, FlexDirection, JustifyContent, Size, Style},
|
||||
Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{App, Handle, View};
|
||||
use llimphi_widget_theme_switcher::theme_switcher_view;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Msg {
|
||||
ChangeTheme(Theme),
|
||||
}
|
||||
|
||||
struct Model {
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
struct Showcase;
|
||||
|
||||
impl App for Showcase {
|
||||
type Model = Model;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · theme-switcher"
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Msg>) -> Model {
|
||||
Model {
|
||||
theme: Theme::dark(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
|
||||
let mut m = model;
|
||||
match msg {
|
||||
Msg::ChangeTheme(t) => m.theme = t,
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn view(model: &Model) -> View<Msg> {
|
||||
let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme);
|
||||
|
||||
let header = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(48.0_f32),
|
||||
},
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::SpaceBetween),
|
||||
padding: Rect {
|
||||
left: length(16.0_f32),
|
||||
right: length(16.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(model.theme.bg_panel)
|
||||
.children(vec![
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(220.0_f32),
|
||||
height: length(32.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(
|
||||
format!("Preset actual: {}", model.theme.name),
|
||||
13.0,
|
||||
model.theme.fg_text,
|
||||
Alignment::Start,
|
||||
),
|
||||
switcher,
|
||||
]);
|
||||
|
||||
let card_a = sample_card("Panel principal", &model.theme, model.theme.bg_panel);
|
||||
let card_b = sample_card(
|
||||
"Strip alternativo",
|
||||
&model.theme,
|
||||
model.theme.bg_panel_alt,
|
||||
);
|
||||
let card_c = sample_card("Input focado", &model.theme, model.theme.bg_input_focus);
|
||||
|
||||
let body = View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(24.0_f32),
|
||||
right: length(24.0_f32),
|
||||
top: length(24.0_f32),
|
||||
bottom: length(24.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(14.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(model.theme.bg_app)
|
||||
.children(vec![card_a, card_b, card_c]);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(model.theme.bg_app)
|
||||
.children(vec![header, body])
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_card(label: &str, theme: &Theme, bg: llimphi_ui::llimphi_raster::peniko::Color) -> View<Msg> {
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(60.0_f32),
|
||||
},
|
||||
padding: Rect {
|
||||
left: length(14.0_f32),
|
||||
right: length(14.0_f32),
|
||||
top: length(0.0_f32),
|
||||
bottom: length(0.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(bg)
|
||||
.radius(6.0)
|
||||
.text_aligned(label.to_string(), 13.0, theme.fg_text, Alignment::Start)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Showcase>();
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `llimphi-widget-theme-switcher` — botón que rota los presets de
|
||||
//! [`llimphi_theme::Theme`].
|
||||
//!
|
||||
//! Análogo Llimphi del `nahual-widget-theme-switcher` GPUI. Diferencia
|
||||
//! estructural: GPUI lleva el theme en un `Global` y el switcher lo
|
||||
//! reemplaza con `cx.set_global`; Llimphi no tiene globals — el caller
|
||||
//! guarda el theme en su `Model` y reasigna en su `update`. El widget
|
||||
//! sólo emite `on_change(next_theme)` cuando el botón se clickea, donde
|
||||
//! `next_theme` es el siguiente preset de [`Theme::next_after`].
|
||||
//!
|
||||
//! El label del botón muestra el nombre del preset actual con un signo
|
||||
//! de rotación (`Tema: Dark ▸`). Los colores salen del `Theme` actual
|
||||
//! para que el switcher sea coherente con el resto de la UI.
|
||||
//!
|
||||
//! # Uso
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use llimphi_widget_theme_switcher::theme_switcher_view;
|
||||
//!
|
||||
//! // En App::view:
|
||||
//! let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme);
|
||||
//! ```
|
||||
//!
|
||||
//! `Msg::ChangeTheme(Theme)` lo define la app; en `update`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! Msg::ChangeTheme(t) => { model.theme = t; }
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use llimphi_theme::Theme;
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, AlignItems, JustifyContent, Size, Style},
|
||||
Rect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::View;
|
||||
|
||||
/// Paleta del switcher. Por default replica el patrón del switcher de
|
||||
/// nahual: `bg_panel_alt` + hover `bg_row_hover`, texto `fg_text`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ThemeSwitcherPalette {
|
||||
pub bg: Color,
|
||||
pub bg_hover: Color,
|
||||
pub fg: Color,
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
impl Default for ThemeSwitcherPalette {
|
||||
fn default() -> Self {
|
||||
Self::from_theme(&Theme::dark())
|
||||
}
|
||||
}
|
||||
|
||||
impl ThemeSwitcherPalette {
|
||||
pub fn from_theme(t: &Theme) -> Self {
|
||||
Self {
|
||||
bg: t.bg_panel_alt,
|
||||
bg_hover: t.bg_row_hover,
|
||||
fg: t.fg_text,
|
||||
radius: 3.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compone el switcher: chip con texto `Tema: <nombre> ▸`. Click rota
|
||||
/// al siguiente preset y emite `on_change(next)`.
|
||||
///
|
||||
/// Toma el `current` por referencia para no clonar el `Theme` entero
|
||||
/// (es `Copy`, pero la API se mantiene consistente con `Palette::from_theme`).
|
||||
/// La paleta se deriva del `current` para que el chip use el mismo set
|
||||
/// de colores que el resto de la UI.
|
||||
pub fn theme_switcher_view<Msg: Clone + 'static>(
|
||||
current: &Theme,
|
||||
on_change: impl Fn(Theme) -> Msg,
|
||||
) -> View<Msg> {
|
||||
let palette = ThemeSwitcherPalette::from_theme(current);
|
||||
theme_switcher_styled(current, &palette, on_change)
|
||||
}
|
||||
|
||||
/// Variante con paleta explícita — útil cuando la app quiere un look
|
||||
/// distinto al default (botón destacado, accent del switcher fijo, etc.).
|
||||
pub fn theme_switcher_styled<Msg: Clone + 'static>(
|
||||
current: &Theme,
|
||||
palette: &ThemeSwitcherPalette,
|
||||
on_change: impl Fn(Theme) -> Msg,
|
||||
) -> View<Msg> {
|
||||
let next = Theme::next_after(current.name);
|
||||
let label = format!("Tema: {} ▸", current.name);
|
||||
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: length(140.0_f32),
|
||||
height: length(26.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()
|
||||
})
|
||||
.fill(palette.bg)
|
||||
.hover_fill(palette.bg_hover)
|
||||
.radius(palette.radius)
|
||||
.text_aligned(label, 11.0, palette.fg, Alignment::Start)
|
||||
.on_click(on_change(next))
|
||||
}
|
||||
|
||||
/// Variante de tamaño flexible — toma el ancho dado por el padre y se
|
||||
/// adapta al alto natural del slot. Útil dentro de toolbars con flexbox.
|
||||
pub fn theme_switcher_flex<Msg: Clone + 'static>(
|
||||
current: &Theme,
|
||||
palette: &ThemeSwitcherPalette,
|
||||
on_change: impl Fn(Theme) -> Msg,
|
||||
) -> View<Msg> {
|
||||
let next = Theme::next_after(current.name);
|
||||
let label = format!("Tema: {} ▸", current.name);
|
||||
|
||||
View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(26.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()
|
||||
})
|
||||
.fill(palette.bg)
|
||||
.hover_fill(palette.bg_hover)
|
||||
.radius(palette.radius)
|
||||
.text_aligned(label, 11.0, palette.fg, Alignment::Start)
|
||||
.on_click(on_change(next))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Msg {
|
||||
Change(&'static str),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switcher_constructs_with_a_default_theme() {
|
||||
let t = Theme::dark();
|
||||
let _v = theme_switcher_view::<Msg>(&t, |th| Msg::Change(th.name));
|
||||
// Si el constructor no panicó, el widget queda armado.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_from_theme_matches_panel_alt_slots() {
|
||||
let t = Theme::dark();
|
||||
let p = ThemeSwitcherPalette::from_theme(&t);
|
||||
// No comparamos por igualdad de Color (no implementa PartialEq);
|
||||
// sí garantizamos que la paleta derivó del theme — radius default.
|
||||
assert_eq!(p.radius, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_change_receives_the_next_preset() {
|
||||
// Verificación funcional independiente: la rotación que verá el
|
||||
// handler coincide con `Theme::next_after`.
|
||||
let current = Theme::dark();
|
||||
let expected_next = Theme::next_after(current.name).name;
|
||||
assert_eq!(expected_next, "Light");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user