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 byWidgetId, 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.
BackendCapstells 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:
| Feature | Backend |
|---|---|
tui | Terminal, via ratatui |
gtk | GTK4, via Cairo + Pango (needs libgtk-4-dev / pkg-config to build) |
macos | Core Graphics + Core Text (only compiles on target_os = "macos") |
win | Direct2D + 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.
Explore the gallery
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 --openfrom a checkout for unreleased work). - crates.io — the published crate.
- GitHub repository — source,
issues, and
CLAUDE.md/CONTRIBUTING.mdfor contributing.
Gallery
Every quadraui primitive demo, generated from the gallery’s own
registry — see tools/site_gen.py.
Content
- Caret Shape
- Chat
- Command Line
- Command Palette
- Editor
- File Picker
- Find & Replace
- Focus
- Form
- Markdown
- Text Display
- Text Input
- Text Selection
Chrome
- Activity Bar
- Bottom Panel
- Float
- Menu Bar
- Panel
- Sidebar
- Split
- Status Bar
- Tab Bar
- Toolbar
- Window Control
- Workspace
Overlays
Data
- Board
- Canvas
- Chart
- Data Table
- Diff View
- Image
- Indicators
- Message List
- Minimap
- Pipeline
- Search Panel
- Tree
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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| Expanded | Error: 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 build | Not captured in this build | |
| Collapsed | Error: 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 build | Not captured in this build | |
| No accent, close only | Error: 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 build | Not 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| 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
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| Debug panel (4 sections) | ||||
| File tree (expand/collapse) |