quadraui 0.6.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
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
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Shared text-selection state machine.
//!
//! `GtkTextSelection`/`TuiTextSelection` were byte-identical structs, and
//! the region registry plus selection management functions were duplicated
//! between `gtk/backend.rs` and `tui/backend.rs`. This module is the one
//! implementation every pixel- and cell-based backend embeds as a field:
//! backend-neutral state plus pure helpers, not a trait, so each backend
//! still owns its own storage location and decides when to call in.
//!
//! ## What stays backend-specific
//!
//! Painting the highlight and extracting the copied text read from two
//! different sources depending on the backend:
//!
//! - **TUI** reads the live `ratatui::buffer::Buffer` cell-by-cell —
//!   only available inside `terminal.draw`'s closure, so `TuiBackend`
//!   keeps its own `apply_selection_highlight`/`extract_selection_text`.
//! - **GTK, Win-GUI, and macOS** are pixel-based and store
//!   selectable source text on the `TextRegion` itself (`TextRegion::lines`
//!   — see that field's doc). [`pixel_selection_ranges`] and
//!   [`extract_lines_pixel`] are the row/column math and text-slicing
//!   those three share; each backend still owns the actual paint call
//!   (Cairo `fill()` for GTK, Direct2D `FillRectangle` for Win-GUI,
//!   CoreGraphics `CGContextFillRect` for macOS) since that's real
//!   toolkit code with no portable shape.

#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
use crate::dispatch::text_selection_line_range;
use crate::dispatch::{DragState, DragTarget, TextRegion};
use crate::event::Point;
#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
use crate::event::Rect;
use crate::types::WidgetId;

/// A finalised or in-progress text selection: a region id plus anchor/focus
/// in the backend's native coordinate space (TUI: cells; GTK/Win-GUI/macOS:
/// pixels). Persists after the mouse button is released until Ctrl-C
/// copies the text or a new mouse-down clears it.
///
/// Lifted from the byte-identical `GtkTextSelection`/`TuiTextSelection`
/// — every field and its meaning is unchanged from those two.
///
/// Named `ActiveTextSelection` (not just `TextSelection`) to avoid a
/// same-crate name clash with the unrelated, differently-shaped
/// [`crate::primitives::rich_text_popup::TextSelection`] — this one
/// is `pub(crate)` (no downstream reaches it directly), so the rename
/// needed no deprecated alias.
#[derive(Debug, Clone)]
pub(crate) struct ActiveTextSelection {
    pub region: WidgetId,
    pub anchor: Point,
    pub focus: Point,
}

/// Per-frame `TextRegion` registry plus the active-selection state a
/// `Backend::register_text_region`/`cancel_text_selection_drag` override
/// needs. Embedded as a field (not a blanket trait impl) by every backend
/// that declares `BackendCaps::text_selection = true` — `GtkBackend`,
/// `TuiBackend`, `WinBackend`, and `MacBackend`.
#[derive(Default)]
pub(crate) struct TextSelectionState {
    /// Selectable text regions registered during the current frame via
    /// `Backend::register_text_region`. Cleared at the start of each
    /// frame by [`Self::begin_frame`].
    pub text_regions: Vec<TextRegion>,
    /// Finalised selection (may persist after mouse-up). `None` when no
    /// selection is active.
    active_selection: Option<ActiveTextSelection>,
    /// The id of the most-recently focused/hovered `TextRegion` — used by
    /// [`Self::select_all_text_region`] to resolve the Ctrl-A target.
    /// Updated by [`Self::set_active_text_selection`] (a drag produced a
    /// `TextSelectionChanged` event) and [`Self::track_focused_text_region`]
    /// (a runner's mouse-down started a `TextSelection` drag before the
    /// first move). Intentionally NOT cleared by
    /// [`Self::clear_text_selection`]/[`Self::clear_selection_display`] so
    /// Ctrl-A still targets the right region after Ctrl-C or a plain click.
    last_text_region_id: Option<WidgetId>,
}

impl TextSelectionState {
    /// Register `region` for the current frame. Backs the
    /// `Backend::register_text_region` override.
    pub fn register_text_region(&mut self, region: TextRegion) {
        self.text_regions.push(region);
    }

    /// Clear the per-frame region registry. Called by the backend's own
    /// `begin_frame` (same lifecycle as its other per-frame caches).
    pub fn begin_frame(&mut self) {
        self.text_regions.clear();
    }

    /// Look up a registered region by id.
    pub fn find_region(&self, id: &WidgetId) -> Option<&TextRegion> {
        self.text_regions.iter().find(|r| &r.id == id)
    }

