quadraui 0.6.1

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
//! Backend-agnostic app code for the toolbar example
//! ([`tui_toolbar`] / `gtk_toolbar`).
//!
//! [`ToolbarApp`] demonstrates the `Toolbar` primitive's full surface:
//! - Action buttons with icon + label + key hint
//! - A toggle action (`is_active` flips on click)
//! - A permanently disabled action (renders dim, swallows clicks)
//! - A `Separator` between button groups
//! - A non-clickable `Label` showing live state ("paused" / "running")
//! - Hover state (highlight tracks the mouse cursor)
//! - **Keyboard focus** (Tab / Shift-Tab cycles focus, Enter / Space activates)
//! - **Nerd-Font icon fallback**: "Filter" and "Reset" register
//!   a glyph + ASCII fallback pair in a [`ToolbarIcons`] table, baked into
//!   the painted `Toolbar` via `ToolbarIcons::apply`. "Continue" and
//!   "Pause" register nothing, so they paint their own icon either way.
//! - **Multi-row `valign`**: the toolbar slot is reserved at
//!   3 rows so `v` can cycle [`ToolbarVAlign`] through `Top` → `Center` →
//!   `Bottom` and visually confirm all three — `Top`'s row 0 + padding
//!   below, `Center`'s pre-#260 middle row, `Bottom`'s padding above +
//!   last row.
//!
//! Controls:
//! - Click an action button       fire it
//! - Tab / Shift-Tab              move keyboard focus between enabled buttons
//! - Enter / Space                activate the focused button
//! - 1 / 2 / 3 / 4               keyboard shortcuts for the four enabled actions
//! - n                           toggle Nerd-Font glyphs vs ASCII fallbacks
//! - v                           cycle toolbar text vertical alignment
//! - q / Esc                     quit

use quadraui::{
    AppLogic, Backend, Color, Icon, InteractionState, Key, NamedKey, Reaction, Rect, StatusBar,
    StatusBarSegment, Toolbar, ToolbarButton, ToolbarHit, ToolbarIcons, ToolbarPaintOptions,
    ToolbarVAlign, UiEvent, WidgetId,
};

pub struct ToolbarApp {
    /// Set when the user toggles the "Filter" action.
    filter_active: bool,
    /// Running vs paused (Label item flips with this).
    running: bool,
    /// Last status line message (echoes the most recent click / keypress).
    last_message: String,
    /// Hovered/pressed toolbar button, keyed by `WidgetId`.
    /// Handed to `Backend::draw_toolbar_interactive` whole —
    /// the rasteriser reads hover and pressed state out of it.
    interaction: InteractionState,
    /// Index into `self.toolbar().buttons` of the keyboard-focused button,
    /// or `None` when the toolbar has no keyboard focus.
    ///
    /// Tab / Shift-Tab advance this through the list of *enabled* action
    /// buttons (skipping separators, labels, and disabled actions).
    /// Enter / Space activate the focused button.
    focused_index: Option<usize>,
    /// Whether the host claims Nerd-Font glyphs are renderable. Pushed to
    /// the backend every frame via `Backend::set_nerd_fonts`, then read
    /// back through `Backend::nerd_fonts_enabled()` to resolve
    /// [`Self::icons`]. The `n` key flips it so the demo shows both
    /// halves of every registered [`Icon`] pair without restarting.
    nerd_fonts: bool,
    /// Vertical alignment of the toolbar's button/label text within its
    /// (3-row) slot. `v` cycles this Top → Center → Bottom.
    valign: ToolbarVAlign,
}

impl ToolbarApp {
    pub fn new() -> Self {
        Self {
            filter_active: false,
            running: true,
            last_message: "Click, Tab to focus, Enter to activate. q=quit".into(),
            interaction: InteractionState::new(),
            focused_index: None,
            nerd_fonts: false,
            valign: ToolbarVAlign::Top,
        }
    }

    /// Cycle [`Self::valign`] Top → Center → Bottom → Top (the 'v' key).
    fn cycle_valign(&mut self) {
        self.valign = match self.valign {
            ToolbarVAlign::Top => ToolbarVAlign::Center,
            ToolbarVAlign::Center => ToolbarVAlign::Bottom,
            ToolbarVAlign::Bottom => ToolbarVAlign::Top,
        };
    }

