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
//! 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 (#409):
//! `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 the #454 fix: [`ShellContext::shell_mut`] reaches
//! the real `AppShell` instance quadraui's internal `ShellAdapter` actually
//! 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 (the class of `Ctrl+B`
//! bug this issue fixes).
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.
///
/// Exists for the issue #552 regression tests: they must click the
/// *n*-th activity-bar row after revealing the title bar, and 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 — and `driver_with_shell` hands back a
/// driver over the opaque `ShellAdapter`, so `driver.app()` cannot reach
/// this `ShellApp` directly.
#[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.clone(),
icon: "E".into(),
tooltip: "Explorer".into(),
title: "EXPLORER".into(),
},
PanelDefinition {
id: search.clone(),
icon: "S".into(),
tooltip: "Search".into(),
title: "SEARCH".into(),
},
PanelDefinition {
id: git.clone(),
icon: "G".into(),
tooltip: "Source Control".into(),
title: "SOURCE CONTROL".into(),
},
],
)
.with_bottom_items(vec![PanelDefinition {
id: settings.clone(),
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
}
}
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 the #552
// regression tests (see `ActivityProbe`). Harmless in the demo.
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());
}
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 (#409). 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` = the #454 fix: `ctx.shell_mut()` reaches the real
// `AppShell` `ShellAdapter` renders, so this app can call
// `toggle_sidebar()` on it directly — no shadow `AppShell`, no
// drift between what this app thinks is visible and what's
// actually painted (the class of bug #454 fixes).
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, the way vimcode
// toggles its menu bar (`engine.menu_bar_visible`). This is the
// transition issue #552 lives in: while the bar is hidden the
// activity bar's origin is 0, so a hit-region that wrongly
// folded that origin in still lined up. The moment the title
// bar is revealed the origin becomes nonzero and the error
// appears — which is why static construction tests never saw
// it and only a toggle exercises the defect (same trap #547
// documented).
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 the
// #552 hover regression test.
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 } => {
// #617: `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, the same shape vimcode's
// menu-bar-reveal path needed (issue #617's downstream
// motivation): before this, `ShellAdapter::handle`
// returned immediately after this call, so a
// `set_title_bar_visible` here would have been skipped
// for the frame and only taken effect once some unrelated
// later event happened to reach `handle`.
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(),
// #1055: `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 (the
// Settings-gear case this issue exists for) opts in explicitly
// by calling `show_panel` with the clicked id, exactly like a
// top-panel switch. Before #1055 this call silently no-opped
// and the header stayed captioned with whatever top panel was
// open previously.
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()
}
}