    /// Return the current active text selection, if any.
    pub fn active_text_selection(&self) -> Option<&ActiveTextSelection> {
        self.active_selection.as_ref()
    }

    /// Update (or start) the active text selection. Called by the runner
    /// when a `TextSelectionChanged` event arrives, and by
    /// [`Self::select_all_text_region`]. Also updates
    /// [`Self::last_text_region_id`] so Ctrl-A can resolve the correct
    /// target even after the drag has ended.
    pub fn set_active_text_selection(&mut self, region: WidgetId, anchor: Point, focus: Point) {
        self.last_text_region_id = Some(region);
        self.active_selection = Some(ActiveTextSelection {
            region,
            anchor,
            focus,
        });
    }

    /// Clear the active text selection highlight only (does NOT end an
    /// in-progress `TextSelection` drag). Called before dispatching a new
    /// mouse-down so the old highlight disappears without interrupting the
    /// drag that is about to start.
    pub fn clear_selection_display(&mut self) {
        self.active_selection = None;
    }

    /// Clear the active text selection and end any in-progress
    /// `TextSelection` drag. Called after Ctrl-C copies the selection or
    /// on a plain click outside any text region.
    pub fn clear_text_selection(&mut self, drag_state: &mut DragState) {
        self.active_selection = None;
        if matches!(drag_state.target(), Some(DragTarget::TextSelection { .. })) {
            drag_state.end();
        }
    }

    /// End any in-progress `TextSelection` drag without clearing the
    /// displayed `active_selection`. Mirrors [`Self::clear_text_selection`]
    /// but preserves the highlight — backs the `Backend` trait's
    /// `cancel_text_selection_drag` override, which apps hosting an
    /// embedded terminal call to abort a speculative drag before
    /// forwarding a click to a PTY.
    pub fn cancel_text_selection_drag(&mut self, drag_state: &mut DragState) {
        if matches!(drag_state.target(), Some(DragTarget::TextSelection { .. })) {
            drag_state.end();
        }
    }

    /// Record that `id` is the most-recently focused/clicked `TextRegion`.
    /// Called by the runner's mouse-down handling after a `TextSelection`
    /// drag begins, so [`Self::select_all_text_region`] can resolve the
    /// correct target even before the first drag-move fires a
    /// `TextSelectionChanged` event.
    pub fn track_focused_text_region(&mut self, id: WidgetId) {
        self.last_text_region_id = Some(id);
    }

    /// Set the active selection to cover the entire visible content of the
    /// most-recently focused `TextRegion`. Returns `true` when a region was
    /// found and the selection was set; `false` when no region can be
    /// resolved (zero registered regions, or multiple with no prior
    /// interaction).
    ///
    /// Target resolution order:
    /// 1. [`Self::last_text_region_id`] if the region is still registered
    ///    this frame.
    /// 2. The sole registered region (if exactly one exists).
    /// 3. Returns `false` — caller should fall through to the app.
    ///
    /// # Viewport-only limitation
    ///
    /// `TextRegion.bounds` is the painted viewport. For scrolled panels
    /// (e.g. long issue bodies) only the on-screen rows are selected.
    /// Full-document select-all requires `TextRegion` to carry
    /// total-content rows; a follow-up issue tracks this.
    pub fn select_all_text_region(&mut self) -> bool {
        // Resolve the target region id.
        let region_id = if let Some(ref id) = self.last_text_region_id {
            if self.text_regions.iter().any(|r| &r.id == id) {
                *id
            } else if self.text_regions.len() == 1 {
                self.text_regions[0].id
            } else {
                return false;
            }
        } else if self.text_regions.len() == 1 {
            self.text_regions[0].id
        } else {
            return false;
        };

        // Clone the bounds to release the borrow before calling
        // `set_active_text_selection`, which needs `&mut self`.
        let bounds = match self.text_regions.iter().find(|r| r.id == region_id) {
            Some(r) => r.bounds,
            None => return false,
        };

        // Anchor at top-left; focus just past the bottom-right so that
        // `text_selection_line_range` covers every row. The focus is
        // clamped to the region bounds by that function, so placing it one
        // unit outside is harmless.
        let anchor = Point::new(bounds.x, bounds.y);
        let focus = Point::new(bounds.x + bounds.width, bounds.y + bounds.height);
        self.set_active_text_selection(region_id, anchor, focus);
        true
    }
}