    /// Nerd-Font glyph + ASCII fallback pairs for two of the buttons
    ///
    /// `ToolbarButton::Action::icon` is a single `String`, so the pair
    /// lives in this side table and [`ToolbarIcons::apply`] writes the
    /// resolved half into the `Toolbar` handed to the backend. Continue
    /// and Pause deliberately register nothing — they keep painting
    /// their own `icon` on either flag value.
    fn icons() -> ToolbarIcons {
        ToolbarIcons::new()
            .with(WidgetId::new("demo:filter"), Icon::new("\u{f0b0}", "*"))
            .with(WidgetId::new("demo:reset"), Icon::new("\u{f021}", "%"))
    }

    /// The `Toolbar` actually painted and hit-tested: [`Self::toolbar`]
    /// with every registered icon override baked in.
    ///
    /// Both `render` and `handle` go through this, so the layout that
    /// measures a two-cell glyph is the same one the click router
    /// hit-tests against.
    fn resolved_toolbar(&self, backend: &dyn Backend) -> Toolbar {
        Self::icons().apply(&self.toolbar(), backend.nerd_fonts_enabled())
    }

    fn toolbar(&self) -> Toolbar {
        Toolbar::new(WidgetId::new("demo:toolbar"))
            .with_buttons(vec![
                ToolbarButton::Action {
                    id: WidgetId::new("demo:continue"),
                    label: "Continue".into(),
                    icon: Some("▶".into()),
                    key_hint: Some("1".into()),
                    enabled: !self.running,
                    is_active: false,
                    tooltip: "Resume execution".into(),
                },
                ToolbarButton::Action {
                    id: WidgetId::new("demo:pause"),
                    label: "Pause".into(),
                    icon: Some("⏸".into()),
                    key_hint: Some("2".into()),
                    enabled: self.running,
                    is_active: false,
                    tooltip: "Pause execution".into(),
                },
                ToolbarButton::Separator,
                ToolbarButton::Action {
                    id: WidgetId::new("demo:filter"),
                    label: "Filter".into(),
                    icon: Some("⚙".into()),
                    key_hint: Some("3".into()),
                    enabled: true,
                    is_active: self.filter_active,
                    tooltip: "Toggle filter".into(),
                },
                ToolbarButton::Action {
                    id: WidgetId::new("demo:reset"),
                    label: "Reset".into(),
                    icon: Some("↺".into()),
                    key_hint: Some("4".into()),
                    enabled: true,
                    is_active: false,
                    tooltip: "Reset to defaults".into(),
                },
                ToolbarButton::Separator,
                ToolbarButton::Action {
                    id: WidgetId::new("demo:debug"),
                    label: "Debug".into(),
                    icon: None,
                    key_hint: None,
                    // Permanently disabled — demonstrates dimmed paint
                    // and that clicks are dropped.
                    enabled: false,
                    is_active: false,
                    tooltip: "Disabled in this build".into(),
                },
                ToolbarButton::Label {
                    text: if self.running {
                        " running".into()
                    } else {
                        " paused".into()
                    },
                    fg: Some(if self.running {
                        Color::rgb(120, 200, 120)
                    } else {
                        Color::rgb(220, 180, 80)
                    }),
                },
            ])
            // `None` (the default) lets the backend pick its theme default (header_bg).
            .with_focused_index(self.focused_index)
    }

