tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Demo de `llimphi-widget-panes` — "tmux de componentes tawasuyu".
2//!
3//! Dos tipos de panel heterogéneos (Contador y Notas) conviviendo en un
4//! mismo árbol BSP que se parte horizontal/vertical, se cierra, se enfoca
5//! (click) y se redimensiona (arrastrando los divisores). Prueba de punta
6//! a punta de que componentes distintos se montan en un layout
7//! intercambiable con splits resizables.
8//!
9//! Correr: `cargo run -p llimphi-widget-panes --example panes_demo --release`
10
11use std::collections::HashMap;
12
13use llimphi_ui::llimphi_layout::taffy::{
14 prelude::{length, percent, FlexDirection, Size, Style},
15 Rect,
16};
17use llimphi_ui::{App, Handle, View};
18use llimphi_theme::Theme;
19use llimphi_widget_panes::{panes_view, Axis, Layout, PaneId, PanesPalette, Side};
20
21struct Demo;
22
23#[derive(Clone)]
24enum Msg {
25 Focus(PaneId),
26 Split(Axis),
27 Close,
28 Resize(Vec<Side>, f32),
29 Inc(PaneId),
30 Dec(PaneId),
31 AddNote(PaneId),
32}
33
34enum Kind {
35 Counter(i64),
36 Notes(Vec<String>),
37}
38
39struct Pane {
40 title: String,
41 kind: Kind,
42}
43
44struct Model {
45 layout: Layout,
46 panes: HashMap<PaneId, Pane>,
47 focused: PaneId,
48 next_id: PaneId,
49 theme: Theme,
50}
51
52impl App for Demo {
53 type Model = Model;
54 type Msg = Msg;
55
56 fn title() -> &'static str {
57 "panes — tmux de componentes tawasuyu"
58 }
59
60 fn init(_: &Handle<Msg>) -> Model {
61 let mut panes = HashMap::new();
62 panes.insert(
63 1,
64 Pane {
65 title: "Contador".into(),
66 kind: Kind::Counter(0),
67 },
68 );
69 panes.insert(
70 2,
71 Pane {
72 title: "Notas".into(),
73 kind: Kind::Notes(vec!["arrastra el divisor del medio →".into()]),
74 },
75 );
76 let mut layout = Layout::single(1);
77 layout.split(1, 2, Axis::Horizontal);
78 Model {
79 layout,
80 panes,
81 focused: 1,
82 next_id: 3,
83 theme: Theme::dark(),
84 }
85 }
86
87 fn update(mut model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
88 match msg {
89 Msg::Focus(id) => model.focused = id,
90 Msg::Split(axis) => {
91 let id = model.next_id;
92 model.next_id += 1;
93 let kind = if id % 2 == 0 {
94 Kind::Counter(0)
95 } else {
96 Kind::Notes(vec![])
97 };
98 let title = match &kind {
99 Kind::Counter(_) => "Contador".to_string(),
100 Kind::Notes(_) => "Notas".to_string(),
101 };
102 model.panes.insert(id, Pane { title, kind });
103 model.layout.split(model.focused, id, axis);
104 model.focused = id;
105 }
106 Msg::Close => {
107 if model.layout.count() > 1 {
108 let target = model.focused;
109 let (nl, removed) = model.layout.clone().without(target);
110 if removed {
111 model.layout = nl;
112 model.panes.remove(&target);
113 model.focused = model.layout.first_leaf();
114 }
115 }
116 }
117 Msg::Resize(path, d) => model.layout.resize(&path, d),
118 Msg::Inc(id) => {
119 if let Some(Pane {
120 kind: Kind::Counter(n),
121 ..
122 }) = model.panes.get_mut(&id)
123 {
124 *n += 1;
125 }
126 }
127 Msg::Dec(id) => {
128 if let Some(Pane {
129 kind: Kind::Counter(n),
130 ..
131 }) = model.panes.get_mut(&id)
132 {
133 *n -= 1;
134 }
135 }
136 Msg::AddNote(id) => {
137 if let Some(Pane {
138 kind: Kind::Notes(v),
139 ..
140 }) = model.panes.get_mut(&id)
141 {
142 let n = v.len() + 1;
143 v.push(format!("nota #{n}"));
144 }
145 }
146 }
147 model
148 }
149
150 fn view(model: &Model) -> View<Msg> {
151 let t = &model.theme;
152 let toolbar = View::new(Style {
153 flex_direction: FlexDirection::Row,
154 gap: Size {
155 width: length(8.0),
156 height: length(8.0),
157 },
158 padding: uniform(8.0),
159 flex_shrink: 0.0,
160 ..Default::default()
161 })
162 .fill(t.bg_panel)
163 .children(vec![
164 button("Split →", Msg::Split(Axis::Horizontal), t),
165 button("Split ↓", Msg::Split(Axis::Vertical), t),
166 button("Cerrar", Msg::Close, t),
167 View::new(Style {
168 flex_grow: 1.0,
169 ..Default::default()
170 }),
171 label(
172 format!("foco #{} · {} paneles", model.focused, model.layout.count()),
173 13.0,
174 t.fg_muted,
175 ),
176 ]);
177
178 let palette = PanesPalette::from_theme(t);
179 let panes = &model.panes;
180 let theme = t;
181 let area = panes_view(
182 &model.layout,
183 model.focused,
184 move |id| render_pane(panes, theme, id),
185 |path, phase, d| {
186 let _ = phase;
187 Some(Msg::Resize(path, d))
188 },
189 Msg::Focus,
190 &palette,
191 );
192
193 let area_wrap = View::new(Style {
194 flex_grow: 1.0,
195 size: Size {
196 width: percent(1.0),
197 height: percent(1.0),
198 },
199 min_size: Size {
200 width: length(0.0),
201 height: length(0.0),
202 },
203 ..Default::default()
204 })
205 .children(vec![area]);
206
207 View::new(Style {
208 flex_direction: FlexDirection::Column,
209 size: Size {
210 width: percent(1.0),
211 height: percent(1.0),
212 },
213 ..Default::default()
214 })
215 .fill(t.bg_app)
216 .children(vec![toolbar, area_wrap])
217 }
218}
219
220fn render_pane(panes: &HashMap<PaneId, Pane>, t: &Theme, id: PaneId) -> View<Msg> {
221 let Some(pane) = panes.get(&id) else {
222 return label("(panel vacío)".to_string(), 14.0, t.fg_muted);
223 };
224
225 let header = label(format!("{} #{id}", pane.title), 13.0, t.fg_text);
226
227 let body = match &pane.kind {
228 Kind::Counter(n) => View::new(Style {
229 flex_direction: FlexDirection::Column,
230 gap: Size {
231 width: length(8.0),
232 height: length(8.0),
233 },
234 ..Default::default()
235 })
236 .children(vec![
237 label(format!("{n}"), 44.0, t.accent),
238 View::new(Style {
239 flex_direction: FlexDirection::Row,
240 gap: Size {
241 width: length(8.0),
242 height: length(8.0),
243 },
244 ..Default::default()
245 })
246 .children(vec![
247 button("−", Msg::Dec(id), t),
248 button("+", Msg::Inc(id), t),
249 ]),
250 ]),
251 Kind::Notes(v) => {
252 let mut lines: Vec<View<Msg>> = v
253 .iter()
254 .map(|s| label(format!("• {s}"), 14.0, t.fg_text))
255 .collect();
256 lines.push(button("+ nota", Msg::AddNote(id), t));
257 View::new(Style {
258 flex_direction: FlexDirection::Column,
259 gap: Size {
260 width: length(6.0),
261 height: length(6.0),
262 },
263 ..Default::default()
264 })
265 .children(lines)
266 }
267 };
268
269 View::new(Style {
270 flex_direction: FlexDirection::Column,
271 gap: Size {
272 width: length(10.0),
273 height: length(10.0),
274 },
275 padding: uniform(12.0),
276 flex_grow: 1.0,
277 ..Default::default()
278 })
279 .children(vec![header, body])
280}
281
282fn button(text: &str, msg: Msg, t: &Theme) -> View<Msg> {
283 View::new(Style {
284 padding: Rect {
285 left: length(12.0),
286 right: length(12.0),
287 top: length(6.0),
288 bottom: length(6.0),
289 },
290 flex_shrink: 0.0,
291 ..Default::default()
292 })
293 .fill(t.bg_button)
294 .hover_fill(t.bg_button_hover)
295 .radius(6.0)
296 .on_click(msg)
297 .children(vec![label(text.to_string(), 14.0, t.fg_text)])
298}
299
300fn label(
301 text: String,
302 size: f32,
303 color: llimphi_ui::llimphi_raster::peniko::Color,
304) -> View<Msg> {
305 View::new(Style::default()).text(text, size, color)
306}
307
308fn uniform(px: f32) -> Rect<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
309 Rect {
310 left: length(px),
311 right: length(px),
312 top: length(px),
313 bottom: length(px),
314 }
315}
316
317fn main() {
318 llimphi_ui::run::<Demo>();
319}