Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

quadraui

One Rust UI codebase that runs in a terminal and as a native desktop window.

quadraui is a widget toolkit with four rendering backends: TUI (via ratatui), GTK4 (Cairo + Pango), macOS (Core Graphics + Core Text) and Windows (Direct2D + DirectWrite). You write your app once, and the same code runs over SSH in a terminal or as a native window, with only the one-line runner call in main changing.

The quadraui gallery running as a native macOS window (Core Graphics). The same widget code renders in a terminal and on the other native backends — compare any demo across backends in the Gallery.

use quadraui::prelude::*;

struct Hello { keys_pressed: u32 }

impl Hello {
    fn config() -> ShellConfig {
        ShellConfig::new("Hello", Vec::new())
            .with_status_bar()
            // No panels, so no activity-bar column to pick one with.
            .with_activity_bar_width(0.0)
    }
}

impl ShellApp for Hello {
    fn render_content(&self, backend: &mut dyn Backend, layout: &AppShellLayout) {
        // The shell already computed where the status bar goes —
        // no viewport arithmetic here. Describe the UI as plain data;
        // the backend rasterises it natively.
        let Some(rect) = layout.status_bar_bounds else { return };
        let bar = StatusBar {
            id: WidgetId::new("status:bar"),
            left_segments: vec![StatusBarSegment {
                text: format!(" Hello, quadraui! (keys: {}) ", self.keys_pressed),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: true,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let mut frame = ScreenLayout::new();
        frame.push(Surface::StatusBar { rect, bar, hovered: None, pressed: None });
        frame.draw(backend);
    }

    fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend, _ctx: &ShellContext) -> Reaction {
        match event {
            UiEvent::KeyPressed { key: Key::Char('q'), .. } => Reaction::Exit,
            UiEvent::KeyPressed { .. } => { self.keys_pressed += 1; Reaction::Redraw }
            _ => Reaction::Continue,
        }
    }
}

fn main() {
    quadraui::tui::shell_runner::run_with_shell(Hello { keys_pressed: 0 }, Hello::config());
    // quadraui::gtk::shell_runner::run_with_shell(...), quadraui::macos::... or
    // quadraui::win::... for a native window: same `Hello`.
}

The full version is quadraui/examples/hello.rs: cargo run -p quadraui --example hello --features tui.

Why it is different

  • Terminal and native from one codebase. Ratatui and Textual have no native backend; egui, iced and Slint have no terminal backend. quadraui has both, behind one trait.
  • Paint and click share one layout. Every primitive exposes a layout(...) that the rasteriser paints from and the host hit-tests against. Mouse clicks cannot drift from what is on screen, because both read the same computed rectangles.
  • Small, and idle when idle. The runtime is event-driven: an app with nothing to do renders nothing.
  • Declarative, serialisable widgets. Primitives are plain data (Serialize), events are plain data routed by WidgetId, and no closures cross the API. That is what makes scripting practical (vimcode lets Lua extensions declare quadraui views), and later, language bindings.
  • Honest about capabilities. BackendCaps tells an app what the current backend can do. Where a terminal cannot do something (a tray icon, a native dialog) the backend degrades and says so, rather than silently doing nothing.
  • Heavily tested. Headless paint-then-click round trips on every backend, and an end-to-end driver that drives real example apps through the actual event loop — see the Gallery for every primitive this produces, captured straight from that same harness.

Browse the Gallery for a demo of every primitive — captured live on the TUI and macOS backends today, with the GTK and Windows columns in progress — or jump to Getting Started to add quadraui to your own project.

Getting Started

Add the dependency

quadraui is published on crates.io (latest 0.2.2) — add it with the backend feature(s) your app needs:

[dependencies]
quadraui = { version = "0.2", features = ["tui"] }

To track unreleased work instead, depend on the develop branch:

[dependencies]
quadraui = { git = "https://github.com/JDonaghy/quadraui", branch = "develop", features = ["tui"] }

The backend features:

FeatureBackend
tuiTerminal, via ratatui
gtkGTK4, via Cairo + Pango (needs libgtk-4-dev / pkg-config to build)
macosCore Graphics + Core Text (only compiles on target_os = "macos")
winDirect2D + DirectWrite

Run the example

git clone https://github.com/JDonaghy/quadraui
cd quadraui
cargo run -p quadraui --example hello --features tui

Press any key to bump the counter; q to quit. Swap --features tui for --features gtk (needs GTK4 installed) to run the exact same Hello app as a native window instead.

cargo run -p quadraui-gallery --features tui

Every primitive this site’s Gallery documents lives in quadraui-gallery/src/demos/ — one Demo implementation per primitive, with a Code tab showing its own source and a Data tab showing the plain JSON it renders.

Where to go next

  • Gallery — every primitive, with its code sample and a backend-support grid.
  • docs.rs — API reference for the published release (or cargo doc -p quadraui --features tui,gtk --open from a checkout for unreleased work).
  • crates.io — the published crate.
  • GitHub repository — source, issues, and CLAUDE.md/CONTRIBUTING.md for contributing.

Gallery

Every quadraui primitive demo, generated from the gallery’s own registry — see tools/site_gen.py.

Content

Chrome

Overlays

Data

Caret Shape

Group: Content

Code

pub struct CaretShapeDemo {
    /// Last shape actually applied via `set_caret_shape`, so repeat
    /// renders of the same variant don't re-issue the (comparatively
    /// heavy) terminal write.
    applied: Cell<Option<EditorCursorShape>>,
    calls: Cell<u32>,
}

impl CaretShapeDemo {
    pub fn new() -> Self {
        Self {
            applied: Cell::new(None),
            calls: Cell::new(0),
        }
    }

    fn shape_for_variant(variant: usize) -> EditorCursorShape {
        match variant {
            1 => EditorCursorShape::Bar,
            2 => EditorCursorShape::Underline,
            _ => EditorCursorShape::Block,
        }
    }

    fn mode_name(shape: EditorCursorShape) -> &'static str {
        match shape {
            EditorCursorShape::Block => "Normal",
            EditorCursorShape::Bar => "Insert",
            EditorCursorShape::Underline => "Replace-pending",
        }
    }

    fn build_editor(shape: EditorCursorShape, area: Rect) -> Editor {
        let text = "the quick brown fox".to_string();
        let text_len = text.len();
        let fg = Color::rgb(220, 220, 220);
        let line = EditorLine {
            raw_text: text,
            gutter_text: "   1".into(),
            spans: vec![EditorStyledSpan {
                start_byte: 0,
                end_byte: text_len,
                style: EditorStyle {
                    fg,
                    bg: None,
                    bold: false,
                    italic: false,
                    font_scale: 1.0,
                },
            }],
            line_idx: 0,
            is_current_line: true,
            is_fold_header: false,
            folded_line_count: 0,
            git_diff: None,
            diff_status: None,
            diagnostics: vec![],
            spell_errors: vec![],
            is_breakpoint: false,
            is_conditional_bp: false,
            is_dap_current: false,
            is_wrap_continuation: false,
            segment_col_offset: 0,
            annotation: None,
            ghost_suffix: None,
            is_ghost_continuation: false,
            indent_guides: vec![],
            colorcolumns: vec![],
        };
        Editor::new(WidgetId::new("gallery:caret-shape:editor"), area)
            .with_lines(vec![line])
            .with_cursor(EditorCursor {
                pos: EditorCursorPos {
                    view_line: 0,
                    col: 0,
                },
                shape,
            })
            .with_total_lines(1)
            .with_max_col(20)
            .with_gutter_char_width(4)
            .with_is_active(true)
            .with_cursorline(true)
            // `'\0'` means "no lightbulb" — this demo has nothing to
            // offer a code action for, unlike the primitive's own
            // default of `'!'`.
            .with_lightbulb_glyph('\0')
    }

    fn status_bar(&self, shape: EditorCursorShape) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:caret-shape:status"),
            left_segments: vec![StatusBarSegment {
                text: " Backend::set_caret_shape ".into(),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: true,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(
                    " mode: {}  calls: {} ",
                    Self::mode_name(shape),
                    self.calls.get()
                ),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }
}

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

impl Demo for CaretShapeDemo {
    fn name(&self) -> &'static str {
        "Caret Shape"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Normal (Block)", "Insert (Bar)", "Replace (Underline)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let shape = Self::shape_for_variant(variant);
        if self.applied.get() != Some(shape) {
            backend.set_caret_shape(shape);
            self.applied.set(Some(shape));
            self.calls.set(self.calls.get() + 1);
        }

        let lh = backend.line_height();
        let bar_h = if lh > 1.5 { lh * 1.5 } else { lh };
        let editor_area = Rect::new(area.x, area.y, area.width, (area.height - bar_h).max(0.0));
        let editor = Self::build_editor(shape, editor_area);
        backend.draw_editor(editor.rect, &editor);

        let bar_rect = Rect::new(area.x, area.y + editor_area.height, area.width, bar_h);
        let _ = backend.draw_status_bar_interactive(
            bar_rect,
            &self.status_bar(shape),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        _variant: usize,
        _event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let shape = Self::shape_for_variant(variant);
        serde_json::json!({
            "mode": Self::mode_name(shape),
            "set_caret_shape_calls": self.calls.get(),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // `Backend::set_caret_shape` is a genuine, permanent no-op on
        // every GUI backend by design (they paint their own caret and
        // have no separate hardware cursor to steer) — not a capability
        // gap `BackendCaps` tracks, so there is nothing to report here.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Normal (Block)
Insert (Bar)
Replace (Underline)

Chat

Group: Content

Code

/// Cadence for the "thinking" spinner's animation frame while
/// `thinking_ticks > 0` — see [`InteractiveState::tick`].
const THINKING_TICK_INTERVAL: Duration = Duration::from_millis(100);

/// Canned (prompt, markdown-reply) pairs cycled through by the
/// "Markdown transcript" variant.
const EXCHANGES: &[(&str, &str)] = &[
    (
        "How do I convert markdown to styled text?",
        "\
# Markdown \u{2192} StyledText

Call `render_markdown_to_styled(input, &theme)`. It returns:

- `lines` — one **StyledText** per source line
- `line_text` — plain text, for hit-tests and search

> `snake_case` identifiers like render_markdown_to_styled stay upright.",
    ),
    (
        "What markdown does it support today?",
        "\
Supported constructs

Inline **bold**, *italic*, and `inline_code` all render with correct spans.

1. Numbered items render with a styled marker
2. Both dash and asterisk bullet syntax work

> This is a blockquote with a `\u{2502}` left-bar decoration.",
    ),
];

/// Variant 0 state: interactive session — echoes the user's message back
/// as a simulated assistant reply after a short "thinking" delay.
struct InteractiveState {
    controller: ChatController,
    turns: Vec<ChatTurn>,
    thinking_ticks: usize,
    pending_reply: Option<String>,
    spinner_frame: usize,
}

impl InteractiveState {
    fn new() -> Self {
        let mut controller = ChatController::new("gallery:chat:interactive");
        controller.set_status(StyledText::plain(
            "Enter to send, Ctrl+C to quit demo input",
        ));
        controller.set_model_label("claude-opus-4-5");
        controller.set_submit_on_enter(true);
        controller.set_hint(Some(StyledText::plain(
            "Enter to send \u{b7} Shift+Enter for a newline",
        )));
        Self {
            controller,
            turns: Vec::new(),
            thinking_ticks: 0,
            pending_reply: None,
            spinner_frame: 0,
        }
    }

    fn sync(&mut self) {
        self.controller.set_transcript(self.turns.clone());
        self.controller.set_busy(self.thinking_ticks > 0);
    }

    fn handle(&mut self, event: &UiEvent, backend: &mut dyn Backend, area: Rect) -> Reaction {
        let ev = self.controller.handle(event, backend, area);
        match ev {
            ChatControllerEvent::Submit { text } => {
                self.turns.push(ChatTurn {
                    role: ChatRole::User,
                    text: StyledText::colored(text.clone(), Color::rgb(220, 220, 220)),
                    timestamp_unix: None,
                    line_scales: Vec::new(),
                });
                self.controller.clear_input();
                self.pending_reply = Some(format!("Echo: {text}"));
                self.thinking_ticks = 5;
                backend.request_frame_in(THINKING_TICK_INTERVAL);
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::StopRequested => {
                self.thinking_ticks = 0;
                self.pending_reply = None;
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::TurnClicked {
                turn_idx,
                row_in_turn: 0,
            } => {
                self.controller.toggle_turn_collapsed(turn_idx);
                self.sync();
                Reaction::Redraw
            }
            ChatControllerEvent::Consumed => Reaction::Redraw,
            _ => {
                if matches!(event, UiEvent::WindowResized { .. }) {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
        }
    }

    fn tick(&mut self, backend: &mut dyn Backend) -> Reaction {
        if self.thinking_ticks == 0 {
            return Reaction::Continue;
        }
        self.thinking_ticks -= 1;
        self.spinner_frame = self.spinner_frame.wrapping_add(1);
        self.controller.set_spinner_frame(self.spinner_frame);
        if self.thinking_ticks == 0 {
            if let Some(reply) = self.pending_reply.take() {
                self.turns.push(ChatTurn {
                    role: ChatRole::Assistant,
                    text: StyledText::colored(reply, Color::rgb(180, 230, 180)),
                    timestamp_unix: None,
                    line_scales: Vec::new(),
                });
            }
        } else {
            backend.request_frame_in(THINKING_TICK_INTERVAL);
        }
        self.sync();
        Reaction::Redraw
    }
}

/// Variant 1 state: canned markdown exchanges pushed via
/// `push_turn_markdown`.
struct TranscriptState {
    controller: ChatController,
    next_exchange: usize,
}

impl TranscriptState {
    fn new() -> Self {
        let mut controller = ChatController::new("gallery:chat:transcript");
        controller.set_status(StyledText::plain(
            "AI transcript \u{b7} Ctrl+S to send \u{b7} PgUp/PgDn scroll",
        ));
        controller.set_scrollbar_width(Some(1.0));
        controller.push_turn(
            ChatRole::System,
            StyledText::plain("Connected. Press Ctrl+S or Alt+Enter to send the next prompt."),
        );
        Self {
            controller,
            next_exchange: 0,
        }
    }

    fn send_next(&mut self) {
        let (prompt, reply_md) = EXCHANGES[self.next_exchange % EXCHANGES.len()];
        self.next_exchange += 1;
        self.controller
            .push_turn(ChatRole::User, StyledText::plain(prompt));
        let theme = Theme::default();
        self.controller
            .push_turn_markdown(ChatRole::Assistant, reply_md, &theme);
    }

    /// Total turns pushed so far: the one `System` turn from [`Self::new`]
    /// plus a (user, assistant) pair per completed [`Self::send_next`]
    /// call. `ChatController` has no public turn-count accessor to read
    /// this back from directly, so it's derived here instead of (wrongly)
    /// reporting `transcript_scroll_top()` as if it were one.
    fn turn_count(&self) -> usize {
        1 + self.next_exchange * 2
    }

    fn handle(&mut self, event: &UiEvent, backend: &mut dyn Backend, area: Rect) -> Reaction {
        match self.controller.handle(event, backend, area) {
            ChatControllerEvent::Submit { .. } => {
                self.send_next();
                Reaction::Redraw
            }
            ChatControllerEvent::Consumed => Reaction::Redraw,
            _ => Reaction::Continue,
        }
    }
}

pub struct ChatDemo {
    interactive: InteractiveState,
    transcript: TranscriptState,
}

impl ChatDemo {
    pub fn new() -> Self {
        Self {
            interactive: InteractiveState::new(),
            transcript: TranscriptState::new(),
        }
    }
}

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

impl Demo for ChatDemo {
    fn name(&self) -> &'static str {
        "Chat"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Interactive", "Markdown transcript"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        match variant {
            1 => self.transcript.controller.render(backend, area),
            _ => self.interactive.controller.render(backend, area),
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            1 => self.transcript.handle(event, backend, area),
            _ => self.interactive.handle(event, backend, area),
        }
    }

    fn tick(&mut self, variant: usize, backend: &mut dyn Backend) -> Reaction {
        match variant {
            1 => Reaction::Continue,
            _ => self.interactive.tick(backend),
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            1 => serde_json::json!({
                "turns": self.transcript.turn_count(),
                "next_exchange": self.transcript.next_exchange,
            }),
            _ => serde_json::json!({
                "turns": self.interactive.turns.len(),
                "thinking_ticks": self.interactive.thinking_ticks,
                "input": self.interactive.controller.input_text(),
            }),
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Interactive
Markdown transcript

Command Line

Group: Content

Code

pub struct CommandLineDemo {
    text: String,
    /// Byte-offset `(start, end)` of the word the "Selection" variant
    /// highlights — computed once from `text` so it can't drift out of
    /// range if the text above ever changes.
    word_range: (usize, usize),
}

impl CommandLineDemo {
    pub fn new() -> Self {
        let text = ":select-me".to_string();
        let word_start = text.find("select-me").expect("literal is present");
        let word_range = (word_start, text.len());
        Self { text, word_range }
    }

    fn command_line(&self) -> CommandLine {
        CommandLine {
            id: WidgetId::new("gallery:command-line"),
            text: self.text.clone(),
            cursor_offset: Some(self.text.len()),
            right_align: false,
        }
    }
}

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

impl Demo for CommandLineDemo {
    fn name(&self) -> &'static str {
        "Command Line"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Plain", "Selection highlight"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let rect = Rect::new(area.x, area.y, area.width, lh);
        let cmd = self.command_line();
        if variant == 1 {
            backend.draw_command_line_selection(rect, &cmd, Some(self.word_range));
        } else {
            backend.draw_command_line(rect, &cmd);
        }
    }

    fn handle(
        &mut self,
        _variant: usize,
        _event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "command_line": self.command_line(),
            "selection": if variant == 1 {
                Some(self.word_range)
            } else {
                None
            },
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Plain
Selection highlight

Command Palette

Group: Content

Code

static BRANCHES: &[&str] = &[
    "main",
    "develop",
    "feature/palette-dual-mode",
    "feature/gtk-rasteriser",
    "fix/vt100-wide-char",
    "fix/word-wrap-styled",
    "refactor/backend-trait",
    "chore/deps-update",
];

pub struct PaletteDemo {
    picker: DualModePaletteController,
    current_branch: String,
    status: String,
}

impl PaletteDemo {
    pub fn new() -> Self {
        Self {
            picker: make_picker(BRANCHES),
            current_branch: "main".into(),
            status: "Tab=toggle mode  Enter=confirm".into(),
        }
    }

    fn popup_rect(variant: usize, area: Rect, backend: &dyn Backend) -> Rect {
        let scale = if variant == 1 { 0.9 } else { 0.55 };
        let w = (area.width * scale).max(40.0);
        let h = (area.height * 0.6).max(10.0 * backend.line_height());
        let x = area.x + (area.width - w) / 2.0;
        let y = area.y + (area.height - h) / 2.0;
        Rect::new(x, y, w, h)
    }

    fn status_bar(&self) -> StatusBar {
        let mode_label = match self.picker.mode() {
            PaletteMode::List => " [LIST] ",
            PaletteMode::Input => " [INPUT] ",
        };
        StatusBar {
            id: WidgetId::new("gallery:palette:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.status),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(35, 55, 90),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![
                StatusBarSegment {
                    text: mode_label.into(),
                    fg: Color::rgb(100, 200, 255),
                    bg: Color::rgb(30, 50, 80),
                    bold: true,
                    action_id: None,
                },
                StatusBarSegment {
                    text: format!("  {}  ", self.current_branch),
                    fg: Color::rgb(180, 255, 140),
                    bg: Color::rgb(30, 80, 30),
                    bold: false,
                    action_id: None,
                },
            ],
        }
    }
}

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

impl Demo for PaletteDemo {
    fn name(&self) -> &'static str {
        "Command Palette"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Compact", "Wide"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_h = lh * 1.5;
        let bar_rect = Rect::new(area.x, area.y + area.height - bar_h, area.width, bar_h);
        let _ = backend.draw_status_bar_interactive(
            bar_rect,
            &self.status_bar(),
            &InteractionState::new(),
        );

        let popup_rect = Self::popup_rect(variant, area, backend);
        self.picker.render(popup_rect, backend);
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let popup_rect = Self::popup_rect(variant, area, backend);
        let lh = backend.line_height();
        let popup_rows = if lh > 0.0 {
            (popup_rect.height / lh) as usize
        } else {
            20
        };
        let visible_rows = popup_rows.saturating_sub(PALETTE_CHROME_ROWS);

        let ev = match event {
            UiEvent::MouseDown { .. } => {
                self.picker
                    .handle_mouse(event, backend, popup_rect, visible_rows)
            }
            _ => self.picker.handle(event, visible_rows),
        };

        match ev {
            DualModePaletteEvent::ItemConfirmed { idx } => {
                let query = self.picker.query().to_lowercase();
                let matched: Vec<&&str> = BRANCHES
                    .iter()
                    .filter(|b| b.to_lowercase().contains(&query))
                    .collect();
                if let Some(branch) = matched.get(idx) {
                    self.current_branch = (*branch).to_string();
                    self.status = format!("Switched to '{}'", self.current_branch);
                }
                Reaction::Redraw
            }
            DualModePaletteEvent::TextConfirmed { value } => {
                if !value.is_empty() {
                    self.current_branch = value.clone();
                    self.status = format!("Created and switched to '{value}'");
                }
                Reaction::Redraw
            }
            DualModePaletteEvent::QueryChanged { value } => {
                let q = value.to_lowercase();
                let matching: Vec<&str> = BRANCHES
                    .iter()
                    .copied()
                    .filter(|b| b.to_lowercase().contains(&q))
                    .collect();
                self.picker.set_items(branches_as_items(&matching));
                Reaction::Redraw
            }
            DualModePaletteEvent::ModeToggled { new_mode } => {
                self.status = match new_mode {
                    PaletteMode::Input => "Type a new branch name, Enter to create".into(),
                    PaletteMode::List => "Type to filter, Enter to select".into(),
                };
                if new_mode == PaletteMode::List {
                    let q = self.picker.query().to_lowercase();
                    let matching: Vec<&str> = BRANCHES
                        .iter()
                        .copied()
                        .filter(|b| b.to_lowercase().contains(&q))
                        .collect();
                    self.picker.set_items(branches_as_items(&matching));
                }
                Reaction::Redraw
            }
            DualModePaletteEvent::Cancelled => {
                self.status = "Dismissed — reopen by switching variants".into();
                Reaction::Redraw
            }
            DualModePaletteEvent::Consumed => Reaction::Redraw,
            // Escape falls through to `Reaction::Continue` — the
            // gallery shell quits on an unconsumed Escape, matching
            // every other gallery demo's convention of not re-handling
            // the quit key itself.
            DualModePaletteEvent::Ignored => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, _variant: usize) -> serde_json::Value {
        serde_json::json!({
            "mode": format!("{:?}", self.picker.mode()),
            "query": self.picker.query(),
            "current_branch": self.current_branch,
            "status": self.status,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

fn make_picker(branches: &[&str]) -> DualModePaletteController {
    let items = branches_as_items(branches);
    DualModePaletteController::new("Switch Branch", Some("New branch:".into()), items)
        .with_id("gallery:branch-picker")
}

fn branches_as_items(branches: &[&str]) -> Vec<PaletteItem> {
    branches
        .iter()
        .map(|name| PaletteItem {
            text: StyledText {
                spans: vec![StyledSpan::plain(*name)],
            },
            detail: None,
            icon: None,
            match_positions: vec![],
            depth: 0,
            expandable: false,
            expanded: false,
        })
        .collect()
}

Backends

VariantTUIGTKmacOSWindows
Compact
Wide

Editor

Group: Content

Code

const LINE_LEN: usize = 500;
/// CSS/Pango generic-family token — see [`quadraui::GenericFamily`] — not
/// a concrete font name, so this resolves to whatever monospace face
/// each backend's host actually has installed instead of assuming a
/// fontconfig-only name like `"DejaVu Sans Mono"` exists everywhere.
const DEMO_FONT_FAMILY: &str = "monospace";
const DEMO_FONT_SIZE_PT: f32 = 24.0;

fn plain_line(raw_text: String, gutter: &str, idx: usize) -> EditorLine {
    let fg = Color::rgb(220, 220, 220);
    // `EditorStyledSpan::{start_byte,end_byte}` are byte offsets (see
    // that struct's own field doc), so `raw_text.len()` — the string's
    // byte length, not a char count — is the correct span end for any
    // text, ASCII or not.
    let end_byte = raw_text.len();
    EditorLine {
        raw_text,
        gutter_text: gutter.to_string(),
        spans: vec![EditorStyledSpan {
            start_byte: 0,
            end_byte,
            style: EditorStyle {
                fg,
                bg: None,
                bold: false,
                italic: false,
                font_scale: 1.0,
            },
        }],
        line_idx: idx,
        is_current_line: idx == 0,
        is_fold_header: false,
        folded_line_count: 0,
        git_diff: None,
        diff_status: None,
        diagnostics: vec![],
        spell_errors: vec![],
        is_breakpoint: false,
        is_conditional_bp: false,
        is_dap_current: false,
        is_wrap_continuation: false,
        segment_col_offset: 0,
        annotation: None,
        ghost_suffix: None,
        is_ghost_continuation: false,
        indent_guides: vec![],
        colorcolumns: vec![],
    }
}

/// Variant 0 state: horizontal-scroll smoke test.
struct HScrollState {
    cursor_col: usize,
    scroll_left: usize,
}

impl HScrollState {
    fn new() -> Self {
        Self {
            cursor_col: 0,
            scroll_left: 0,
        }
    }

    fn line_text() -> String {
        (0..LINE_LEN)
            .map(|i| ((i % 10) as u8 + b'0') as char)
            .collect()
    }

    fn viewport_cols(area: Rect, backend: &dyn Backend) -> usize {
        let cw = backend.char_width();
        let gutter_w = 4.0 * cw;
        ((area.width - gutter_w) / cw).floor().max(1.0) as usize
    }

    fn ensure_cursor_visible(&mut self, viewport_cols: usize) {
        if self.cursor_col < self.scroll_left {
            self.scroll_left = self.cursor_col;
        } else if self.cursor_col >= self.scroll_left + viewport_cols {
            self.scroll_left = self.cursor_col + 1 - viewport_cols;
        }
    }

    fn build_editor(&self, area: Rect) -> Editor {
        let line = plain_line(Self::line_text(), "   1", 0);
        Editor::new(WidgetId::new("gallery:editor:hscroll"), area)
            .with_lines(vec![line])
            .with_cursor(EditorCursor {
                pos: EditorCursorPos {
                    view_line: 0,
                    col: self.cursor_col,
                },
                shape: EditorCursorShape::Block,
            })
            .with_scroll_left(self.scroll_left)
            .with_total_lines(1)
            .with_max_col(LINE_LEN)
            .with_gutter_char_width(4)
            .with_is_active(true)
            .with_cursorline(true)
            .with_lightbulb_glyph('\0')
    }
}

/// Font this demo restores on variant 0 once it has applied the
/// override at least once — the shell runner's own documented default
/// (`ShellConfig`'s doc: GTK falls back to `"Monospace 11"` when no
/// `with_editor_font` override is configured), expressed as the generic
/// token so every backend resolves it to its own native monospace face
/// rather than this demo hardcoding one.
const DEFAULT_FONT_FAMILY: &str = "monospace";
const DEFAULT_FONT_SIZE_PT: f32 = 11.0;

/// Variant 1 state: font-override smoke test. `applied` tracks the
/// `(family, size_pt)` pair actually last pushed to the backend, so
/// repeated renders of the same variant don't re-issue `set_editor_font`
/// every frame — mirroring the `Caret Shape` demo's
/// `Cell<Option<EditorCursorShape>>` debounce pattern, including its
/// shape: `None` until this demo's font is touched for the first time,
/// so a session that never visits "Font override" never calls
/// `set_editor_font` at all.
struct FontState {
    applied: Cell<Option<(&'static str, f32)>>,
    calls: Cell<u32>,
}

impl FontState {
    fn new() -> Self {
        Self {
            applied: Cell::new(None),
            calls: Cell::new(0),
        }
    }

    /// Re-applies the override (debounced) and counts the call.
    fn apply_override(&self, backend: &mut dyn Backend) {
        let desired = (DEMO_FONT_FAMILY, DEMO_FONT_SIZE_PT);
        if self.applied.get() != Some(desired) {
            backend.set_editor_font(desired.0, desired.1);
            self.applied.set(Some(desired));
            self.calls.set(self.calls.get() + 1);
        }
    }

    /// Puts the default font back, but only if this demo had actually
    /// overridden it — leaving a session that never visited "Font
    /// override" untouched, and not counting the restore in `calls`
    /// (that counter answers "how many times did the override fire",
    /// not "how many times was the font touched at all").
    ///
    /// Only reaches the backend while this `EditorDemo` instance is
    /// itself the one rendering: the gallery's `Demo` trait has no
    /// "leaving this demo" hook, so switching straight from "Font
    /// override" to another demo (Diff View, Minimap, Caret Shape, …)
    /// still leaves the 24pt override live there until this demo's own
    /// variant 0 renders again.
    fn restore_default_if_overridden(&self, backend: &mut dyn Backend) {
        let default = (DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE_PT);
        if matches!(self.applied.get(), Some(current) if current != default) {
            backend.set_editor_font(default.0, default.1);
            self.applied.set(Some(default));
        }
    }

    fn build_editor(area: Rect) -> Editor {
        let texts = [
            "The quick brown fox jumps over the lazy dog",
            "set_editor_font() painted this at 24pt \"monospace\"",
        ];
        let lines = texts
            .iter()
            .enumerate()
            .map(|(idx, text)| plain_line(text.to_string(), &format!("{:>4}", idx + 1), idx))
            .collect();
        Editor::new(WidgetId::new("gallery:editor:font"), area)
            .with_lines(lines)
            .with_cursor(EditorCursor {
                pos: EditorCursorPos {
                    view_line: 0,
                    col: 0,
                },
                shape: EditorCursorShape::Block,
            })
            .with_total_lines(texts.len())
            .with_gutter_char_width(4)
            .with_is_active(true)
            .with_cursorline(true)
            .with_lightbulb_glyph('\0')
    }
}

pub struct EditorDemo {
    hscroll: HScrollState,
    font: FontState,
}

impl EditorDemo {
    pub fn new() -> Self {
        Self {
            hscroll: HScrollState::new(),
            font: FontState::new(),
        }
    }
}

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

impl Demo for EditorDemo {
    fn name(&self) -> &'static str {
        "Editor"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Horizontal scroll", "Font override"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_h = if lh > 1.5 { lh * 1.5 } else { lh };
        let editor_area = Rect::new(area.x, area.y, area.width, (area.height - bar_h).max(0.0));
        let bar_rect = Rect::new(area.x, area.y + editor_area.height, area.width, bar_h);

        let status = match variant {
            1 => {
                self.font.apply_override(backend);
                let editor = FontState::build_editor(editor_area);
                backend.draw_editor(editor.rect, &editor);
                format!(
                    " editor font: {DEMO_FONT_FAMILY} {DEMO_FONT_SIZE_PT}pt  calls: {} ",
                    self.font.calls.get()
                )
            }
            _ => {
                self.font.restore_default_if_overridden(backend);
                let editor = self.hscroll.build_editor(editor_area);
                backend.draw_editor(editor.rect, &editor);
                let vpc = HScrollState::viewport_cols(editor_area, backend);
                format!(
                    " col {} / {}  scroll_left {}  viewport_cols {} ",
                    self.hscroll.cursor_col + 1,
                    LINE_LEN,
                    self.hscroll.scroll_left,
                    vpc
                )
            }
        };

        let bar = StatusBar {
            id: WidgetId::new("gallery:editor:status"),
            left_segments: vec![StatusBarSegment {
                text: status,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(bar_rect, &bar, &InteractionState::new());
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        if variant != 0 {
            return Reaction::Continue;
        }
        let vpc = HScrollState::viewport_cols(area, backend);
        match event {
            UiEvent::KeyPressed { key, .. } => {
                match key {
                    Key::Char('$') | Key::Named(NamedKey::End) => {
                        self.hscroll.cursor_col = LINE_LEN - 1;
                    }
                    Key::Char('0') | Key::Named(NamedKey::Home) => {
                        self.hscroll.cursor_col = 0;
                    }
                    Key::Char('l') | Key::Named(NamedKey::Right) => {
                        if self.hscroll.cursor_col < LINE_LEN - 1 {
                            self.hscroll.cursor_col += 1;
                        }
                    }
                    Key::Char('h') | Key::Named(NamedKey::Left) => {
                        self.hscroll.cursor_col = self.hscroll.cursor_col.saturating_sub(1);
                    }
                    _ => return Reaction::Continue,
                }
                self.hscroll.ensure_cursor_visible(vpc);
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            1 => serde_json::json!({
                "font_family": DEMO_FONT_FAMILY,
                "font_size_pt": DEMO_FONT_SIZE_PT,
                "set_editor_font_calls": self.font.calls.get(),
            }),
            _ => serde_json::json!({
                "cursor_col": self.hscroll.cursor_col,
                "scroll_left": self.hscroll.scroll_left,
                "line_len": LINE_LEN,
            }),
        }
    }

    fn caps_note(&self, variant: usize, caps: &BackendCaps) -> Option<String> {
        if variant == 1 && !caps.generic_font_families {
            Some(
                "This backend has a fixed cell grid — set_editor_font is a structural \
                 no-op here; the font override has no visible effect."
                    .to_string(),
            )
        } else {
            None
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Horizontal scroll
Font override

File Picker

Group: Content

Code

/// Materialise a small, fixed directory tree for the pickers to list.
///
/// Deliberately **not** this crate's own `src/demos/` (that directory's
/// contents change every time a future demo is ported, so the picker's
/// listing — and any capture-mode snapshot of it — would silently churn
/// on unrelated PRs) and **not** a path derived from
/// `env!("CARGO_MANIFEST_DIR")` (a build-machine path baked in at
/// compile time; `CLAUDE.md`'s `cargo xwin test` flow runs the compiled
/// `.exe` on a separate Windows host where that path doesn't resolve).
///
/// The returned [`TempDir`]'s own path is unique per run, but the file
/// *names* inside it are fixed, so the listing itself is deterministic.
/// Callers must keep the `TempDir` alive for as long as the picker
/// needs its contents — dropping it deletes the directory.
fn build_demo_tree() -> TempDir {
    let dir = tempfile::tempdir().expect("create a temp dir for the file picker demo");
    let root = dir.path();
    std::fs::write(root.join("README.md"), b"# quadraui gallery demo tree\n")
        .expect("write README.md");
    std::fs::write(root.join("main.rs"), b"fn main() {}\n").expect("write main.rs");
    std::fs::create_dir(root.join("src")).expect("create src/");
    std::fs::write(root.join("src").join("lib.rs"), b"// lib\n").expect("write src/lib.rs");
    std::fs::create_dir(root.join("assets")).expect("create assets/");
    std::fs::write(root.join("assets").join("logo.svg"), b"<svg/>\n")
        .expect("write assets/logo.svg");
    dir
}

pub struct FilePickerDemo {
    // Kept alive for `FilePickerDemo`'s whole lifetime: the pickers
    // below hold only the `PathBuf` this resolves to, not the `TempDir`
    // itself, and dropping it would delete the tree out from under them.
    root_dir: TempDir,
    open_picker: FilePickerController,
    save_picker: FilePickerController,
    folder_picker: FolderPickerController,
    confirmed: Option<String>,
}

impl FilePickerDemo {
    pub fn new() -> Self {
        let root_dir = build_demo_tree();
        let root: PathBuf = root_dir.path().to_path_buf();
        Self {
            root_dir,
            open_picker: FilePickerController::new(FilePickerMode::Open, root.clone(), vec![]),
            save_picker: FilePickerController::new(FilePickerMode::Save, root.clone(), vec![])
                .with_initial_filename("untitled.rs"),
            folder_picker: FolderPickerController::new(root, vec![], false),
            confirmed: None,
        }
    }

    fn popup_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let w = (area.width * 0.8).max(40.0);
        let h = (area.height * 0.8).max(12.0 * backend.line_height());
        let x = area.x + (area.width - w) / 2.0;
        let y = area.y + (area.height - h) / 2.0;
        Rect::new(x, y, w, h)
    }

    fn status_bar(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:file-picker:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(
                    " {} ",
                    self.confirmed
                        .as_deref()
                        .unwrap_or("navigate, Enter to confirm")
                ),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 60, 100),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    fn visible_rows(popup_rect: Rect, backend: &dyn Backend) -> usize {
        let lh = backend.line_height();
        let rows = if lh > 0.0 {
            (popup_rect.height / lh) as usize
        } else {
            24
        };
        rows.saturating_sub(PALETTE_CHROME_ROWS)
    }
}

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

impl Demo for FilePickerDemo {
    fn name(&self) -> &'static str {
        // Not "File & Folder Picker": the sidebar's demo-name column is
        // narrow enough (~20 cells) that the longer title clips, hiding
        // the final letter rather than revealing the folder variant.
        // The "Choose folder" entry in `variants()` is what actually
        // surfaces it, once this row is selected.
        "File Picker"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Open file", "Save file", "Choose folder"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_h = lh * 1.5;
        let bar_rect = Rect::new(area.x, area.y + area.height - bar_h, area.width, bar_h);
        let _ = backend.draw_status_bar_interactive(
            bar_rect,
            &self.status_bar(),
            &InteractionState::new(),
        );

        let popup_rect = Self::popup_rect(area, backend);
        match variant {
            0 => self.open_picker.render(popup_rect, backend),
            1 => self.save_picker.render(popup_rect, backend),
            _ => self.folder_picker.render(popup_rect, backend),
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let popup_rect = Self::popup_rect(area, backend);
        let visible_rows = Self::visible_rows(popup_rect, backend);
        match variant {
            0 => match self.open_picker.handle(event, visible_rows) {
                FilePickerEvent::Confirmed { path } => {
                    self.confirmed = Some(format!("Opened: {}", path.display()));
                    Reaction::Redraw
                }
                FilePickerEvent::Cancelled => {
                    self.confirmed = Some("Dismissed".into());
                    Reaction::Redraw
                }
                FilePickerEvent::Consumed => Reaction::Redraw,
                FilePickerEvent::Ignored => Reaction::Continue,
            },
            1 => match self.save_picker.handle(event, visible_rows) {
                FilePickerEvent::Confirmed { path } => {
                    self.confirmed = Some(format!("Saved: {}", path.display()));
                    Reaction::Redraw
                }
                FilePickerEvent::Cancelled => {
                    self.confirmed = Some("Dismissed".into());
                    Reaction::Redraw
                }
                FilePickerEvent::Consumed => Reaction::Redraw,
                FilePickerEvent::Ignored => Reaction::Continue,
            },
            _ => match self.folder_picker.handle(event, visible_rows) {
                FolderPickerEvent::Confirmed { path } => {
                    self.confirmed = Some(format!("Chose folder: {}", path.display()));
                    Reaction::Redraw
                }
                FolderPickerEvent::Cancelled => {
                    self.confirmed = Some("Dismissed".into());
                    Reaction::Redraw
                }
                FolderPickerEvent::Consumed => Reaction::Redraw,
                FolderPickerEvent::Ignored => Reaction::Continue,
            },
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let mode = match variant {
            0 => "open",
            1 => "save",
            _ => "folder",
        };
        serde_json::json!({
            "mode": mode,
            "root": self.root_dir.path().display().to_string(),
            "confirmed": self.confirmed,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // Both controllers are in-app pickers (quadraui's own
        // palette-rendered list, not a native OS dialog), so they work
        // identically everywhere — there's no `BackendCaps` flag to
        // check and no gap to report.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Open file
Save file
Choose folder

Find & Replace

Group: Content

Code

/// Narrower than [`FR_PANEL_WIDTH`] — the "Compact" variant.
const COMPACT_WIDTH: u16 = 36;

pub struct FindReplaceDemo {
    panel: FindReplacePanel,
    last_click: Option<String>,
}

impl FindReplaceDemo {
    pub fn new() -> Self {
        Self {
            panel: FindReplacePanel {
                query: "needle".into(),
                replacement: String::new(),
                show_replace: true,
                focus: 0,
                cursor: 6,
                sel_anchor: None,
                match_info: "1 of 3".into(),
                case_sensitive: false,
                whole_word: false,
                use_regex: false,
                preserve_case: false,
                in_selection: false,
                group_bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
                panel_width: FR_PANEL_WIDTH,
                replace_one_glyph: "R1".into(),
                replace_all_glyph: "R*".into(),
                hit_regions: Vec::new(),
            },
            last_click: None,
        }
    }

    fn panel_width_for(variant: usize) -> u16 {
        if variant == 0 {
            COMPACT_WIDTH
        } else {
            FR_PANEL_WIDTH
        }
    }

    /// Build the panel actually painted/hit-tested for `variant`:
    /// `self.panel`'s persisted fields (query, toggles, `show_replace`,
    /// ...) with `panel_width` and `group_bounds` set for this variant
    /// and `hit_regions` recomputed to match — `hit_regions` is laid out
    /// *for a specific width*, so reusing the regions computed for one
    /// variant while painting another would paint/hit-test columns that
    /// don't exist in the narrower box.
    fn panel_for(&self, variant: usize, group_bounds: Rect) -> FindReplacePanel {
        let panel_width = Self::panel_width_for(variant);
        let (hit_regions, _input_w) = compute_find_replace_hit_regions(
            panel_width,
            self.panel.show_replace,
            &self.panel.match_info,
            self.panel.replace_one_glyph.chars().count() as u16,
            self.panel.replace_all_glyph.chars().count() as u16,
        );
        let mut panel = self.panel.clone();
        panel.panel_width = panel_width;
        panel.group_bounds = group_bounds;
        panel.hit_regions = hit_regions;
        panel
    }

    /// The `area` `render` paints the panel into, used to keep
    /// `panel.group_bounds` and click routing in agreement.
    fn editor_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    /// The panel's content corner (inside its one-cell border), in the
    /// same absolute coordinates the rasteriser paints at, scaled by the
    /// real `char_width`/`line_height` of the backend that is about to
    /// hit-test against it (see the module doc for why this is a
    /// cross-backend *approximation*, not each backend's exact anchor
    /// formula).
    fn content_origin(
        panel: &FindReplacePanel,
        rect: Rect,
        char_width: f32,
        line_height: f32,
    ) -> (f32, f32) {
        let cw = char_width.max(1.0);
        let lh = line_height.max(1.0);
        let panel_w = (panel.panel_width as f32 * cw).min((rect.width - 2.0 * cw).max(0.0));
        let gb = &panel.group_bounds;
        let gb_right = rect.x + gb.x + gb.width;
        let x = (gb_right - (panel_w + cw)).max(rect.x);
        let y = gb.y.max(lh);
        (x + cw, y + lh)
    }

    fn status_bar(&self) -> StatusBar {
        let msg = match &self.last_click {
            Some(m) => format!(" {m} "),
            None => " click a toggle or nav button ".into(),
        };
        StatusBar {
            id: WidgetId::new("gallery:find-replace:status"),
            left_segments: vec![StatusBarSegment {
                text: msg,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for FindReplaceDemo {
    fn name(&self) -> &'static str {
        "Find & Replace"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Compact", "Wide"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = Self::editor_rect(area, backend);
        let panel = self.panel_for(variant, Rect::new(0.0, 0.0, rect.width, rect.height));
        backend.draw_find_replace(rect, &panel);

        let lh = backend.line_height();
        let status_rect = Rect::new(area.x, rect.y + rect.height, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                let rect = Self::editor_rect(area, backend);
                let group_bounds = Rect::new(0.0, 0.0, rect.width, rect.height);
                let probe = self.panel_for(variant, group_bounds);
                let char_width = backend.char_width();
                let line_height = backend.line_height();
                let origin = Self::content_origin(&probe, rect, char_width, line_height);
                let hit = probe.hit_test(position.x, position.y, origin, char_width, line_height);
                self.panel.group_bounds = group_bounds;
                self.last_click = Some(match hit {
                    FindReplaceHit::Target(target) => {
                        match target {
                            FindReplaceClickTarget::ToggleCase => {
                                self.panel.case_sensitive = !self.panel.case_sensitive
                            }
                            FindReplaceClickTarget::ToggleWholeWord => {
                                self.panel.whole_word = !self.panel.whole_word
                            }
                            FindReplaceClickTarget::ToggleRegex => {
                                self.panel.use_regex = !self.panel.use_regex
                            }
                            FindReplaceClickTarget::TogglePreserveCase => {
                                self.panel.preserve_case = !self.panel.preserve_case
                            }
                            FindReplaceClickTarget::ToggleInSelection => {
                                self.panel.in_selection = !self.panel.in_selection
                            }
                            FindReplaceClickTarget::Chevron => {
                                self.panel.show_replace = !self.panel.show_replace;
                            }
                            _ => {}
                        }
                        format!("clicked {target:?}")
                    }
                    FindReplaceHit::Empty => "clicked empty area".into(),
                });
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        // `FindReplacePanel` doesn't derive `Serialize` (it's not meant
        // to round-trip, unlike most primitives) — report the fields a
        // consumer would actually care about by hand.
        serde_json::json!({
            "query": self.panel.query,
            "replacement": self.panel.replacement,
            "show_replace": self.panel.show_replace,
            "panel_width": Self::panel_width_for(variant),
            "case_sensitive": self.panel.case_sensitive,
            "whole_word": self.panel.whole_word,
            "use_regex": self.panel.use_regex,
            "preserve_case": self.panel.preserve_case,
            "in_selection": self.panel.in_selection,
            "match_info": self.panel.match_info,
            "last_click": self.last_click,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Compact
Wide

Focus

Group: Content

Code

const LEFT_ID: &str = "gallery:focus:left";
const RIGHT_ID: &str = "gallery:focus:right";
const STATUS_ID: &str = "gallery:focus:status";

pub struct FocusDemo {
    /// `RefCell`-wrapped so `render` (`&self`, called every frame with
    /// the *current* variant) can resync the ring's order the moment the
    /// gallery's variant picker changes it, without waiting for the next
    /// `handle()` call — the variant-picker click itself is handled
    /// entirely inside the shell and never reaches `Demo::handle`.
    focus: RefCell<FocusRing>,
    variant: Cell<usize>,
}

impl FocusDemo {
    pub fn new() -> Self {
        Self {
            focus: RefCell::new(Self::ring_for_variant(0)),
            variant: Cell::new(0),
        }
    }

    fn ring_for_variant(variant: usize) -> FocusRing {
        if variant == 1 {
            FocusRing::new(vec![STATUS_ID, RIGHT_ID, LEFT_ID])
        } else {
            FocusRing::new(vec![LEFT_ID, RIGHT_ID, STATUS_ID])
        }
    }

    /// Resync the ring to `variant`'s declared order, preserving the
    /// cycling position only when the order hasn't actually changed
    /// (switching to the variant already active is a no-op).
    fn sync(&self, variant: usize) {
        if self.variant.get() != variant {
            self.variant.set(variant);
            *self.focus.borrow_mut() = Self::ring_for_variant(variant);
        }
    }

    fn list(&self, id: &str, items: &[&str], focused: bool) -> ListView {
        ListView {
            id: WidgetId::new(id),
            title: None,
            items: items
                .iter()
                .map(|name| ListItem {
                    text: StyledText::plain(*name),
                    detail: None,
                    icon: None,
                    decoration: Default::default(),
                })
                .collect(),
            selected_idx: 0,
            scroll_offset: 0,
            has_focus: focused,
            bordered: true,
            h_scroll: 0,
            max_content_width: None,
            show_v_scrollbar: false,
        }
    }

    fn status_bar(&self, focused: bool) -> StatusBar {
        let (fg, bg) = if focused {
            (Color::rgb(255, 255, 255), Color::rgb(60, 100, 160))
        } else {
            (Color::rgb(220, 220, 220), Color::rgb(40, 40, 60))
        };
        let current = self
            .focus
            .borrow()
            .current()
            .map(|id| id.as_str().to_string())
            .unwrap_or_else(|| "none".to_string());
        StatusBar {
            id: WidgetId::new(STATUS_ID),
            left_segments: vec![StatusBarSegment {
                text: format!(" focus: {current} "),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: " Tab / Shift+Tab to move focus ".into(),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
        }
    }
}

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

impl Demo for FocusDemo {
    fn name(&self) -> &'static str {
        "Focus"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Reading order", "Reverse order"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        self.sync(variant);
        let lh = backend.line_height();
        let status_h = (lh * 1.5).round();
        let body_h = (area.height - status_h).max(0.0);
        let half_w = (area.width / 2.0).round();

        let ring = self.focus.borrow();
        let left_focused = ring.is_focused(&WidgetId::new(LEFT_ID));
        let right_focused = ring.is_focused(&WidgetId::new(RIGHT_ID));
        let status_focused = ring.is_focused(&WidgetId::new(STATUS_ID));
        drop(ring);

        let left = self.list(LEFT_ID, &["alpha", "bravo", "charlie"], left_focused);
        let right = self.list(RIGHT_ID, &["one", "two", "three"], right_focused);

        backend.draw_list(Rect::new(area.x, area.y, half_w, body_h), &left);
        backend.draw_list(
            Rect::new(area.x + half_w, area.y, area.width - half_w, body_h),
            &right,
        );
        let _ = backend.draw_status_bar_interactive(
            Rect::new(area.x, area.y + body_h, area.width, status_h),
            &self.status_bar(status_focused),
            &quadraui::InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        self.sync(variant);
        match event {
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Tab),
                ..
            } => {
                self.focus.borrow_mut().advance();
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::BackTab),
                ..
            } => {
                self.focus.borrow_mut().retreat();
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        self.sync(variant);
        let ring = self.focus.borrow();
        serde_json::json!({
            "order": ring.items().iter().map(|id| id.as_str()).collect::<Vec<_>>(),
            "focused": ring.current().map(|id| id.as_str()),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Reading order
Reverse order

Form

Group: Content

Code

/// Variant 1 ("Search & replace") state: a mini find/replace panel.
struct SearchReplaceState {
    search_query: String,
    replace_query: String,
    case_sensitive: bool,
    whole_word: bool,
    regex: bool,
    focus: FocusRing,
    last_action: String,
}

impl SearchReplaceState {
    fn new() -> Self {
        Self {
            search_query: "hello".into(),
            replace_query: String::new(),
            case_sensitive: true,
            whole_word: false,
            regex: false,
            focus: FocusRing::new(vec!["search", "toggles", "replace", "buttons"]),
            last_action: "—".into(),
        }
    }

    fn build_form(&self) -> Form {
        Form {
            id: WidgetId::new("gallery:form:search-replace"),
            fields: vec![
                FormField {
                    id: WidgetId::new("search"),
                    label: StyledText::plain("Find"),
                    kind: FieldKind::TextInput {
                        value: self.search_query.clone(),
                        placeholder: "Search…".into(),
                        cursor: Some(self.search_query.len()),
                        selection_anchor: None,
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("toggles"),
                    label: StyledText::default(),
                    kind: FieldKind::ToggleGroup {
                        toggles: vec![
                            ToggleGroupItem {
                                id: WidgetId::new("case"),
                                label: "Aa".into(),
                                value: self.case_sensitive,
                            },
                            ToggleGroupItem {
                                id: WidgetId::new("word"),
                                label: "Ab|".into(),
                                value: self.whole_word,
                            },
                            ToggleGroupItem {
                                id: WidgetId::new("regex"),
                                label: ".*".into(),
                                value: self.regex,
                            },
                        ],
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("replace"),
                    label: StyledText::plain("Replace"),
                    kind: FieldKind::TextInput {
                        value: self.replace_query.clone(),
                        placeholder: "Replace…".into(),
                        cursor: Some(self.replace_query.len()),
                        selection_anchor: None,
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
                FormField {
                    id: WidgetId::new("buttons"),
                    label: StyledText::default(),
                    kind: FieldKind::ButtonRow {
                        buttons: vec![
                            ButtonRowItem {
                                id: WidgetId::new("find-next"),
                                label: "Find Next".into(),
                                disabled: false,
                                icon: None,
                            },
                            ButtonRowItem {
                                id: WidgetId::new("replace-one"),
                                label: "Replace".into(),
                                disabled: false,
                                icon: None,
                            },
                            ButtonRowItem {
                                id: WidgetId::new("replace-all"),
                                label: "Replace All".into(),
                                disabled: self.search_query.is_empty(),
                                icon: None,
                            },
                        ],
                    },
                    hint: StyledText::default(),
                    disabled: false,
                    validation: None,
                },
            ],
            focused_field: self.focus.current().cloned(),
            scroll_offset: 0,
            has_focus: true,
        }
    }

    fn click(&mut self, id: &WidgetId) {
        match id.as_str() {
            "case" => {
                self.case_sensitive = !self.case_sensitive;
                self.last_action = format!("case={}", self.case_sensitive);
            }
            "word" => {
                self.whole_word = !self.whole_word;
                self.last_action = format!("word={}", self.whole_word);
            }
            "regex" => {
                self.regex = !self.regex;
                self.last_action = format!("regex={}", self.regex);
            }
            "find-next" => self.last_action = "Find Next".into(),
            "replace-one" => self.last_action = "Replace".into(),
            "replace-all" => {
                if !self.search_query.is_empty() {
                    self.last_action = "Replace All".into();
                }
            }
            other => {
                self.focus.set(id);
                self.last_action = format!("focus → {other}");
            }
        }
    }
}

/// Variant 2 ("Scrolling") state: 20 toggles, enough to overflow a
/// typical gallery viewport and exercise `FormController`'s scrollbar.
struct ScrollState {
    fc: FormController,
    toggles: Vec<bool>,
    last_action: String,
}

fn scroll_form_from_toggles(toggles: &[bool]) -> Form {
    let fields: Vec<FormField> = toggles
        .iter()
        .enumerate()
        .map(|(i, &val)| FormField {
            id: WidgetId::new(format!("toggle-{i}")),
            label: StyledText::plain(format!("Setting {}", i + 1)),
            kind: FieldKind::Toggle { value: val },
            hint: StyledText::default(),
            disabled: false,
            validation: None,
        })
        .collect();
    Form {
        id: WidgetId::new("gallery:form:settings-fields"),
        fields,
        focused_field: None,
        scroll_offset: 0,
        has_focus: true,
    }
}

impl ScrollState {
    fn new() -> Self {
        let toggles = vec![false; 20];
        let mut fc = FormController::new("gallery:form:settings".to_string());
        // Seed the form immediately so the very first render (before any
        // `handle()` event reaches `FormController`) isn't blank —
        // `FormController::render` reads back whatever `set_form` last
        // stored, it never builds from scratch.
        fc.set_form(scroll_form_from_toggles(&toggles));
        Self {
            fc,
            toggles,
            last_action: "—".into(),
        }
    }

    fn build_form(&self) -> Form {
        scroll_form_from_toggles(&self.toggles)
    }
}

/// All `FieldKind` variants, laid out statically — variant 0 ("All field
/// kinds"). No interactive state: this is a reference sheet, not a live
/// control surface.
fn build_all_fields_form() -> Form {
    Form {
        id: WidgetId::new("gallery:form:all-fields"),
        fields: vec![
            FormField {
                id: WidgetId::new("hdr"),
                label: StyledText::plain("Editor"),
                kind: FieldKind::Label,
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("line-numbers"),
                label: StyledText::plain("Show line numbers"),
                kind: FieldKind::Toggle { value: true },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("name"),
                label: StyledText::plain("Name"),
                kind: FieldKind::TextInput {
                    value: "quadraui".into(),
                    placeholder: String::new(),
                    cursor: Some(4),
                    selection_anchor: None,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("save"),
                label: StyledText::plain("Save settings"),
                kind: FieldKind::Button,
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("version"),
                label: StyledText::plain("Version"),
                kind: FieldKind::ReadOnly {
                    value: StyledText::plain("v0.1.2"),
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("font-size"),
                label: StyledText::plain("Font size"),
                kind: FieldKind::Slider {
                    value: 14.0,
                    min: 8.0,
                    max: 32.0,
                    step: 1.0,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("accent"),
                label: StyledText::plain("Accent colour"),
                kind: FieldKind::ColorPicker {
                    value: Color::rgb(0x7a, 0xb4, 0xff),
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("theme"),
                label: StyledText::plain("Theme"),
                kind: FieldKind::Dropdown {
                    options: vec![
                        StyledText::plain("One Dark"),
                        StyledText::plain("Solarized Light"),
                    ],
                    selected_idx: 1,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("notes"),
                label: StyledText::plain("Notes"),
                kind: FieldKind::TextArea {
                    value: "Release notes go here.".into(),
                    placeholder: String::new(),
                    cursor: None,
                    visible_rows: 3,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("password"),
                label: StyledText::plain("Password"),
                kind: FieldKind::PasswordInput {
                    value: "secret1".into(),
                    placeholder: String::new(),
                    cursor: None,
                    mask_char: '•',
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("scope"),
                label: StyledText::default(),
                kind: FieldKind::SegmentedControl {
                    options: vec!["File".into(), "Project".into()],
                    selected_idx: 0,
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("flags"),
                label: StyledText::plain("Flags"),
                kind: FieldKind::ToggleGroup {
                    toggles: vec![ToggleGroupItem {
                        id: WidgetId::new("case"),
                        label: "Case sensitive".into(),
                        value: true,
                    }],
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("actions"),
                label: StyledText::plain("Actions"),
                kind: FieldKind::ButtonRow {
                    buttons: vec![ButtonRowItem {
                        id: WidgetId::new("run"),
                        label: "Run action".into(),
                        disabled: false,
                        icon: None,
                    }],
                },
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("toolbar"),
                label: StyledText::plain("Toolbar"),
                kind: FieldKind::Toolbar(
                    Toolbar::new(WidgetId::new("toolbar-field")).with_buttons(vec![
                        ToolbarButton::Action {
                            id: WidgetId::new("build"),
                            label: "Build".into(),
                            icon: None,
                            key_hint: None,
                            enabled: true,
                            is_active: false,
                            tooltip: "Build the project".into(),
                        },
                    ]),
                ),
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
            FormField {
                id: WidgetId::new("due-date"),
                label: StyledText::plain("Due date"),
                kind: FieldKind::DatePicker(DatePicker::new(
                    WidgetId::new("due-date-picker"),
                    CalendarDate::new(2024, 3, 15),
                )),
                hint: StyledText::default(),
                disabled: false,
                validation: None,
            },
        ],
        focused_field: None,
        scroll_offset: 0,
        has_focus: true,
    }
}

pub struct FormDemo {
    search_replace: SearchReplaceState,
    scroll: ScrollState,
}

impl FormDemo {
    pub fn new() -> Self {
        Self {
            search_replace: SearchReplaceState::new(),
            scroll: ScrollState::new(),
        }
    }

    fn status_bar(text: &str, hint: &str) -> StatusBar {
        let fg = Color::rgb(220, 220, 220);
        let bg = Color::rgb(40, 40, 60);
        StatusBar {
            id: WidgetId::new("gallery:form:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {text} "),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(" {hint} "),
                fg,
                bg,
                bold: false,
                action_id: None,
            }],
        }
    }

    fn form_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for FormDemo {
    fn name(&self) -> &'static str {
        "Form"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["All field kinds", "Search & replace", "Scrolling"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let form_rect = Self::form_rect(area, backend);
        match variant {
            0 => backend.draw_form(area, &build_all_fields_form()),
            1 => {
                backend.draw_form(form_rect, &self.search_replace.build_form());
                let bar = Self::status_bar(
                    &format!("last: {}", self.search_replace.last_action),
                    "click / Tab / Shift+Tab",
                );
                let _ = backend.draw_status_bar_interactive(
                    Self::status_rect(area, backend),
                    &bar,
                    &InteractionState::new(),
                );
            }
            _ => {
                self.scroll.fc.render(backend, form_rect);
                let bar = Self::status_bar(
                    &format!(
                        "scroll={} | {}",
                        self.scroll.fc.scroll_offset(),
                        self.scroll.last_action
                    ),
                    "scroll / click / drag scrollbar",
                );
                let _ = backend.draw_status_bar_interactive(
                    Self::status_rect(area, backend),
                    &bar,
                    &InteractionState::new(),
                );
            }
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            0 => Reaction::Continue,
            1 => match event {
                UiEvent::MouseDown {
                    button: MouseButton::Left,
                    position,
                    ..
                } => {
                    let form_rect = Self::form_rect(area, backend);
                    let layout = backend.form_layout(form_rect, &self.search_replace.build_form());
                    // `FormLayout`'s hit regions are relative to
                    // `form_rect`'s own top-left corner, not the
                    // surface origin (`compose::FormController::click_inner`
                    // does the same subtraction) — `position` is an
                    // absolute surface coordinate, which only happens to
                    // equal the local one when `form_rect` starts at
                    // `(0, 0)`, which it never does once this demo is
                    // embedded below the gallery's own chrome.
                    match layout.hit_test(position.x - form_rect.x, position.y - form_rect.y) {
                        FormHit::Field(id) => {
                            self.search_replace.click(&id);
                            Reaction::Redraw
                        }
                        FormHit::Empty => Reaction::Continue,
                    }
                }
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Named(NamedKey::Tab) => {
                        self.search_replace.focus.advance();
                        Reaction::Redraw
                    }
                    Key::Named(NamedKey::BackTab) => {
                        self.search_replace.focus.retreat();
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
            _ => {
                self.scroll.fc.set_form(self.scroll.build_form());
                self.scroll.fc.set_backend_info(backend.line_height());
                let rect = Self::form_rect(area, backend);
                match self.scroll.fc.handle_cached(event, rect) {
                    FormControllerEvent::FormAction(FormEvent::ToggleChanged { id, value }) => {
                        if let Ok(idx) = id
                            .as_str()
                            .strip_prefix("toggle-")
                            .unwrap_or("")
                            .parse::<usize>()
                        {
                            if idx < self.scroll.toggles.len() {
                                self.scroll.toggles[idx] = value;
                            }
                        }
                        self.scroll.last_action = format!("{} = {value}", id.as_str());
                        Reaction::Redraw
                    }
                    FormControllerEvent::FormAction(other) => {
                        self.scroll.last_action = format!("{other:?}");
                        Reaction::Redraw
                    }
                    FormControllerEvent::ScrollChanged | FormControllerEvent::Consumed => {
                        Reaction::Redraw
                    }
                    FormControllerEvent::Ignored => Reaction::Continue,
                }
            }
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            0 => serde_json::to_value(build_all_fields_form()).unwrap_or(serde_json::Value::Null),
            1 => serde_json::json!({
                "form": self.search_replace.build_form(),
                "last_action": self.search_replace.last_action,
            }),
            _ => serde_json::json!({
                "scroll_offset": self.scroll.fc.scroll_offset(),
                "toggles_on": self.scroll.toggles.iter().filter(|&&v| v).count(),
                "last_action": self.scroll.last_action,
            }),
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
All field kinds
Search & replace
Scrolling

Markdown

Group: Content

Code

/// Markdown source shared by both variants — deliberately includes
/// `snake_case` identifiers and a whitespace-flanked `*` so the
/// flanking guard is exercised visually: `foo_bar` and `a * b` render
/// upright, while `*italic*`, `_also_`, `**bold**` and `` `code` `` get
/// their styling.
const DOC: &str = "\
Markdown adapter demo
# Headings scale up
Body text with **bold**, *italic*, _also italic_, and `inline_code`.
Identifiers like foo_bar and baz_qux stay upright (no intraword emphasis).
Arithmetic like a * b * c is left alone too.

- A short bullet item.
- A longer bullet item that wraps gracefully when the viewport is narrow,
  with styling like **bold** still preserved.

```rust
fn main() {
    println!(\"fenced code is never wrapped\");
}
```";

/// Variant 0 state: a [`RichTextPopup`] over the unwrapped adapter
/// output. `font_role` toggles between the UI font and the editor's
/// monospace font (`f` key) — visual confirmation that
/// `Backend::draw_rich_text_popup_with_font_role` reaches a real
/// second paint path, not just the plain one.
struct PopupState {
    scroll_top: usize,
    font_role: FontRole,
}

impl PopupState {
    fn new() -> Self {
        Self {
            scroll_top: 0,
            font_role: FontRole::Chrome,
        }
    }
}

/// Variant 1 state: a word-wrapped [`ListView`] over the adapter's
/// wrapping entry point.
struct WrapState {
    scroll_offset: usize,
    selected_idx: usize,
}

impl WrapState {
    fn new() -> Self {
        Self {
            scroll_offset: 0,
            selected_idx: 0,
        }
    }
}

pub struct MarkdownDemo {
    popup: PopupState,
    wrap: WrapState,
}

impl MarkdownDemo {
    pub fn new() -> Self {
        Self {
            popup: PopupState::new(),
            wrap: WrapState::new(),
        }
    }

    fn render_popup(&self, backend: &mut dyn Backend, area: Rect) {
        let theme = Theme::default();
        let rendered = render_markdown_to_styled(DOC, &theme);

        let col_w = backend.char_width();
        let row_h = backend.line_height();
        let widest = rendered
            .line_text
            .iter()
            .map(|l| l.chars().count())
            .max()
            .unwrap_or(0);
        let content_w = (widest as f32 + 1.0) * col_w;
        // Grows with the pane instead of a fixed row count, so a tall
        // viewport actually shows more of the document —
        // `RichTextPopup::layout` still clamps this down to whatever the
        // viewport has room for, this just stops it from being
        // needlessly short on a tall one.
        let max_visible_rows = ((area.height / row_h).floor() as usize).max(4);

        let popup = RichTextPopup {
            id: WidgetId::new("gallery:markdown:popup"),
            lines: rendered.lines,
            line_text: rendered.line_text,
            line_scales: rendered.line_scales,
            scroll_top: self.popup.scroll_top,
            max_visible_rows,
            has_focus: true,
            selection: None,
            links: Vec::new(),
            focused_link: None,
            placement: PopupPlacement::Below,
            padding: 1.0,
            fg: None,
            bg: None,
        };

        let anchor_x = area.x + area.width * 0.05;
        let anchor_y = area.y + row_h;
        let measure =
            RichTextPopupMeasure::new(content_w, row_h).with_scale_rows(backend.scales_text_rows());
        let layout = popup.layout(anchor_x, anchor_y, area, measure, |_, start, end| {
            (end - start) as f32 * col_w
        });
        match self.popup.font_role {
            FontRole::Chrome => backend.draw_rich_text_popup(&popup, &layout),
            FontRole::Editor => {
                backend.draw_rich_text_popup_with_font_role(&popup, &layout, FontRole::Editor)
            }
        }
    }

    fn content_cols(area: Rect, backend: &dyn Backend) -> usize {
        let cw = backend.char_width();
        let total_cols = (area.width / cw).floor() as usize;
        total_cols.saturating_sub(2)
    }

    /// How many word-wrapped rows [`Self::render_wrap`] will build for
    /// `area`'s current width — used by `handle` to clamp
    /// `WrapState::scroll_offset`/`selected_idx` to the list's real
    /// bounds rather than relying solely on `ListView`'s own internal
    /// clamp (`primitives/list.rs`), so the demo's own state never
    /// drifts arbitrarily far past what's actually on screen.
    fn wrap_row_count(area: Rect, backend: &dyn Backend) -> usize {
        let width = Self::content_cols(area, backend).max(10);
        render_markdown_to_styled_wrapped(DOC, &Theme::default(), width)
            .lines
            .len()
    }

    fn render_wrap(&self, backend: &mut dyn Backend, area: Rect) {
        let theme = Theme::default();
        let width = Self::content_cols(area, backend);
        let rendered = render_markdown_to_styled_wrapped(DOC, &theme, width.max(10));

        let items: Vec<ListItem> = rendered
            .lines
            .into_iter()
            .map(|styled| ListItem {
                text: styled,
                detail: None,
                icon: None,
                decoration: Decoration::Normal,
            })
            .collect();
        let n = items.len();

        let list = ListView {
            id: WidgetId::new("gallery:markdown:wrap"),
            title: Some(StyledText::plain(" word-wrapped ")),
            items,
            selected_idx: self.wrap.selected_idx.min(n.saturating_sub(1)),
            scroll_offset: self.wrap.scroll_offset,
            has_focus: true,
            bordered: false,
            h_scroll: 0,
            max_content_width: None,
            show_v_scrollbar: true,
        };
        backend.draw_list(area, &list);
    }
}

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

impl Demo for MarkdownDemo {
    fn name(&self) -> &'static str {
        "Markdown"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Popup (unwrapped)", "List (word-wrap)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        match variant {
            1 => self.render_wrap(backend, area),
            _ => self.render_popup(backend, area),
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match variant {
            1 => match event {
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Char('j') | Key::Named(NamedKey::Down) => {
                        let last = Self::wrap_row_count(area, backend).saturating_sub(1);
                        self.wrap.scroll_offset =
                            self.wrap.scroll_offset.saturating_add(1).min(last);
                        self.wrap.selected_idx = self.wrap.selected_idx.saturating_add(1).min(last);
                        Reaction::Redraw
                    }
                    Key::Char('k') | Key::Named(NamedKey::Up) => {
                        self.wrap.scroll_offset = self.wrap.scroll_offset.saturating_sub(1);
                        self.wrap.selected_idx = self.wrap.selected_idx.saturating_sub(1);
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
            _ => match event {
                UiEvent::KeyPressed { key, .. } => match key {
                    Key::Named(NamedKey::Down) => {
                        self.popup.scroll_top = self.popup.scroll_top.saturating_add(1);
                        Reaction::Redraw
                    }
                    Key::Named(NamedKey::Up) => {
                        self.popup.scroll_top = self.popup.scroll_top.saturating_sub(1);
                        Reaction::Redraw
                    }
                    Key::Char('f') => {
                        self.popup.font_role = match self.popup.font_role {
                            FontRole::Chrome => FontRole::Editor,
                            FontRole::Editor => FontRole::Chrome,
                        };
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Continue,
            },
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            1 => serde_json::json!({
                "scroll_offset": self.wrap.scroll_offset,
                "selected_idx": self.wrap.selected_idx,
            }),
            _ => serde_json::json!({
                "scroll_top": self.popup.scroll_top,
                "font_role": match self.popup.font_role {
                    FontRole::Chrome => "Chrome",
                    FontRole::Editor => "Editor",
                },
            }),
        }
    }

    fn caps_note(&self, variant: usize, caps: &BackendCaps) -> Option<String> {
        if variant == 0 && !caps.generic_font_families {
            Some(
                "This backend has a fixed cell grid — the 'f' font-role toggle has no \
                 visible effect here."
                    .to_string(),
            )
        } else {
            None
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Popup (unwrapped)
List (word-wrap)

Text Display

Group: Content

Code

/// Distinctive word placed at the very end of the long line in the
/// word-wrap variant. Never painted anywhere on screen if `TextDisplay`
/// truncates instead of wrapping.
const TAIL_MARKER: &str = "TAILMARKER";

fn long_line_text() -> String {
    let mut s = String::from(
        "This one line of prose is deliberately far wider than the viewport \
         so that it cannot possibly fit on a single row no matter how the \
         backend measures character width, which is exactly the scenario \
         a pane that silently truncated long lines at its right edge \
         instead of wrapping them onto continuation rows the reader could \
         actually scroll to and read, ",
    );
    s.push_str(TAIL_MARKER);
    s
}

fn log_lines() -> Vec<TextDisplayLine> {
    vec![
        TextDisplayLine {
            spans: vec![StyledSpan::plain("server started on :8080")],
            decoration: Decoration::Normal,
            timestamp: Some("12:00:00".into()),
        },
        TextDisplayLine {
            spans: vec![StyledSpan::plain("accepted connection from 10.0.0.4")],
            decoration: Decoration::Normal,
            timestamp: Some("12:00:02".into()),
        },
        TextDisplayLine {
            spans: vec![StyledSpan::plain("slow query: 840ms")],
            decoration: Decoration::Warning,
            timestamp: Some("12:00:05".into()),
        },
        TextDisplayLine {
            spans: vec![StyledSpan::plain("connection reset by peer")],
            decoration: Decoration::Error,
            timestamp: Some("12:00:06".into()),
        },
        TextDisplayLine {
            spans: vec![StyledSpan::plain("retry succeeded")],
            decoration: Decoration::Muted,
            timestamp: Some("12:00:07".into()),
        },
    ]
}

fn wrap_lines() -> Vec<TextDisplayLine> {
    vec![
        TextDisplayLine {
            spans: vec![StyledSpan::plain(long_line_text())],
            decoration: Decoration::Normal,
            timestamp: None,
        },
        TextDisplayLine {
            spans: vec![StyledSpan::plain("A short line follows.")],
            decoration: Decoration::Normal,
            timestamp: None,
        },
    ]
}

pub struct TextDisplayDemo {
    scroll_offset: usize,
    auto_scroll: bool,
}

impl TextDisplayDemo {
    pub fn new() -> Self {
        Self {
            scroll_offset: 0,
            auto_scroll: true,
        }
    }

    fn build(&self, variant: usize) -> TextDisplay {
        let lines = if variant == 1 {
            wrap_lines()
        } else {
            log_lines()
        };
        let mut display = TextDisplay::new(WidgetId::new("gallery:text-display"));
        display.lines = lines;
        display.scroll_offset = self.scroll_offset;
        display.auto_scroll = self.auto_scroll;
        display.show_scrollbar = true;
        display
    }
}

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

impl Demo for TextDisplayDemo {
    fn name(&self) -> &'static str {
        "Text Display"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Log tail (decorations)", "Word-wrap (long line)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let body = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
        let display = self.build(variant);
        backend.draw_text_display(body, &display);

        let status_rect = Rect::new(area.x, area.y + body.height, area.width, lh);
        let text = format!(
            " auto_scroll: {}  scroll_offset: {}  (k/j or \u{2191}/\u{2193} scroll, G return to live) ",
            self.auto_scroll, self.scroll_offset
        );
        let bar = StatusBar {
            id: WidgetId::new("gallery:text-display:status"),
            left_segments: vec![StatusBarSegment {
                text,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(status_rect, &bar, &InteractionState::new());
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed { key, .. } => match key {
                Key::Char('j') | Key::Named(NamedKey::Down) => {
                    self.scroll_offset = self.scroll_offset.saturating_add(1);
                    self.auto_scroll = false;
                    Reaction::Redraw
                }
                Key::Char('k') | Key::Named(NamedKey::Up) => {
                    self.scroll_offset = self.scroll_offset.saturating_sub(1);
                    self.auto_scroll = false;
                    Reaction::Redraw
                }
                Key::Char('G') | Key::Named(NamedKey::End) => {
                    self.auto_scroll = true;
                    Reaction::Redraw
                }
                _ => Reaction::Continue,
            },
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "variant": variant,
            "scroll_offset": self.scroll_offset,
            "auto_scroll": self.auto_scroll,
            "line_count": self.build(variant).lines.len(),
        })
    }
}

Backends

VariantTUIGTKmacOSWindows
Log tail (decorations)
Word-wrap (long line)

Text Input

Group: Content

Code

pub struct TextInputDemo {
    /// `inputs[0]` is the "Empty" variant's buffer, `inputs[1]` is the
    /// "Prefilled" variant's — kept independent so typing in one never
    /// leaks into the other when the gallery's variant picker switches.
    inputs: Vec<TextEditor>,
}

impl TextInputDemo {
    pub fn new() -> Self {
        let mut empty = TextInput::new(WidgetId::new("gallery:text-input:empty"));
        empty.placeholder = Some(
            "Type something. Enter for newline, arrows/Home/End to move, Shift to select.".into(),
        );
        empty.has_focus = true;

        let mut prefilled = TextInput::new(WidgetId::new("gallery:text-input:prefilled"))
            .with_lines(vec![
                "fix: handle empty selection range".to_string(),
                String::new(),
                "Click to move the cursor, or select and retype.".to_string(),
            ]);
        prefilled.has_focus = true;

        Self {
            inputs: vec![TextEditor::new(empty), TextEditor::new(prefilled)],
        }
    }

    fn editor(&self, variant: usize) -> &TextEditor {
        &self.inputs[variant.min(self.inputs.len() - 1)]
    }

    fn status(&self, variant: usize) -> StatusBar {
        let editor = self.editor(variant);
        let cursor = format!(
            " line {} col {} ",
            editor.cursor_line + 1,
            editor.cursor_col + 1,
        );
        StatusBar {
            id: WidgetId::new("gallery:text-input:status"),
            left_segments: vec![StatusBarSegment {
                text: " Type to edit — Shift+arrows to select ".into(),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: cursor,
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    fn input_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for TextInputDemo {
    fn name(&self) -> &'static str {
        "Text Input"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Empty", "Prefilled"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let input_rect = Self::input_rect(area, backend);
        backend.draw_text_input(input_rect, self.editor(variant));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let idx = variant.min(self.inputs.len() - 1);
        match event {
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                let rect = Self::input_rect(area, backend);
                let layout = backend.text_input_layout(rect, &self.inputs[idx]);
                let op = match layout.hit_test(position.x, position.y) {
                    TextInputHit::Line { line_idx } => EditOp::SetCursor {
                        line: line_idx,
                        col: self.inputs[idx].cursor_col,
                        extend: false,
                    },
                    TextInputHit::EmptyArea => {
                        let last = self.inputs[idx].lines.len().saturating_sub(1);
                        EditOp::SetCursor {
                            line: last,
                            col: usize::MAX,
                            extend: false,
                        }
                    }
                };
                if self.inputs[idx].apply(op) {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
            UiEvent::KeyPressed { key, modifiers, .. } => {
                if matches!(key, quadraui::Key::Char(_)) && (modifiers.ctrl || modifiers.cmd) {
                    return Reaction::Continue;
                }
                match EditOp::from_key(key, *modifiers) {
                    Some(op) => {
                        if self.inputs[idx].apply(op) {
                            Reaction::Redraw
                        } else {
                            Reaction::Continue
                        }
                    }
                    None => Reaction::Continue,
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(&self.editor(variant).input).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Empty
Prefilled

Text Selection

Group: Content

Code

const PANGRAM_LINES: &[&str] = &[
    "The quick brown fox jumps over the lazy dog.",
    "Pack my box with five dozen liquor jugs.",
    "How vexingly quick daft zebras jump!",
    "The five boxing wizards jump quickly.",
];

const HAIKU_LINES: &[&str] = &[
    "An old silent pond",
    "A frog jumps into the pond—",
    "Splash! Silence again.",
];

const CONTENT_ID: &str = "gallery:text-selection:content";

pub struct TextSelectionDemo {
    status: String,
}

impl TextSelectionDemo {
    pub fn new() -> Self {
        Self { status: hint() }
    }

    fn lines(variant: usize) -> &'static [&'static str] {
        if variant == 1 {
            HAIKU_LINES
        } else {
            PANGRAM_LINES
        }
    }

    fn content_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for TextSelectionDemo {
    fn name(&self) -> &'static str {
        "Text Selection"
    }

    fn group(&self) -> &'static str {
        "Content"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Pangrams", "Haiku"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let content = Self::content_rect(area, backend);
        let lh = backend.line_height();
        let lines = Self::lines(variant);
        let fg = Color::rgb(200, 200, 200);
        let bg = Color::rgb(20, 20, 35);
        let mut rendered_rows = 0usize;
        for (i, &line) in lines.iter().enumerate() {
            let row_y = content.y + i as f32 * lh;
            if row_y + lh > content.y + content.height {
                break;
            }
            let bar = StatusBar {
                id: WidgetId::new(format!("{CONTENT_ID}-row-{i}")),
                left_segments: vec![StatusBarSegment {
                    text: format!(" {line}"),
                    fg,
                    bg,
                    bold: false,
                    action_id: None,
                }],
                right_segments: vec![],
            };
            let _ = backend.draw_status_bar_interactive(
                Rect::new(content.x, row_y, content.width, lh),
                &bar,
                &InteractionState::new(),
            );
            rendered_rows = i + 1;
        }
        backend.register_text_region(TextRegion {
            id: WidgetId::new(CONTENT_ID),
            bounds: Rect::new(
                content.x,
                content.y,
                content.width,
                rendered_rows as f32 * lh,
            ),
            lines: lines.iter().map(|s| s.to_string()).collect(),
        });

        let status_bar = StatusBar {
            id: WidgetId::new("gallery:text-selection:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.status),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(
            Self::status_rect(area, backend),
            &status_bar,
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::TextCopied(text) => {
                let preview: String = text.chars().take(40).collect();
                let ellipsis = if text.chars().count() > 40 { "…" } else { "" };
                self.status =
                    format!("Copied: \"{preview}{ellipsis}\" — drag or Ctrl-A to select again");
                Reaction::Redraw
            }
            UiEvent::TextSelectionChanged { anchor, focus, .. } => {
                // Rows are reported relative to the content area's own
                // top edge, not the whole demo area — subtract
                // `content.y` first, or this echoes absolute screen rows
                // (e.g. "rows 6–8" for the demo's first three lines).
                let content = Self::content_rect(area, backend);
                let lh = backend.line_height();
                let a_row = ((anchor.y - content.y) / lh).floor() as usize + 1;
                let f_row = ((focus.y - content.y) / lh).floor() as usize + 1;
                let (start, end) = if a_row <= f_row {
                    (a_row, f_row)
                } else {
                    (f_row, a_row)
                };
                self.status = if start == end {
                    format!("Selecting row {start} — Ctrl-C to copy")
                } else {
                    format!("Selecting rows {start}–{end} — Ctrl-C to copy")
                };
                Reaction::Redraw
            }
            UiEvent::MouseDown { .. } => {
                self.status = hint();
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "lines": Self::lines(variant),
            "status": self.status,
        })
    }

    fn caps_note(&self, _variant: usize, caps: &BackendCaps) -> Option<String> {
        if caps.text_selection {
            None
        } else {
            Some(
                "This backend doesn't implement drag-to-select — drag and Ctrl-C/Ctrl-A \
                 have no visible effect here."
                    .to_string(),
            )
        }
    }
}

fn hint() -> String {
    "drag to select · Ctrl-A select all · Ctrl-C copy".into()
}

Backends

VariantTUIGTKmacOSWindows
Pangrams
Haiku

Activity Bar

Group: Chrome

Code

const LABELS: [&str; 3] = ["Explorer", "Search", "Source Control"];
const ICONS: [&str; 3] = ["E", "S", "G"];

fn item_id(variant: usize, i: usize) -> WidgetId {
    WidgetId::new(format!("gallery:activity-bar:{variant}:{i}"))
}

pub struct ActivityBarDemo {
    active: [usize; 2],
    last_action: String,
}

impl ActivityBarDemo {
    pub fn new() -> Self {
        Self {
            active: [0, 0],
            last_action: "ready".into(),
        }
    }

    fn bar(&self, variant: usize) -> ActivityBar {
        let items = ICONS
            .iter()
            .enumerate()
            .map(|(i, icon)| ActivityItem {
                id: item_id(variant, i),
                icon: (*icon).into(),
                tooltip: LABELS[i].to_string(),
                is_active: i == self.active[variant],
                is_keyboard_selected: false,
            })
            .collect();
        ActivityBar {
            id: WidgetId::new(format!("gallery:activity-bar:{variant}")),
            top_items: items,
            bottom_items: vec![],
            // Variant 0 paints the accent line; variant 1 relies solely on
            // the row-fill style below, so this is `None` there.
            active_accent: if variant == 0 {
                Some(Color::rgb(100, 150, 255))
            } else {
                None
            },
            selection_bg: None,
            is_keyboard_focused: false,
        }
    }

    /// Only meaningful for variant 1 — the VS-Code-style soft chip fill.
    fn style(&self) -> ActivityBarStyle {
        ActivityBarStyle::new().with_active_bg(Color::rgb(49, 50, 51))
    }

    fn activate(&mut self, variant: usize, idx: usize) {
        self.active[variant] = idx;
        self.last_action = format!("activated: {}", LABELS[idx]);
    }
}

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

impl Demo for ActivityBarDemo {
    fn name(&self) -> &'static str {
        "Activity Bar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Accent line", "Row fill (VS Code)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = area;
        let bar = self.bar(variant);
        if variant == 0 {
            let _ = backend.draw_activity_bar(rect, &bar, None);
        } else {
            let _ = backend.draw_activity_bar_with_style(rect, &bar, None, &self.style());
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::MouseDown { position, .. } => {
                let rect = area;
                if position.x < rect.x
                    || position.x >= rect.x + rect.width
                    || position.y < rect.y
                    || position.y >= rect.y + rect.height
                {
                    return Reaction::Continue;
                }
                let bar = self.bar(variant);
                let hits = backend.activity_bar_layout(rect, &bar);
                let rel_y = position.y - rect.y;
                for hit in &hits {
                    if rel_y >= hit.y_start && rel_y < hit.y_end {
                        if let Some(idx) =
                            (0..LABELS.len()).find(|&i| hit.id == item_id(variant, i))
                        {
                            self.activate(variant, idx);
                            return Reaction::Redraw;
                        }
                    }
                }
                Reaction::Continue
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let mut value = serde_json::to_value(self.bar(variant)).unwrap_or(serde_json::Value::Null);
        if let Some(obj) = value.as_object_mut() {
            obj.insert(
                "last_action".into(),
                serde_json::Value::String(self.last_action.clone()),
            );
        }
        value
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Accent line
Row fill (VS Code)

Bottom Panel

Group: Chrome

Code

struct TerminalContent {
    lines: Vec<String>,
}

impl BackendWidget for TerminalContent {
    fn render(&self, backend: &mut dyn Backend, rect: Rect) {
        if rect.height < 1.0 {
            return;
        }
        let lh = backend.line_height();
        let prompt = self
            .lines
            .last()
            .map(|l| format!("$ {l}"))
            .unwrap_or_else(|| "$ _".to_string());
        let bar = StatusBar {
            id: WidgetId::new("gallery:bottom-panel:terminal"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {prompt} "),
                fg: Color::rgb(100, 200, 100),
                bg: Color::rgb(20, 20, 20),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        backend.draw_status_bar_interactive(
            Rect::new(rect.x, rect.y, rect.width, lh),
            &bar,
            &InteractionState::new(),
        );
    }
}

struct ProblemsContent {
    problems: Vec<String>,
}

impl BackendWidget for ProblemsContent {
    fn render(&self, backend: &mut dyn Backend, rect: Rect) {
        if rect.height < 1.0 {
            return;
        }
        let lh = backend.line_height();
        for (i, problem) in self.problems.iter().enumerate() {
            let y = rect.y + i as f32 * lh;
            if y + lh > rect.y + rect.height {
                break;
            }
            let bar = StatusBar {
                id: WidgetId::new(format!("gallery:bottom-panel:problem:{i}")),
                left_segments: vec![StatusBarSegment {
                    text: format!("  \u{26a0} {problem} "),
                    fg: Color::rgb(255, 180, 50),
                    bg: Color::rgb(25, 25, 25),
                    bold: false,
                    action_id: None,
                }],
                right_segments: vec![],
            };
            backend.draw_status_bar_interactive(
                Rect::new(rect.x, y, rect.width, lh),
                &bar,
                &InteractionState::new(),
            );
        }
    }
}

fn build_tabs() -> Vec<BottomPanelTab> {
    vec![
        BottomPanelTab {
            id: "bp:terminal".into(),
            label: "TERMINAL".into(),
            closable: true,
            badge: None,
            content: Box::new(TerminalContent {
                lines: vec!["echo hello".into()],
            }),
        },
        BottomPanelTab {
            id: "bp:problems".into(),
            label: "PROBLEMS".into(),
            closable: true,
            badge: Some("2".into()),
            content: Box::new(ProblemsContent {
                problems: vec![
                    "src/main.rs:12 — unused variable `x`".into(),
                    "src/lib.rs:4 — missing semicolon".into(),
                ],
            }),
        },
    ]
}

/// `variant == 1` starts already maximised, showing the full-height tab
/// strip a user would get after clicking `^`.
fn build_controller(variant: usize) -> BottomPanelController {
    let tabs = build_tabs();
    let active_tab_id = tabs[0].id.clone();
    BottomPanelController::new(BottomPanelConfig {
        tabs,
        active_tab_id,
        maximised: variant == 1,
        height_fraction: 1.0,
    })
}

pub struct BottomPanelDemo {
    // `BottomPanelController::render` needs `&mut self` to cache its hit
    // map for `handle_click` — `Demo::render` only hands out `&self` —
    // so each variant's controller lives behind its own `RefCell`.
    controllers: [RefCell<BottomPanelController>; 2],
    last_event: String,
}

impl BottomPanelDemo {
    pub fn new() -> Self {
        Self {
            controllers: [
                RefCell::new(build_controller(0)),
                RefCell::new(build_controller(1)),
            ],
            last_event: "click a tab, × to close, ^ to maximise".into(),
        }
    }
}

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

impl Demo for BottomPanelDemo {
    fn name(&self) -> &'static str {
        "Bottom Panel"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Two tabs", "Maximised"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let panel_rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
        self.controllers[variant]
            .borrow_mut()
            .render(backend, panel_rect);

        let hint_rect = Rect::new(area.x, area.y + panel_rect.height, area.width, lh);
        let bar = StatusBar {
            id: WidgetId::new("gallery:bottom-panel:hint"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.last_event),
                fg: Color::rgb(200, 200, 200),
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(hint_rect, &bar, &InteractionState::new());
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        if let UiEvent::MouseDown { position, .. } = event {
            // `last_hits` / `last_strip_bounds` were populated by the
            // `render` call this same frame already made.
            if let Some(ev) = self.controllers[variant]
                .borrow_mut()
                .handle_click(position.x, position.y)
            {
                self.last_event = match ev {
                    BottomPanelEvent::TabActivated(id) => format!("Tab activated: {id}"),
                    BottomPanelEvent::TabClosed(id) => format!("Tab closed: {id}"),
                    BottomPanelEvent::MaximiseToggled => "Maximise toggled".into(),
                    BottomPanelEvent::Resized(h) => format!("Resized: {h:.0}"),
                };
                return Reaction::Redraw;
            }
        }
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let controller = self.controllers[variant].borrow();
        serde_json::json!({
            "active_tab_id": controller.active_tab_id,
            "maximised": controller.maximised,
            "tabs": controller.tabs().iter().map(|t| &t.label).collect::<Vec<_>>(),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Two tabs
Maximised

Float

Group: Chrome

Code

const HINT_ID: &str = "gallery:float:hint";
const MENU_ID: &str = "gallery:float:menu";

const COMMANDS: &[&str] = &["Open File", "Save", "Rename", "Duplicate", "Close"];
const MENU_ITEMS: &[&str] = &["Run now", "Run later", "Cancel"];

pub struct FloatDemo {
    selected: usize,
    hint_open: bool,
    menu_open: bool,
    menu_selected: usize,
    last_action: String,
}

impl FloatDemo {
    pub fn new() -> Self {
        Self {
            selected: 0,
            hint_open: false,
            menu_open: false,
            menu_selected: 0,
            last_action: String::new(),
        }
    }

    fn row_rect(&self, area: Rect, backend: &dyn Backend, idx: usize) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + idx as f32 * lh, area.width, lh)
    }

    fn menu_anchor(&self, variant: usize, area: Rect, backend: &dyn Backend) -> Anchor {
        let side = if variant == 0 {
            Side::Bottom
        } else {
            Side::Top
        };
        Anchor::new(self.row_rect(area, backend, self.selected), side)
    }

    fn menu_float(&self, variant: usize, area: Rect, backend: &dyn Backend) -> Float {
        Float::new(
            WidgetId::new(MENU_ID),
            self.menu_anchor(variant, area, backend),
        )
    }

    fn menu_layout(&self, variant: usize, area: Rect, backend: &dyn Backend) -> FloatLayout {
        let cw = backend.char_width();
        let lh = backend.line_height();
        let float = self.menu_float(variant, area, backend);
        let measure = FloatMeasure::new(cw * 16.0, lh * (MENU_ITEMS.len() as f32 + 2.0));
        float.layout(area, measure)
    }

    fn hint_anchor(&self, area: Rect, backend: &dyn Backend) -> Anchor {
        let lh = backend.line_height();
        let cw = backend.char_width();
        let w = cw * 7.0;
        Anchor::new(
            Rect::new(area.x + area.width - w, area.y + area.height - lh, w, lh),
            Side::Top,
        )
    }

    fn hint_float(&self, area: Rect, backend: &dyn Backend) -> Float {
        let mut f = Float::new(WidgetId::new(HINT_ID), self.hint_anchor(area, backend));
        f.focusable = false;
        f
    }

    fn hint_layout(&self, area: Rect, backend: &dyn Backend) -> FloatLayout {
        let cw = backend.char_width();
        let lh = backend.line_height();
        let float = self.hint_float(area, backend);
        let measure = FloatMeasure::new(cw * 22.0, lh * 5.0);
        float.layout(area, measure)
    }

    fn line_at(backend: &mut dyn Backend, row: Rect, id: &str, text: String, selected: bool) {
        let bg = if selected {
            Color::rgb(60, 60, 90)
        } else {
            Color::rgb(24, 24, 32)
        };
        let bar = StatusBar {
            id: WidgetId::new(id),
            left_segments: vec![StatusBarSegment {
                text,
                fg: Color::rgb(220, 220, 220),
                bg,
                bold: selected,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(row, &bar, &InteractionState::new());
    }

    fn paint_menu_content(&self, backend: &mut dyn Backend, layout: &FloatLayout) {
        let lh = backend.line_height();
        let cb = layout.content_bounds;
        for (i, item) in MENU_ITEMS.iter().enumerate() {
            let y = cb.y + i as f32 * lh;
            if y + lh > cb.y + cb.height {
                break;
            }
            Self::line_at(
                backend,
                Rect::new(cb.x, y, cb.width, lh),
                &format!("gallery:float:menu-item:{i}"),
                format!(" {item} "),
                i == self.menu_selected,
            );
        }
    }

    fn paint_hint_content(&self, backend: &mut dyn Backend, layout: &FloatLayout) {
        let lh = backend.line_height();
        let cb = layout.content_bounds;
        let lines = ["Up/Down move", "Enter runs", "m = menu"];
        for (i, text) in lines.iter().enumerate() {
            let y = cb.y + i as f32 * lh;
            if y + lh > cb.y + cb.height {
                break;
            }
            Self::line_at(
                backend,
                Rect::new(cb.x, y, cb.width, lh),
                &format!("gallery:float:hint-line:{i}"),
                format!(" {text} "),
                false,
            );
        }
    }

    fn open_menu(&mut self, variant: usize, area: Rect, backend: &mut dyn Backend) {
        self.menu_open = true;
        self.menu_selected = 0;
        let bounds = self.menu_layout(variant, area, backend).bounds;
        let float = self.menu_float(variant, area, backend);
        backend
            .modal_stack_handle()
            .borrow_mut()
            .push_float(&float, bounds);
    }

    fn close_menu(&mut self, backend: &mut dyn Backend) {
        self.menu_open = false;
        backend
            .modal_stack_handle()
            .borrow_mut()
            .pop(&WidgetId::new(MENU_ID));
    }

    fn toggle_hint(&mut self, area: Rect, backend: &mut dyn Backend) {
        if self.hint_open {
            self.hint_open = false;
            backend
                .modal_stack_handle()
                .borrow_mut()
                .pop(&WidgetId::new(HINT_ID));
        } else {
            self.hint_open = true;
            let bounds = self.hint_layout(area, backend).bounds;
            let float = self.hint_float(area, backend);
            backend
                .modal_stack_handle()
                .borrow_mut()
                .push_float(&float, bounds);
        }
    }
}

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

impl Demo for FloatDemo {
    fn name(&self) -> &'static str {
        "Float"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Menu below row", "Menu above row"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        for (i, cmd) in COMMANDS.iter().enumerate() {
            let row = self.row_rect(area, backend, i);
            Self::line_at(
                backend,
                row,
                &format!("gallery:float:cmd:{i}"),
                format!(" {cmd} "),
                i == self.selected,
            );
        }

        if self.menu_open {
            let layout = self.menu_layout(variant, area, backend);
            let float = self.menu_float(variant, area, backend);
            backend.draw_float(&float, &layout);
            self.paint_menu_content(backend, &layout);
        }
        if self.hint_open {
            let layout = self.hint_layout(area, backend);
            let float = self.hint_float(area, backend);
            backend.draw_float(&float, &layout);
            self.paint_hint_content(backend, &layout);
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('?'),
                ..
            } => {
                self.toggle_hint(area, backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('m'),
                ..
            } => {
                if self.menu_open {
                    self.close_menu(backend);
                } else {
                    self.open_menu(variant, area, backend);
                }
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Escape),
                ..
            } if self.menu_open => {
                self.close_menu(backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Escape),
                ..
            } if self.hint_open => {
                self.toggle_hint(area, backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Up),
                ..
            } => {
                let focused = backend
                    .modal_stack_handle()
                    .borrow()
                    .top_focusable()
                    .map(|e| e.id);
                if focused == Some(WidgetId::new(MENU_ID)) {
                    self.menu_selected = self.menu_selected.saturating_sub(1);
                } else {
                    self.selected = self.selected.saturating_sub(1);
                }
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Down),
                ..
            } => {
                let focused = backend
                    .modal_stack_handle()
                    .borrow()
                    .top_focusable()
                    .map(|e| e.id);
                if focused == Some(WidgetId::new(MENU_ID)) {
                    self.menu_selected = (self.menu_selected + 1).min(MENU_ITEMS.len() - 1);
                } else {
                    self.selected = (self.selected + 1).min(COMMANDS.len() - 1);
                }
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Enter),
                ..
            } => {
                let focused = backend
                    .modal_stack_handle()
                    .borrow()
                    .top_focusable()
                    .map(|e| e.id);
                if focused == Some(WidgetId::new(MENU_ID)) {
                    self.last_action = format!(
                        "{} / {}",
                        COMMANDS[self.selected], MENU_ITEMS[self.menu_selected]
                    );
                    self.close_menu(backend);
                } else {
                    self.last_action = COMMANDS[self.selected].to_string();
                }
                Reaction::Redraw
            }
            UiEvent::MouseDown {
                widget: Some(ref id),
                position,
                ..
            } if id.as_str() == MENU_ID => {
                let layout = self.menu_layout(variant, area, backend);
                let lh = backend.line_height();
                let rel_y = position.y - layout.content_bounds.y;
                if rel_y >= 0.0 {
                    let idx = (rel_y / lh).floor() as usize;
                    if idx < MENU_ITEMS.len() {
                        self.last_action =
                            format!("{} / {}", COMMANDS[self.selected], MENU_ITEMS[idx]);
                        self.close_menu(backend);
                    }
                }
                Reaction::Redraw
            }
            UiEvent::MouseDown {
                widget: Some(ref id),
                ..
            } if id.as_str() == HINT_ID => {
                self.toggle_hint(area, backend);
                Reaction::Redraw
            }
            UiEvent::MouseDown {
                widget: None,
                position,
                ..
            } => {
                let lh = backend.line_height();
                let idx = ((position.y - area.y) / lh).floor() as usize;
                if idx < COMMANDS.len() {
                    self.selected = idx;
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, _variant: usize) -> serde_json::Value {
        serde_json::json!({
            "selected": COMMANDS[self.selected],
            "menu_open": self.menu_open,
            "hint_open": self.hint_open,
            "last_action": self.last_action,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Menu below row
Menu above row

Menu Bar

Group: Chrome

Code

fn action(id: &str, label: &str) -> ContextMenuItem {
    ContextMenuItem {
        id: Some(WidgetId::new(id)),
        label: StyledText::plain(label),
        ..Default::default()
    }
}

fn separator() -> ContextMenuItem {
    ContextMenuItem::default()
}

fn flat_menus() -> Vec<MenuDef> {
    vec![
        MenuDef {
            id: WidgetId::new("file"),
            label: "&File".into(),
            disabled: false,
            items: vec![
                action("new", "New File"),
                action("open", "Open File"),
                action("save", "Save"),
                separator(),
                action("quit", "Quit"),
            ],
        },
        MenuDef {
            id: WidgetId::new("edit"),
            label: "&Edit".into(),
            disabled: false,
            items: vec![action("undo", "Undo"), action("redo", "Redo")],
        },
    ]
}

fn submenu_menus() -> Vec<MenuDef> {
    vec![MenuDef {
        id: WidgetId::new("view"),
        label: "&View".into(),
        disabled: false,
        items: vec![
            action("sidebar", "Toggle Sidebar"),
            separator(),
            ContextMenuItem {
                id: Some(WidgetId::new("export")),
                label: StyledText::plain("Export"),
                submenu: Some(vec![
                    action("export-png", "PNG"),
                    action("export-svg", "SVG"),
                ]),
                ..Default::default()
            },
        ],
    }]
}

pub struct MenuBarDemo {
    menus: [MenuSystem; 2],
    last_action: [String; 2],
}

impl MenuBarDemo {
    pub fn new() -> Self {
        Self {
            menus: [
                MenuSystem::new(flat_menus()),
                MenuSystem::new(submenu_menus()),
            ],
            last_action: [
                "click a menu or press Alt+F/E".into(),
                "click View, then hover Export".into(),
            ],
        }
    }

    fn bar_rect(area: Rect, backend: &dyn Backend) -> Rect {
        Rect::new(area.x, area.y, area.width, backend.line_height())
    }

    fn hint_bar(&self, variant: usize) -> StatusBar {
        StatusBar {
            id: WidgetId::new(format!("gallery:menu-bar:{variant}:hint")),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.last_action[variant]),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for MenuBarDemo {
    fn name(&self) -> &'static str {
        "Menu Bar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Dropdown", "Submenu cascade"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let bar_rect = Self::bar_rect(area, backend);
        self.menus[variant].render(backend, bar_rect);

        let lh = backend.line_height();
        let hint_rect = Rect::new(area.x, area.y + lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            hint_rect,
            &self.hint_bar(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let bar_rect = Self::bar_rect(area, backend);
        match self.menus[variant].handle(event, backend, bar_rect) {
            MenuEvent::Activated(id) => {
                self.last_action[variant] = format!("activated: {}", id.as_str());
                Reaction::Redraw
            }
            MenuEvent::StateChanged | MenuEvent::Consumed => Reaction::Redraw,
            MenuEvent::Ignored => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "menu_open": self.menus[variant].is_open(),
            "last_action": self.last_action[variant],
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Dropdown
Submenu cascade

Panel

Group: Chrome

Code

const CONTENT_LINES: &[&str] = &[
    "The quick brown fox jumps over the lazy dog.",
    "Pack my box with five dozen liquor jugs.",
    "How vexingly quick daft zebras jump!",
];

const VARIANT_COUNT: usize = 3;

pub struct PanelDemo {
    // Per-variant collapsed state: variant 0 starts expanded, variant 1
    // starts collapsed, variant 2 (no maximize action) stays expanded.
    collapsed: [bool; VARIANT_COUNT],
    last_message: String,
}

impl PanelDemo {
    pub fn new() -> Self {
        Self {
            collapsed: [false, true, false],
            last_message: "Click the title bar or the actions".into(),
        }
    }

    /// Variant 2 drops the accent colour and the maximize action — a
    /// minimal panel with only a close button, the shape a transient
    /// popover host would use.
    fn panel(&self, variant: usize) -> Panel {
        let collapsed = self.collapsed[variant];
        let mut actions = vec![PanelAction {
            id: WidgetId::new("gallery:panel:close"),
            icon: "×".into(),
            tooltip: "Close".into(),
            is_active: false,
        }];
        if variant != 2 {
            actions.push(PanelAction {
                id: WidgetId::new("gallery:panel:maximize"),
                icon: if collapsed { "+" } else { "□" }.into(),
                tooltip: if collapsed { "Expand" } else { "Maximize" }.into(),
                is_active: collapsed,
            });
        }
        Panel {
            id: WidgetId::new("gallery:panel"),
            title: Some(StyledText {
                spans: vec![StyledSpan::plain("Demo Panel")],
            }),
            actions,
            accent: if variant == 2 {
                None
            } else {
                Some(Color::rgb(40, 80, 120))
            },
            collapsed,
        }
    }

    fn dispatch_action(&mut self, variant: usize, hit: PanelHit) -> bool {
        match hit {
            PanelHit::Action(id) if id.as_str() == "gallery:panel:close" => {
                self.last_message = "Close clicked".into();
                true
            }
            PanelHit::Action(id) if id.as_str() == "gallery:panel:maximize" => {
                self.collapsed[variant] = !self.collapsed[variant];
                self.last_message = if self.collapsed[variant] {
                    "Collapsed".into()
                } else {
                    "Expanded".into()
                };
                true
            }
            PanelHit::TitleBar(_) => {
                self.last_message = "Title bar clicked".into();
                true
            }
            _ => false,
        }
    }

    fn hint_bar(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:panel:hint"),
            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![],
        }
    }
}

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

impl Demo for PanelDemo {
    fn name(&self) -> &'static str {
        "Panel"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Expanded", "Collapsed", "No accent, close only"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let panel_rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
        let panel = self.panel(variant);
        let layout = backend.draw_panel(panel_rect, &panel);

        let cb = layout.content_bounds;
        for (i, line) in CONTENT_LINES.iter().enumerate() {
            let y = cb.y + i as f32 * lh;
            if y + lh > cb.y + cb.height {
                break;
            }
            let bar = StatusBar {
                id: WidgetId::new(format!("gallery:panel:line:{i}")),
                left_segments: vec![StatusBarSegment {
                    text: format!(" {line} "),
                    fg: Color::rgb(210, 210, 210),
                    bg: Color::rgb(20, 20, 35),
                    bold: false,
                    action_id: None,
                }],
                right_segments: vec![],
            };
            let _ = backend.draw_status_bar_interactive(
                Rect::new(cb.x, y, cb.width, lh),
                &bar,
                &InteractionState::new(),
            );
        }

        let hint_rect = Rect::new(area.x, area.y + panel_rect.height, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            hint_rect,
            &self.hint_bar(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::MouseDown { position, .. } => {
                let lh = backend.line_height();
                let panel_rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                let panel = self.panel(variant);
                let layout = backend.panel_layout(panel_rect, &panel);
                let hit = layout.hit_test(position.x, position.y);
                if self.dispatch_action(variant, hit) {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.panel(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
ExpandedError: TuiDriver: “Expanded” not painted: 1 │ CHROME Demo │ Code │ Data │ 2 │ Activity Bar Two tabs Maximised 3 │ Menu Bar TERMINAL× PROBLEMS (2)× ^ 4 │ Toolbar $ echo hello 5 │ Status Bar │ Tab Bar │ Bottom Panel │ Panel │ Sidebar │ Split │ Float │ Window Control │ Workspace │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ click a tab, × to close, ^ to maximise │ Gallery ready — click a group, then a demo. │ → group: Chrome │ MouseDown │ → demo selected: Bottom Panel │ │ │ │Not captured in this buildNot captured in this build
CollapsedError: TuiDriver: “Collapsed” not painted: 1 │ CHROME Demo │ Code │ Data │ 2 │ Activity Bar Two tabs Maximised 3 │ Menu Bar TERMINAL× PROBLEMS (2)× ^ 4 │ Toolbar $ echo hello 5 │ Status Bar │ Tab Bar │ Bottom Panel │ Panel │ Sidebar │ Split │ Float │ Window Control │ Workspace │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ click a tab, × to close, ^ to maximise │ Gallery ready — click a group, then a demo. │ → group: Chrome │ MouseDown │ → demo selected: Bottom Panel │ │ │ │Not captured in this buildNot captured in this build
No accent, close onlyError: TuiDriver: “No accent, close only” not painted: 1 │ CHROME Demo │ Code │ Data │ 2 │ Activity Bar Two tabs Maximised 3 │ Menu Bar TERMINAL× PROBLEMS (2)× ^ 4 │ Toolbar $ echo hello 5 │ Status Bar │ Tab Bar │ Bottom Panel │ Panel │ Sidebar │ Split │ Float │ Window Control │ Workspace │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ click a tab, × to close, ^ to maximise │ Gallery ready — click a group, then a demo. │ → group: Chrome │ MouseDown │ → demo selected: Bottom Panel │ │ │ │Not captured in this buildNot captured in this build

Sidebar

Group: Chrome

Code

const REVEAL_ROW_COUNT: usize = 30;
const BODY_ROW_COUNT: usize = 40;

fn flat_rows(n: usize) -> Vec<TreeRow> {
    (0..n)
        .map(|i| TreeRow {
            path: vec![i as u16],
            indent: 0,
            icon: None,
            text: StyledText::plain(format!("item{i}")),
            badge: None,
            is_expanded: None,
            decoration: Decoration::Normal,
            edit: None,
        })
        .collect()
}

/// Rows for the "Header groups" variant: two file-header rows, each
/// followed by two match rows — `Decoration::Header` vs. `Normal`.
fn grouped_rows() -> Vec<TreeRow> {
    let mut rows = Vec::new();
    for (file_idx, file) in ["src/main.rs", "src/lib.rs"].iter().enumerate() {
        rows.push(TreeRow {
            path: vec![file_idx as u16],
            indent: 0,
            icon: None,
            text: StyledText::plain(*file),
            badge: Some(quadraui::Badge::plain("2")),
            is_expanded: None,
            decoration: Decoration::Header,
            edit: None,
        });
        for match_idx in 0..2 {
            rows.push(TreeRow {
                path: vec![file_idx as u16, match_idx as u16],
                indent: 1,
                icon: None,
                text: StyledText::plain(format!("  match {match_idx} in {file}")),
                badge: None,
                is_expanded: None,
                decoration: Decoration::Normal,
                edit: None,
            });
        }
    }
    rows
}

struct TreeBody(TreeView);

impl BackendWidget for TreeBody {
    fn render(&self, backend: &mut dyn Backend, rect: Rect) {
        backend.draw_tree(rect, &self.0);
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChromeMode {
    None,
    Header,
    HeaderAndSearch,
}

impl ChromeMode {
    fn next(self) -> Self {
        match self {
            ChromeMode::None => ChromeMode::Header,
            ChromeMode::Header => ChromeMode::HeaderAndSearch,
            ChromeMode::HeaderAndSearch => ChromeMode::None,
        }
    }
}

pub struct SidebarDemo {
    // Variant 0: tree + reveal.
    reveal_sidebar: SidebarSystem,
    reveal_last: String,
    // Variant 1: SidebarPanel + toolbar + tasks.
    tasks: Vec<String>,
    tasks_selected: Option<usize>,
    filter_on: bool,
    tasks_last: String,
    hover: ToolbarHoverTracker,
    pressed: Option<WidgetId>,
    next_task_id: usize,
    // Variant 2: header groups.
    groups_sidebar: SidebarSystem,
    groups_last: String,
    // Variant 3: SidebarPanelBody + chrome cycle.
    body_rows: Vec<TreeRow>,
    body_scroll: usize,
    chrome_mode: ChromeMode,
    body_last: String,
}

impl SidebarDemo {
    pub fn new() -> Self {
        let mut reveal_sidebar = SidebarSystem::new(vec![SidebarSectionDef::new("items", "ITEMS")]);
        reveal_sidebar.set_rows(0, flat_rows(REVEAL_ROW_COUNT));
        reveal_sidebar.set_navigation_mode(NavigationMode::Selection);
        reveal_sidebar.set_active_section(Some(0));

        let mut groups_sidebar =
            SidebarSystem::new(vec![SidebarSectionDef::new("results", "RESULTS")]);
        groups_sidebar.set_rows(0, grouped_rows());
        groups_sidebar.set_navigation_mode(NavigationMode::Selection);
        groups_sidebar.set_active_section(Some(0));

        Self {
            reveal_sidebar,
            reveal_last: "—".into(),
            tasks: vec![
                "Review PR #257".into(),
                "Write toolbar tests".into(),
                "Land sidebar panel".into(),
            ],
            tasks_selected: Some(0),
            filter_on: false,
            tasks_last: "click toolbar buttons, then list rows".into(),
            hover: ToolbarHoverTracker::new(),
            pressed: None,
            next_task_id: 4,
            groups_sidebar,
            groups_last: "—".into(),
            body_rows: flat_rows(BODY_ROW_COUNT),
            body_scroll: 0,
            chrome_mode: ChromeMode::HeaderAndSearch,
            body_last: "—".into(),
        }
    }

    // ── Variant 1 helpers ────────────────────────────────────────────

    fn toolbar(&self) -> Toolbar {
        Toolbar::new(WidgetId::new("gallery:sidebar:toolbar")).with_buttons(vec![
            ToolbarButton::Action {
                id: WidgetId::new("gallery:sidebar:add"),
                label: "".into(),
                icon: Some("+".into()),
                key_hint: None,
                enabled: true,
                is_active: false,
                tooltip: "Add task".into(),
            },
            ToolbarButton::Separator,
            ToolbarButton::Action {
                id: WidgetId::new("gallery:sidebar:filter"),
                label: "Filter".into(),
                icon: Some("⚙".into()),
                key_hint: None,
                enabled: true,
                is_active: self.filter_on,
                tooltip: "Toggle filter".into(),
            },
            ToolbarButton::Action {
                id: WidgetId::new("gallery:sidebar:clear"),
                label: "Clear".into(),
                icon: None,
                key_hint: None,
                enabled: !self.tasks.is_empty(),
                is_active: false,
                tooltip: "Remove all tasks".into(),
            },
        ])
    }

    fn panel(&self) -> SidebarPanel {
        SidebarPanel {
            id: WidgetId::new("gallery:sidebar:panel"),
            toolbar: Some(self.toolbar()),
            toolbar_height: Some(2.0),
        }
    }

    fn dispatch_toolbar(&mut self, id: &WidgetId) {
        match id.as_str() {
            "gallery:sidebar:add" => {
                self.tasks.push(format!("Task #{}", self.next_task_id));
                self.next_task_id += 1;
                self.tasks_last = "Added a task".into();
            }
            "gallery:sidebar:filter" => {
                self.filter_on = !self.filter_on;
                self.tasks_last = format!("Filter {}", if self.filter_on { "on" } else { "off" });
            }
            "gallery:sidebar:clear" => {
                self.tasks.clear();
                self.tasks_selected = None;
                self.tasks_last = "Cleared all tasks".into();
            }
            _ => {}
        }
    }

    // ── Variant 3 helpers ────────────────────────────────────────────

    fn body_chrome(&self) -> SidebarPanelChrome {
        match self.chrome_mode {
            ChromeMode::None => SidebarPanelChrome::None,
            ChromeMode::Header => SidebarPanelChrome::Header("ITEMS".into()),
            ChromeMode::HeaderAndSearch => SidebarPanelChrome::HeaderAndSearch {
                header: "ITEMS".into(),
                query: String::new(),
                placeholder: "Filter items".into(),
                active: false,
            },
        }
    }
}

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

impl Demo for SidebarDemo {
    fn name(&self) -> &'static str {
        "Sidebar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &[
            "Tree + reveal",
            "Toolbar header",
            "Header groups",
            "Body chrome",
        ]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        match variant {
            0 => {
                let sidebar_rect =
                    Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                self.reveal_sidebar.render(backend, sidebar_rect);
                let hint = Rect::new(area.x, area.y + sidebar_rect.height, area.width, lh);
                let bar = StatusBar {
                    id: WidgetId::new("gallery:sidebar:reveal-hint"),
                    left_segments: vec![StatusBarSegment {
                        text: format!(" last: {} ", self.reveal_last),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                    right_segments: vec![StatusBarSegment {
                        text: " ↑↓ select · z collapse · g reveal ".into(),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                };
                let _ = backend.draw_status_bar_interactive(hint, &bar, &InteractionState::new());
            }
            1 => {
                let panel_rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                let layout = backend.draw_sidebar_panel_interactive(
                    panel_rect,
                    &self.panel(),
                    &InteractionState::from_parts(self.hover.current(), self.pressed),
                );
                let content = layout.content_bounds;
                let header_text = if self.filter_on {
                    "  Tasks (filtered)  "
                } else {
                    "  Tasks  "
                };
                let _ = backend.draw_status_bar_interactive(
                    Rect::new(content.x, content.y, content.width, lh),
                    &StatusBar {
                        id: WidgetId::new("gallery:sidebar:tasks-header"),
                        left_segments: vec![StatusBarSegment {
                            text: header_text.into(),
                            fg: Color::rgb(200, 200, 200),
                            bg: Color::rgb(20, 20, 20),
                            bold: true,
                            action_id: None,
                        }],
                        right_segments: vec![],
                    },
                    &InteractionState::new(),
                );
                for (i, task) in self.tasks.iter().enumerate() {
                    let row_y = content.y + lh * (1.0 + i as f32);
                    if row_y + lh > content.y + content.height {
                        break;
                    }
                    let (fg, bg) = if Some(i) == self.tasks_selected {
                        (Color::rgb(255, 255, 255), Color::rgb(60, 100, 160))
                    } else {
                        (Color::rgb(220, 220, 220), Color::rgb(20, 20, 20))
                    };
                    let _ = backend.draw_status_bar_interactive(
                        Rect::new(content.x, row_y, content.width, lh),
                        &StatusBar {
                            id: WidgetId::new(format!("gallery:sidebar:task-row:{i}")),
                            left_segments: vec![StatusBarSegment {
                                text: format!("  {task}  "),
                                fg,
                                bg,
                                bold: false,
                                action_id: None,
                            }],
                            right_segments: vec![],
                        },
                        &InteractionState::new(),
                    );
                }
                let hint = Rect::new(area.x, area.y + panel_rect.height, area.width, lh);
                let bar = StatusBar {
                    id: WidgetId::new("gallery:sidebar:tasks-hint"),
                    left_segments: vec![StatusBarSegment {
                        text: format!(" {} ", self.tasks_last),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                    right_segments: vec![],
                };
                let _ = backend.draw_status_bar_interactive(hint, &bar, &InteractionState::new());
            }
            2 => {
                let sidebar_rect =
                    Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                self.groups_sidebar.render(backend, sidebar_rect);
                let hint = Rect::new(area.x, area.y + sidebar_rect.height, area.width, lh);
                let bar = StatusBar {
                    id: WidgetId::new("gallery:sidebar:groups-hint"),
                    left_segments: vec![StatusBarSegment {
                        text: format!(" last: {} ", self.groups_last),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                    right_segments: vec![],
                };
                let _ = backend.draw_status_bar_interactive(hint, &bar, &InteractionState::new());
            }
            _ => {
                let panel_rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                let panel = SidebarPanelBody {
                    background: Some(Color::rgb(24, 24, 28)),
                    chrome: self.body_chrome(),
                    scrollbar_gutter: Some(backend.char_width().max(1.0)),
                };
                let tree = TreeView {
                    id: WidgetId::new("gallery:sidebar:body-tree"),
                    rows: self.body_rows.clone(),
                    selection_mode: quadraui::SelectionMode::Single,
                    selected_path: None,
                    scroll_offset: self.body_scroll,
                    style: TreeStyle::default(),
                    has_focus: true,
                };
                let layout = panel.render(backend, panel_rect, &TreeBody(tree));
                if let Some(sb_rect) = layout.scrollbar_rect {
                    let line_h = lh.max(1.0);
                    let visible = (sb_rect.height / line_h).max(1.0);
                    let sb = Scrollbar::vertical(
                        WidgetId::new("gallery:sidebar:body-scrollbar"),
                        sb_rect,
                        self.body_scroll as f32,
                        self.body_rows.len() as f32,
                        visible,
                        line_h,
                    );
                    backend.draw_scrollbar(sb_rect, &sb);
                }
                let hint = Rect::new(area.x, area.y + panel_rect.height, area.width, lh);
                let bar = StatusBar {
                    id: WidgetId::new("gallery:sidebar:body-hint"),
                    left_segments: vec![StatusBarSegment {
                        text: format!(" chrome={:?} last: {} ", self.chrome_mode, self.body_last),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                    right_segments: vec![StatusBarSegment {
                        text: " ↑↓ scroll · c cycle chrome ".into(),
                        fg: Color::rgb(220, 220, 220),
                        bg: Color::rgb(40, 40, 60),
                        bold: false,
                        action_id: None,
                    }],
                };
                let _ = backend.draw_status_bar_interactive(hint, &bar, &InteractionState::new());
            }
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let lh = backend.line_height();
        match variant {
            0 => {
                self.reveal_sidebar
                    .set_backend_info(backend.line_height(), backend.msv_metrics());
                let rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                match self.reveal_sidebar.handle(event, backend, rect) {
                    SidebarEvent::RowSelected { section, path } => {
                        self.reveal_last = format!("sel→{section} {path:?}");
                        Reaction::Redraw
                    }
                    SidebarEvent::StateChanged
                    | SidebarEvent::Consumed
                    | SidebarEvent::ScrollChanged { .. } => Reaction::Redraw,
                    SidebarEvent::Ignored => match event {
                        UiEvent::KeyPressed {
                            key: quadraui::Key::Char('z'),
                            ..
                        } => {
                            let collapsed = !self.reveal_sidebar.is_collapsed(0);
                            self.reveal_sidebar.set_collapsed(0, collapsed);
                            self.reveal_last =
                                format!("{}→0", if collapsed { "collapsed" } else { "expanded" });
                            Reaction::Redraw
                        }
                        UiEvent::KeyPressed {
                            key: quadraui::Key::Char('g'),
                            ..
                        } => {
                            let target = vec![(REVEAL_ROW_COUNT - 1) as u16];
                            self.reveal_sidebar.reveal(0, &target, rect);
                            self.reveal_last = format!("reveal→0 {target:?}");
                            Reaction::Redraw
                        }
                        _ => Reaction::Continue,
                    },
                    _ => Reaction::Redraw,
                }
            }
            1 => match event {
                UiEvent::MouseMoved { position, .. } => {
                    let panel_rect =
                        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                    let layout = backend.sidebar_panel_layout(panel_rect, &self.panel());
                    let mut new_hover = self.hover.clone();
                    if let Some(tlayout) = &layout.toolbar_layout {
                        new_hover.update(tlayout, position.x, position.y);
                    } else {
                        new_hover.clear();
                    }
                    if new_hover.current() != self.hover.current() {
                        self.hover = new_hover;
                        Reaction::Redraw
                    } else {
                        Reaction::Continue
                    }
                }
                UiEvent::MouseDown { position, .. } => {
                    let panel_rect =
                        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                    let layout = backend.sidebar_panel_layout(panel_rect, &self.panel());
                    match layout.hit_test(position.x, position.y) {
                        SidebarPanelHit::ToolbarButton(id) => {
                            self.pressed = Some(id);
                            Reaction::Redraw
                        }
                        SidebarPanelHit::Content { y, .. } => {
                            if y >= lh {
                                let idx = ((y - lh) / lh).floor() as usize;
                                if idx < self.tasks.len() {
                                    self.tasks_selected = Some(idx);
                                    self.tasks_last = format!("Selected: {}", self.tasks[idx]);
                                    return Reaction::Redraw;
                                }
                            }
                            Reaction::Continue
                        }
                        _ => Reaction::Continue,
                    }
                }
                UiEvent::MouseUp { position, .. } => {
                    let panel_rect =
                        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                    let layout = backend.sidebar_panel_layout(panel_rect, &self.panel());
                    let pressed = self.pressed.take();
                    if let (Some(pressed_id), SidebarPanelHit::ToolbarButton(release_id)) =
                        (pressed, layout.hit_test(position.x, position.y))
                    {
                        if pressed_id == release_id {
                            self.dispatch_toolbar(&release_id);
                        }
                        return Reaction::Redraw;
                    }
                    Reaction::Redraw
                }
                _ => Reaction::Continue,
            },
            2 => {
                let rect = Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0));
                match self.groups_sidebar.handle(event, backend, rect) {
                    SidebarEvent::RowSelected { section, path } => {
                        self.groups_last = format!("sel→{section} {path:?}");
                        Reaction::Redraw
                    }
                    SidebarEvent::HeaderActivated { section } => {
                        self.groups_last = format!("header→{section}");
                        Reaction::Redraw
                    }
                    SidebarEvent::StateChanged
                    | SidebarEvent::Consumed
                    | SidebarEvent::ScrollChanged { .. } => Reaction::Redraw,
                    SidebarEvent::Ignored => Reaction::Continue,
                    _ => Reaction::Redraw,
                }
            }
            _ => match event {
                UiEvent::KeyPressed {
                    key: quadraui::Key::Char('c'),
                    ..
                } => {
                    self.chrome_mode = self.chrome_mode.next();
                    self.body_last = format!("chrome→{:?}", self.chrome_mode);
                    Reaction::Redraw
                }
                UiEvent::KeyPressed {
                    key: quadraui::Key::Named(quadraui::NamedKey::Up),
                    ..
                } => {
                    self.body_scroll = self.body_scroll.saturating_sub(1);
                    Reaction::Redraw
                }
                UiEvent::KeyPressed {
                    key: quadraui::Key::Named(quadraui::NamedKey::Down),
                    ..
                } => {
                    let max = self.body_rows.len().saturating_sub(1);
                    self.body_scroll = (self.body_scroll + 1).min(max);
                    Reaction::Redraw
                }
                _ => Reaction::Continue,
            },
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            0 => serde_json::json!({ "last": self.reveal_last, "row_count": REVEAL_ROW_COUNT }),
            1 => serde_json::json!({ "tasks": self.tasks, "selected": self.tasks_selected }),
            2 => serde_json::json!({ "last": self.groups_last }),
            _ => serde_json::json!({
                "chrome_mode": format!("{:?}", self.chrome_mode),
                "row_count": self.body_rows.len(),
            }),
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Tree + reveal
Toolbar header
Header groups
Body chrome

Split

Group: Chrome

Code

const DIVIDER_TOLERANCE: f32 = 2.0;

pub struct SplitDemo {
    ratio: f32,
    direction: SplitDirection,
    dragging: bool,
    tree: SplitTree,
    tree_drag: DragState,
}

impl SplitDemo {
    pub fn new() -> Self {
        Self {
            ratio: 0.5,
            direction: SplitDirection::Horizontal,
            dragging: false,
            tree: Self::default_tree(),
            tree_drag: DragState::new(),
        }
    }

    fn default_tree() -> SplitTree {
        SplitTree::split(
            SplitDirection::Horizontal,
            0.5,
            SplitTree::split(
                SplitDirection::Vertical,
                0.5,
                SplitTree::leaf(WidgetId::new("gallery:split-tree:a")),
                SplitTree::leaf(WidgetId::new("gallery:split-tree:b")),
            ),
            SplitTree::leaf(WidgetId::new("gallery:split-tree:c")),
        )
    }

    fn split(&self) -> Split {
        Split {
            id: WidgetId::new("gallery:split"),
            direction: self.direction,
            ratio: self.ratio,
            first_min: 0.0,
            second_min: 0.0,
        }
    }

    fn fill_pane(backend: &mut dyn Backend, bounds: Rect, label: &str, fg: Color, bg: Color) {
        let lh = backend.line_height();
        let label_rect = Rect::new(bounds.x, bounds.y, bounds.width, lh.min(bounds.height));
        let bar = StatusBar {
            id: WidgetId::new(format!("gallery:split:label:{label}")),
            left_segments: vec![StatusBarSegment {
                text: format!(" {label} "),
                fg,
                bg,
                bold: true,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(label_rect, &bar, &InteractionState::new());
    }
}

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

impl Demo for SplitDemo {
    fn name(&self) -> &'static str {
        "Split"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Two-pane", "Nested tree"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        if variant == 0 {
            let split = self.split();
            let layout = backend.draw_split(area, &split);
            Self::fill_pane(
                backend,
                layout.first_bounds,
                "FIRST",
                Color::rgb(255, 255, 255),
                Color::rgb(60, 60, 100),
            );
            Self::fill_pane(
                backend,
                layout.second_bounds,
                "SECOND",
                Color::rgb(255, 255, 255),
                Color::rgb(100, 60, 60),
            );
        } else {
            let layout = backend.draw_split_tree(area, &self.tree);
            let colors = [
                (Color::rgb(255, 255, 255), Color::rgb(60, 60, 100)),
                (Color::rgb(255, 255, 255), Color::rgb(60, 100, 60)),
                (Color::rgb(255, 255, 255), Color::rgb(100, 60, 60)),
            ];
            for (i, (id, leaf_rect)) in layout.leaves.iter().enumerate() {
                let (fg, bg) = colors[i % colors.len()];
                Self::fill_pane(backend, *leaf_rect, id.as_str(), fg, bg);
            }
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        if variant == 0 {
            match event {
                UiEvent::MouseDown { position, .. } => {
                    let split = self.split();
                    let layout = backend.split_layout(area, &split);
                    if let SplitHit::Divider(_) = layout.hit_test(position.x, position.y) {
                        self.dragging = true;
                    }
                    Reaction::Continue
                }
                UiEvent::MouseMoved { position, .. } => {
                    if !self.dragging {
                        return Reaction::Continue;
                    }
                    let new_ratio = match self.direction {
                        SplitDirection::Horizontal => {
                            if area.width > 0.0 {
                                ((position.x - area.x) / area.width).clamp(0.05, 0.95)
                            } else {
                                0.5
                            }
                        }
                        SplitDirection::Vertical => {
                            if area.height > 0.0 {
                                ((position.y - area.y) / area.height).clamp(0.05, 0.95)
                            } else {
                                0.5
                            }
                        }
                    };
                    if (new_ratio - self.ratio).abs() > 0.001 {
                        self.ratio = new_ratio;
                        Reaction::Redraw
                    } else {
                        Reaction::Continue
                    }
                }
                UiEvent::MouseUp { .. } => {
                    self.dragging = false;
                    Reaction::Continue
                }
                _ => Reaction::Continue,
            }
        } else {
            match event {
                UiEvent::MouseDown { position, .. } => {
                    let layout = backend.split_tree_layout(area, &self.tree);
                    if let Some(split_index) = layout.hit_test_divider(*position, DIVIDER_TOLERANCE)
                    {
                        if let Some(div) = layout
                            .dividers
                            .iter()
                            .find(|d| d.split_index == split_index)
                        {
                            self.tree_drag.begin(DragTarget::SplitDivider {
                                tree: WidgetId::new("gallery:split-tree"),
                                split_index,
                                direction: div.direction,
                                axis_start: div.axis_start,
                                axis_size: div.axis_size,
                            });
                        }
                    }
                    Reaction::Continue
                }
                UiEvent::MouseMoved { position, buttons } => {
                    if !self.tree_drag.is_active() {
                        return Reaction::Continue;
                    }
                    let events =
                        quadraui::dispatch_mouse_drag(&self.tree_drag, *position, *buttons);
                    let mut redraw = false;
                    for ev in events {
                        if let UiEvent::SplitDividerDragged {
                            split_index,
                            new_ratio,
                            ..
                        } = ev
                        {
                            redraw |= self.tree.set_ratio_at_index(split_index, new_ratio);
                        }
                    }
                    if redraw {
                        Reaction::Redraw
                    } else {
                        Reaction::Continue
                    }
                }
                UiEvent::MouseUp { .. } => {
                    self.tree_drag.end();
                    Reaction::Continue
                }
                _ => Reaction::Continue,
            }
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        if variant == 0 {
            serde_json::to_value(self.split()).unwrap_or(serde_json::Value::Null)
        } else {
            serde_json::json!({
                "leaf_count": self.tree.leaf_count(),
                "split_count": self.tree.split_count(),
            })
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Two-pane
Nested tree

Status Bar

Group: Chrome

Code

/// Build the status bar for either state: `dirty == true` pads the mode
/// segment wide enough, at this demo's width, to force a low-priority
/// right segment to drop under priority-drop truncation.
fn status_bar(dirty: bool) -> StatusBar {
    let mode_text = if dirty {
        format!("NORMAL{}", " [+]".repeat(60))
    } else {
        "NORMAL".to_string()
    };
    let plain = |text: &str| StatusBarSegment {
        text: text.to_string(),
        fg: Color::rgb(220, 220, 220),
        bg: Color::rgb(40, 80, 120),
        bold: false,
        action_id: None,
    };
    StatusBar {
        id: WidgetId::new("gallery:status-bar"),
        left_segments: vec![StatusBarSegment {
            text: mode_text,
            fg: Color::rgb(255, 255, 255),
            bg: Color::rgb(40, 80, 120),
            bold: true,
            action_id: None,
        }],
        // Least-important first, cursor-position segment last — the
        // documented convention that keeps it visible under
        // priority-drop.
        right_segments: vec![
            plain("Spaces: 4"),
            plain("UTF-8"),
            plain("LF"),
            plain("Ln 1, Col 1"),
        ],
    }
}

pub struct StatusBarDemo;

impl StatusBarDemo {
    pub fn new() -> Self {
        Self
    }
}

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

impl Demo for StatusBarDemo {
    fn name(&self) -> &'static str {
        "Status Bar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Clean", "Dirty (priority drop)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let rect = Rect::new(area.x, area.y, area.width, lh);
        let bar = status_bar(variant == 1);
        let _ = backend.draw_status_bar_interactive(rect, &bar, &InteractionState::new());
    }

    fn handle(
        &mut self,
        _variant: usize,
        _event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(status_bar(variant == 1)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Clean
Dirty (priority drop)

Tab Bar

Group: Chrome

Code

const CHROME_LABELS: [&str; 2] = ["main.rs", "lib.rs"];
const ICON_LABELS: [&str; 3] = [" main.rs ", " Cargo.toml ", " README.md "];
const WIDE_LABEL: &str = " 1: 日本語.rs ";
const ASCII_LABEL: &str = " 2: main.rs ";

pub struct TabBarDemo {
    chrome_active: usize,
    chrome_action: String,
    icons_active: usize,
    icons_on: bool,
    wide_active: usize,
}

impl TabBarDemo {
    pub fn new() -> Self {
        Self {
            chrome_active: 0,
            chrome_action: "ready".into(),
            icons_active: 0,
            icons_on: true,
            wide_active: 0,
        }
    }

    // ── Variant 0: bracket chrome ───────────────────────────────────

    fn chrome_bar(&self) -> TabBar {
        TabBar {
            id: WidgetId::new("gallery:tab-bar:chrome"),
            tabs: CHROME_LABELS
                .iter()
                .enumerate()
                .map(|(i, label)| TabItem {
                    label: (*label).to_string(),
                    is_active: i == self.chrome_active,
                    is_closable: i == self.chrome_active,
                    ..Default::default()
                })
                .collect(),
            scroll_offset: 0,
            right_segments: vec![],
            active_accent: None,
            show_tab_close: true,
            compact: false,
        }
    }

    fn chrome(&self) -> TabChrome {
        TabChrome::new(TabFrame::Brackets)
    }

    // ── Variant 1: per-tab icons ─────────────────────────────────────

    fn icons(&self) -> Vec<Option<TabIcon>> {
        vec![
            Some(TabIcon {
                glyph: "R".to_string(),
                color: Color::rgb(222, 165, 132),
            }),
            Some(TabIcon {
                glyph: "T".to_string(),
                color: Color::rgb(160, 190, 210),
            }),
            Some(TabIcon {
                glyph: "M".to_string(),
                color: Color::rgb(120, 170, 240),
            }),
        ]
    }

    fn icons_bar(&self) -> TabBar {
        TabBar {
            id: WidgetId::new("gallery:tab-bar:icons"),
            tabs: ICON_LABELS
                .iter()
                .enumerate()
                .map(|(i, label)| TabItem {
                    label: (*label).to_string(),
                    is_active: i == self.icons_active,
                    ..Default::default()
                })
                .collect(),
            scroll_offset: 0,
            right_segments: vec![],
            active_accent: None,
            show_tab_close: true,
            compact: false,
        }
    }

    // ── Variant 2: wide (CJK) glyph ───────────────────────────────────

    fn wide_bar(&self) -> TabBar {
        TabBar {
            id: WidgetId::new("gallery:tab-bar:wide"),
            tabs: vec![
                TabItem {
                    label: WIDE_LABEL.to_string(),
                    is_active: self.wide_active == 0,
                    is_closable: true,
                    ..Default::default()
                },
                TabItem {
                    label: ASCII_LABEL.to_string(),
                    is_active: self.wide_active == 1,
                    is_closable: true,
                    ..Default::default()
                },
            ],
            scroll_offset: 0,
            right_segments: vec![],
            active_accent: Some(Color::rgb(80, 160, 240)),
            show_tab_close: true,
            compact: false,
        }
    }

    fn rect(area: Rect, backend: &dyn Backend) -> Rect {
        Rect::new(area.x, area.y, area.width, backend.line_height())
    }

    fn toggle_icons(&mut self) {
        self.icons_on = !self.icons_on;
    }

    fn close_chrome_tab(&mut self, i: usize) {
        self.chrome_action = format!("closed {}", CHROME_LABELS[i]);
    }

    fn activate_chrome_tab(&mut self, i: usize) {
        self.chrome_active = i;
        self.chrome_action = format!("activated {}", CHROME_LABELS[i]);
    }
}

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

impl Demo for TabBarDemo {
    fn name(&self) -> &'static str {
        "Tab Bar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Chrome frame", "Icons", "Wide glyphs"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = Self::rect(area, backend);
        match variant {
            0 => {
                backend.draw_tab_bar_with_chrome_layout(
                    rect,
                    &self.chrome_bar(),
                    None,
                    &self.chrome(),
                );
            }
            1 => {
                let icons = if self.icons_on { self.icons() } else { vec![] };
                backend.draw_tab_bar_icons_layout(rect, &self.icons_bar(), &icons, None);
            }
            _ => {
                backend.draw_tab_bar_layout(rect, &self.wide_bar(), None);
            }
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let rect = Self::rect(area, backend);
        match event {
            UiEvent::MouseDown { position, .. } => {
                if position.y < rect.y || position.y >= rect.y + rect.height {
                    return Reaction::Continue;
                }
                let local_x = position.x - rect.x;
                let local_y = position.y - rect.y;
                match variant {
                    0 => {
                        let layout = backend.resolve_tab_bar_layout_with_chrome(
                            rect,
                            &self.chrome_bar(),
                            &self.chrome(),
                        );
                        match layout.hit_test(local_x, local_y) {
                            TabBarHit::TabClose(i) => {
                                self.close_chrome_tab(i);
                                Reaction::Redraw
                            }
                            TabBarHit::Tab(i) => {
                                self.activate_chrome_tab(i);
                                Reaction::Redraw
                            }
                            _ => Reaction::Continue,
                        }
                    }
                    1 => {
                        let icons = if self.icons_on { self.icons() } else { vec![] };
                        let layout =
                            backend.resolve_tab_bar_layout_icons(rect, &self.icons_bar(), &icons);
                        if let TabBarHit::Tab(i) = layout.hit_test(local_x, local_y) {
                            self.icons_active = i;
                            Reaction::Redraw
                        } else {
                            Reaction::Continue
                        }
                    }
                    _ => {
                        let layout = backend.resolve_tab_bar_layout(rect, &self.wide_bar());
                        if let TabBarHit::Tab(i) = layout.hit_test(local_x, local_y) {
                            self.wide_active = i;
                            Reaction::Redraw
                        } else {
                            Reaction::Continue
                        }
                    }
                }
            }
            UiEvent::KeyPressed {
                key: quadraui::Key::Char('i'),
                ..
            } if variant == 1 => {
                self.toggle_icons();
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        match variant {
            0 => {
                let mut value =
                    serde_json::to_value(self.chrome_bar()).unwrap_or(serde_json::Value::Null);
                if let Some(obj) = value.as_object_mut() {
                    obj.insert(
                        "chrome_action".into(),
                        serde_json::Value::String(self.chrome_action.clone()),
                    );
                }
                value
            }
            1 => serde_json::to_value(self.icons_bar()).unwrap_or(serde_json::Value::Null),
            _ => serde_json::to_value(self.wide_bar()).unwrap_or(serde_json::Value::Null),
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Chrome frame
Icons
Wide glyphs

Toolbar

Group: Chrome

Code

pub struct ToolbarDemo {
    filter_active: bool,
    running: bool,
    last_message: String,
    interaction: InteractionState,
    focused_index: Option<usize>,
}

impl ToolbarDemo {
    pub fn new() -> Self {
        Self {
            filter_active: false,
            running: true,
            last_message: "Click, Tab to focus, Enter to activate".into(),
            interaction: InteractionState::new(),
            focused_index: None,
        }
    }

    /// `variant == 1` drops button labels and keeps only icon + tooltip —
    /// the compact style a space-constrained host toolbar would use.
    fn toolbar(&self, variant: usize) -> Toolbar {
        let labeled = variant == 0;
        let label = |text: &str| if labeled { text.into() } else { String::new() };
        Toolbar::new(WidgetId::new("gallery:toolbar"))
            .with_buttons(vec![
                ToolbarButton::Action {
                    id: WidgetId::new("gallery:toolbar:continue"),
                    label: label("Continue"),
                    icon: Some("▶".into()),
                    key_hint: Some("1".into()),
                    enabled: !self.running,
                    is_active: false,
                    tooltip: "Resume execution".into(),
                },
                ToolbarButton::Action {
                    id: WidgetId::new("gallery:toolbar:pause"),
                    label: label("Pause"),
                    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("gallery:toolbar:filter"),
                    label: label("Filter"),
                    icon: Some("⚙".into()),
                    key_hint: Some("3".into()),
                    enabled: true,
                    is_active: self.filter_active,
                    tooltip: "Toggle filter".into(),
                },
                ToolbarButton::Action {
                    id: WidgetId::new("gallery:toolbar:debug"),
                    label: label("Debug"),
                    // Every button carries an icon so the icon-only
                    // variant has something to paint for each one.
                    icon: Some("⚑".into()),
                    key_hint: None,
                    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)
                    }),
                },
            ])
            .with_focused_index(self.focused_index)
    }

    fn toolbar_rect(area: Rect, backend: &dyn Backend) -> Rect {
        Rect::new(area.x, area.y, area.width, backend.line_height())
    }

    fn hint_bar(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:toolbar:hint"),
            left_segments: vec![StatusBarSegment {
                text: format!("  {} ", self.last_message),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

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

    fn focusable_indices(&self, variant: usize) -> Vec<usize> {
        self.toolbar(variant)
            .buttons
            .into_iter()
            .enumerate()
            .filter_map(|(i, btn)| match btn {
                ToolbarButton::Action { enabled, .. } if enabled => Some(i),
                _ => None,
            })
            .collect()
    }

    fn advance_focus(&mut self, variant: usize, forward: bool) {
        let candidates = self.focusable_indices(variant);
        if candidates.is_empty() {
            return;
        }
        self.focused_index = Some(match self.focused_index {
            None => {
                if forward {
                    candidates[0]
                } else {
                    *candidates.last().unwrap()
                }
            }
            Some(current) => match candidates.iter().position(|&i| i == current) {
                None => candidates[0],
                Some(p) => {
                    let next = if forward {
                        (p + 1) % candidates.len()
                    } else {
                        (p + candidates.len() - 1) % candidates.len()
                    };
                    candidates[next]
                }
            },
        });
    }

    fn activate_focused(&mut self, variant: usize) -> bool {
        let Some(idx) = self.focused_index else {
            return false;
        };
        let bar = self.toolbar(variant);
        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 ToolbarDemo {
    fn default() -> Self {
        Self::new()
    }
}

impl Demo for ToolbarDemo {
    fn name(&self) -> &'static str {
        "Toolbar"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Labeled", "Icon-only"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let rect = Self::toolbar_rect(area, backend);
        let bar = self.toolbar(variant);
        let _ = backend.draw_toolbar_interactive(rect, &bar, &self.interaction);

        let hint_rect = Rect::new(area.x, area.y + lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            hint_rect,
            &self.hint_bar(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Tab),
                ..
            } => {
                self.advance_focus(variant, true);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::BackTab),
                ..
            } => {
                self.advance_focus(variant, false);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Enter) | Key::Char(' '),
                ..
            } => {
                if self.activate_focused(variant) {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
            UiEvent::KeyPressed {
                key: Key::Char(c @ '1'..='3'),
                ..
            } => {
                let id = match c {
                    '1' => "gallery:toolbar:continue",
                    '2' => "gallery:toolbar:pause",
                    _ => "gallery:toolbar:filter",
                };
                let bar = self.toolbar(variant);
                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
            }
            UiEvent::MouseMoved { .. } | UiEvent::MouseDown { .. } | UiEvent::MouseUp { .. } => {
                let rect = Self::toolbar_rect(area, backend);
                let bar = self.toolbar(variant);
                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,
                };
                let was_pressed = self.interaction.pressed().cloned();
                let changed = self.interaction.handle_mouse(event, hit_test);

                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
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.toolbar(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Labeled
Icon-only

Window Control

Group: Chrome

Code

pub struct WindowControlDemo {
    status: String,
    fullscreen: bool,
    always_on_top: bool,
}

impl WindowControlDemo {
    pub fn new() -> Self {
        Self {
            status: "click a button to try it".into(),
            fullscreen: false,
            always_on_top: false,
        }
    }

    fn describe(action: &str, result: ServiceResult<()>) -> String {
        match result {
            Ok(()) => format!("{action}: ok"),
            Err(e) => format!("{action}: {e:?}"),
        }
    }

    fn toolbar(&self, variant: usize) -> Toolbar {
        let btn = |id: &str, label: &str| ToolbarButton::Action {
            id: WidgetId::new(id),
            label: label.into(),
            icon: None,
            key_hint: None,
            enabled: true,
            is_active: false,
            tooltip: label.into(),
        };
        let buttons = if variant == 0 {
            vec![
                btn("gallery:wc:title", "Set Title"),
                btn("gallery:wc:fullscreen", "Fullscreen"),
                btn("gallery:wc:always-on-top", "Always-on-top"),
                btn("gallery:wc:minimize", "Minimize"),
                btn("gallery:wc:restore", "Restore"),
                btn("gallery:wc:center", "Center"),
            ]
        } else {
            // A smaller, realistic toolbar — the subset most apps actually
            // expose, without the window-manager-only Minimize/Center pair.
            vec![
                btn("gallery:wc:title", "Set Title"),
                btn("gallery:wc:fullscreen", "Fullscreen"),
                btn("gallery:wc:restore", "Restore"),
            ]
        };
        Toolbar::new(WidgetId::new("gallery:window-control")).with_buttons(buttons)
    }

    fn status_bar(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:window-control:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.status),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    fn dispatch(&mut self, id: &WidgetId, backend: &mut dyn Backend) {
        let result;
        let action;
        match id.as_str() {
            "gallery:wc:title" => {
                action = "set_title";
                result = match backend.window() {
                    Some(w) => w.set_title("quadraui gallery"),
                    None => Err(BackendError::Unsupported),
                };
            }
            "gallery:wc:fullscreen" => {
                action = "set_fullscreen";
                self.fullscreen = !self.fullscreen;
                let want = self.fullscreen;
                result = match backend.window() {
                    Some(w) => w.set_fullscreen(want),
                    None => Err(BackendError::Unsupported),
                };
            }
            "gallery:wc:always-on-top" => {
                action = "set_always_on_top";
                self.always_on_top = !self.always_on_top;
                let want = self.always_on_top;
                result = match backend.window() {
                    Some(w) => w.set_always_on_top(want),
                    None => Err(BackendError::Unsupported),
                };
            }
            "gallery:wc:minimize" => {
                action = "minimize";
                result = match backend.window() {
                    Some(w) => w.minimize(),
                    None => Err(BackendError::Unsupported),
                };
            }
            "gallery:wc:restore" => {
                action = "restore";
                result = match backend.window() {
                    Some(w) => w.restore(),
                    None => Err(BackendError::Unsupported),
                };
            }
            "gallery:wc:center" => {
                action = "center";
                result = match backend.window() {
                    Some(w) => w.center(),
                    None => Err(BackendError::Unsupported),
                };
            }
            _ => return,
        }
        self.status = Self::describe(action, result);
    }
}

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

impl Demo for WindowControlDemo {
    fn name(&self) -> &'static str {
        "Window Control"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Full toolbar", "Common subset"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_rect = Rect::new(area.x, area.y, area.width, lh);
        let _ = backend.draw_toolbar_interactive(
            bar_rect,
            &self.toolbar(variant),
            &InteractionState::new(),
        );

        let status_rect = Rect::new(area.x, area.y + lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        if let UiEvent::MouseDown { position, .. } = event {
            let lh = backend.line_height();
            let bar_rect = Rect::new(area.x, area.y, area.width, lh);
            if position.y >= bar_rect.y && position.y < bar_rect.y + bar_rect.height {
                let bar = self.toolbar(variant);
                let layout = backend.toolbar_layout(bar_rect, &bar);
                if let ToolbarHit::Button(id) = layout.hit_test(position.x, position.y) {
                    self.dispatch(&id, backend);
                    return Reaction::Redraw;
                }
            }
        }
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, _variant: usize) -> serde_json::Value {
        serde_json::json!({
            "status": self.status,
            "fullscreen": self.fullscreen,
            "always_on_top": self.always_on_top,
        })
    }

    fn caps_note(&self, _variant: usize, caps: &BackendCaps) -> Option<String> {
        // `BackendCaps::window_control` is the honest signal for whether
        // `Backend::window()` returns `Some` at all; each button's own
        // `ServiceResult` (shown live in the status line below) then
        // covers which individual operations that toplevel supports.
        if caps.window_control {
            None
        } else {
            Some(
                "This backend has no window handle (BackendCaps::window_control \
                 is false) — expect every button here to report Unsupported."
                    .into(),
            )
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Full toolbar
Common subset

Workspace

Group: Chrome

Code

const INITIAL: [(&str, &str); 3] = [
    ("gallery:doc:alpha", "alpha"),
    ("gallery:doc:beta", "beta"),
    ("gallery:doc:gamma", "gamma"),
];

const BACKLOG: [(&str, &str); 2] = [
    ("gallery:doc:delta", "delta-doc"),
    ("gallery:doc:epsilon", "epsilon-doc"),
];

const BAR_ID: &str = "gallery:workspace:tabs";
const VARIANT_COUNT: usize = 2;

/// Build variant `variant`'s initial controller: variant 1 opens one
/// extra preview tab on top of the three permanent docs variant 0 has.
fn build_workspace(variant: usize) -> WorkspaceController {
    let mut workspace = WorkspaceController::new(BAR_ID);
    for (id, label) in INITIAL {
        workspace.open(WorkspaceDoc::new(id, label));
    }
    if variant == 1 {
        let (id, label) = BACKLOG[0];
        workspace.open_preview(WorkspaceDoc::new(id, label));
    } else {
        workspace.activate(INITIAL[0].0);
    }
    workspace
}

pub struct WorkspaceDemo {
    // `RefCell` so `render(&self, …)` can call the controller's `&mut
    // self` render — each variant gets its own instance and counters so
    // switching variants doesn't share state.
    workspaces: [RefCell<WorkspaceController>; VARIANT_COUNT],
    next_backlog: [usize; VARIANT_COUNT],
    next_preview_backlog: [usize; VARIANT_COUNT],
    last_event: String,
}

impl WorkspaceDemo {
    pub fn new() -> Self {
        Self {
            workspaces: [
                RefCell::new(build_workspace(0)),
                RefCell::new(build_workspace(1)),
            ],
            next_backlog: [0; VARIANT_COUNT],
            next_preview_backlog: [0, 1],
            last_event: "ready".into(),
        }
    }

    fn describe(event: &WorkspaceEvent) -> String {
        match event {
            WorkspaceEvent::Opened { id, .. } => format!("opened {id}"),
            WorkspaceEvent::Activated { id, .. } => format!("activated {id}"),
            WorkspaceEvent::Closed { id, .. } => format!("closed {id}"),
            WorkspaceEvent::Reordered { id, from, to } => format!("reordered {id} {from}->{to}"),
            WorkspaceEvent::Promoted { id } => format!("promoted {id}"),
            _ => "unknown workspace event".to_string(),
        }
    }

    fn record(&mut self, events: &[WorkspaceEvent]) -> Reaction {
        if events.is_empty() {
            return Reaction::Continue;
        }
        self.last_event = events
            .iter()
            .map(Self::describe)
            .collect::<Vec<_>>()
            .join(", ");
        Reaction::Redraw
    }

    fn open_next_backlog_doc(&mut self, variant: usize) -> Reaction {
        let Some((id, label)) = BACKLOG.get(self.next_backlog[variant]).copied() else {
            self.last_event = "backlog exhausted".into();
            return Reaction::Redraw;
        };
        self.next_backlog[variant] += 1;
        let events = self.workspaces[variant]
            .borrow_mut()
            .open(WorkspaceDoc::new(id, label));
        self.record(&events)
    }

    fn open_next_preview_doc(&mut self, variant: usize) -> Reaction {
        let Some((id, label)) = BACKLOG.get(self.next_preview_backlog[variant]).copied() else {
            self.last_event = "preview backlog exhausted".into();
            return Reaction::Redraw;
        };
        self.next_preview_backlog[variant] += 1;
        let events = self.workspaces[variant]
            .borrow_mut()
            .open_preview(WorkspaceDoc::new(id, label));
        self.record(&events)
    }

    fn promote_active(&mut self, variant: usize) -> Reaction {
        let mut ws = self.workspaces[variant].borrow_mut();
        let Some(id) = ws.active_id().map(str::to_string) else {
            return Reaction::Continue;
        };
        match ws.promote(&id) {
            Some(ev) => {
                drop(ws);
                self.record(&[ev])
            }
            None => Reaction::Continue,
        }
    }

    fn label_bar(id: &str, text: String, fg: Color) -> StatusBar {
        StatusBar {
            id: WidgetId::new(id),
            left_segments: vec![StatusBarSegment {
                text,
                fg,
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for WorkspaceDemo {
    fn name(&self) -> &'static str {
        "Workspace"
    }

    fn group(&self) -> &'static str {
        "Chrome"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Permanent tabs", "With preview tab"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let strip_h = lh;
        let strip = Rect::new(area.x, area.y, area.width, strip_h);
        self.workspaces[variant].borrow_mut().render(backend, strip);

        let body_rect = Rect::new(area.x, area.y + strip_h, area.width, lh);
        let ws = self.workspaces[variant].borrow();
        let body = match ws.active_id() {
            Some(id) if ws.is_preview(id) => format!(" viewing: {id} (preview) "),
            Some(id) => format!(" viewing: {id} "),
            None => " viewing: (no documents open) ".to_string(),
        };
        backend.draw_status_bar_interactive(
            body_rect,
            &Self::label_bar("gallery:workspace:body", body, Color::rgb(220, 220, 220)),
            &InteractionState::new(),
        );
        let hint_rect = Rect::new(area.x, area.y + strip_h + lh, area.width, lh);
        backend.draw_status_bar_interactive(
            hint_rect,
            &Self::label_bar(
                "gallery:workspace:hint",
                format!(" last: {} ", self.last_event),
                Color::rgb(150, 200, 150),
            ),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('o'),
                ..
            } => self.open_next_backlog_doc(variant),
            UiEvent::KeyPressed {
                key: Key::Char('p'),
                ..
            } => self.open_next_preview_doc(variant),
            UiEvent::KeyPressed {
                key: Key::Char('m'),
                ..
            } => self.promote_active(variant),
            UiEvent::KeyPressed { key, modifiers, .. } => {
                let handled = self.workspaces[variant]
                    .borrow_mut()
                    .handle_key(key, *modifiers);
                match handled {
                    Some(ev) => self.record(&[ev]),
                    None => Reaction::Continue,
                }
            }
            UiEvent::MouseDown {
                position, button, ..
            } => {
                let events = self.workspaces[variant]
                    .borrow_mut()
                    .handle_click(position.x, position.y, *button);
                self.record(&events)
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let ws = self.workspaces[variant].borrow();
        serde_json::json!({
            "docs": ws.docs().iter().map(|d| &d.label).collect::<Vec<_>>(),
            "active": ws.active_id(),
            "last_event": self.last_event,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Permanent tabs
With preview tab

Clipboard

Group: Overlays

Code

const SEEDS: [&str; 3] = [
    "Copy me to the system clipboard!",
    "Line one\nLine two\nLine three",
    "café façade 日本語 🎉",
];

pub struct ClipboardDemo {
    editors: Vec<TextEditor>,
    statuses: Vec<String>,
}

impl ClipboardDemo {
    pub fn new() -> Self {
        let editors = SEEDS
            .iter()
            .enumerate()
            .map(|(i, seed)| {
                let mut input = TextInput::new(WidgetId::new(format!("gallery:clipboard:{i}")));
                input.lines = seed.lines().map(str::to_string).collect();
                input.has_focus = true;
                let last_line = input.lines.len().saturating_sub(1);
                input.cursor_line = last_line;
                input.cursor_col = input.lines.get(last_line).map_or(0, |l| l.chars().count());
                TextEditor::new(input)
            })
            .collect();
        Self {
            editors,
            statuses: vec!["Ctrl-C copies - Ctrl-V pastes".to_string(); SEEDS.len()],
        }
    }

    fn text(&self, variant: usize) -> String {
        self.editors[variant].input.lines.join("\n")
    }

    fn status_bar(&self, variant: usize) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:clipboard:status"),
            left_segments: vec![StatusBarSegment {
                text: " Clipboard demo ".into(),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: true,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.statuses[variant]),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }
}

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

impl Demo for ClipboardDemo {
    fn name(&self) -> &'static str {
        "Clipboard"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Plain text", "Multi-line", "Unicode"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let pad = lh;

        let status_rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(variant),
            &InteractionState::new(),
        );

        let input_rect = Rect::new(
            area.x + pad,
            area.y + pad,
            (area.width - pad * 2.0).max(0.0),
            (area.height - pad * 2.0 - lh).max(lh),
        );
        backend.draw_text_input(input_rect, &self.editors[variant].input);
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match event {
            // Ctrl-C: write the whole field to the system clipboard via
            // all three `Clipboard::write_text` legs. No active
            // text-selection is needed here — this exercises the
            // service directly.
            UiEvent::KeyPressed {
                key: Key::Char('c') | Key::Char('C'),
                modifiers,
                ..
            } if modifiers.ctrl && !modifiers.alt && !modifiers.cmd => {
                let text = self.text(variant);
                backend.services().clipboard().write_text(&text);
                self.statuses[variant] = format!("Copied {} chars", text.chars().count());
                Reaction::Redraw
            }
            // Ctrl-V: read the system clipboard back — proves a round
            // trip through whichever leg actually landed the write,
            // including a paste made from outside this process.
            UiEvent::KeyPressed {
                key: Key::Char('v') | Key::Char('V'),
                modifiers,
                ..
            } if modifiers.ctrl && !modifiers.alt && !modifiers.cmd => {
                match backend.services().clipboard().read_text() {
                    Some(text) => {
                        let count = text.chars().count();
                        self.editors[variant].apply(EditOp::MoveDocEnd { extend: false });
                        self.editors[variant].apply(EditOp::InsertText(text));
                        self.statuses[variant] = format!("Pasted {count} chars");
                    }
                    None => self.statuses[variant] = "Clipboard read returned nothing".to_string(),
                }
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Backspace),
                ..
            } => {
                self.editors[variant].apply(EditOp::DeleteBackward);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char(c),
                modifiers,
                ..
            } if !modifiers.ctrl && !modifiers.alt && !modifiers.cmd => {
                self.editors[variant].apply(EditOp::InsertChar(*c));
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "text": self.text(variant),
            "status": self.statuses[variant],
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // `PlatformServices::clipboard()` has no no-op default and no
        // `BackendCaps` flag — every backend implements a real
        // read/write path (TUI's own three-leg fallback included), so
        // there's no gap to report.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Plain text
Multi-line
Unicode

Context Menu

Group: Overlays

Code

pub struct ContextMenuDemo {
    ctx_menu: ContextMenuController,
    last_action: Option<String>,
}

impl ContextMenuDemo {
    pub fn new() -> Self {
        Self {
            ctx_menu: ContextMenuController::new(),
            last_action: None,
        }
    }

    /// The [`MenuStyle`] each variant requests.
    fn requested_style(variant: usize) -> MenuStyle {
        match variant {
            0 => MenuStyle::Auto,
            1 => MenuStyle::Native,
            _ => MenuStyle::Custom,
        }
    }

    fn build_context_menu(&self) -> ContextMenu {
        ContextMenu {
            id: WidgetId::new("gallery:context-menu"),
            items: vec![
                action_item("gallery:ctx:cut", "Cut", Some(KeyBinding::Cut)),
                action_item("gallery:ctx:copy", "Copy", Some(KeyBinding::Copy)),
                action_item("gallery:ctx:paste", "Paste", Some(KeyBinding::Paste)),
                ContextMenuItem::default(), // separator
                action_item(
                    "gallery:ctx:select_all",
                    "Select All",
                    Some(KeyBinding::SelectAll),
                ),
                ContextMenuItem::default(),
                action_item("gallery:ctx:about", "About this demo", None),
            ],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::AnchorPoint,
        }
    }

    /// One handler for the context-menu events, reached either from the
    /// top-level `UiEvent` match (native path, queued by
    /// `Backend::show_context_menu`) or from `ContextMenuOutcome::Event`
    /// (painted path, returned synchronously by `ContextMenuController::
    /// handle`) — one code path regardless of which path produced the
    /// event.
    fn handle_menu_event(&mut self, event: UiEvent) -> Reaction {
        match event {
            UiEvent::ContextMenuItemActivated(id) => {
                self.last_action = Some(id.as_str().to_string());
                Reaction::Redraw
            }
            UiEvent::ContextMenuDismissed => Reaction::Redraw,
            _ => Reaction::Continue,
        }
    }

    fn status_bar(&self, variant: usize, effective: ResolvedMenuStyle) -> StatusBar {
        let left = match &self.last_action {
            Some(s) => format!(" Last action: {s} "),
            None => " Right-click anywhere ".to_string(),
        };
        let style_name = match Self::requested_style(variant) {
            MenuStyle::Auto => "Auto",
            MenuStyle::Native => "Native",
            MenuStyle::Custom => "Custom",
        };
        let resolved_name = match effective {
            ResolvedMenuStyle::Native => "native",
            ResolvedMenuStyle::Custom => "custom",
        };
        let right = format!(" style={style_name} effective={resolved_name} ");
        StatusBar {
            id: WidgetId::new("gallery:context-menu:status"),
            left_segments: vec![StatusBarSegment {
                text: left,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: true,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: right,
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }
}

fn action_item(id: &str, label: &str, binding: Option<KeyBinding>) -> ContextMenuItem {
    ContextMenuItem {
        id: Some(WidgetId::new(id)),
        label: StyledText::plain(label),
        key_equivalent: binding.map(|b| Accelerator {
            id: AcceleratorId::new(id),
            binding: b,
            scope: AcceleratorScope::Global,
            label: None,
        }),
        ..Default::default()
    }
}

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

impl Demo for ContextMenuDemo {
    fn name(&self) -> &'static str {
        "Context Menu"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Auto", "Native", "Custom"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        // Reasserted every frame so the active variant's requested style
        // is always the one in effect while this demo is showing —
        // switching the variant picker is the one way to change it.
        backend.set_menu_style(Self::requested_style(variant));
        let bar = self.status_bar(variant, backend.effective_menu_style());
        let lh = backend.line_height();
        let rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(rect, &bar, &InteractionState::new());

        // Layer the painted context menu (if one is open) above
        // everything else, per `ContextMenuController`'s contract. A
        // no-op when the native path is in effect — the OS owns that
        // surface.
        self.ctx_menu.render(backend);
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        // Route through the controller first — on the `Custom` path this
        // may return one of the same two events the `Native` path
        // delivers later via the top-level match below.
        match self.ctx_menu.handle(event, backend) {
            ContextMenuOutcome::Event(ev) => return self.handle_menu_event(ev),
            // Navigation/hit-testing inside the open menu (e.g. arrow
            // keys moving the selection) — nothing to route, but the
            // new selection needs painting.
            ContextMenuOutcome::Consumed => return Reaction::Redraw,
            ContextMenuOutcome::Ignored => {}
        }

        match event {
            UiEvent::MouseDown {
                button: quadraui::MouseButton::Right,
                position,
                ..
            } => {
                let menu = self.build_context_menu();
                // The one call: native on macOS under `Auto`/`Native`,
                // painted everywhere else (and on macOS under `Custom`)
                // — same call either way.
                self.ctx_menu.open(menu, *position, backend);
                Reaction::Redraw
            }
            // Native-path activation/dismissal arrives here as a
            // top-level event, queued by `Backend::show_context_menu`.
            UiEvent::ContextMenuItemActivated(_) | UiEvent::ContextMenuDismissed => {
                self.handle_menu_event(event.clone())
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "requested_style": format!("{:?}", Self::requested_style(variant)),
            "last_action": self.last_action,
        })
    }

    fn caps_note(&self, _variant: usize, caps: &BackendCaps) -> Option<String> {
        if caps.native_menu {
            None
        } else {
            Some(
                "This backend has no native OS menu (BackendCaps::native_menu is false) — \
                 every MenuStyle resolves to the painted ContextMenuController path shown here, \
                 even when 'Native' is requested."
                    .into(),
            )
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Auto
Native
Custom

Dialog

Group: Overlays

Code

/// Id the dialog is registered under in the [`quadraui::ModalStack`]. The
/// same id comes back on `MouseDown { widget }` for clicks inside it.
const DIALOG_ID: &str = "gallery:dialog:confirm-delete";

/// Rows in the list — enough that the centred dialog covers a
/// contiguous middle band, including one row's own text.
const ROW_COUNT: usize = 14;

pub struct DialogDemo {
    rows: Vec<String>,
    selected: Option<String>,
    dialog_open: bool,
}

impl DialogDemo {
    pub fn new() -> Self {
        Self {
            rows: (0..ROW_COUNT).map(|i| format!("pod-{i:02}")).collect(),
            selected: None,
            dialog_open: false,
        }
    }

    fn dialog(&self, variant: usize) -> Dialog {
        match variant {
            0 => Dialog {
                id: WidgetId::new(DIALOG_ID),
                title: StyledText::plain("Confirm delete"),
                body: vec![
                    StyledText::plain("Really delete?"),
                    StyledText::plain("This cannot be undone."),
                ],
                table: None,
                buttons: vec![
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:ok"),
                        label: "OK".into(),
                        is_default: true,
                        is_cancel: false,
                        tint: None,
                    },
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:cancel"),
                        label: "Cancel".into(),
                        is_default: false,
                        is_cancel: true,
                        tint: None,
                    },
                ],
                severity: None,
                vertical_buttons: false,
                input: None,
            },
            1 => Dialog {
                id: WidgetId::new(DIALOG_ID),
                title: StyledText::plain("Delete permanently?"),
                body: vec![
                    StyledText::plain("This removes the pod and all its logs."),
                    StyledText::plain("This cannot be undone."),
                ],
                table: None,
                buttons: vec![
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:keep"),
                        label: "Keep".into(),
                        is_default: true,
                        is_cancel: true,
                        tint: None,
                    },
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:delete"),
                        label: "Delete".into(),
                        is_default: false,
                        is_cancel: false,
                        tint: Some(Color::rgb(200, 60, 60)),
                    },
                ],
                severity: Some(DialogSeverity::Error),
                vertical_buttons: false,
                input: None,
            },
            _ => Dialog {
                id: WidgetId::new(DIALOG_ID),
                title: StyledText::plain("Unsaved changes"),
                body: vec![StyledText::plain(
                    "What should happen to your changes before closing?",
                )],
                table: None,
                buttons: vec![
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:save"),
                        label: "Save".into(),
                        is_default: true,
                        is_cancel: false,
                        tint: None,
                    },
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:discard"),
                        label: "Discard".into(),
                        is_default: false,
                        is_cancel: false,
                        tint: Some(Color::rgb(200, 60, 60)),
                    },
                    DialogButton {
                        id: WidgetId::new("gallery:dialog:cancel-vertical"),
                        label: "Cancel".into(),
                        is_default: false,
                        is_cancel: true,
                        tint: None,
                    },
                ],
                severity: Some(DialogSeverity::Warning),
                vertical_buttons: true,
                input: None,
            },
        }
    }

    /// Dialog layout for `area`. Called from both `render` and `handle`
    /// so paint and hit-test can never disagree.
    fn dialog_layout(&self, backend: &dyn Backend, area: Rect, variant: usize) -> DialogLayout {
        let m = backend.measure();
        let lh = m.line_height;
        let char_w = m.char_width;
        let dialog = self.dialog(variant);
        let button_width = dialog
            .buttons
            .iter()
            .map(|b| backend.measure_text(&b.label, FontRole::Chrome).0)
            .fold(0.0_f32, f32::max)
            + char_w * 2.0;
        // Snapped to a whole multiple of `char_w`, not the raw
        // `area.width * 0.6` — see `snap_for_centering`'s doc for why a
        // fractional content width breaks hit-testing on TUI.
        let width = (((area.width * 0.6).clamp(char_w * 24.0, char_w * 48.0) / char_w).round()
            * char_w)
            .max(char_w);
        let title_height = lh;
        let body_height = lh * dialog.body.len() as f32;
        let button_row_height = lh;
        let padding = lh;
        // A vertical button stack reserves one row per button instead
        // of one row total — matches the block height `Dialog::layout`
        // itself substitutes internally, so the two never disagree on
        // the dialog's total content height.
        let button_block_height = if dialog.vertical_buttons {
            button_row_height * dialog.buttons.len().max(1) as f32
        } else {
            button_row_height
        };
        let total_height = padding * 2.0 + title_height + body_height + button_block_height;
        let measure = DialogMeasure {
            width,
            title_height,
            body_height,
            table_height: 0.0,
            input_height: 0.0,
            button_row_height,
            button_width,
            button_gap: char_w * 2.0,
            padding,
        };
        let centering_area = snap_for_centering(area, width, total_height, char_w, lh);
        dialog.layout(centering_area, measure, |_| ToolbarItemMeasure::new(0.0))
    }

    fn row_at(&self, backend: &dyn Backend, area: Rect, y: f32) -> Option<usize> {
        let lh = backend.line_height();
        if lh <= 0.0 || y < area.y {
            return None;
        }
        let idx = ((y - area.y) / lh).floor() as usize;
        (idx < self.rows.len()).then_some(idx)
    }

    /// True if `y` falls on the `Open dialog` button row (immediately
    /// below the last list row).
    fn on_open_button(&self, backend: &dyn Backend, area: Rect, y: f32) -> bool {
        let lh = backend.line_height();
        lh > 0.0 && self.row_at(backend, area, y).is_none() && {
            let idx = ((y - area.y) / lh).floor() as usize;
            idx == self.rows.len()
        }
    }

    fn status_bar(&self, variant: usize) -> StatusBar {
        let selected = self.selected.clone().unwrap_or_else(|| "nothing".into());
        let variant_label = match variant {
            0 => "Confirm",
            1 => "Destructive",
            _ => "Vertical",
        };
        StatusBar {
            id: WidgetId::new("gallery:dialog:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" [{variant_label}] selected: {selected} "),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: " click a row, or Open dialog ".into(),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    fn line(&self, backend: &mut dyn Backend, rect: Rect, id: &str, text: String) {
        let bar = StatusBar {
            id: WidgetId::new(id),
            left_segments: vec![StatusBarSegment {
                text,
                fg: Color::rgb(210, 210, 210),
                bg: Color::rgb(24, 24, 32),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        };
        let _ = backend.draw_status_bar_interactive(rect, &bar, &InteractionState::new());
    }

    fn open_dialog(&mut self, backend: &mut dyn Backend, area: Rect, variant: usize) {
        self.dialog_open = true;
        let bounds = self.dialog_layout(backend, area, variant).bounds;
        backend
            .modal_stack_handle()
            .borrow_mut()
            .push(WidgetId::new(DIALOG_ID), bounds);
    }

    fn close_dialog(&mut self, backend: &mut dyn Backend) {
        self.dialog_open = false;
        backend
            .modal_stack_handle()
            .borrow_mut()
            .pop(&WidgetId::new(DIALOG_ID));
    }
}

/// Trim `area` by less than two `char_w`/`lh` cells on each axis so that
/// `Dialog::layout`'s centering — `(area_dim - content_dim) / 2` —
/// always lands on a whole cell.
///
/// That division is only a whole number when `area_dim` and
/// `content_dim` share the same parity (both even or both odd
/// multiples of the cell size). `content_w`/`content_h` here are
/// always whole multiples of `char_w`/`lh` (every `DialogMeasure`
/// field this demo sets is), but `area` is this demo's own
/// Demo-tab content rect, sized by whatever the gallery's chrome
/// leaves over — not under this function's control, and not always
/// the same parity as the content. A half-cell offset is invisible on
/// GTK's sub-pixel rendering but breaks TUI outright: the painted
/// glyph lands on a whole cell regardless, while the unrounded
/// hit-test rect stays half a cell off, so a real click on a real
/// button can resolve to [`DialogHit::Body`] instead of
/// [`DialogHit::Button`].
fn snap_for_centering(area: Rect, content_w: f32, content_h: f32, char_w: f32, lh: f32) -> Rect {
    let trim_w = if char_w > 0.0 {
        (area.width - content_w).rem_euclid(2.0 * char_w)
    } else {
        0.0
    };
    let trim_h = if lh > 0.0 {
        (area.height - content_h).rem_euclid(2.0 * lh)
    } else {
        0.0
    };
    Rect::new(area.x, area.y, area.width - trim_w, area.height - trim_h)
}

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

impl Demo for DialogDemo {
    fn name(&self) -> &'static str {
        "Dialog"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Confirm", "Destructive", "Vertical"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();

        for (i, row) in self.rows.iter().enumerate() {
            self.line(
                backend,
                Rect::new(area.x, area.y + i as f32 * lh, area.width, lh),
                &format!("gallery:dialog:row-{i}"),
                format!(" {row} "),
            );
        }
        self.line(
            backend,
            Rect::new(area.x, area.y + self.rows.len() as f32 * lh, area.width, lh),
            "gallery:dialog:open-dialog",
            " Open dialog ".into(),
        );

        let status_rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(variant),
            &InteractionState::new(),
        );

        // Modal paints last (highest z) — the ModalStack has no opinion
        // on draw order, only on hit-test precedence.
        if self.dialog_open {
            let layout = self.dialog_layout(backend, area, variant);
            backend.draw_dialog(&self.dialog(variant), &layout);
        }
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Escape),
                ..
            } if self.dialog_open => {
                self.close_dialog(backend);
                Reaction::Redraw
            }

            // ── Click inside the open modal ─────────────────────────────
            //
            // quadraui tagged this one with the dialog's id, so it can
            // only be the dialog's. Note the absence of any
            // `self.dialog_open` test: the tag *is* the test.
            UiEvent::MouseDown {
                widget: Some(ref id),
                position,
                ..
            } if id.as_str() == DIALOG_ID => {
                let layout = self.dialog_layout(backend, area, variant);
                if let DialogHit::Button(_) = layout.hit_test(position.x, position.y) {
                    self.close_dialog(backend);
                }
                Reaction::Redraw
            }

            // ── Base-layer click ────────────────────────────────────────
            UiEvent::MouseDown {
                widget: None,
                position,
                ..
            } => {
                if self.on_open_button(backend, area, position.y) && !self.dialog_open {
                    self.open_dialog(backend, area, variant);
                } else if let Some(idx) = self.row_at(backend, area, position.y) {
                    self.selected = Some(self.rows[idx].clone());
                }
                Reaction::Redraw
            }

            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, _variant: usize) -> serde_json::Value {
        serde_json::json!({
            "selected": self.selected,
            "dialog_open": self.dialog_open,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // `draw_dialog` + the `ModalStack` click-arbitration pipeline are
        // required, backend-generic machinery with no capability flag —
        // every backend routes clicks through the same stack.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Confirm
Destructive
Vertical

File Dialog

Group: Overlays

Code

/// Materialise a small, fixed directory tree for the native dialogs to
/// list — see `file_picker.rs`'s `build_demo_tree` for why this is a
/// synthetic tempdir rather than this crate's own source tree or
/// `CARGO_MANIFEST_DIR`.
fn build_demo_tree() -> TempDir {
    let dir = tempfile::tempdir().expect("create a temp dir for the file dialog demo");
    let root = dir.path();
    std::fs::write(root.join("README.md"), b"# quadraui gallery demo tree\n")
        .expect("write README.md");
    std::fs::write(root.join("main.rs"), b"fn main() {}\n").expect("write main.rs");
    dir
}

pub struct FileDialogDemo {
    root_dir: TempDir,
    status: String,
    picked: Option<PathBuf>,
}

impl FileDialogDemo {
    pub fn new() -> Self {
        Self {
            root_dir: build_demo_tree(),
            status: "Enter (or o/s/f) opens the dialog".to_string(),
            picked: None,
        }
    }

    fn variant_label(variant: usize) -> &'static str {
        match variant {
            0 => "Open",
            1 => "Save as",
            _ => "Folder",
        }
    }

    fn base_opts(&self) -> FileDialogOptions {
        FileDialogOptions {
            initial_dir: Some(self.root_dir.path().to_path_buf()),
            ..Default::default()
        }
    }

    /// Record a dialog outcome. `path` is `None` when the user cancelled
    /// (or the backend can't offer that dialog at all).
    fn record(&mut self, ok_prefix: &str, cancel_label: &str, path: Option<PathBuf>) {
        match path {
            Some(p) => {
                self.status = format!("{ok_prefix}: {}", p.display());
                self.picked = Some(p);
            }
            None => {
                self.status = format!("{cancel_label} cancelled (or unsupported on this backend)");
                self.picked = None;
            }
        }
    }

    /// The outcome message goes in a **left** segment and only the
    /// picked path's final component goes in the right one — a right
    /// segment carrying a full absolute path clips at the bar's right
    /// edge and can blank the whole message; see `file_picker.rs`'s
    /// sibling `FileDialogDemo::status_bar` in `quadraui/examples` for
    /// the regression this split avoids.
    fn status_bar(&self, variant: usize) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:file-dialog:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" [{}] {} ", Self::variant_label(variant), self.status),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: match &self.picked {
                Some(p) => {
                    let leaf = p
                        .file_name()
                        .map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_else(|| p.display().to_string());
                    vec![StatusBarSegment {
                        text: format!(" \u{2713} {leaf} "),
                        fg: Color::rgb(150, 240, 150),
                        bg: Color::rgb(30, 80, 30),
                        bold: false,
                        action_id: None,
                    }]
                }
                None => vec![],
            },
        }
    }

    fn open_file(&mut self, backend: &mut dyn Backend) {
        let opts = FileDialogOptions {
            title: Some("Open File".to_string()),
            filters: vec![("Rust files".to_string(), vec!["rs".to_string()])],
            ..self.base_opts()
        };
        let picked = backend.services().show_file_open_dialog(opts);
        self.record("Opened", "Open", picked);
    }

    fn save_file(&mut self, backend: &mut dyn Backend) {
        let opts = FileDialogOptions {
            title: Some("Save As".to_string()),
            initial_filename: Some("untitled.txt".to_string()),
            ..self.base_opts()
        };
        let picked = backend.services().show_file_save_dialog(opts);
        self.record("Save as", "Save", picked);
    }

    fn open_folder(&mut self, backend: &mut dyn Backend) {
        let opts = FileDialogOptions {
            title: Some("Open Folder".to_string()),
            ..self.base_opts()
        };
        let picked = backend.services().show_folder_open_dialog(opts);
        self.record("Folder", "Folder pick", picked);
    }
}

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

impl Demo for FileDialogDemo {
    fn name(&self) -> &'static str {
        "File Dialog"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Open", "Save as", "Folder"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let status_rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('o'),
                ..
            } => {
                self.open_file(backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('s'),
                ..
            } => {
                self.save_file(backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('f'),
                ..
            } => {
                self.open_folder(backend);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(quadraui::NamedKey::Enter),
                ..
            } => {
                match variant {
                    0 => self.open_file(backend),
                    1 => self.save_file(backend),
                    _ => self.open_folder(backend),
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "variant": Self::variant_label(variant),
            "root": self.root_dir.path().display().to_string(),
            "status": self.status,
            "picked": self.picked.as_ref().map(|p| p.display().to_string()),
        })
    }

    fn caps_note(&self, variant: usize, caps: &BackendCaps) -> Option<String> {
        match variant {
            0 | 1 if !caps.file_dialogs => Some(
                "This backend has no native file-picker facility (BackendCaps::file_dialogs is \
                 false) — open/save still resolve a real path through an in-canvas \
                 FilePickerController driven by a nested draw-and-read loop."
                    .into(),
            ),
            2 if !caps.folder_dialogs => Some(
                "This backend has no native folder-picker facility (BackendCaps::folder_dialogs \
                 is false) and no in-canvas fallback is wired up for it yet — the folder dialog \
                 degrades all the way to None."
                    .into(),
            ),
            _ => None,
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Open
Save as
Folder

Help Overlay

Group: Overlays

Code

const EXPLORER_PANEL: &str = "gallery:help:explorer";
const GIT_PANEL: &str = "gallery:help:git";
const SETTINGS_PANEL: &str = "gallery:help:settings";

pub struct HelpOverlayDemo {
    registry: HelpRegistry,
    overlay: HelpOverlayController,
}

impl HelpOverlayDemo {
    pub fn new() -> Self {
        let mut registry = HelpRegistry::new();
        registry.register(
            EXPLORER_PANEL,
            ViewHelp::new("Explorer")
                .with_notes(vec![
                    HelpNote::new("\u{25cf}", "File has unsaved changes"),
                    HelpNote::new("M", "File modified since last commit"),
                ])
                .with_actions(vec![
                    HelpAction::new("explorer.new_file", "New File", "Create a new file")
                        .with_accelerator("Ctrl+N"),
                    HelpAction::new(
                        "explorer.reveal",
                        "Reveal in Finder",
                        "Show the selected file on disk",
                    )
                    .with_accelerator("Ctrl+Shift+R"),
                ]),
        );
        registry.register(
            GIT_PANEL,
            ViewHelp::new("Source Control")
                .with_notes(vec![
                    HelpNote::new("M", "Modified"),
                    HelpNote::new("A", "Added"),
                    HelpNote::new("U", "Untracked"),
                ])
                .with_actions(vec![
                    HelpAction::new("git.commit", "Commit", "Commit staged changes")
                        .with_accelerator("Ctrl+Enter"),
                    HelpAction::new(
                        "git.discard",
                        "Discard Changes",
                        "Revert the selected file to HEAD",
                    ),
                ]),
        );
        // `SETTINGS_PANEL` is deliberately never registered — the
        // "no help available" fallback this demo exists to show.

        Self {
            registry,
            overlay: HelpOverlayController::new().with_id("gallery:help:cheatsheet"),
        }
    }

    fn panel_id(variant: usize) -> &'static str {
        match variant {
            0 => EXPLORER_PANEL,
            1 => GIT_PANEL,
            _ => SETTINGS_PANEL,
        }
    }

    fn panel_label(variant: usize) -> &'static str {
        match variant {
            0 => "Explorer",
            1 => "Source Control",
            _ => "Settings (no help registered)",
        }
    }

    fn active_view_help(&self, variant: usize) -> Option<&ViewHelp> {
        self.registry.get(Self::panel_id(variant))
    }

    fn status_bar(variant: usize, open: bool) -> StatusBar {
        let hint = if open {
            " Esc or ? to close "
        } else {
            " ? = open help for this panel "
        };
        StatusBar {
            id: WidgetId::new("gallery:help:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" Panel: {} ", Self::panel_label(variant)),
                fg: Color::rgb(200, 200, 200),
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: hint.into(),
                fg: Color::rgb(140, 200, 255),
                bg: Color::rgb(20, 40, 70),
                bold: true,
                action_id: None,
            }],
        }
    }
}

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

impl Demo for HelpOverlayDemo {
    fn name(&self) -> &'static str {
        "Help Overlay"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Explorer", "Source Control", "Settings (no help)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_rect = Rect::new(area.x, area.y, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            bar_rect,
            &Self::status_bar(variant, self.overlay.is_open()),
            &InteractionState::new(),
        );

        self.overlay
            .render(area, backend, self.active_view_help(variant));
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match self.overlay.handle(event) {
            HelpOverlayEvent::Opened | HelpOverlayEvent::Closed | HelpOverlayEvent::Consumed => {
                Reaction::Redraw
            }
            HelpOverlayEvent::Ignored => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "panel": Self::panel_label(variant),
            "has_help": self.active_view_help(variant).is_some(),
            "overlay_open": self.overlay.is_open(),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // `HelpOverlayController` is an in-canvas compose controller with
        // no `Backend` capability flag — it works identically on every
        // backend, including the Settings variant's no-help fallback.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Explorer
Source Control
Settings (no help)

Message Dialog

Group: Overlays

Code

pub struct MessageDialogDemo {
    dialog: Option<MessageDialogController>,
    status: String,
}

impl MessageDialogDemo {
    pub fn new() -> Self {
        Self {
            dialog: None,
            status: "press Enter to open the dialog".into(),
        }
    }

    fn controller_dialog() -> MessageDialogController {
        MessageDialogController::new(MessageDialogOptions {
            title: "Unsaved Changes".to_string(),
            body: "You have unsaved changes.\nDo you want to save before closing?".to_string(),
            buttons: vec![
                MessageDialogButton {
                    id: WidgetId::new("gallery:message-dialog:save"),
                    label: "Save".into(),
                    is_default: true,
                    is_cancel: false,
                },
                MessageDialogButton {
                    id: WidgetId::new("gallery:message-dialog:discard"),
                    label: "Discard".into(),
                    is_default: false,
                    is_cancel: true,
                },
            ],
            severity: Some(DialogSeverity::Warning),
        })
    }

    /// Same descriptor shape as [`Self::controller_dialog`], as a raw
    /// [`Dialog`] — [`native_dialog_options`] maps this to
    /// [`MessageDialogOptions`] for the native-service variant. Has no
    /// `table`/`input`, so the mapping always succeeds.
    fn native_dialog() -> Dialog {
        Dialog {
            id: WidgetId::new("gallery:message-dialog:native"),
            title: StyledText::plain("Discard unsaved changes?"),
            body: vec![StyledText::plain(
                "main.rs has unsaved changes. This cannot be undone.",
            )],
            buttons: vec![
                DialogButton {
                    id: WidgetId::new("gallery:message-dialog:keep"),
                    label: "Keep Editing".to_string(),
                    is_default: true,
                    is_cancel: true,
                    tint: None,
                },
                DialogButton {
                    id: WidgetId::new("gallery:message-dialog:discard-native"),
                    label: "Discard".to_string(),
                    is_default: false,
                    is_cancel: false,
                    tint: Some(Color::rgb(200, 60, 60)),
                },
            ],
            severity: Some(DialogSeverity::Warning),
            vertical_buttons: false,
            table: None,
            input: None,
        }
    }

    fn status_bar(&self, variant: usize) -> StatusBar {
        let variant_label = if variant == 0 {
            "Direct controller"
        } else {
            "Native service"
        };
        StatusBar {
            id: WidgetId::new("gallery:message-dialog:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" [{variant_label}] {} ", self.status),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 60, 100),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: " Enter=open ".into(),
                fg: Color::rgb(180, 180, 180),
                bg: Color::rgb(40, 60, 100),
                bold: false,
                action_id: None,
            }],
        }
    }
}

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

impl Demo for MessageDialogDemo {
    fn name(&self) -> &'static str {
        "Message Dialog"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Direct controller", "Native service"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let bar_rect = Rect::new(area.x, area.y, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            bar_rect,
            &self.status_bar(variant),
            &InteractionState::new(),
        );

        if variant == 0 {
            if let Some(dialog) = &self.dialog {
                dialog.render(backend);
            }
        }
        // The native-service variant (`variant == 1`) never has a
        // lingering `self.dialog` to render: `handle` below resolves it
        // synchronously inside the same call that opens it (either via a
        // real native alert on GTK, or the in-canvas nested loop on TUI),
        // so there is nothing left open by the time `render` runs again.
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        if variant == 0 {
            if let Some(ref mut dialog) = self.dialog {
                match dialog.handle(event, backend) {
                    MessageDialogEvent::Resolved(id) => {
                        self.status = format!("Resolved: {}", id.as_str());
                        self.dialog = None;
                        return Reaction::Redraw;
                    }
                    MessageDialogEvent::Consumed => return Reaction::Redraw,
                    MessageDialogEvent::Ignored => {}
                }
            } else if let UiEvent::KeyPressed {
                key: Key::Named(quadraui::NamedKey::Enter),
                ..
            } = event
            {
                self.dialog = Some(Self::controller_dialog());
                self.status = "Save changes? — choose Save or Discard".into();
                return Reaction::Redraw;
            }
            return Reaction::Continue;
        }

        // Native-service variant.
        if let UiEvent::KeyPressed {
            key: Key::Named(quadraui::NamedKey::Enter),
            ..
        } = event
        {
            let dialog = Self::native_dialog();
            self.status = match native_dialog_options(&dialog) {
                Some(opts) => match backend.services().show_message_dialog(opts) {
                    Some(id) if id == dialog.buttons[0].id => "Kept editing".to_string(),
                    Some(id) if id == dialog.buttons[1].id => "Discarded".to_string(),
                    Some(other) => format!("Unexpected button: {other:?}"),
                    None => "Cancelled (or unsupported on this backend)".to_string(),
                },
                None => "This dialog has no native mapping".to_string(),
            };
            return Reaction::Redraw;
        }
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "variant": if variant == 0 { "direct_controller" } else { "native_service" },
            "status": self.status,
            "open": self.dialog.is_some(),
        })
    }

    fn caps_note(&self, variant: usize, caps: &BackendCaps) -> Option<String> {
        if variant == 1 && !caps.native_dialogs {
            Some(
                "This backend has no native alert facility (BackendCaps::native_dialogs is \
                 false) — show_message_dialog drives the same MessageDialogController in-canvas \
                 instead, through a nested draw-and-read loop, and still returns a real chosen \
                 button."
                    .into(),
            )
        } else {
            None
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
Direct controller
Native service

Toast

Group: Overlays

Code

pub struct ToastDemo {
    toasts: Vec<Toast>,
    next_id: usize,
    controller: ToastStackController,
}

impl ToastDemo {
    pub fn new() -> Self {
        Self {
            toasts: vec![Toast {
                id: WidgetId::new("gallery:toast:welcome"),
                title: "Welcome".into(),
                body: "1-4=add  a=add with action  Tab=focus stack".into(),
                severity: ToastSeverity::Info,
                actions: Vec::new(),
                accent: None,
            }],
            next_id: 1,
            controller: ToastStackController::new(),
        }
    }

    fn add(&mut self, severity: ToastSeverity, actions: Vec<ToastButton>) {
        let label = match severity {
            ToastSeverity::Info => "Info",
            ToastSeverity::Success => "Success",
            ToastSeverity::Warning => "Warning",
            ToastSeverity::Error => "Error",
        };
        let id = format!("gallery:toast:{}", self.next_id);
        self.next_id += 1;
        self.toasts.push(Toast {
            id: WidgetId::new(id),
            title: format!("{label} notification"),
            body: format!("Toast #{}", self.next_id - 1),
            severity,
            actions,
            accent: None,
        });
    }

    fn stack(&self) -> ToastOverlay {
        ToastOverlay {
            id: WidgetId::new("gallery:toast-stack"),
            corner: ToastCorner::BottomRight,
            toasts: self.toasts.clone(),
            focus: self.controller.focus(),
        }
    }

    fn hint_bar(&self) -> StatusBar {
        let text = if self.controller.is_focused() {
            " Tab/\u{2190}/\u{2192}=cycle buttons  \u{2191}/\u{2193}=toast  Enter=activate  Esc=unfocus "
        } else {
            " 1-4=add severity | a=add with action | Tab=focus stack | click \u{d7}=dismiss "
        };
        StatusBar {
            id: WidgetId::new("gallery:toast-hint"),
            left_segments: vec![StatusBarSegment {
                text: text.into(),
                fg: Color::rgb(190, 190, 190),
                bg: Color::rgb(30, 30, 30),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    /// Split `area` into the hint row (top line) and the toast overlay's
    /// own area (everything below it). Shared by `render` and `handle`
    /// so the two can never derive different geometry for the same
    /// frame.
    fn overlay_area(backend: &dyn Backend, area: Rect) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + lh, area.width, (area.height - lh).max(0.0))
    }
}

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

impl Demo for ToastDemo {
    fn name(&self) -> &'static str {
        "Toast"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn render(&self, _variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let hint_rect = Rect::new(area.x, area.y, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            hint_rect,
            &self.hint_bar(),
            &InteractionState::new(),
        );

        let overlay_area = Self::overlay_area(backend, area);
        let _ = backend.draw_toast_overlay(overlay_area, &self.stack());
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        // The controller only ever consumes `KeyPressed` while the stack
        // has focus — every other event (including every key while
        // unfocused) comes back `Ignored`, so the severity/action keys
        // below still work unimpeded until `Tab` claims focus.
        let stack = self.stack();
        match self.controller.handle(event, &stack) {
            ToastStackEvent::Action(id) => {
                if let Some(t) = self
                    .toasts
                    .iter()
                    .find(|t| t.actions.iter().any(|a| a.id == id))
                {
                    let toast_id = t.id;
                    self.toasts.retain(|t| t.id != toast_id);
                }
                return Reaction::Redraw;
            }
            ToastStackEvent::Dismiss(id) => {
                self.toasts.retain(|t| t.id != id);
                return Reaction::Redraw;
            }
            ToastStackEvent::Consumed | ToastStackEvent::FocusReturned => {
                return Reaction::Redraw;
            }
            ToastStackEvent::Ignored => {}
        }

        match event {
            UiEvent::KeyPressed {
                key: Key::Named(NamedKey::Tab),
                ..
            } => {
                self.controller.give_focus(&stack);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('1'),
                ..
            } => {
                self.add(ToastSeverity::Info, Vec::new());
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('2'),
                ..
            } => {
                self.add(ToastSeverity::Success, Vec::new());
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('3'),
                ..
            } => {
                self.add(ToastSeverity::Warning, Vec::new());
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('4'),
                ..
            } => {
                self.add(ToastSeverity::Error, Vec::new());
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('a'),
                ..
            } => {
                self.add(
                    ToastSeverity::Error,
                    vec![ToastButton {
                        id: WidgetId::new("gallery:toast-action:retry"),
                        label: "Retry".into(),
                        primary: true,
                    }],
                );
                Reaction::Redraw
            }
            UiEvent::MouseDown { position, .. } => {
                let overlay_area = Self::overlay_area(backend, area);
                let stack = self.stack();
                let layout = backend.toast_stack_layout(overlay_area, &stack);
                match layout.hit_test(position.x, position.y) {
                    ToastHit::Dismiss(id) => {
                        self.toasts.retain(|t| t.id != id);
                        Reaction::Redraw
                    }
                    ToastHit::Action(_) | ToastHit::Body(_) => Reaction::Redraw,
                    ToastHit::Empty => Reaction::Continue,
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, _variant: usize) -> serde_json::Value {
        serde_json::to_value(self.stack()).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, caps: &BackendCaps) -> Option<String> {
        if caps.notifications {
            None
        } else {
            Some(
                "This backend has no native OS notification centre (BackendCaps::notifications \
                 is false) — quadraui::compose::notify_or_toast would fall back to exactly the \
                 in-app toast shown below."
                    .into(),
            )
        }
    }
}

Backends

VariantTUIGTKmacOSWindows
default

Tooltip

Group: Overlays

Code

const ANCHOR_TEXT: &str = "hover target";
const TOOLTIP_TEXT: &str = "Tooltip content";
const TOOLTIP_TITLE: &str = "Info";

pub struct TooltipDemo {
    show_title: bool,
}

impl TooltipDemo {
    pub fn new() -> Self {
        Self { show_title: true }
    }

    fn border(variant: usize) -> TooltipBorder {
        match variant {
            0 => TooltipBorder::Sides,
            1 => TooltipBorder::Full,
            _ => TooltipBorder::None,
        }
    }

    fn border_label(variant: usize) -> &'static str {
        match variant {
            0 => "Sides",
            1 => "Full",
            _ => "None",
        }
    }

    fn tooltip() -> Tooltip {
        let mut tip = Tooltip::new(WidgetId::new("gallery:tooltip:tip"), TOOLTIP_TEXT);
        tip.placement = TooltipPlacement::Bottom;
        tip
    }

    /// `Full` needs a top and bottom border row on top of the one content
    /// row; `Sides`/`None` reserve no border rows at all (see the
    /// contract note on `TooltipMeasure`).
    fn rows(variant: usize) -> f32 {
        if variant == 1 {
            3.0
        } else {
            1.0
        }
    }

    fn status_bar(&self, variant: usize) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:tooltip:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(
                    " border: {} | title: {} ",
                    Self::border_label(variant),
                    self.show_title
                ),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: " t=toggle title (Full only) ".into(),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    fn anchor_bar() -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:tooltip:anchor"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {ANCHOR_TEXT} "),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(37, 37, 38),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for TooltipDemo {
    fn name(&self) -> &'static str {
        "Tooltip"
    }

    fn group(&self) -> &'static str {
        "Overlays"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Sides", "Full", "None"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let lh = backend.line_height();
        let cw = backend.char_width();

        // Anchor bar at the top — the element the tooltip "describes".
        let anchor = Rect::new(area.x, area.y, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            anchor,
            &Self::anchor_bar(),
            &InteractionState::new(),
        );

        // Status bar at the bottom shows the current border/title choice
        // plus the key hint.
        let status_rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status_bar(variant),
            &InteractionState::new(),
        );

        // Tooltip renders between the two bars, below the anchor.
        let clamp = Rect::new(
            area.x,
            area.y + lh,
            area.width,
            (area.height - 2.0 * lh).max(0.0),
        );
        let tooltip = Self::tooltip();
        let measure = TooltipMeasure::new(
            cw * (TOOLTIP_TEXT.chars().count() as f32 + 4.0),
            lh * Self::rows(variant),
        );
        let layout = tooltip.layout(anchor, clamp, measure, lh);
        let mut chrome = TooltipChrome::new(Self::border(variant));
        if self.show_title && variant == 1 {
            chrome.title = Some(TOOLTIP_TITLE.to_string());
        }
        backend.draw_tooltip_with_chrome(&tooltip, &layout, &chrome);
    }

    fn handle(
        &mut self,
        _variant: usize,
        event: &UiEvent,
        _backend: &mut dyn Backend,
        _area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('t'),
                ..
            } => {
                self.show_title = !self.show_title;
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "border": Self::border_label(variant),
            "title_shown": self.show_title && variant == 1,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        // `draw_tooltip_with_chrome` is a required `Backend` trait method
        // with no no-op default — every backend paints the same chrome
        // vocabulary, so there's no capability gap to report here.
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Sides
Full
None

Board

Group: Data

Code

pub struct BoardDemo {
    models: Vec<BoardModel>,
    last_message: String,
}

impl BoardDemo {
    pub fn new() -> Self {
        Self {
            models: vec![sprint_board(), empty_board()],
            last_message: "j/k=move  h/l=col  g/G=top/bottom".into(),
        }
    }

    fn model(&self, variant: usize) -> &BoardModel {
        &self.models[variant.min(self.models.len() - 1)]
    }

    fn model_mut(&mut self, variant: usize) -> &mut BoardModel {
        let idx = variant.min(self.models.len() - 1);
        &mut self.models[idx]
    }

    fn board_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:board: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![],
        }
    }
}

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

fn key_to_board_action(key: &Key) -> Option<BoardAction> {
    match key {
        Key::Char('j') => Some(BoardAction::MoveSelection(MoveDir::Down)),
        Key::Char('k') => Some(BoardAction::MoveSelection(MoveDir::Up)),
        Key::Char('h') => Some(BoardAction::MoveSelection(MoveDir::Left)),
        Key::Char('l') => Some(BoardAction::MoveSelection(MoveDir::Right)),
        Key::Char('g') => Some(BoardAction::JumpToTop),
        Key::Char('G') => Some(BoardAction::JumpToBottom),
        _ => None,
    }
}

fn badge(label: &str, status: BadgeStatus) -> CardBadge {
    CardBadge {
        label: label.into(),
        status,
    }
}

fn sprint_board() -> BoardModel {
    let backlog = BoardColumn {
        id: WidgetId::new("gallery:board:col:backlog"),
        title: "Backlog".into(),
        cards: vec![
            BoardCard {
                id: WidgetId::new("gallery:board:card:1"),
                title: "Improve search indexing perf".into(),
                labels: vec!["perf".into()],
                badges: vec![
                    badge("P", BadgeStatus::Passed),
                    badge("W", BadgeStatus::Pending),
                ],
                hint: None,
            },
            BoardCard {
                id: WidgetId::new("gallery:board:card:2"),
                title: "Fix memory leak in parser".into(),
                labels: vec!["bug".into()],
                badges: vec![],
                hint: None,
            },
        ],
        scroll_offset: 0,
    };
    let in_progress = BoardColumn {
        id: WidgetId::new("gallery:board:col:in-progress"),
        title: "In Progress".into(),
        cards: vec![BoardCard {
            id: WidgetId::new("gallery:board:card:3"),
            title: "Upgrade dependency stack".into(),
            labels: vec!["deps".into()],
            badges: vec![
                badge("P", BadgeStatus::Passed),
                badge("W", BadgeStatus::Passed),
                badge("T", BadgeStatus::Blocked),
            ],
            hint: Some("blocked: upstream async-std 2.0 compat".into()),
        }],
        scroll_offset: 0,
    };
    let review = BoardColumn {
        id: WidgetId::new("gallery:board:col:review"),
        title: "Review".into(),
        cards: vec![BoardCard {
            id: WidgetId::new("gallery:board:card:4"),
            title: "Paginate API list endpoints".into(),
            labels: vec!["api".into()],
            badges: vec![
                badge("P", BadgeStatus::Passed),
                badge("W", BadgeStatus::Passed),
                badge("R", BadgeStatus::Warning),
            ],
            hint: Some("reviewer: needs integration test".into()),
        }],
        scroll_offset: 0,
    };
    BoardModel {
        id: WidgetId::new("gallery:board:main"),
        columns: vec![backlog, in_progress, review],
        selected_card_id: Some(WidgetId::new("gallery:board:card:3")),
        col_scroll_offset: 0,
    }
}

fn empty_board() -> BoardModel {
    BoardModel {
        id: WidgetId::new("gallery:board:empty"),
        columns: vec![
            BoardColumn {
                id: WidgetId::new("gallery:board:empty:todo"),
                title: "To Do".into(),
                cards: vec![],
                scroll_offset: 0,
            },
            BoardColumn {
                id: WidgetId::new("gallery:board:empty:done"),
                title: "Done".into(),
                cards: vec![],
                scroll_offset: 0,
            },
        ],
        selected_card_id: None,
        col_scroll_offset: 0,
    }
}

impl Demo for BoardDemo {
    fn name(&self) -> &'static str {
        "Board"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Sprint board", "Empty board"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let board_rect = Self::board_rect(area, backend);
        let _ = backend.draw_board(board_rect, self.model(variant));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed { key, .. } => {
                let Some(action) = key_to_board_action(key) else {
                    return Reaction::Continue;
                };
                let model = self.model_mut(variant);
                match action {
                    BoardAction::MoveSelection(dir) => model.move_selection(dir),
                    BoardAction::JumpToTop => model.jump_to_top(),
                    BoardAction::JumpToBottom => model.jump_to_bottom(),
                    _ => {}
                }
                Reaction::Redraw
            }
            UiEvent::MouseDown { position, .. } => {
                let board_rect = Self::board_rect(area, backend);
                let layout = backend.board_layout(board_rect, self.model(variant));
                match layout.hit_test(position.x, position.y) {
                    BoardHit::Card(id) => {
                        let title = self
                            .model(variant)
                            .columns
                            .iter()
                            .find_map(|col| {
                                col.cards
                                    .iter()
                                    .find(|c| c.id == id)
                                    .map(|c| c.title.clone())
                            })
                            .unwrap_or_default();
                        self.model_mut(variant).selected_card_id = Some(id);
                        self.last_message = format!("Selected: {title}");
                        Reaction::Redraw
                    }
                    BoardHit::ColumnHeader(_) | BoardHit::Empty => Reaction::Continue,
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.model(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Sprint board
Empty board

Canvas

Group: Data

Code

pub struct CanvasDemo {
    values: Vec<u8>,
}

impl CanvasDemo {
    pub fn new() -> Self {
        Self {
            values: vec![30, 72],
        }
    }

    fn value(&self, variant: usize) -> u8 {
        self.values[variant.min(self.values.len() - 1)]
    }

    fn gauge_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        let margin = lh;
        Rect::new(
            area.x + margin,
            area.y + lh * 2.0,
            (area.width - margin * 2.0).max(0.0),
            lh,
        )
    }

    fn gauge_canvas(&self, variant: usize, rect: Rect) -> Canvas {
        let w = rect.width;
        let h = rect.height;
        let value = self.value(variant);
        let fill_w = (w * value as f32 / 100.0).clamp(0.0, w);
        let track_color = Color::rgb(60, 60, 60);
        let fill_color = if variant == 1 {
            Color::rgb(220, 140, 40)
        } else {
            Color::rgb(40, 160, 90)
        };
        let tick_color = Color::rgb(220, 220, 220);
        let text_color = Color::rgb(255, 255, 255);
        Canvas {
            id: WidgetId::new(format!("gallery:canvas:gauge:{variant}")),
            ops: vec![
                DrawOp::Rect {
                    rect: Rect::new(0.0, 0.0, w, h),
                    color: track_color,
                },
                DrawOp::Rect {
                    rect: Rect::new(0.0, 0.0, fill_w, h),
                    color: fill_color,
                },
                DrawOp::Line {
                    from: Point::new(w / 2.0, 0.0),
                    to: Point::new(w / 2.0, h),
                    color: tick_color,
                    stroke_width: 1.0,
                },
                DrawOp::TextRun {
                    rect: Rect::new(2.0, 0.0, w.max(1.0), h),
                    text: format!("{value}%"),
                    color: text_color,
                },
            ],
        }
    }

    fn status(&self, variant: usize) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:canvas:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(
                    " gauge: {}% — click it or +/-/arrows to adjust ",
                    self.value(variant)
                ),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for CanvasDemo {
    fn name(&self) -> &'static str {
        "Canvas"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Health gauge (green)", "Load gauge (amber)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = Self::gauge_rect(area, backend);
        let _ = backend.draw_canvas(rect, &self.gauge_canvas(variant, rect));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let idx = variant.min(self.values.len() - 1);
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('+') | Key::Named(quadraui::NamedKey::Up),
                ..
            } => {
                self.values[idx] = self.values[idx].saturating_add(10).min(100);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('-') | Key::Named(quadraui::NamedKey::Down),
                ..
            } => {
                self.values[idx] = self.values[idx].saturating_sub(10);
                Reaction::Redraw
            }
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                let rect = Self::gauge_rect(area, backend);
                let layout = backend.canvas_layout(rect, &self.gauge_canvas(variant, rect));
                if layout.hit_test(position.x, position.y) == CanvasHit::Inside {
                    let frac = ((position.x - layout.bounds.x) / layout.bounds.width.max(1.0))
                        .clamp(0.0, 1.0);
                    self.values[idx] = (frac * 100.0).round() as u8;
                    return Reaction::Redraw;
                }
                Reaction::Continue
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        let rect = Rect::new(0.0, 0.0, 40.0, 1.0);
        serde_json::to_value(self.gauge_canvas(variant, rect)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Health gauge (green)
Load gauge (amber)

Chart

Group: Data

Code

pub struct ChartDemo {
    sparkline_data: Vec<f64>,
    hovered_point: Option<(usize, usize)>,
    crosshair_x: Option<f64>,
}

impl ChartDemo {
    pub fn new() -> Self {
        Self {
            sparkline_data: vec![30.0, 45.0, 25.0, 60.0, 50.0, 70.0, 55.0, 80.0, 40.0, 65.0],
            hovered_point: None,
            crosshair_x: None,
        }
    }

    fn sparkline(&self) -> Chart {
        Chart {
            id: WidgetId::new("gallery:chart:spark"),
            kind: ChartKind::Sparkline,
            series: vec![Series {
                label: "CPU".into(),
                data: self.sparkline_data.clone(),
                color: Some(Color::rgb(80, 200, 120)),
                fill: false,
            }],
            x_label: None,
            y_label: None,
            y_range: Some((0.0, 100.0)),
            x_range: None,
            show_legend: false,
            y_ticks: None,
            x_ticks: None,
            show_grid: false,
        }
    }

    fn line_chart(&self) -> Chart {
        let n = 20;
        let cpu: Vec<f64> = (0..n)
            .map(|i| 30.0 + 20.0 * ((i as f64 * 0.3).sin()) + (i as f64 * 0.5))
            .collect();
        let mem: Vec<f64> = (0..n)
            .map(|i| 50.0 + 15.0 * ((i as f64 * 0.2 + 1.0).cos()))
            .collect();
        Chart {
            id: WidgetId::new("gallery:chart:line"),
            kind: ChartKind::Line,
            series: vec![
                Series {
                    label: "CPU".into(),
                    data: cpu,
                    color: Some(Color::rgb(80, 160, 255)),
                    fill: false,
                },
                Series {
                    label: "Memory".into(),
                    data: mem,
                    color: Some(Color::rgb(255, 120, 80)),
                    fill: true,
                },
            ],
            x_label: Some("Time (s)".into()),
            y_label: Some("Usage".into()),
            y_range: Some((0.0, 100.0)),
            x_range: None,
            show_legend: true,
            y_ticks: Some(5),
            x_ticks: None,
            show_grid: true,
        }
    }

    fn bar_chart(&self, kind: ChartKind) -> Chart {
        Chart {
            id: WidgetId::new("gallery:chart:bar"),
            kind,
            series: vec![
                Series {
                    label: "OK".into(),
                    data: vec![120.0, 230.0, 180.0, 310.0, 95.0],
                    color: Some(Color::rgb(80, 220, 120)),
                    fill: false,
                },
                Series {
                    label: "Slow".into(),
                    data: vec![40.0, 60.0, 25.0, 70.0, 15.0],
                    color: Some(Color::rgb(220, 180, 60)),
                    fill: false,
                },
                Series {
                    label: "Failed".into(),
                    data: vec![10.0, 0.0, 35.0, 20.0, 5.0],
                    color: Some(Color::rgb(255, 100, 100)),
                    fill: false,
                },
            ],
            x_label: Some("Endpoint".into()),
            y_label: None,
            y_range: None,
            x_range: None,
            show_legend: true,
            y_ticks: None,
            x_ticks: None,
            show_grid: false,
        }
    }

    fn chart(&self, variant: usize) -> Chart {
        match variant {
            0 => self.sparkline(),
            1 => self.line_chart(),
            2 => self.bar_chart(ChartKind::Bar),
            _ => self.bar_chart(ChartKind::BarGrouped),
        }
    }

    fn chart_rect(variant: usize, area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        if variant == 0 {
            Rect::new(area.x, area.y, area.width, lh)
        } else {
            Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
        }
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self, variant: usize) -> StatusBar {
        let label = match variant {
            0 => "Sparkline",
            1 => "Line",
            2 => "Bar (stacked)",
            _ => "Bar (grouped)",
        };
        StatusBar {
            id: WidgetId::new("gallery:chart:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {label} — hover to inspect a point "),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 40, 60),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for ChartDemo {
    fn name(&self) -> &'static str {
        "Chart"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Sparkline", "Line", "Bar (stacked)", "Bar (grouped)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let chart_rect = Self::chart_rect(variant, area, backend);
        let hp = if variant == 0 {
            None
        } else {
            self.hovered_point
        };
        let cx = if variant == 0 { None } else { self.crosshair_x };
        let _ = backend.draw_chart(chart_rect, &self.chart(variant), hp, cx);

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: quadraui::Key::Char(' '),
                ..
            } if variant == 0 => {
                let last = self.sparkline_data.last().copied().unwrap_or(50.0);
                let tick = self.sparkline_data.len() as f64;
                let delta = ((tick * 1.7).sin() * 15.0) + 5.0;
                self.sparkline_data.push((last + delta).clamp(0.0, 100.0));
                if self.sparkline_data.len() > 60 {
                    self.sparkline_data.remove(0);
                }
                Reaction::Redraw
            }
            UiEvent::MouseMoved { position, .. } if variant != 0 => {
                let chart_rect = Self::chart_rect(variant, area, backend);
                let chart = self.chart(variant);
                let layout = backend.chart_layout(chart_rect, &chart);
                let snap = backend.line_height() * 2.0;
                self.hovered_point = layout.nearest_point(position.x, position.y, snap);
                let data_len = chart.max_data_len();
                if position.x >= layout.plot_area.x
                    && position.x < layout.plot_area.x + layout.plot_area.width
                    && position.y >= layout.plot_area.y
                    && position.y < layout.plot_area.y + layout.plot_area.height
                {
                    self.crosshair_x = Some(layout.screen_to_data_x(position.x, data_len));
                } else {
                    self.crosshair_x = None;
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.chart(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Sparkline
Line
Bar (stacked)
Bar (grouped)

Data Table

Group: Data

Code

pub struct DataTableDemo {
    columns: Vec<Column>,
    selected: Option<usize>,
    sort_col: Option<usize>,
    sort_asc: bool,
    show_footer: bool,
    hovered_idx: Option<usize>,
}

impl DataTableDemo {
    pub fn new() -> Self {
        Self {
            columns: vec![
                Column {
                    title: "Name".into(),
                    width: ColumnWidth::Flex(3.0),
                    align: ColumnAlign::Left,
                },
                Column {
                    title: "Status".into(),
                    width: ColumnWidth::Flex(1.5),
                    align: ColumnAlign::Left,
                },
                Column {
                    title: "Age".into(),
                    width: ColumnWidth::Flex(0.5),
                    align: ColumnAlign::Right,
                },
                Column {
                    title: "Restarts".into(),
                    width: ColumnWidth::Fixed(10.0),
                    align: ColumnAlign::Right,
                },
            ],
            selected: Some(0),
            sort_col: Some(0),
            sort_asc: true,
            show_footer: true,
            hovered_idx: None,
        }
    }

    fn pod_rows() -> Vec<DataRow> {
        let pods = [
            ("nginx-7d9b8c66b-x2j4k", "Running", "3d", "0"),
            ("redis-master-0", "Running", "5d", "1"),
            ("api-gateway-5f6c8d-9mn2q", "Running", "1d", "0"),
            ("worker-batch-7b9c4-kl3m8", "Pending", "2m", "0"),
            ("grafana-5f4c8d-mn2q7", "CrashLoopBackOff", "1h", "14"),
            ("prometheus-server-0", "Running", "3d", "0"),
        ];
        pods.iter()
            .map(|(name, status, age, restarts)| DataRow {
                cells: vec![
                    StyledText::plain(*name),
                    if status.starts_with("Running") {
                        StyledText::colored(*status, Color::rgb(80, 200, 80))
                    } else if status.starts_with("CrashLoopBackOff") {
                        StyledText::colored(*status, Color::rgb(220, 60, 60))
                    } else {
                        StyledText::colored(*status, Color::rgb(220, 180, 50))
                    },
                    StyledText::plain(*age),
                    StyledText::plain(*restarts),
                ],
                decoration: Default::default(),
            })
            .collect()
    }

    fn keybinding_rows() -> Vec<DataRow> {
        [
            ("Ctrl+Enter", "Stage hunk"),
            ("Ctrl+Shift+Enter", "Stage file"),
            ("Ctrl+Z", "Revert hunk"),
            ("[", "Previous change"),
            ("]", "Next change"),
            ("Esc", "Close"),
        ]
        .iter()
        .map(|(key, action)| DataRow {
            cells: vec![StyledText::plain(*key), StyledText::plain(*action)],
            decoration: Default::default(),
        })
        .collect()
    }

    fn footer_row(&self) -> DataRow {
        let rows = Self::pod_rows();
        let total_restarts: u32 = rows
            .iter()
            .filter_map(|r| r.cells.get(3))
            .filter_map(|c| {
                let text: String = c.spans.iter().map(|s| s.text.as_str()).collect();
                text.parse::<u32>().ok()
            })
            .sum();
        DataRow {
            cells: vec![
                StyledText::plain(format!("{} pods", rows.len())),
                StyledText::plain(""),
                StyledText::plain(""),
                StyledText::colored(total_restarts.to_string(), Color::rgb(220, 180, 50)),
            ],
            decoration: Default::default(),
        }
    }

    fn table(&self, variant: usize) -> DataTable {
        if variant == 0 {
            let mut rows = Self::pod_rows();
            if let Some(col) = self.sort_col {
                rows.sort_by(|a, b| {
                    let a_text: String = a
                        .cells
                        .get(col)
                        .map(|c| c.spans.iter().map(|s| s.text.as_str()).collect())
                        .unwrap_or_default();
                    let b_text: String = b
                        .cells
                        .get(col)
                        .map(|c| c.spans.iter().map(|s| s.text.as_str()).collect())
                        .unwrap_or_default();
                    let cmp = a_text.cmp(&b_text);
                    if self.sort_asc {
                        cmp
                    } else {
                        cmp.reverse()
                    }
                });
            }
            DataTable {
                id: WidgetId::new("gallery:data-table:pods"),
                columns: self.columns.clone(),
                rows,
                selected_idx: self.selected,
                scroll_offset: 0,
                sort: self.sort_col.map(|c| {
                    (
                        c,
                        if self.sort_asc {
                            SortDirection::Ascending
                        } else {
                            SortDirection::Descending
                        },
                    )
                }),
                has_focus: true,
                show_scrollbar: true,
                min_total_width: None,
                h_scroll: 0.0,
                column_overrides: Vec::new(),
                footer: if self.show_footer {
                    Some(self.footer_row())
                } else {
                    None
                },
            }
        } else {
            DataTable {
                id: WidgetId::new("gallery:data-table:keybindings"),
                columns: vec![
                    Column {
                        title: "Key".into(),
                        width: ColumnWidth::Flex(1.0),
                        align: ColumnAlign::Left,
                    },
                    Column {
                        title: "Action".into(),
                        width: ColumnWidth::Flex(2.0),
                        align: ColumnAlign::Left,
                    },
                ],
                rows: Self::keybinding_rows(),
                selected_idx: None,
                scroll_offset: 0,
                sort: None,
                has_focus: false,
                show_scrollbar: false,
                min_total_width: None,
                h_scroll: 0.0,
                column_overrides: Vec::new(),
                footer: None,
            }
        }
    }

    fn status(&self, variant: usize) -> StatusBar {
        let text = if variant == 0 {
            let sort_text = match self.sort_col {
                Some(c) => format!(
                    "sort: {} {}",
                    self.columns[c].title,
                    if self.sort_asc { "asc" } else { "desc" }
                ),
                None => "sort: none".into(),
            };
            format!(" {sort_text} — click header to sort, f=footer ")
        } else {
            " static reference table — no sort, no selection ".into()
        };
        StatusBar {
            id: WidgetId::new("gallery:data-table:status"),
            left_segments: vec![StatusBarSegment {
                text,
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 40, 60),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    fn table_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for DataTableDemo {
    fn name(&self) -> &'static str {
        "Data Table"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Pods (sortable)", "Keybindings (static)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let table_rect = Self::table_rect(area, backend);
        let hovered = if variant == 0 { self.hovered_idx } else { None };
        let _ = backend.draw_data_table(table_rect, &self.table(variant), hovered);

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        if variant != 0 {
            return Reaction::Continue;
        }
        let table_rect = Self::table_rect(area, backend);
        let total = Self::pod_rows().len();
        match event {
            UiEvent::KeyPressed { key, .. } => match key {
                Key::Char('j') => {
                    let cur = self.selected.unwrap_or(0);
                    if cur + 1 < total {
                        self.selected = Some(cur + 1);
                    }
                    Reaction::Redraw
                }
                Key::Char('k') => {
                    let cur = self.selected.unwrap_or(0);
                    self.selected = Some(cur.saturating_sub(1));
                    Reaction::Redraw
                }
                Key::Char('s') => {
                    self.sort_col = match self.sort_col {
                        None => Some(0),
                        Some(c) if c + 1 < self.columns.len() => Some(c + 1),
                        Some(_) => None,
                    };
                    Reaction::Redraw
                }
                Key::Char('d') => {
                    self.sort_asc = !self.sort_asc;
                    Reaction::Redraw
                }
                Key::Char('f') => {
                    self.show_footer = !self.show_footer;
                    Reaction::Redraw
                }
                _ => Reaction::Continue,
            },
            UiEvent::MouseDown { position, .. } => {
                let table = self.table(variant);
                let layout = backend.data_table_layout(table_rect, &table);
                // `DataTableLayout::hit_test` is viewport-relative (measured
                // from the table's own left/top edge), not absolute like
                // most other primitives' layouts — localize before testing.
                let (lx, ly) = (position.x - table_rect.x, position.y - table_rect.y);
                match layout.hit_test(lx, ly, 0, total) {
                    DataTableHit::Header { col } => {
                        if self.sort_col == Some(col) {
                            self.sort_asc = !self.sort_asc;
                        } else {
                            self.sort_col = Some(col);
                            self.sort_asc = true;
                        }
                        Reaction::Redraw
                    }
                    DataTableHit::Row { idx } => {
                        self.selected = Some(idx);
                        Reaction::Redraw
                    }
                    _ => Reaction::Continue,
                }
            }
            UiEvent::MouseMoved { position, .. } => {
                let table = self.table(variant);
                let layout = backend.data_table_layout(table_rect, &table);
                let (lx, ly) = (position.x - table_rect.x, position.y - table_rect.y);
                let old = self.hovered_idx;
                self.hovered_idx = match layout.hit_test(lx, ly, 0, total) {
                    DataTableHit::Row { idx } => Some(idx),
                    _ => None,
                };
                if self.hovered_idx != old {
                    Reaction::Redraw
                } else {
                    Reaction::Continue
                }
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.table(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Pods (sortable)
Keybindings (static)

Diff View

Group: Data

Code

const LEFT: &str = "\
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

fn main() {
    println!(\"{}\", add(3, 4));
}";

const RIGHT: &str = "\
fn add(a: i32, b: i32) -> i64 {
    (a + b) as i64
}

fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

fn subtract(a: i32, b: i32) -> i32 {
    a - b
}

fn main() {
    println!(\"{}\", add(3, 4));
    println!(\"{}\", subtract(10, 3));
}";

pub struct DiffViewDemo {
    views: Vec<DiffView>,
    last_click: Vec<Option<String>>,
    last_visible_rows: Vec<usize>,
    last_total_rows: Vec<usize>,
}

impl DiffViewDemo {
    pub fn new() -> Self {
        let hunks = quadraui::compute_hunks(LEFT, RIGHT);
        let side_by_side = DiffView {
            id: WidgetId::new("gallery:diff-view:side-by-side"),
            left: LEFT.to_string(),
            right: RIGHT.to_string(),
            left_label: Some("original".to_string()),
            right_label: Some("modified".to_string()),
            hunks: hunks.clone(),
            mode: DiffMode::SideBySide,
            editability: DiffEditability::ReadOnly,
            scroll_offset: 0,
            focused_pane: DiffPane::Left,
            has_focus: true,
        };
        let mut unified = side_by_side.clone();
        unified.id = WidgetId::new("gallery:diff-view:unified");
        unified.mode = DiffMode::Unified;
        Self {
            views: vec![side_by_side, unified],
            last_click: vec![None, None],
            last_visible_rows: vec![24, 24],
            last_total_rows: vec![0, 0],
        }
    }

    fn diff_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self, variant: usize) -> StatusBar {
        let msg = match &self.last_click[variant] {
            Some(m) => format!(" {m} "),
            None => " click a row — j/k scroll ".into(),
        };
        StatusBar {
            id: WidgetId::new("gallery:diff-view:status"),
            left_segments: vec![StatusBarSegment {
                text: msg,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for DiffViewDemo {
    fn name(&self) -> &'static str {
        "Diff View"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Side by side", "Unified"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = Self::diff_rect(area, backend);
        let _layout = backend.draw_diff_view(rect, &self.views[variant]);

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let rect = Self::diff_rect(area, backend);
        // Refresh the cached layout extent (visible/total rows) before
        // acting on a scroll key — mirrors `DiffViewApp::last_layout`,
        // but recomputed per-call since `Demo::handle` takes `&mut self`
        // without a cached-layout slot of its own.
        let lh = backend.line_height();
        let geometry = self.views[variant].layout(rect, lh);
        self.last_visible_rows[variant] = geometry.visible_rows.max(1);
        self.last_total_rows[variant] = geometry.total_rows;
        let visible = self.last_visible_rows[variant];
        let total = self.last_total_rows[variant];

        match event {
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                self.last_click[variant] = Some(match geometry.hit_test(position.x, position.y) {
                    DiffViewHit::Row { row_idx, pane } => {
                        let rows = self.views[variant].flat_rows();
                        let kind = rows
                            .get(row_idx)
                            .map(|r| format!("{:?}", r.kind))
                            .unwrap_or_else(|| "?".into());
                        match pane {
                            Some(DiffPane::Left) => {
                                format!("clicked row {row_idx} ({kind}) [left pane]")
                            }
                            Some(DiffPane::Right) => {
                                format!("clicked row {row_idx} ({kind}) [right pane]")
                            }
                            None => format!("clicked row {row_idx} ({kind})"),
                        }
                    }
                    DiffViewHit::UnifiedHeader { hunk_idx } => {
                        format!("clicked hunk header {hunk_idx}")
                    }
                    DiffViewHit::Empty => "clicked empty area".into(),
                });
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('j'),
                ..
            } => {
                if total > visible {
                    self.views[variant].scroll_offset =
                        (self.views[variant].scroll_offset + 1).min(total - visible);
                }
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('k'),
                ..
            } => {
                self.views[variant].scroll_offset =
                    self.views[variant].scroll_offset.saturating_sub(1);
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(&self.views[variant]).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Side by side
Unified

Image

Group: Data

Code

pub struct ImageDemo {
    menu_bar: MenuBar,
    last_action: Option<String>,
}

impl ImageDemo {
    pub fn new() -> Self {
        Self {
            menu_bar: MenuBar {
                id: WidgetId::new("gallery:image:menu-bar"),
                items: vec![
                    menu_item("gallery:image:file", "&File"),
                    menu_item("gallery:image:edit", "&Edit"),
                    menu_item("gallery:image:view", "&View"),
                ],
                open_item: None,
                focused_item: None,
            },
            last_action: None,
        }
    }

    fn logo(&self, variant: usize) -> Image {
        Image {
            id: WidgetId::new("gallery:image:logo"),
            source: ImageSource::Bytes(LOGO_PNG.to_vec()),
            intrinsic_size: Some((24, 24)),
            fit: if variant == 1 {
                ImageFit::Cover
            } else {
                ImageFit::Contain
            },
            fallback_text: "[Q]".into(),
        }
    }

    fn bar_rects(area: Rect, backend: &dyn Backend) -> (Rect, Rect, Rect) {
        let lh = backend.line_height();
        let full = Rect::new(area.x, area.y, area.width, lh);
        let icon_width = lh * 4.0;
        let icon_rect = Rect::new(full.x, full.y, icon_width, full.height);
        let items_rect = Rect::new(
            full.x + icon_width,
            full.y,
            (full.width - icon_width).max(0.0),
            full.height,
        );
        (full, icon_rect, items_rect)
    }

    fn status(&self) -> StatusBar {
        let msg = match &self.last_action {
            Some(a) => format!(" last: {a} "),
            None => " click a menu item or the logo ".into(),
        };
        StatusBar {
            id: WidgetId::new("gallery:image:status"),
            left_segments: vec![StatusBarSegment {
                text: msg,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for ImageDemo {
    fn name(&self) -> &'static str {
        "Image"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Contain", "Cover"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let (_full, icon_rect, items_rect) = Self::bar_rects(area, backend);
        let _ = backend.draw_image(icon_rect, &self.logo(variant));
        let _ = backend.draw_menu_bar(items_rect, &self.menu_bar);

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                let (_full, icon_rect, items_rect) = Self::bar_rects(area, backend);
                let image_layout = self.logo(variant).layout(icon_rect);
                if image_layout.hit_test(position.x, position.y) == ImageHit::Image {
                    self.last_action = Some("clicked logo".into());
                    return Reaction::Redraw;
                }
                let layout = backend.menu_bar_layout(items_rect, &self.menu_bar);
                if let MenuBarHit::Item(idx) = layout.hit_test(position.x, position.y) {
                    self.last_action =
                        Some(format!("activated: {}", self.menu_bar.items[idx].label));
                    return Reaction::Redraw;
                }
                Reaction::Continue
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "image": self.logo(variant),
            "last_action": self.last_action,
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Contain
Cover

Indicators

Group: Data

Code

pub struct IndicatorsDemo {
    progress: f32,
    cancellable: bool,
    spinner_frame: usize,
    last_message: String,
}

impl IndicatorsDemo {
    pub fn new() -> Self {
        Self {
            progress: 0.3,
            cancellable: false,
            spinner_frame: 0,
            last_message: "space=+10% r=reset c=cancel".into(),
        }
    }

    fn progress_bar(&self, variant: usize) -> ProgressBar {
        ProgressBar {
            id: WidgetId::new("gallery:indicators:progress"),
            label: if variant == 1 {
                "Working...".into()
            } else {
                format!("{:.0}%", self.progress * 100.0)
            },
            value: if variant == 1 {
                None
            } else {
                Some(self.progress)
            },
            frame_idx: self.spinner_frame,
            cancellable: self.cancellable,
            accent: None,
        }
    }

    fn spinner(&self) -> Spinner {
        Spinner {
            id: WidgetId::new("gallery:indicators:spinner"),
            label: "Loading...".into(),
            frame_idx: self.spinner_frame,
            accent: None,
        }
    }

    fn status(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:indicators: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![],
        }
    }

    fn progress_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x + 1.0, area.y + lh, (area.width - 2.0).max(0.0), lh)
    }

    fn spinner_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(
            area.x + 1.0,
            area.y + lh * 3.0,
            (area.width - 2.0).max(0.0),
            lh,
        )
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for IndicatorsDemo {
    fn name(&self) -> &'static str {
        "Indicators"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Progress", "Indeterminate"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let progress_rect = Self::progress_rect(area, backend);
        let _ = backend.draw_progress(progress_rect, &self.progress_bar(variant));

        let spinner_rect = Self::spinner_rect(area, backend);
        let _ = backend.draw_spinner(spinner_rect, &self.spinner());

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char(' '),
                ..
            } => {
                self.progress = (self.progress + 0.1).min(1.0);
                self.spinner_frame += 1;
                self.last_message = format!("Progress: {:.0}%", self.progress * 100.0);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('r'),
                ..
            } => {
                self.progress = 0.0;
                self.last_message = "Reset".into();
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('c'),
                ..
            } => {
                self.cancellable = !self.cancellable;
                self.last_message = if self.cancellable {
                    "Cancel enabled".into()
                } else {
                    "Cancel disabled".into()
                };
                Reaction::Redraw
            }
            UiEvent::MouseDown { position, .. } => {
                let progress_rect = Self::progress_rect(area, backend);
                let bar = self.progress_bar(variant);
                let layout = backend.progress_layout(progress_rect, &bar);
                match layout.hit_test(position.x, position.y) {
                    ProgressBarHit::Cancel(_) => {
                        self.last_message = "Cancelled!".into();
                        self.progress = 0.0;
                    }
                    ProgressBarHit::Body(_) => {
                        self.last_message = "Bar clicked".into();
                    }
                    ProgressBarHit::Empty => return Reaction::Continue,
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "progress_bar": self.progress_bar(variant),
            "spinner": self.spinner(),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }

    fn tick(&mut self, _variant: usize, _backend: &mut dyn Backend) -> Reaction {
        self.spinner_frame = self.spinner_frame.wrapping_add(1);
        Reaction::Redraw
    }
}

Backends

VariantTUIGTKmacOSWindows
Progress
Indeterminate

Message List

Group: Data

Code

pub struct MessageListDemo {
    rows: Vec<Vec<MessageRow>>,
    scroll_top: Vec<usize>,
    last_click: Vec<Option<String>>,
}

impl MessageListDemo {
    pub fn new() -> Self {
        let plain = (0..30)
            .map(|i| {
                let fg = if i % 2 == 0 {
                    Color::rgb(220, 220, 220)
                } else {
                    Color::rgb(140, 200, 255)
                };
                MessageRow::new(format!("row {i}: quadraui MessageList demo"), fg, 0.0)
            })
            .collect();
        let log = (0..30)
            .map(|i| {
                let (fg, label) = match i % 3 {
                    0 => (Color::rgb(220, 220, 220), "INFO"),
                    1 => (Color::rgb(230, 190, 60), "WARN"),
                    _ => (Color::rgb(230, 90, 90), "ERROR"),
                };
                MessageRow::new(format!("[{label}] event #{i} processed"), fg, 0.0)
            })
            .collect();
        Self {
            rows: vec![plain, log],
            scroll_top: vec![0, 0],
            last_click: vec![None, None],
        }
    }

    fn list_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn list(&self, variant: usize) -> MessageList {
        MessageList {
            id: WidgetId::new("gallery:message-list"),
            rows: self.rows[variant].clone(),
            scroll_top: self.scroll_top[variant],
        }
    }

    fn status(&self, variant: usize) -> StatusBar {
        let msg = match &self.last_click[variant] {
            Some(m) => format!(" {m} "),
            None => " click a row — j/k scroll ".into(),
        };
        StatusBar {
            id: WidgetId::new("gallery:message-list:status"),
            left_segments: vec![StatusBarSegment {
                text: msg,
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for MessageListDemo {
    fn name(&self) -> &'static str {
        "Message List"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Plain rows", "Severity log"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let rect = Self::list_rect(area, backend);
        backend.draw_message_list(rect, &self.list(variant));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                let rect = Self::list_rect(area, backend);
                let measure = MessageListMeasure::from_metrics(&backend.measure());
                let list = self.list(variant);
                self.last_click[variant] =
                    Some(match list.hit_test(rect, measure, position.x, position.y) {
                        MessageListHit::Row(idx) => {
                            format!("clicked row {idx}: {}", self.rows[variant][idx].text)
                        }
                        MessageListHit::Empty => "clicked empty area".into(),
                    });
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('j'),
                ..
            } => {
                let max = self.rows[variant].len().saturating_sub(1);
                self.scroll_top[variant] = (self.scroll_top[variant] + 1).min(max);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Char('k'),
                ..
            } => {
                self.scroll_top[variant] = self.scroll_top[variant].saturating_sub(1);
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "row_count": self.rows[variant].len(),
            "scroll_top": self.scroll_top[variant],
            "last_click": self.last_click[variant],
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Plain rows
Severity log

Minimap

Group: Data

Code

const VIEWPORT_ROWS: usize = 10;
const LINES_PER_ROW: usize = 4;

pub struct MinimapDemo {
    buffer: Vec<String>,
    scroll_offset: usize,
}

impl MinimapDemo {
    pub fn new() -> Self {
        let buffer = (0..120)
            .map(|i| match i % 6 {
                0 => format!("fn function_{i}() {{"),
                1 => "    let value = compute();".to_string(),
                2 => "    // a short comment explaining it".to_string(),
                3 => String::new(),
                4 => "    value.process()".to_string(),
                _ => "}".to_string(),
            })
            .collect();
        Self {
            buffer,
            scroll_offset: 0,
        }
    }

    fn scale(variant: usize) -> MinimapScale {
        if variant == 1 {
            MinimapScale::Two
        } else {
            MinimapScale::One
        }
    }

    fn minimap(&self, area: Rect, backend: &dyn Backend) -> Minimap {
        let buffer = &self.buffer;
        let lines = sample_blocks(buffer.len(), buffer.len(), |i| buffer[i].clone());

        let visible_row_start = lines
            .iter()
            .position(|l| l.line_idx >= self.scroll_offset)
            .unwrap_or(0);
        let visible_row_end = lines
            .iter()
            .position(|l| l.line_idx >= self.scroll_offset + VIEWPORT_ROWS)
            .unwrap_or(lines.len());

        let mut minimap = Minimap {
            id: WidgetId::new("gallery:minimap"),
            lines,
            syntax_spans: Vec::new(),
            visible_row_start,
            visible_row_count: visible_row_end.saturating_sub(visible_row_start).max(1),
            total_buffer_lines: self.buffer.len(),
        };

        let minimap_rect = Self::minimap_rect(area, backend);
        let cols_per_cell = backend.minimap_layout(minimap_rect, &minimap).cols_per_cell;

        let raw_spans: Vec<MinimapSpan> = minimap
            .lines
            .iter()
            .enumerate()
            .filter_map(|(idx, l)| {
                let trimmed = l.text.trim_start();
                if trimmed.starts_with("fn") {
                    Some(MinimapSpan {
                        line_idx: idx,
                        start_col: 0,
                        end_col: 2,
                        color: Color::rgb(80, 160, 255),
                    })
                } else if trimmed.starts_with("//") {
                    Some(MinimapSpan {
                        line_idx: idx,
                        start_col: 4,
                        end_col: l.text.len(),
                        color: Color::rgb(100, 180, 100),
                    })
                } else {
                    None
                }
            })
            .collect();
        let grid = MinimapGrid {
            rows: minimap.lines.len().div_ceil(LINES_PER_ROW).max(1),
            cols: 200,
            lines_per_row: LINES_PER_ROW,
            cols_per_cell,
        };
        minimap.syntax_spans = aggregate_spans(&raw_spans, grid);
        minimap
    }

    fn minimap_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        let minimap_w = (backend.char_width() * 14.0).min(area.width);
        Rect::new(
            area.x + area.width - minimap_w,
            area.y,
            minimap_w,
            (area.height - lh).max(0.0),
        )
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self, variant: usize) -> StatusBar {
        let scale_label = if variant == 1 { "2 (2x4)" } else { "1 (1x2)" };
        StatusBar {
            id: WidgetId::new("gallery:minimap:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" line {} — scale {scale_label} ", self.scroll_offset),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: " up/down=scroll click=seek ".into(),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    fn seek(&mut self, fraction: f32) {
        let max_start = self.buffer.len().saturating_sub(VIEWPORT_ROWS);
        let target = (fraction as f64 * max_start as f64).round() as usize;
        self.scroll_offset = target.min(max_start);
    }
}

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

impl Demo for MinimapDemo {
    fn name(&self) -> &'static str {
        "Minimap"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Scale 1 (1x2)", "Scale 2 (2x4)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        backend.set_minimap_scale(Self::scale(variant));
        let minimap_rect = Self::minimap_rect(area, backend);
        let minimap = self.minimap(area, backend);
        let _ = backend.draw_minimap(minimap_rect, &minimap);

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Named(quadraui::NamedKey::Down),
                ..
            } => {
                let max_start = self.buffer.len().saturating_sub(VIEWPORT_ROWS);
                self.scroll_offset = (self.scroll_offset + 1).min(max_start);
                Reaction::Redraw
            }
            UiEvent::KeyPressed {
                key: Key::Named(quadraui::NamedKey::Up),
                ..
            } => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
                Reaction::Redraw
            }
            UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } => {
                backend.set_minimap_scale(Self::scale(variant));
                let minimap_rect = Self::minimap_rect(area, backend);
                let minimap = self.minimap(area, backend);
                let layout = backend.minimap_layout(minimap_rect, &minimap);
                if let MinimapHit::Seek { fraction } = layout.hit_test(position.x, position.y) {
                    self.seek(fraction);
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "scale": format!("{:?}", Self::scale(variant)),
            "scroll_offset": self.scroll_offset,
            "total_lines": self.buffer.len(),
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Scale 1 (1x2)
Scale 2 (2x4)

Pipeline

Group: Data

Code

pub struct PipelineDemo {
    stages: Vec<Vec<PipelineStage>>,
    focused_stage: Vec<Option<usize>>,
    last_message: String,
}

fn in_progress_stages() -> Vec<PipelineStage> {
    vec![
        PipelineStage {
            label: "Checkout".into(),
            status: StageStatus::Done,
            action: None,
        },
        PipelineStage {
            label: "Build".into(),
            status: StageStatus::Done,
            action: None,
        },
        PipelineStage {
            label: "Test".into(),
            status: StageStatus::Active,
            action: Some("Retry".into()),
        },
        PipelineStage {
            label: "Deploy".into(),
            status: StageStatus::Pending,
            action: Some("Go".into()),
        },
        PipelineStage {
            label: "Notify".into(),
            status: StageStatus::Pending,
            action: Some("Skip".into()),
        },
    ]
}

fn failed_stages() -> Vec<PipelineStage> {
    vec![
        PipelineStage {
            label: "Checkout".into(),
            status: StageStatus::Done,
            action: None,
        },
        PipelineStage {
            label: "Build".into(),
            status: StageStatus::Failed,
            action: Some("Retry".into()),
        },
        PipelineStage {
            label: "Test".into(),
            status: StageStatus::Failed,
            action: Some("Retry".into()),
        },
        PipelineStage {
            label: "Deploy".into(),
            status: StageStatus::Pending,
            action: Some("Go".into()),
        },
        PipelineStage {
            label: "Notify".into(),
            status: StageStatus::Pending,
            action: Some("Skip".into()),
        },
    ]
}

impl PipelineDemo {
    pub fn new() -> Self {
        Self {
            stages: vec![in_progress_stages(), failed_stages()],
            // Index 2 is the stage with something to act on in both
            // variants ("Test" is Active in the in-progress variant,
            // Failed in the failed one) — focusing it by default means
            // `Enter` has an immediate, visible effect without the user
            // first pressing `→` twice.
            focused_stage: vec![Some(2), Some(2)],
            last_message: "←/→=focus  Enter=action  r=reset".into(),
        }
    }

    fn view(&self, variant: usize) -> PipelineView {
        PipelineView {
            id: WidgetId::new("gallery:pipeline"),
            stages: self.stages[variant].clone(),
            focused_stage: self.focused_stage[variant],
        }
    }

    fn pipeline_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        let pv_h = (lh * 5.0).min((area.height - lh).max(0.0));
        let pv_y = area.y + ((area.height - lh - pv_h) / 2.0).max(0.0);
        let margin = (lh * 2.0).min(area.width / 2.0);
        Rect::new(
            area.x + margin,
            pv_y,
            (area.width - margin * 2.0).max(0.0),
            pv_h,
        )
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self) -> StatusBar {
        StatusBar {
            id: WidgetId::new("gallery:pipeline: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![],
        }
    }

    fn apply_event(&mut self, variant: usize, event: PipelineEvent) {
        match event {
            PipelineEvent::StageAction { index } => {
                let label = self.stages[variant][index].label.clone();
                let action = self.stages[variant][index]
                    .action
                    .clone()
                    .unwrap_or_default();
                self.last_message = format!("{action} on '{label}'");
                if self.stages[variant][index].status == StageStatus::Active
                    || self.stages[variant][index].status == StageStatus::Failed
                {
                    self.stages[variant][index].status = StageStatus::Done;
                }
            }
            PipelineEvent::StageSelected { index } => {
                self.last_message = format!("Stage {index}: {}", self.stages[variant][index].label);
            }
            PipelineEvent::KeyPressed { key, .. } => {
                self.last_message = format!("Key: {key}");
            }
        }
    }
}

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

impl Demo for PipelineDemo {
    fn name(&self) -> &'static str {
        "Pipeline"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["In progress", "Failed"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let pv_rect = Self::pipeline_rect(area, backend);
        let _ = backend.draw_pipeline_view(pv_rect, &self.view(variant));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        match event {
            UiEvent::KeyPressed {
                key: Key::Char('r'),
                ..
            } => {
                self.stages[variant] = if variant == 0 {
                    in_progress_stages()
                } else {
                    failed_stages()
                };
                self.focused_stage[variant] = Some(2);
                self.last_message = "Reset".into();
                Reaction::Redraw
            }
            UiEvent::KeyPressed { key, modifiers, .. } => {
                let key_str = match key {
                    Key::Named(quadraui::NamedKey::Left) => "Left",
                    Key::Named(quadraui::NamedKey::Right) => "Right",
                    Key::Named(quadraui::NamedKey::Enter) => "Enter",
                    Key::Char(c) => {
                        let s = c.to_string();
                        let mut view = self.view(variant);
                        return match view.handle_key(&s, *modifiers) {
                            Some(ev) => {
                                self.apply_event(variant, ev);
                                self.focused_stage[variant] = view.focused_stage;
                                Reaction::Redraw
                            }
                            None => Reaction::Continue,
                        };
                    }
                    _ => return Reaction::Continue,
                };
                let mut view = self.view(variant);
                if let Some(ev) = view.handle_key(key_str, *modifiers) {
                    self.apply_event(variant, ev);
                }
                self.focused_stage[variant] = view.focused_stage;
                Reaction::Redraw
            }
            UiEvent::MouseDown { position, .. } => {
                let pv_rect = Self::pipeline_rect(area, backend);
                let view = self.view(variant);
                let layout = backend.pipeline_view_layout(pv_rect, &view);
                match layout.hit_test(position.x, position.y) {
                    PipelineHit::Action(idx) => {
                        self.last_message =
                            format!("Action on stage {idx}: {}", self.stages[variant][idx].label);
                        self.focused_stage[variant] = Some(idx);
                    }
                    PipelineHit::Body(idx) => {
                        self.last_message =
                            format!("Selected stage {idx}: {}", self.stages[variant][idx].label);
                        self.focused_stage[variant] = Some(idx);
                    }
                    PipelineHit::Empty => return Reaction::Continue,
                }
                Reaction::Redraw
            }
            _ => Reaction::Continue,
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::to_value(self.view(variant)).unwrap_or(serde_json::Value::Null)
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
In progress
Failed

Search Panel

Group: Data

Code

struct SearchMatch {
    line: usize,
    text: &'static str,
}

struct FileResult {
    path: &'static str,
    matches: &'static [SearchMatch],
    expanded: bool,
}

pub struct SearchPanelDemo {
    results: Vec<Vec<FileResult>>,
    selected_path: Vec<Option<Vec<u16>>>,
    last_message: Vec<String>,
}

impl SearchPanelDemo {
    pub fn new() -> Self {
        Self {
            results: vec![fake_results(), Vec::new()],
            selected_path: vec![None, None],
            last_message: vec![
                "Click a result to jump".into(),
                "No matches for this query".into(),
            ],
        }
    }

    fn build_tree_rows(&self, variant: usize) -> Vec<TreeRow> {
        let mut rows = Vec::new();
        for (fi, file) in self.results[variant].iter().enumerate() {
            rows.push(TreeRow {
                path: vec![fi as u16],
                indent: 0,
                icon: None,
                text: StyledText {
                    spans: vec![
                        StyledSpan::plain(file.path),
                        StyledSpan {
                            text: format!(" ({} matches)", file.matches.len()),
                            fg: Some(Color::rgb(120, 120, 120)),
                            bg: None,
                            bold: false,
                            italic: false,
                            underline: false,
                        },
                    ],
                },
                badge: None,
                is_expanded: Some(file.expanded),
                decoration: Decoration::Header,
                edit: None,
            });
            if file.expanded {
                for (mi, m) in file.matches.iter().enumerate() {
                    rows.push(TreeRow {
                        path: vec![fi as u16, mi as u16],
                        indent: 1,
                        icon: None,
                        text: StyledText {
                            spans: vec![
                                StyledSpan {
                                    text: format!("{:>4}: ", m.line),
                                    fg: Some(Color::rgb(100, 100, 100)),
                                    bg: None,
                                    bold: false,
                                    italic: false,
                                    underline: false,
                                },
                                StyledSpan::plain(m.text),
                            ],
                        },
                        badge: None,
                        is_expanded: None,
                        decoration: Decoration::Normal,
                        edit: None,
                    });
                }
            }
        }
        rows
    }

    fn build_view(&self, variant: usize) -> MultiSectionView {
        let rows = self.build_tree_rows(variant);
        let tree = TreeView {
            id: WidgetId::new("gallery:search-panel:results"),
            rows,
            selection_mode: quadraui::SelectionMode::Single,
            selected_path: self.selected_path[variant].clone(),
            scroll_offset: 0,
            style: quadraui::TreeStyle::default(),
            has_focus: true,
        };
        MultiSectionView {
            id: WidgetId::new("gallery:search-panel"),
            sections: vec![Section {
                id: "results".into(),
                header: SectionHeader {
                    icon: None,
                    title: StyledText::plain("SEARCH RESULTS"),
                    badge: None,
                    actions: vec![],
                    show_chevron: false,
                },
                body: SectionBody::Tree(tree),
                aux: Some(SectionAux::Search(InlineInput {
                    id: WidgetId::new("gallery:search-panel:input"),
                    text: String::new(),
                    caret: 0,
                    placeholder: Some("Search".into()),
                    has_focus: false,
                })),
                size: SectionSize::EqualShare,
                collapsed: false,
                min_size: None,
                max_size: None,
            }],
            active_section: Some(0),
            axis: quadraui::MsvAxis::Vertical,
            allow_resize: false,
            allow_collapse: false,
            scroll_mode: quadraui::ScrollMode::PerSection,
            has_focus: true,
            panel_scroll: 0.0,
        }
    }

    fn status(&self, variant: usize) -> StatusBar {
        let match_count: usize = self.results[variant].iter().map(|f| f.matches.len()).sum();
        let file_count = self.results[variant].len();
        StatusBar {
            id: WidgetId::new("gallery:search-panel:status"),
            left_segments: vec![StatusBarSegment {
                text: format!(" {} ", self.last_message[variant]),
                fg: Color::rgb(255, 255, 255),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![StatusBarSegment {
                text: format!(" {match_count} matches in {file_count} files "),
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 80, 120),
                bold: false,
                action_id: None,
            }],
        }
    }

    fn panel_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }
}

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

impl Demo for SearchPanelDemo {
    fn name(&self) -> &'static str {
        "Search Panel"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Results found", "No matches"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let panel_rect = Self::panel_rect(area, backend);
        backend.draw_multi_section_view(panel_rect, &self.build_view(variant));

        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        if let UiEvent::MouseDown { position, .. } = event {
            let panel_rect = Self::panel_rect(area, backend);
            let view = self.build_view(variant);
            let layout = backend.msv_layout(panel_rect, &view);
            match layout.hit_test(position.x, position.y) {
                MultiSectionViewHit::Aux {
                    kind: AuxHit::Input,
                    ..
                } => {
                    self.last_message[variant] = "Input focused".into();
                }
                MultiSectionViewHit::Body { section, .. } => {
                    if let Some(sl) = layout.sections.get(section) {
                        if let SectionBody::Tree(ref tree) = view.sections[section].body {
                            let tree_layout = backend.tree_layout(sl.body_bounds, tree);
                            let local_x = position.x - sl.body_bounds.x;
                            let local_y = position.y - sl.body_bounds.y;
                            match tree_layout.hit_test(local_x, local_y) {
                                TreeViewHit::Chevron(row_idx) | TreeViewHit::Row(row_idx)
                                    if tree.rows[row_idx].is_expanded.is_some() =>
                                {
                                    let fi = tree.rows[row_idx].path[0] as usize;
                                    if let Some(file) = self.results[variant].get_mut(fi) {
                                        file.expanded = !file.expanded;
                                        self.last_message[variant] = format!(
                                            "{} {}",
                                            if file.expanded {
                                                "Expanded"
                                            } else {
                                                "Collapsed"
                                            },
                                            file.path
                                        );
                                    }
                                }
                                TreeViewHit::Row(row_idx) => {
                                    let row = &tree.rows[row_idx];
                                    self.selected_path[variant] = Some(row.path.clone());
                                    if row.path.len() >= 2 {
                                        let fi = row.path[0] as usize;
                                        let mi = row.path[1] as usize;
                                        if let Some(file) = self.results[variant].get(fi) {
                                            if let Some(m) = file.matches.get(mi) {
                                                self.last_message[variant] =
                                                    format!("Jump: {}:{}", file.path, m.line);
                                            }
                                        }
                                    }
                                }
                                TreeViewHit::Chevron(_) | TreeViewHit::Empty => {
                                    self.last_message[variant] = "Empty area".into();
                                }
                            }
                        }
                    }
                }
                _ => {}
            }
            return Reaction::Redraw;
        }
        Reaction::Continue
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        serde_json::json!({
            "file_count": self.results[variant].len(),
            "match_count": self.results[variant].iter().map(|f| f.matches.len()).sum::<usize>(),
            "last_message": self.last_message[variant],
        })
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Results found
No matches

Tree

Group: Data

Code

fn fake_rows(prefix: &str, n: usize) -> Vec<TreeRow> {
    (0..n)
        .map(|i| TreeRow {
            path: vec![i as u16],
            indent: 0,
            icon: None,
            text: StyledText::plain(format!("{prefix}{i}")),
            badge: None,
            is_expanded: None,
            decoration: Decoration::Normal,
            edit: None,
        })
        .collect()
}

/// A tiny two-level file tree: `src/` expanded with two children,
/// `tests/` collapsed with one hidden child — the app (not the
/// primitive) owns which children are currently visible.
struct FileTree {
    src_expanded: bool,
    tests_expanded: bool,
}

impl FileTree {
    fn rows(&self) -> Vec<TreeRow> {
        let mut rows = vec![TreeRow {
            path: vec![0],
            indent: 0,
            icon: None,
            text: StyledText::plain("src/"),
            badge: None,
            is_expanded: Some(self.src_expanded),
            decoration: Decoration::Normal,
            edit: None,
        }];
        if self.src_expanded {
            rows.push(TreeRow {
                path: vec![0, 0],
                indent: 1,
                icon: None,
                text: StyledText::plain("main.rs"),
                badge: None,
                is_expanded: None,
                decoration: Decoration::Normal,
                edit: None,
            });
            rows.push(TreeRow {
                path: vec![0, 1],
                indent: 1,
                icon: None,
                text: StyledText::plain("lib.rs"),
                badge: None,
                is_expanded: None,
                decoration: Decoration::Normal,
                edit: None,
            });
        }
        rows.push(TreeRow {
            path: vec![1],
            indent: 0,
            icon: None,
            text: StyledText::plain("tests/"),
            badge: None,
            is_expanded: Some(self.tests_expanded),
            decoration: Decoration::Normal,
            edit: None,
        });
        if self.tests_expanded {
            rows.push(TreeRow {
                path: vec![1, 0],
                indent: 1,
                icon: None,
                text: StyledText::plain("smoke.rs"),
                badge: None,
                is_expanded: None,
                decoration: Decoration::Normal,
                edit: None,
            });
        }
        rows
    }

    /// Flip the expand state of the top-level row named by `path[0]`
    /// (`0` = `src/`, `1` = `tests/`) and return a label for the status
    /// line.
    fn toggle(&mut self, path: &[u16]) -> String {
        match path.first() {
            Some(0) => {
                self.src_expanded = !self.src_expanded;
                format!(
                    "src/ → {}",
                    if self.src_expanded {
                        "expanded"
                    } else {
                        "collapsed"
                    }
                )
            }
            Some(1) => {
                self.tests_expanded = !self.tests_expanded;
                format!(
                    "tests/ → {}",
                    if self.tests_expanded {
                        "expanded"
                    } else {
                        "collapsed"
                    }
                )
            }
            _ => "—".into(),
        }
    }
}

pub struct TreeDemo {
    // Variant 0: multi-section debug sidebar.
    debug: SidebarSystem,
    debug_last: String,
    // Variant 1: single expandable file tree.
    file_tree: FileTree,
    file_sidebar: SidebarSystem,
    file_last: String,
}

impl TreeDemo {
    pub fn new() -> Self {
        let mut debug = SidebarSystem::new(vec![
            SidebarSectionDef::new("variables", "VARIABLES"),
            SidebarSectionDef::new("watch", "WATCH"),
            SidebarSectionDef::new("call-stack", "CALL STACK"),
            SidebarSectionDef::new("breakpoints", "BREAKPOINTS"),
        ]);
        debug.set_rows(0, fake_rows("v", 8));
        debug.set_rows(1, fake_rows("w", 4));
        debug.set_rows(2, fake_rows("frame", 3));
        debug.set_rows(3, fake_rows("bp", 0));
        debug.set_navigation_mode(NavigationMode::Selection);
        debug.set_scroll_mode(ScrollMode::WholePanel);
        debug.set_active_section(Some(0));

        let file_tree = FileTree {
            src_expanded: true,
            tests_expanded: false,
        };
        let mut file_sidebar =
            SidebarSystem::new(vec![SidebarSectionDef::new("files", "EXPLORER")]);
        file_sidebar.set_rows(0, file_tree.rows());
        file_sidebar.set_navigation_mode(NavigationMode::Selection);
        file_sidebar.set_active_section(Some(0));

        Self {
            debug,
            debug_last: "—".into(),
            file_tree,
            file_sidebar,
            file_last: "—".into(),
        }
    }

    fn content_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y, area.width, (area.height - lh).max(0.0))
    }

    fn status_rect(area: Rect, backend: &dyn Backend) -> Rect {
        let lh = backend.line_height();
        Rect::new(area.x, area.y + area.height - lh, area.width, lh)
    }

    fn status(&self, variant: usize) -> StatusBar {
        let text = if variant == 0 {
            format!(" Tab cycles sections — last: {} ", self.debug_last)
        } else {
            format!(
                " click a header (or Space on it) to expand/collapse — last: {} ",
                self.file_last
            )
        };
        StatusBar {
            id: WidgetId::new("gallery:tree:status"),
            left_segments: vec![StatusBarSegment {
                text,
                fg: Color::rgb(220, 220, 220),
                bg: Color::rgb(40, 40, 60),
                bold: false,
                action_id: None,
            }],
            right_segments: vec![],
        }
    }
}

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

impl Demo for TreeDemo {
    fn name(&self) -> &'static str {
        "Tree"
    }

    fn group(&self) -> &'static str {
        "Data"
    }

    fn variants(&self) -> &'static [&'static str] {
        &["Debug panel (4 sections)", "File tree (expand/collapse)"]
    }

    fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
        let content = Self::content_rect(area, backend);
        if variant == 0 {
            self.debug.render(backend, content);
        } else {
            self.file_sidebar.render(backend, content);
        }
        let status_rect = Self::status_rect(area, backend);
        let _ = backend.draw_status_bar_interactive(
            status_rect,
            &self.status(variant),
            &InteractionState::new(),
        );
    }

    fn handle(
        &mut self,
        variant: usize,
        event: &UiEvent,
        backend: &mut dyn Backend,
        area: Rect,
    ) -> Reaction {
        let content = Self::content_rect(area, backend);
        if variant == 0 {
            match self.debug.handle(event, backend, content) {
                SidebarEvent::HeaderActivated { section } => {
                    self.debug_last = format!("header→{section}");
                    Reaction::Redraw
                }
                SidebarEvent::RowSelected { section, path } => {
                    self.debug_last = format!("sel→{section} {path:?}");
                    Reaction::Redraw
                }
                SidebarEvent::StateChanged
                | SidebarEvent::Consumed
                | SidebarEvent::ScrollChanged { .. } => Reaction::Redraw,
                SidebarEvent::Ignored => Reaction::Continue,
                _ => Reaction::Redraw,
            }
        } else {
            match self.file_sidebar.handle(event, backend, content) {
                SidebarEvent::RowToggleExpand { path, .. } => {
                    self.file_last = self.file_tree.toggle(&path);
                    self.file_sidebar.set_rows(0, self.file_tree.rows());
                    Reaction::Redraw
                }
                SidebarEvent::RowSelected { path, .. } => {
                    self.file_last = format!("sel {path:?}");
                    Reaction::Redraw
                }
                SidebarEvent::StateChanged
                | SidebarEvent::Consumed
                | SidebarEvent::ScrollChanged { .. } => Reaction::Redraw,
                SidebarEvent::Ignored => match event {
                    UiEvent::KeyPressed {
                        key: Key::Char(' '),
                        ..
                    } => {
                        let ev = self.file_sidebar.toggle_expand_selected();
                        if let SidebarEvent::RowToggleExpand { path, .. } = ev {
                            self.file_last = self.file_tree.toggle(&path);
                            self.file_sidebar.set_rows(0, self.file_tree.rows());
                            Reaction::Redraw
                        } else {
                            Reaction::Continue
                        }
                    }
                    _ => Reaction::Continue,
                },
                _ => Reaction::Redraw,
            }
        }
    }

    fn source(&self) -> &'static str {
        extract_region(SOURCE)
    }

    fn data(&self, variant: usize) -> serde_json::Value {
        if variant == 0 {
            serde_json::json!({
                "active_section": self.debug.active_section(),
                "last": self.debug_last,
            })
        } else {
            serde_json::json!({
                "src_expanded": self.file_tree.src_expanded,
                "tests_expanded": self.file_tree.tests_expanded,
                "last": self.file_last,
            })
        }
    }

    fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
        None
    }
}

Backends

VariantTUIGTKmacOSWindows
Debug panel (4 sections)
File tree (expand/collapse)