1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
//! Minimal AppShell runner demo — proves `run_with_shell()` pattern.
//!
//! The consumer implements [`ShellApp`] (~30 lines) instead of
//! `AppLogic` (~80 lines). The shell owns activity bar, sidebar header,
//! divider drag, and panel switching. The consumer renders sidebar
//! content + main content into the bounds the shell provides.
//!
//! Also demonstrates the activity-bar keyboard-cursor hook:
//! `Tab` calls [`ShellContext::request_activity_keyboard_focus`] to enter
//! keyboard-cursor mode; from there `j`/`k` (or arrows) move the cursor,
//! `l`/`Enter`/`Space` activates the selected panel, and `Esc`/`h`/`Left`
//! cancels — all driven internally by the shell runner, not this app. A
//! `ShellApp` only needs to pick its own trigger key(s) and call
//! `request_activity_keyboard_focus()`; a different consumer could bind
//! `Ctrl+W` instead of `Tab` with no quadraui changes.
//!
//! `Ctrl+B` demonstrates direct access to the real `AppShell` instance:
//! [`ShellContext::shell_mut`] reaches the actual shell that quadraui's
//! internal `ShellAdapter` renders, so a consumer-driven binding can call
//! `ctx.shell_mut().toggle_sidebar()` directly instead of tracking a shadow
//! `AppShell` that can drift from what's on screen.
use std::cell::Cell;
use std::rc::Rc;
use quadraui::compose::app_shell::{AppShellEvent, AppShellLayout, PanelDefinition};
use quadraui::{
Backend, Color, Icon, InteractionState, Key, Modifiers, NamedKey, Reaction, Rect, ShellApp,
ShellConfig, ShellContext, StatusBar, StatusBarSegment, UiEvent, WidgetId,
};
/// Shared handle onto the activity-bar rect the shell last handed this
/// app in `render_content`, in the backend's native units.
///
/// Used by regression tests to click the *n*-th activity-bar row after
/// revealing the title bar; the row origin differs per backend (TUI cells
/// vs GTK pixels). Reading the real painted bounds beats hardcoding a
/// coordinate that would be silently wrong on one backend.
#[derive(Clone, Default)]
pub struct ActivityProbe {
bounds: Rc<Cell<Option<Rect>>>,
hovered: Rc<Cell<Option<usize>>>,
}
// This module is `#[path]`-included by several test binaries and by the
// `tui_/gtk_appshell_demo` examples. Only `cross_backend_parity` reads the
// probe, so every other target sees these accessors as dead — the standard
// cost of a shared example module, not a sign they're unused.
#[allow(dead_code)]
impl ActivityProbe {
/// The activity bar's rect as of the most recent frame.
pub fn bounds(&self) -> Option<Rect> {
self.bounds.get()
}
/// `AppShell::hovered_activity_idx()` as of the last pointer move
/// this app saw. `AppShell` reports `MouseMoved` as `Ignored`, so the
/// event reaches `ShellApp::handle` *after* the shell has already
/// updated its hover state — making this an honest read of what the
/// shell decided, not a re-derivation.
pub fn hovered_idx(&self) -> Option<usize> {
self.hovered.get()
}
}
pub struct AppShellDemo {
last_event: String,
/// Set by the `p` key binding below; polled once via
/// `take_requested_panel` to exercise the programmatic panel-switch
/// hook (a bug class seen in consumer apps) — proves an app can
/// jump straight to a panel (no ActivityBar click) and still get the
/// ActivityBar highlight + sidebar header updated to match.
pending_panel: Option<WidgetId>,
/// Written every frame from `render_content`; read by tests.
probe: ActivityProbe,
}
impl AppShellDemo {
pub fn new() -> Self {
Self {
last_event: "Tab=focus bar | click icons | drag divider | p=jump to Source Control \
| Ctrl+B=toggle sidebar | t=toggle title bar | q=quit"
.into(),
pending_panel: None,
probe: ActivityProbe::default(),
}
}
/// Clone the activity-bar geometry handle before moving `self` into a
/// driver. See [`ActivityProbe`].
///
/// Dead in every target except `cross_backend_parity` — see the note
/// on `impl ActivityProbe`.
#[allow(dead_code)]
pub fn probe(&self) -> ActivityProbe {
self.probe.clone()
}
pub fn config() -> ShellConfig {
let explorer = WidgetId::new("panel:explorer");
let search = WidgetId::new("panel:search");
let git = WidgetId::new("panel:git");
let settings = WidgetId::new("panel:settings");
ShellConfig::new(
"AppShell Demo",
vec![
PanelDefinition {
id: explorer,
icon: "E".into(),
tooltip: "Explorer".into(),
title: "EXPLORER".into(),
},
PanelDefinition {
id: search,
icon: "S".into(),
tooltip: "Search".into(),
title: "SEARCH".into(),
},
PanelDefinition {
id: git,
icon: "G".into(),
tooltip: "Source Control".into(),
title: "SOURCE CONTROL".into(),
},
],
)
.with_bottom_items(vec![PanelDefinition {
id: settings,
icon: "*".into(),
tooltip: "Settings".into(),
title: "Settings".into(),
}])
// A distinct codicon glyph per panel — the GUI backends'
// `nerd_fonts_enabled` default is `true` (this crate's own
// codicon font is always bundled and self-registered, so there
// is no "Nerd Font not installed" risk the way an app-chosen
// glyph would carry), so these paint as real icons with no
// further app configuration; TUI still renders each panel's
// plain ASCII `icon` string above (`E`/`S`/`G`/`*`) via the
// `nerd_fonts_enabled`-gated `Icon::fallback` path, so this
// call changes nothing on TUI.
.with_panel_icon(explorer, Icon::new("\u{eaf0}", "E")) // codicon-files
.with_panel_icon(search, Icon::new("\u{ea6d}", "S")) // codicon-search
.with_panel_icon(git, Icon::new("\u{ea68}", "G")) // codicon-source-control
.with_panel_icon(settings, Icon::new("\u{eb51}", "*")) // codicon-settings-gear
.with_status_bar()
.with_status_bar_under_main_column()
.with_command_line()
}
}
impl Default for AppShellDemo {
fn default() -> Self {
Self::new()
}
}
impl ShellApp for AppShellDemo {
fn render_content(&self, backend: &mut dyn Backend, layout: &AppShellLayout) {
let lh = backend.line_height();
// Publish the activity bar's real painted bounds for regression tests.
self.probe.bounds.set(Some(layout.activity_bar_bounds));
if let Some(content) = layout.sidebar_content_bounds {
let label = StatusBar {
id: WidgetId::new("sidebar-content"),
left_segments: vec![StatusBarSegment {
text: " (sidebar content here) ".into(),
fg: Color::rgb(140, 140, 140),
bg: Color::rgb(30, 30, 30),
bold: false,
action_id: None,
}],
right_segments: vec![],
};
let rect = Rect::new(content.x, content.y, content.width, lh);
backend.draw_status_bar_interactive(rect, &label, &InteractionState::new());
}
let main_label = StatusBar {
id: WidgetId::new("main-label"),
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 rect = Rect::new(
layout.main_content_bounds.x,
layout.main_content_bounds.y,
layout.main_content_bounds.width,
lh,
);
backend.draw_status_bar_interactive(rect, &main_label, &InteractionState::new());
if let Some(sb) = layout.status_bar_bounds {
let bar = StatusBar {
id: WidgetId::new("demo-status-bar"),
left_segments: vec![StatusBarSegment {
text: " STATUS BAR (main-column width) ".into(),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(0, 122, 204),
bold: true,
action_id: None,
}],
right_segments: vec![StatusBarSegment {
text: format!(" x={:.0} w={:.0} ", sb.x, sb.width),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(0, 100, 180),
bold: false,
action_id: None,
}],
};
backend.draw_status_bar_interactive(sb, &bar, &InteractionState::new());
}
if let Some(cmd) = layout.command_line_bounds {
let bar = StatusBar {
id: WidgetId::new("demo-command-line"),
left_segments: vec![StatusBarSegment {
text: " COMMAND LINE ".into(),
fg: Color::rgb(200, 200, 200),
bg: Color::rgb(50, 50, 50),
bold: false,
action_id: None,
}],
right_segments: vec![],
};
backend.draw_status_bar_interactive(cmd, &bar, &InteractionState::new());
}
}
fn handle(
&mut self,
event: UiEvent,
_backend: &mut dyn Backend,
ctx: &ShellContext,
) -> Reaction {
match &event {
UiEvent::KeyPressed {
key: Key::Char('q') | Key::Named(NamedKey::Escape),
..
} => Reaction::Exit,
// Tab is this demo's chosen trigger for entering activity-bar
// keyboard-cursor mode. The shell runner (not this app)
// owns j/k/Enter/Esc navigation from here — see
// `ShellAdapter::handle`. A different `ShellApp` is free to
// bind a different key (e.g. `Ctrl+W`) to the same request.
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Tab),
..
} => {
ctx.request_activity_keyboard_focus();
self.last_event = "Activity bar focused (j/k, Enter, Esc)".into();
Reaction::Redraw
}
// `p` = jump straight to the Source Control panel the way an
// action handler would (no ActivityBar click) — queues a
// `take_requested_panel()` switch for `ShellAdapter` to apply.
UiEvent::KeyPressed {
key: Key::Char('p'),
..
} => {
self.pending_panel = Some(WidgetId::new("panel:git"));
self.last_event = "Requested programmatic switch to panel:git".into();
Reaction::Redraw
}
// `Ctrl+B`: `ctx.shell_mut()` reaches the real `AppShell` the
// `ShellAdapter` renders, so this app can call `toggle_sidebar()`
// directly without tracking a shadow `AppShell` that could drift from
// what's actually painted.
UiEvent::KeyPressed {
key: Key::Char('b'),
modifiers: Modifiers { ctrl: true, .. },
..
} => {
let now_visible = {
let mut shell = ctx.shell_mut();
shell.toggle_sidebar();
shell.sidebar_visible()
};
self.last_event = if now_visible {
"Sidebar shown (Ctrl+B via ctx.shell_mut())".into()
} else {
"Sidebar hidden (Ctrl+B via ctx.shell_mut())".into()
};
Reaction::Redraw
}
// `t`: reveal/hide the title bar at runtime. While hidden, the activity
// bar origin is 0; revealing it makes the origin nonzero, exposing any
// hit-region that wrongly folded the origin in.
UiEvent::KeyPressed {
key: Key::Char('t'),
..
} => {
let now_visible = {
let mut shell = ctx.shell_mut();
let next = !shell.title_bar_visible();
shell.set_title_bar_visible(next);
next
};
self.last_event = if now_visible {
"Title bar shown (t)".into()
} else {
"Title bar hidden (t)".into()
};
Reaction::Redraw
}
// Pointer moves are `Ignored` by `AppShell` (it updates hover and passes
// the event on), so by the time we see one the shell's hover state is
// already settled — record it for hover regression tests.
UiEvent::MouseMoved { .. } => {
self.probe.hovered.set(ctx.shell().hovered_activity_idx());
Reaction::Continue
}
UiEvent::MouseDown { position, .. } => {
if ctx.in_sidebar(position.x, position.y) {
self.last_event = format!(
"Sidebar click (panel: {})",
ctx.active_panel_id.map(|id| id.as_str()).unwrap_or("none")
);
Reaction::Redraw
} else if ctx.in_main(position.x, position.y) {
self.last_event = "Main area click".into();
Reaction::Redraw
} else {
Reaction::Continue
}
}
_ => Reaction::Continue,
}
}
fn on_shell_event_ctx(&mut self, event: &AppShellEvent, ctx: &ShellContext) {
self.last_event = match event {
AppShellEvent::PanelChanged { panel_id } => {
// `on_shell_event_ctx` receives a `ShellContext`, so an app can push
// shell state back on the same frame this notification fires — no
// intervening `handle` dispatch required. Reveal the title bar the
// moment Source Control becomes active.
if panel_id.as_str() == "panel:git" {
ctx.shell_mut().set_title_bar_visible(true);
}
format!("Panel: {}", panel_id.as_str())
}
AppShellEvent::SidebarHidden => "Sidebar hidden".into(),
AppShellEvent::SidebarResized { new_width } => {
format!("Resized: {new_width:.0}px")
}
AppShellEvent::BottomPanelResized { new_height } => {
format!("Bottom panel: {new_height:.0}px")
}
AppShellEvent::BottomPanelHidden => "Bottom panel hidden".into(),
// `AppShell` itself never opens the sidebar for a bottom-item click
// (a notifications bell shouldn't have to become the sidebar's owner
// just because it was clicked) — an app that does want its bottom item
// to own the sidebar opts in explicitly by calling `show_panel` with
// the clicked id, exactly like a top-panel switch.
AppShellEvent::BottomItemClicked { id } => {
ctx.shell_mut().show_panel(id);
format!("Bottom: {}", id.as_str())
}
_ => return,
};
}
fn take_requested_panel(&mut self) -> Option<WidgetId> {
self.pending_panel.take()
}
}