    fn status_bar(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("status"),
            left_segments: vec![StatusBarSegment {
                text: format!("  {} ", self.last_message),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(" n=nerd icons  v=valign:{:?}  q=quit ", self.valign),
                fg: Color::rgb(200, 200, 200),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    /// Reserved height of the toolbar slot, in rows. 3 rows (not 1) so
    /// `v` cycling [`ToolbarVAlign`] actually moves the painted
    /// button row visibly instead of every variant collapsing onto the
    /// same single row.
    const TOOLBAR_ROWS: f32 = 3.0;

    /// Layout rect of the toolbar inside the viewport. Shared between
    /// `render` (paint) and `handle` (hit-test) so paint and click
    /// consume the same coordinates — the source-of-truth contract.
    fn toolbar_rect(backend: &dyn Backend) -> Rect {
        let viewport = backend.viewport();
        let lh = backend.line_height();
        Rect::new(0.0, lh, viewport.width, lh * Self::TOOLBAR_ROWS)
    }

    fn dispatch(&mut self, id: &WidgetId) {
        match id.as_str() {
            "demo:continue" => {
                self.running = true;
                self.last_message = "Continue".into();
            }
            "demo:pause" => {
                self.running = false;
                self.last_message = "Paused".into();
            }
            "demo:filter" => {
                self.filter_active = !self.filter_active;
                self.last_message =
                    format!("Filter {}", if self.filter_active { "on" } else { "off" });
            }
            "demo:reset" => {
                self.filter_active = false;
                self.running = true;
                self.last_message = "Reset".into();
            }
            other => {
                self.last_message = format!("Unknown action: {}", other);
            }
        }
    }

    /// Return the indices of all enabled `Action` buttons in the current
    /// toolbar, in order. Used by Tab navigation to skip separators, labels,
    /// and disabled actions.
    fn focusable_indices(&self) -> Vec<usize> {
        self.toolbar()
            .buttons
            .into_iter()
            .enumerate()
            .filter_map(|(i, btn)| match btn {
                ToolbarButton::Action { enabled, .. } if enabled => Some(i),
                _ => None,
            })
            .collect()
    }

    /// Advance keyboard focus to the next (or previous) focusable button.
    ///
    /// `forward`: `true` for Tab, `false` for Shift-Tab.
    ///
    /// When no button has focus, Tab lands on the first focusable button;
    /// Shift-Tab lands on the last. Focus wraps around.
    fn advance_focus(&mut self, forward: bool) {
        let candidates = self.focusable_indices();
        if candidates.is_empty() {
            return;
        }
        self.focused_index = Some(match self.focused_index {
            None => {
                if forward {
                    candidates[0]
                } else {
                    *candidates.last().unwrap()
                }
            }
            Some(current) => {
                let pos = candidates.iter().position(|&i| i == current);
                match pos {
                    None => candidates[0], // stale index (e.g. button just disabled)
                    Some(p) => {
                        let next = if forward {
                            (p + 1) % candidates.len()
                        } else {
                            (p + candidates.len() - 1) % candidates.len()
                        };
                        candidates[next]
                    }
                }
            }
        });
    }

    /// Activate the currently focused button (Enter / Space). No-op when
    /// no button has focus.
    fn activate_focused(&mut self) -> bool {
        let idx = match self.focused_index {
            Some(i) => i,
            None => return false,
        };
        let bar = self.toolbar();
        if let Some(ToolbarButton::Action { id, enabled, .. }) = bar.buttons.get(idx) {
            if *enabled {
                let id = *id;
                self.dispatch(&id);
                return true;
            }
        }
        false
    }
}

impl Default for ToolbarApp {
    fn default() -> Self {
        Self::new()
    }
}

impl AppLogic for ToolbarApp {
    type AreaId = ();

    fn render(&self, backend: &mut dyn Backend, _area: ()) {
        // Push the host's Nerd-Font capability every frame, per the contract.
        backend.set_nerd_fonts(self.nerd_fonts);

        let viewport = backend.viewport();
        let lh = backend.line_height();

        // Title row.
        let title_rect = Rect::new(0.0, 0.0, viewport.width, lh);
        let _ = backend.draw_status_bar_interactive(
            title_rect,
            &StatusBar {
                id: WidgetId::new("title"),
                left_segments: vec![StatusBarSegment {
                    text: "  Toolbar primitive demo ".into(),
                    fg: Color::rgb(255, 255, 255),
                    bg: Color::rgb(30, 30, 30),
                    bold: true,
                    action_id: None,
                }],
                right_segments: vec![],
            },
            &InteractionState::new(),
        );

        // Toolbar in the second row.
        let rect = Self::toolbar_rect(backend);
        let bar = self.resolved_toolbar(backend);
        let _ = backend.draw_toolbar_with_options(
            rect,
            &bar,
            &self.interaction,
            ToolbarPaintOptions {
                valign: self.valign,
            },
        );

        // Status bar at the bottom.
        let status_rect = Rect::new(0.0, viewport.height - lh, viewport.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(),
            &InteractionState::new(),
        );
    }

    fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
        match event {
            // ── Quit ──────────────────────────────────────────────────────────
            UiEvent::KeyPressed {
                key: Key::Char('q'),
                ..
            }
            | UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Escape),
                ..
            } => Reaction::Exit,

            // ── Tab: move keyboard focus forward ───────────────────────────
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Tab),
                ..
            } => {
                self.advance_focus(true);
                let focused_label = self.focused_index.and_then(|idx| {
                    let bar = self.toolbar();
                    bar.buttons.into_iter().nth(idx).and_then(|btn| match btn {
                        ToolbarButton::Action { label, .. } => Some(label),
                        _ => None,
                    })
                });
                self.last_message = match focused_label {
                    Some(l) => format!("Focused: {l} (Enter to activate)"),
                    None => "Focus cleared".into(),
                };
                Reaction::Redraw
            }