/// Convert a pixel-space selection (`anchor`/`focus`, plus the selected
/// region's `bounds`) into cell-relative `(row, col_start, col_end)` ranges
/// via [`text_selection_line_range`] — the row/column math
/// `GtkBackend`'s/`WinBackend`'s `apply_selection_highlight`/
/// `extract_selection_text` share (both pixel-based backends storing
/// source text on the `TextRegion`, unlike TUI which reads the live cell
/// buffer).
///
/// `None` when `line_height`/`char_width` aren't positive (metrics not
/// known yet, e.g. before the first real frame).
///
/// `#[cfg(any(feature = "gtk", feature = "win", all(feature = "macos", target_os
/// = "macos")))]`: the only three adopters today — a `tui`-only build (the
/// `--features tui` clippy leg) would otherwise flag this as dead code.
#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
pub(crate) fn pixel_selection_ranges(
    region_bounds: Rect,
    anchor: Point,
    focus: Point,
    line_height: f32,
    char_width: f32,
) -> Option<Vec<(u16, f32, f32)>> {
    if line_height <= 0.0 || char_width <= 0.0 {
        return None;
    }
    let bx = region_bounds.x;
    let by = region_bounds.y;
    let bw = region_bounds.width / char_width;
    let bh = region_bounds.height / line_height;
    let cell_bounds = Rect::new(0.0, 0.0, bw, bh);
    let anchor_cell = Point {
        x: (anchor.x - bx) / char_width,
        y: (anchor.y - by) / line_height,
    };
    let focus_cell = Point {
        x: (focus.x - bx) / char_width,
        y: (focus.y - by) / line_height,
    };
    Some(text_selection_line_range(
        anchor_cell,
        focus_cell,
        cell_bounds,
    ))
}

/// Extract the selected text from `region.lines` (pixel-based backends
/// only — see the module doc) using pixel `anchor`/`focus`. Returns an
/// empty string when there is no `lines` content or the metrics aren't
/// known yet. Shared by GTK's, Win-GUI's, and macOS's
/// `extract_selection_text`.
#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
pub(crate) fn extract_lines_pixel(
    region: &TextRegion,
    anchor: Point,
    focus: Point,
    line_height: f32,
    char_width: f32,
) -> String {
    if region.lines.is_empty() {
        return String::new();
    }
    let Some(ranges) =
        pixel_selection_ranges(region.bounds, anchor, focus, line_height, char_width)
    else {
        return String::new();
    };

    let mut lines: Vec<String> = Vec::with_capacity(ranges.len());
    for (row_cell, col_start, col_end) in ranges {
        let line_idx = row_cell as usize;
        let Some(src) = region.lines.get(line_idx) else {
            continue;
        };
        let col_start = col_start as usize;
        let col_end = col_end as usize;
        // Extract by character index (col_start..col_end).
        let chars: Vec<char> = src.chars().collect();
        let s: String = chars
            .get(col_start.min(chars.len())..col_end.min(chars.len()))
            .unwrap_or(&[])
            .iter()
            .collect();
        lines.push(s.trim_end().to_string());
    }
    lines.join("\n")
}

#[cfg(test)]
mod tests {
    //! Coverage for the state machine and pixel math. `TuiBackend`/`GtkBackend`/
    //! `WinBackend` each keep their own thinner tests proving they delegate here
    //! correctly; this module owns the actual behaviour under test.
    use super::*;
    use crate::event::Rect;

    fn region(id: &str, x: f32, y: f32, w: f32, h: f32, lines: Vec<&str>) -> TextRegion {
        TextRegion {
            id: WidgetId::new(id),
            bounds: Rect::new(x, y, w, h),
            lines: lines.into_iter().map(String::from).collect(),
        }
    }

    #[test]
    fn register_and_clear_round_trip() {
        let mut state = TextSelectionState::default();
        state.register_text_region(region("r1", 0.0, 0.0, 100.0, 50.0, vec![]));
        state.register_text_region(region("r2", 0.0, 50.0, 100.0, 50.0, vec![]));
        assert_eq!(state.text_regions.len(), 2);
        state.begin_frame();
        assert!(state.text_regions.is_empty());
    }

    #[test]
    fn set_and_clear_active_selection() {
        let mut state = TextSelectionState::default();
        state.register_text_region(region("r", 0.0, 0.0, 200.0, 100.0, vec![]));
        assert!(state.active_text_selection().is_none());
        state.set_active_text_selection(
            WidgetId::new("r"),
            Point::new(0.0, 0.0),
            Point::new(50.0, 20.0),
        );
        assert!(state.active_text_selection().is_some());
        state.clear_selection_display();
        assert!(state.active_text_selection().is_none());
    }

