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
//! Mouse-drag text-selection + Ctrl-C copy demo for [`ShellApp`].
//!
//! [`SelectionDemo`] implements [`ShellApp`] and demonstrates that the selection
//! pipeline — drag to select, Ctrl-A select-all, Ctrl-C copy — works
//! when an app is driven by [`quadraui::tui::shell_runner::run_with_shell`]
//! (the path used by shell-driven hosts) as it does with the plain `tui::run` path.
//!
//! The pipeline lives in `quadraui::tui::run::dispatch_event`; both runners
//! converge on it.
//!
//! ## Controls
//!
//! | Input | Action |
//! |---------------------------|-----------------------------------------|
//! | Click-drag content lines | Start / extend text selection |
//! | Ctrl-A | Select all lines in the content area |
//! | Ctrl-C (with selection) | Copy selection; shows a preview |
//! | q / Esc | Quit |
//!
//! ## Mouse-reporting terminals
//!
//! When an embedded terminal has mouse reporting enabled (vim, tmux, …)
//! the app should call `backend.cancel_text_selection_drag()` immediately
//! after `TerminalSession::forward_mouse` returns `true`. This cancels the
//! speculative `DragTarget::TextSelection` drag that `apply_dispatch`
//! starts *before* `handle()` is called, preventing spurious
//! `TextSelectionChanged` events on subsequent mouse moves. See the
//! [`Backend::cancel_text_selection_drag`] doc for the full code pattern.
//!
//! This demo does not embed a PTY, so the call is not needed here. The
//! canonical example is `tui_terminal.rs` + `common/terminal_app.rs`.
use quadraui::compose::app_shell::AppShellLayout;
use quadraui::{
Backend, Color, InteractionState, Key, NamedKey, Reaction, Rect, ShellApp, ShellConfig,
ShellContext, StatusBar, StatusBarSegment, TextRegion, UiEvent, WidgetId,
};
// ── Content ───────────────────────────────────────────────────────────────────
/// Sample lines displayed in the selectable content area.
pub 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!",
"The five boxing wizards jump quickly.",
"Sphinx of black quartz, judge my vow.",
"Mr. Jock, TV quiz PhD, bags few lynx.",
"Waltz, bad nymph, for quick jigs vex.",
];
/// Widget id for the selectable text region (stable across frames).
pub const CONTENT_ID: &str = "selection-demo-content";
// ── App ───────────────────────────────────────────────────────────────────────
/// Demo app: selectable text panel driven by `run_with_shell`.
pub struct SelectionDemo {
/// Message shown in the bottom status bar.
status: String,
}
impl SelectionDemo {
pub fn new() -> Self {
Self { status: hint() }
}
/// Build the [`ShellConfig`] (no sidebar panels — main-area only).
pub fn config() -> ShellConfig {
ShellConfig::new("Selection Demo", vec![])
}
// ── Render helpers ────────────────────────────────────────────────────────
/// Paint `CONTENT_LINES` row by row into `bounds`. Returns the actual
/// rendered bounds (may be shorter than `bounds` when fewer lines fit).
fn fill_content(&self, backend: &mut dyn Backend, bounds: Rect) -> Rect {
if bounds.width < 1.0 || bounds.height < 1.0 {
return Rect::new(bounds.x, bounds.y, bounds.width, 0.0);
}
let lh = backend.line_height();
let fg = Color::rgb(200, 200, 200);
let bg = Color::rgb(20, 20, 35);
let mut rendered_rows: usize = 0;
for (i, &line) in CONTENT_LINES.iter().enumerate() {
let row_y = bounds.y + i as f32 * lh;
if row_y + lh > bounds.y + bounds.height {
break;
}
let row_rect = Rect::new(bounds.x, row_y, bounds.width, lh);
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![],
};
backend.draw_status_bar_interactive(row_rect, &bar, &InteractionState::new());
rendered_rows = i + 1;
}
Rect::new(bounds.x, bounds.y, bounds.width, rendered_rows as f32 * lh)
}
}
impl Default for SelectionDemo {
fn default() -> Self {
Self::new()
}
}
// ── ShellApp impl ─────────────────────────────────────────────────────────────
impl ShellApp for SelectionDemo {
fn render_content(&self, backend: &mut dyn Backend, layout: &AppShellLayout) {
let lh = backend.line_height();
let main = layout.main_content_bounds;
// Reserve one row at the bottom for the status bar.
let content_area = Rect::new(main.x, main.y, main.width, (main.height - lh).max(0.0));
let rendered = self.fill_content(backend, content_area);
// Register the selectable text region. Pixel-based backends (GTK,
// macOS) use `lines` for text extraction; TUI reads ratatui cells.
backend.register_text_region(TextRegion {
id: WidgetId::new(CONTENT_ID),
bounds: rendered,
lines: CONTENT_LINES.iter().map(|s| s.to_string()).collect(),
});
// Status bar at the bottom of the main content area.
let status_rect = Rect::new(main.x, main.y + main.height - lh, main.width, lh);
let status_bar = StatusBar {
id: WidgetId::new("selection-demo-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![],
};
backend.draw_status_bar_interactive(status_rect, &status_bar, &InteractionState::new());
}
fn handle(
&mut self,
event: UiEvent,
backend: &mut dyn Backend,
_ctx: &ShellContext,
) -> Reaction {
match event {
// ── Quit ─────────────────────────────────────────────────────────
UiEvent::KeyPressed {
key: Key::Char('q') | Key::Named(NamedKey::Escape),
..
} => Reaction::Exit,
// ── Copy confirmation ─────────────────────────────────────────────
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
}
// ── Selection-in-progress feedback ────────────────────────────────
UiEvent::TextSelectionChanged { anchor, focus, .. } => {
let lh = backend.line_height();
let a_row = (anchor.y / lh).floor() as usize + 1;
let f_row = (focus.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
}
// ── Click resets hint ─────────────────────────────────────────────
UiEvent::MouseDown { .. } => {
self.status = hint();
Reaction::Redraw
}
UiEvent::WindowResized { .. } => Reaction::Redraw,
_ => Reaction::Continue,
}
}
}
fn hint() -> String {
"drag to select · Ctrl-A select all · Ctrl-C copy · q quit".into()
}