            // ── Shift-Tab (BackTab): move keyboard focus backward ──────────
            // Real terminals (crossterm, GTK) send Shift-Tab as
            // `NamedKey::BackTab` with no shift modifier — not as Tab + shift.
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::BackTab),
                ..
            } => {
                self.advance_focus(false);
                let focused_label = self.focused_index.and_then(|idx| {
                    let bar = self.toolbar();
                    bar.buttons.into_iter().nth(idx).and_then(|btn| match btn {
                        ToolbarButton::Action { label, .. } => Some(label),
                        _ => None,
                    })
                });
                self.last_message = match focused_label {
                    Some(l) => format!("Focused: {l} (Enter to activate)"),
                    None => "Focus cleared".into(),
                };
                Reaction::Redraw
            }

            // ── Enter / Space: activate focused button ─────────────────────
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Enter) | Key::Char(' '),
                ..
            } => {
                if self.activate_focused() {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }

            // ── n: toggle Nerd-Font glyphs vs ASCII fallbacks ──────────────
            UiEvent::KeyPressed {
                key: Key::Char('n'),
                ..
            } => {
                self.nerd_fonts = !self.nerd_fonts;
                self.last_message = format!(
                    "Nerd-Font icons {}",
                    if self.nerd_fonts { "on" } else { "off" }
                );
                Reaction::Redraw
            }

            // ── v: cycle toolbar text vertical alignment ───────────────────
            UiEvent::KeyPressed {
                key: Key::Char('v'),
                ..
            } => {
                self.cycle_valign();
                self.last_message = format!("Toolbar valign: {:?}", self.valign);
                Reaction::Redraw
            }

            // ── Number shortcuts for individual buttons ────────────────────
            UiEvent::KeyPressed {
                key: Key::Char(c), ..
            } => {
                let id = match c {
                    '1' => Some("demo:continue"),
                    '2' => Some("demo:pause"),
                    '3' => Some("demo:filter"),
                    '4' => Some("demo:reset"),
                    _ => None,
                };
                if let Some(id) = id {
                    // Honour the toolbar's enabled state — same gate the
                    // hit-test path applies to mouse clicks.
                    let bar = self.toolbar();
                    let allowed = bar.buttons.iter().any(|btn| {
                        matches!(
                            btn,
                            ToolbarButton::Action { id: bid, enabled, .. }
                                if bid.as_str() == id && *enabled
                        )
                    });
                    if allowed {
                        self.dispatch(&WidgetId::new(id));
                        return Reaction::Redraw;
                    }
                }
                Reaction::Continue
            }

            // ── Mouse: hover / press / release ────────────────────────────
            // One arm, one convention: `self.interaction`
            // (an `InteractionState`) owns *all* hover/pressed
            // bookkeeping for every mouse event, and `render` hands the
            // whole thing to `draw_toolbar_interactive` above. This app
            // supplies only the one thing the store can't know — how to
            // resolve a screen position to a `WidgetId` against the
            // toolbar layout it just painted.
            //
            // Two `InteractionState` behaviours are load-bearing here
            // and differ from the hand-rolled arms this replaced:
            // a non-left `MouseDown` never lights the pressed highlight,
            // and a left `MouseDown` that misses every button clears a
            // stale pressed id instead of leaving it lit. Both are
            // covered by `quadraui::interaction`'s unit tests and by
            // `toolbar_press_highlights_only_on_left_button` /
            // `toolbar_press_then_click_empty_space_clears_the_highlight`
            // in `tests/tui_example_driver.rs`.
            UiEvent::MouseMoved { .. } | UiEvent::MouseDown { .. } | UiEvent::MouseUp { .. } => {
                let rect = Self::toolbar_rect(backend);
                // The *resolved* bar, exactly as painted — a two-cell
                // Nerd glyph makes every button to its right start two
                // cells later, and the click router has to agree.
                let bar = self.resolved_toolbar(backend);
                let layout = backend.toolbar_layout(rect, &bar);
                let hit_test = |x: f32, y: f32| match layout.hit_test(x, y) {
                    ToolbarHit::Button(id) => Some(id),
                    ToolbarHit::Empty => None,
                };

                // Snapshot before the store consumes the event: a
                // `MouseUp` clears `pressed`, and the click contract
                // needs to know what *was* held.
                let was_pressed = self.interaction.pressed().cloned();
                let changed = self.interaction.handle_mouse(&event, hit_test);

                // Release on the same button that was pressed = a click.
                if let UiEvent::MouseUp { position, .. } = &event {
                    if let (Some(pressed_id), ToolbarHit::Button(release_id)) =
                        (was_pressed, layout.hit_test(position.x, position.y))
                    {
                        if pressed_id == release_id {
                            self.dispatch(&release_id);
                        }
                    }
                    return Reaction::Redraw;
                }

                if changed {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }

            UiEvent::WindowResized { .. } => Reaction::Redraw,
            _ => Reaction::Continue,
        }
    }
}