    #[test]
    fn clear_text_selection_also_ends_a_text_selection_drag() {
        let mut state = TextSelectionState::default();
        let mut drag = DragState::new();
        drag.begin(DragTarget::TextSelection {
            region: WidgetId::new("r"),
            anchor: Point::new(0.0, 0.0),
        });
        state.set_active_text_selection(
            WidgetId::new("r"),
            Point::new(0.0, 0.0),
            Point::new(10.0, 10.0),
        );
        state.clear_text_selection(&mut drag);
        assert!(state.active_text_selection().is_none());
        assert!(
            drag.target().is_none(),
            "clear_text_selection must also end an in-progress TextSelection drag"
        );
    }

    #[test]
    fn clear_text_selection_leaves_an_unrelated_drag_alone() {
        let mut state = TextSelectionState::default();
        let mut drag = DragState::new();
        drag.begin(DragTarget::ScrollbarY {
            widget: WidgetId::new("sb"),
            track_start: 0.0,
            track_length: 100.0,
            thumb_length: 20.0,
            max_scroll: 10,
            grab_offset: 0.0,
            inverted: false,
        });
        state.clear_text_selection(&mut drag);
        assert!(
            drag.target().is_some(),
            "clear_text_selection must not touch a non-TextSelection drag"
        );
    }

    #[test]
    fn cancel_text_selection_drag_preserves_the_displayed_selection() {
        let mut state = TextSelectionState::default();
        let mut drag = DragState::new();
        drag.begin(DragTarget::TextSelection {
            region: WidgetId::new("r"),
            anchor: Point::new(0.0, 0.0),
        });
        state.set_active_text_selection(
            WidgetId::new("r"),
            Point::new(0.0, 0.0),
            Point::new(10.0, 10.0),
        );
        state.cancel_text_selection_drag(&mut drag);
        assert!(drag.target().is_none());
        assert!(
            state.active_text_selection().is_some(),
            "cancel_text_selection_drag must not clear the displayed selection"
        );
    }

    #[test]
    fn select_all_targets_the_sole_region() {
        let mut state = TextSelectionState::default();
        state.register_text_region(region("body", 0.0, 0.0, 10.0, 5.0, vec![]));
        assert!(state.select_all_text_region());
        let sel = state
            .active_text_selection()
            .expect("selection should be active");
        assert_eq!(sel.anchor, Point::new(0.0, 0.0));
        assert_eq!(sel.focus, Point::new(10.0, 5.0));
    }

    #[test]
    fn select_all_returns_false_with_no_regions() {
        let mut state = TextSelectionState::default();
        assert!(!state.select_all_text_region());
        assert!(state.active_text_selection().is_none());
    }

    #[test]
    fn select_all_prefers_the_tracked_region_over_an_ambiguous_pair() {
        let mut state = TextSelectionState::default();
        state.register_text_region(region("r1", 0.0, 0.0, 10.0, 5.0, vec![]));
        state.register_text_region(region("r2", 0.0, 5.0, 10.0, 5.0, vec![]));
        state.track_focused_text_region(WidgetId::new("r2"));
        assert!(state.select_all_text_region());
        assert_eq!(
            state.active_text_selection().unwrap().region,
            WidgetId::new("r2")
        );
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn extract_lines_pixel_single_row() {
        let region = region(
            "body",
            0.0,
            0.0,
            200.0,
            100.0,
            vec!["The quick brown fox jumps over the lazy dog."],
        );
        // char_w = 10, line_h = 20 → columns 0..20 covers "The quick brown fox "
        let text = extract_lines_pixel(
            &region,
            Point::new(0.0, 0.0),
            Point::new(200.0, 10.0),
            20.0,
            10.0,
        );
        assert_eq!(text, "The quick brown fox");
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn extract_lines_pixel_multi_row() {
        let region = region(
            "body",
            0.0,
            0.0,
            200.0,
            100.0,
            vec!["first line here", "second line here"],
        );
        let text = extract_lines_pixel(
            &region,
            Point::new(0.0, 0.0),
            Point::new(200.0, 30.0),
            20.0,
            10.0,
        );
        assert_eq!(text, "first line here\nsecond line here");
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn extract_lines_pixel_empty_when_no_lines_content() {
        let region = region("body", 0.0, 0.0, 200.0, 32.0, vec![]);
        let text = extract_lines_pixel(
            &region,
            Point::new(0.0, 0.0),
            Point::new(200.0, 32.0),
            16.0,
            8.0,
        );
        assert_eq!(text, "");
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn extract_lines_pixel_empty_when_metrics_unknown() {
        let region = region("body", 0.0, 0.0, 200.0, 32.0, vec!["hello"]);
        let text = extract_lines_pixel(
            &region,
            Point::new(0.0, 0.0),
            Point::new(200.0, 32.0),
            0.0,
            0.0,
        );
        assert_eq!(text, "");
    }
}