• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 20885462867

10 Jan 2026 10:38PM UTC coverage: 57.056% (+10.0%) from 47.071%
20885462867

Pull #20

github

web-flow
Merge d8a3a10c3 into bfecf0f75
Pull Request #20: Initial clipboard and offscreen buffer support

2046 of 3183 new or added lines in 26 files covered. (64.28%)

78 existing lines in 15 files now uncovered.

6788 of 11897 relevant lines covered (57.06%)

9.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.18
/src/components/selectable_text.rs
1
// TODO: Either make this a component or extract from components directory as a helper
2

3
//! Shared selection and clipboard plumbing for text-oriented components.
4
//!
5
//! This module wires together the concepts needed by both the terminal and
6
//! text-renderer components so they can share selection math, clipboard
7
//! extraction, and drag tracking. It intentionally keeps the public surface
8
//! small for now; future commits can extend it with clipboard drivers and
9
//! richer rendering hooks.
10

11
use std::time::{Duration, Instant};
12

13
use crate::constants::{
14
    EDGE_PAD_HORIZONTAL, EDGE_PAD_VERTICAL, TEXT_SELECTION_DRAG_IDLE_TIMEOUT_BASE,
15
    TEXT_SELECTION_DRAG_IDLE_TIMEOUT_HORIZONTAL, TEXT_SELECTION_DRAG_IDLE_TIMEOUT_VERTICAL,
16
};
17
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
18
use ratatui::layout::Rect;
19

20
/// Logical coordinates inside a text surface.
21
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22
pub struct LogicalPosition {
23
    pub row: usize,
24
    pub column: usize,
25
}
26

27
impl LogicalPosition {
28
    pub fn new(row: usize, column: usize) -> Self {
19✔
29
        Self { row, column }
19✔
30
    }
19✔
31
}
32

33
/// Represents a start/end pair of logical positions.
34
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
pub struct SelectionRange {
36
    pub start: LogicalPosition,
37
    pub end: LogicalPosition,
38
}
39

40
impl SelectionRange {
41
    /// Return the range sorted from earliest to latest position.
42
    pub fn normalized(self) -> Self {
2✔
43
        if self.start <= self.end {
2✔
44
            self
1✔
45
        } else {
46
            Self {
1✔
47
                start: self.end,
1✔
48
                end: self.start,
1✔
49
            }
1✔
50
        }
51
    }
2✔
52

53
    /// True when the range spans at least one cell.
54
    pub fn is_non_empty(self) -> bool {
7✔
55
        self.start != self.end
7✔
56
    }
7✔
57

58
    /// Returns true when `pos` falls inside the normalized range (end-exclusive).
NEW
59
    pub fn contains(&self, pos: LogicalPosition) -> bool {
×
NEW
60
        let normalized = self.normalized();
×
NEW
61
        normalized.start <= pos && pos < normalized.end
×
NEW
62
    }
×
63
}
64

65
impl PartialOrd for LogicalPosition {
66
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2✔
67
        Some(self.cmp(other))
2✔
68
    }
2✔
69
}
70

71
impl Ord for LogicalPosition {
72
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2✔
73
        match self.row.cmp(&other.row) {
2✔
74
            std::cmp::Ordering::Equal => self.column.cmp(&other.column),
1✔
75
            ord => ord,
1✔
76
        }
77
    }
2✔
78
}
79

80
/// Host components implement this to let the controller map pixels to content
81
/// coordinates and fetch the selected text payload.
82
pub trait SelectableSurface {
83
    /// Current viewport, used to reject events outside the rendered area.
84
    fn viewport(&self) -> Rect;
85

86
    /// Translate the given terminal-space coordinate into a logical position
87
    /// within the component.
88
    fn position_at(&self, column: u16, row: u16) -> Option<LogicalPosition>;
89

90
    /// Build a clipboard-ready string for the provided range.
91
    fn text_for_range(&self, range: SelectionRange) -> Option<String>;
92
}
93

94
/// Describes the viewport and scrolling capabilities needed to normalize mouse
95
/// coordinates and auto-scroll while selecting.
96
pub trait SelectionViewport {
97
    /// Rectangle describing the currently rendered area for the component.
98
    fn selection_viewport(&self) -> Rect;
99

100
    /// Map the provided screen-space point to a logical text position.
101
    fn logical_position_from_point(&mut self, column: u16, row: u16) -> Option<LogicalPosition>;
102

103
    /// Scroll vertically by `delta` logical rows. Positive values move down.
104
    fn scroll_selection_vertical(&mut self, delta: isize);
105

106
    /// Scroll horizontally by `delta` logical columns. Implementors may ignore
107
    /// this if horizontal scrolling is unsupported.
NEW
108
    fn scroll_selection_horizontal(&mut self, _delta: isize) {}
×
109

110
    /// Current viewport offsets (column, row) within the underlying content.
NEW
111
    fn selection_viewport_offsets(&self) -> (usize, usize) {
×
NEW
112
        (0, 0)
×
NEW
113
    }
×
114

115
    /// Logical content size (width, height) backing the viewport. Defaults to
116
    /// the viewport dimensions for non-scrollable surfaces.
NEW
117
    fn selection_content_size(&self) -> (usize, usize) {
×
NEW
118
        let area = self.selection_viewport();
×
NEW
119
        (area.width as usize, area.height as usize)
×
NEW
120
    }
×
121
}
122

123
/// Hosts that store their own `SelectionController` implement this so shared
124
/// helpers can operate on both the viewport and controller without double
125
/// borrowing.
126
pub trait SelectionHost: SelectionViewport {
127
    fn selection_controller(&mut self) -> &mut SelectionController;
128
}
129

130
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131
enum Phase {
132
    Idle,
133
    Dragging,
134
}
135

136
#[derive(Debug, Clone, Copy)]
137
struct SelectionState {
138
    anchor: Option<LogicalPosition>,
139
    cursor: Option<LogicalPosition>,
140
    phase: Phase,
141
    pointer: Option<(u16, u16)>,
142
    last_pointer_event: Option<Instant>,
143
    button_down: bool,
144
}
145

146
impl Default for SelectionState {
147
    fn default() -> Self {
46✔
148
        Self {
46✔
149
            anchor: None,
46✔
150
            cursor: None,
46✔
151
            phase: Phase::Idle,
46✔
152
            pointer: None,
46✔
153
            last_pointer_event: None,
46✔
154
            button_down: false,
46✔
155
        }
46✔
156
    }
46✔
157
}
158

159
/// Minimal controller that tracks selection anchors and produces clipboard
160
/// payloads. Rendering hooks will be added in future commits.
161
#[derive(Debug, Clone, Default)]
162
pub struct SelectionController {
163
    state: SelectionState,
164
}
165

166
impl SelectionController {
167
    pub fn new() -> Self {
37✔
168
        Self::default()
37✔
169
    }
37✔
170

171
    /// Reset the controller to its idle state.
172
    pub fn clear(&mut self) {
9✔
173
        self.state = SelectionState::default();
9✔
174
    }
9✔
175

176
    /// Begin a drag selection at the provided logical position.
177
    pub fn begin_drag(&mut self, pos: LogicalPosition) {
9✔
178
        self.state.anchor = Some(pos);
9✔
179
        self.state.cursor = Some(pos);
9✔
180
        self.state.phase = Phase::Dragging;
9✔
181
        self.touch_pointer_clock();
9✔
182
        self.state.button_down = true;
9✔
183
    }
9✔
184

185
    /// Update the current drag cursor.
186
    pub fn update_drag(&mut self, pos: LogicalPosition) {
8✔
187
        if self.state.phase == Phase::Dragging {
8✔
188
            self.state.cursor = Some(pos);
8✔
189
        }
8✔
190
    }
8✔
191

192
    /// Finalize the drag. Returns the normalized range if a non-empty
193
    /// selection exists.
194
    pub fn finish_drag(&mut self) -> Option<SelectionRange> {
4✔
195
        if self.state.phase != Phase::Dragging {
4✔
NEW
196
            return None;
×
197
        }
4✔
198
        self.state.phase = Phase::Idle;
4✔
199
        self.clear_pointer();
4✔
200
        self.state.button_down = false;
4✔
201
        let range = self.selection_range();
4✔
202
        if range.is_some_and(|r| r.is_non_empty()) {
4✔
203
            range
1✔
204
        } else {
205
            self.clear();
3✔
206
            None
3✔
207
        }
208
    }
4✔
209

210
    /// True when a non-empty selection exists.
211
    pub fn has_selection(&self) -> bool {
6✔
212
        self.selection_range().is_some_and(|r| r.is_non_empty())
6✔
213
    }
6✔
214

215
    /// True while a drag gesture is active.
216
    pub fn is_dragging(&self) -> bool {
34✔
217
        self.state.phase == Phase::Dragging
34✔
218
    }
34✔
219

220
    /// Inspect the current range (anchor -> cursor).
221
    pub fn selection_range(&self) -> Option<SelectionRange> {
15✔
222
        match (self.state.anchor, self.state.cursor) {
15✔
223
            (Some(start), Some(end)) => Some(SelectionRange { start, end }),
7✔
224
            _ => None,
8✔
225
        }
226
    }
15✔
227

228
    /// Ask the surface for clipboard text covering the current selection.
NEW
229
    pub fn copy_selection<S: SelectableSurface>(&self, surface: &S) -> Option<String> {
×
NEW
230
        let range = self.selection_range()?.normalized();
×
NEW
231
        surface.text_for_range(range)
×
NEW
232
    }
×
233

234
    pub fn set_pointer(&mut self, column: u16, row: u16) {
10✔
235
        self.state.pointer = Some((column, row));
10✔
236
        self.touch_pointer_clock();
10✔
237
    }
10✔
238

239
    pub fn clear_pointer(&mut self) {
4✔
240
        self.state.pointer = None;
4✔
241
        self.state.last_pointer_event = None;
4✔
242
    }
4✔
243

244
    pub fn pointer(&self) -> Option<(u16, u16)> {
3✔
245
        self.state.pointer
3✔
246
    }
3✔
247

248
    pub fn set_button_down(&mut self, pressed: bool) {
12✔
249
        self.state.button_down = pressed;
12✔
250
    }
12✔
251

252
    pub fn button_down(&self) -> bool {
9✔
253
        self.state.button_down
9✔
254
    }
9✔
255

256
    fn touch_pointer_clock(&mut self) {
19✔
257
        self.state.last_pointer_event = Some(Instant::now());
19✔
258
    }
19✔
259

260
    fn pointer_stale(&self, now: Instant, timeout: Duration) -> bool {
1✔
261
        if self.state.phase != Phase::Dragging {
1✔
NEW
262
            return false;
×
263
        }
1✔
264
        let Some(last) = self.state.last_pointer_event else {
1✔
NEW
265
            return true;
×
266
        };
267
        now.duration_since(last) > timeout
1✔
268
    }
1✔
269
}
270

271
/// Shared mouse handler that begins/updates/ends selections and auto-scrolls
272
/// when the cursor leaves the viewport.
273
pub fn handle_selection_mouse<H: SelectionHost>(
9✔
274
    host: &mut H,
9✔
275
    enabled: bool,
9✔
276
    mouse: &MouseEvent,
9✔
277
) -> bool {
9✔
278
    if !enabled {
9✔
NEW
279
        return false;
×
280
    }
9✔
281
    let area = host.selection_viewport();
9✔
282
    if area.width == 0 || area.height == 0 {
9✔
NEW
283
        return false;
×
284
    }
9✔
285
    match mouse.kind {
5✔
286
        MouseEventKind::Down(MouseButton::Left) => {
287
            if rect_contains(area, mouse.column, mouse.row)
5✔
288
                && let Some(pos) = host.logical_position_from_point(mouse.column, mouse.row)
5✔
289
            {
290
                {
5✔
291
                    let selection = host.selection_controller();
5✔
292
                    selection.begin_drag(pos);
5✔
293
                    selection.set_pointer(mouse.column, mouse.row);
5✔
294
                    selection.set_button_down(true);
5✔
295
                }
5✔
296
                return true;
5✔
NEW
297
            }
×
NEW
298
            false
×
299
        }
300
        MouseEventKind::Drag(MouseButton::Left) => {
301
            {
302
                let selection = host.selection_controller();
2✔
303
                if !selection.is_dragging() {
2✔
NEW
304
                    return false;
×
305
                }
2✔
306
                selection.set_pointer(mouse.column, mouse.row);
2✔
307
                selection.set_button_down(true);
2✔
308
            }
309
            auto_scroll_selection(host, mouse.column, mouse.row);
2✔
310
            if let Some(pos) = host.logical_position_from_point(mouse.column, mouse.row) {
2✔
311
                host.selection_controller().update_drag(pos);
2✔
312
            }
2✔
313
            true
2✔
314
        }
315
        MouseEventKind::Up(MouseButton::Left) => {
316
            if !host.selection_controller().is_dragging() {
1✔
NEW
317
                return false;
×
318
            }
1✔
319
            let controller = host.selection_controller();
1✔
320
            controller.set_button_down(false);
1✔
321
            let _ = controller.finish_drag();
1✔
322
            true
1✔
323
        }
324
        MouseEventKind::Moved => {
325
            let selection = host.selection_controller();
1✔
326
            if !selection.is_dragging() {
1✔
NEW
327
                return false;
×
328
            }
1✔
329

330
            // If the button is down, differentiate two cases:
331
            // - selection non-empty: finalize the drag (tests expect this)
332
            // - selection empty: update pointer/cursor like a Drag so the
333
            //   selection can cross the anchor without freezing.
334
            if selection.button_down() {
1✔
335
                // Treat a Moved event when our internal button state indicates
336
                // the button is still down as equivalent to a Drag event.
337
                // This avoids finalizing the selection prematurely when the
338
                // input stream sends Moved events during a continuous press
339
                // (e.g., due to rapid motion or event coalescing).
340
                {
1✔
341
                    let selection = host.selection_controller();
1✔
342
                    selection.set_pointer(mouse.column, mouse.row);
1✔
343
                    selection.set_button_down(true);
1✔
344
                }
1✔
345
                auto_scroll_selection(host, mouse.column, mouse.row);
1✔
346
                if let Some(pos) = host.logical_position_from_point(mouse.column, mouse.row) {
1✔
347
                    host.selection_controller().update_drag(pos);
1✔
348
                }
1✔
349
                return true;
1✔
NEW
350
            }
×
351

352
            // Button not down -> finalize as before.
NEW
353
            let controller = host.selection_controller();
×
NEW
354
            let _ = controller.finish_drag();
×
NEW
355
            true
×
356
        }
NEW
357
        _ => false,
×
358
    }
359
}
9✔
360

361
fn auto_scroll_selection<V: SelectionViewport>(viewport: &mut V, column: u16, row: u16) -> bool {
4✔
362
    let area = viewport.selection_viewport();
4✔
363
    if area.width == 0 || area.height == 0 {
4✔
NEW
364
        return false;
×
365
    }
4✔
366

367
    let (offset_x, offset_y) = viewport.selection_viewport_offsets();
4✔
368
    let (content_w, content_h) = viewport.selection_content_size();
4✔
369
    let view_w = area.width as usize;
4✔
370
    let view_h = area.height as usize;
4✔
371
    let max_off_x = content_w.saturating_sub(view_w);
4✔
372
    let max_off_y = content_h.saturating_sub(view_h);
4✔
373
    let mut scrolled = false;
4✔
374

375
    let top = area.y;
4✔
376
    let bottom_edge = area.y.saturating_add(area.height).saturating_sub(1);
4✔
377
    let mut scroll_up_dist = 0;
4✔
378
    if row < top {
4✔
NEW
379
        scroll_up_dist = top.saturating_sub(row);
×
380
    } else if row <= top.saturating_add(EDGE_PAD_VERTICAL) {
4✔
381
        scroll_up_dist = top.saturating_add(EDGE_PAD_VERTICAL).saturating_sub(row);
2✔
382
    }
2✔
383
    if scroll_up_dist > 0 && offset_y > 0 {
4✔
NEW
384
        let delta = edge_scroll_step(scroll_up_dist, 2, 12);
×
NEW
385
        if delta != 0 {
×
NEW
386
            viewport.scroll_selection_vertical(-delta);
×
NEW
387
            scrolled = true;
×
NEW
388
        }
×
389
    }
4✔
390

391
    let mut scroll_down_dist = 0;
4✔
392
    if row > bottom_edge {
4✔
NEW
393
        scroll_down_dist = row.saturating_sub(bottom_edge);
×
394
    } else if row.saturating_add(EDGE_PAD_VERTICAL) >= bottom_edge
4✔
395
        && row >= bottom_edge.saturating_sub(EDGE_PAD_VERTICAL)
1✔
396
    {
1✔
397
        scroll_down_dist = row.saturating_sub(bottom_edge.saturating_sub(EDGE_PAD_VERTICAL));
1✔
398
    }
3✔
399
    if scroll_down_dist > 0 && offset_y < max_off_y {
4✔
NEW
400
        let delta = edge_scroll_step(scroll_down_dist, 2, 12);
×
NEW
401
        if delta != 0 {
×
NEW
402
            viewport.scroll_selection_vertical(delta);
×
NEW
403
            scrolled = true;
×
NEW
404
        }
×
405
    }
4✔
406

407
    let left = area.x;
4✔
408
    let right_edge = area.x.saturating_add(area.width).saturating_sub(1);
4✔
409

410
    let mut scroll_left_dist = 0;
4✔
411
    if column < left {
4✔
NEW
412
        scroll_left_dist = left.saturating_sub(column);
×
413
    } else if column <= left.saturating_add(EDGE_PAD_HORIZONTAL) {
4✔
414
        scroll_left_dist = left
1✔
415
            .saturating_add(EDGE_PAD_HORIZONTAL)
1✔
416
            .saturating_sub(column);
1✔
417
    }
3✔
418
    if scroll_left_dist > 0 && offset_x > 0 {
4✔
419
        let delta = edge_scroll_step(scroll_left_dist, 1, 80);
1✔
420
        if delta != 0 {
1✔
421
            viewport.scroll_selection_horizontal(-delta);
1✔
422
            scrolled = true;
1✔
423
        }
1✔
424
    }
3✔
425

426
    let mut scroll_right_dist = 0;
4✔
427
    if column > right_edge {
4✔
428
        scroll_right_dist = column.saturating_sub(right_edge);
1✔
429
    } else if column.saturating_add(EDGE_PAD_HORIZONTAL) >= right_edge
3✔
NEW
430
        && column >= right_edge.saturating_sub(EDGE_PAD_HORIZONTAL)
×
NEW
431
    {
×
NEW
432
        scroll_right_dist = column.saturating_sub(right_edge.saturating_sub(EDGE_PAD_HORIZONTAL));
×
433
    }
3✔
434
    if scroll_right_dist > 0 && offset_x < max_off_x {
4✔
435
        let delta = edge_scroll_step(scroll_right_dist, 1, 80);
1✔
436
        if delta != 0 {
1✔
437
            viewport.scroll_selection_horizontal(delta);
1✔
438
            scrolled = true;
1✔
439
        }
1✔
440
    }
3✔
441

442
    scrolled
4✔
443
}
4✔
444

445
/// Continue scrolling/selection updates using the last drag pointer, even when
446
/// no new mouse events arrive (e.g., cursor held outside the viewport).
447
pub fn maintain_selection_drag<H: SelectionHost>(host: &mut H) -> bool {
23✔
448
    let pointer = {
2✔
449
        let selection = host.selection_controller();
23✔
450
        if !selection.is_dragging() {
23✔
451
            return false;
21✔
452
        }
2✔
453
        selection.pointer()
2✔
454
    };
455

456
    let Some((column, row)) = pointer else {
2✔
NEW
457
        let _ = host.selection_controller().finish_drag();
×
NEW
458
        return false;
×
459
    };
460

461
    let area = host.selection_viewport();
2✔
462
    let inside_viewport = rect_contains(area, column, row);
2✔
463
    let timeout = drag_idle_timeout(area, column, row);
2✔
464
    let stale = {
2✔
465
        let selection = host.selection_controller();
2✔
466
        if !selection.button_down() {
2✔
467
            true
1✔
468
        } else {
469
            selection.pointer_stale(Instant::now(), timeout) && !inside_viewport
1✔
470
        }
471
    };
472

473
    if stale {
2✔
474
        let controller = host.selection_controller();
1✔
475
        controller.set_button_down(false);
1✔
476
        let _ = controller.finish_drag();
1✔
477
        return false;
1✔
478
    }
1✔
479

480
    maintain_selection_drag_active(host)
1✔
481
}
23✔
482

483
fn maintain_selection_drag_active<H: SelectionHost>(host: &mut H) -> bool {
1✔
484
    if !host.selection_controller().is_dragging() {
1✔
NEW
485
        return false;
×
486
    }
1✔
487

488
    let pointer = host.selection_controller().pointer();
1✔
489
    let Some((column, row)) = pointer else {
1✔
NEW
490
        let _ = host.selection_controller().finish_drag();
×
NEW
491
        return false;
×
492
    };
493

494
    let mut changed = auto_scroll_selection(host, column, row);
1✔
495
    if let Some(pos) = host.logical_position_from_point(column, row) {
1✔
496
        host.selection_controller().update_drag(pos);
1✔
497
        changed = true;
1✔
498
    }
1✔
499
    changed
1✔
500
}
1✔
501

502
fn drag_idle_timeout(area: Rect, column: u16, row: u16) -> Duration {
2✔
503
    if area.width == 0 || area.height == 0 {
2✔
NEW
504
        return TEXT_SELECTION_DRAG_IDLE_TIMEOUT_BASE;
×
505
    }
2✔
506
    let horiz_outside = column < area.x || column >= area.x.saturating_add(area.width);
2✔
507
    let vert_outside = row < area.y || row >= area.y.saturating_add(area.height);
2✔
508

509
    let mut timeout = TEXT_SELECTION_DRAG_IDLE_TIMEOUT_BASE;
2✔
510
    if vert_outside {
2✔
NEW
511
        timeout = timeout.max(TEXT_SELECTION_DRAG_IDLE_TIMEOUT_VERTICAL);
×
512
    }
2✔
513
    if horiz_outside {
2✔
514
        timeout = timeout.max(TEXT_SELECTION_DRAG_IDLE_TIMEOUT_HORIZONTAL);
1✔
515
    }
1✔
516
    timeout
2✔
517
}
2✔
518

519
fn edge_scroll_step(distance: u16, divisor: u16, max_step: u16) -> isize {
7✔
520
    if distance == 0 || max_step == 0 {
7✔
NEW
521
        return 0;
×
522
    }
7✔
523
    let div = divisor.max(1);
7✔
524
    let mut step = 1 + distance.saturating_sub(1) / div;
7✔
525
    if step > max_step {
7✔
526
        step = max_step;
2✔
527
    }
5✔
528
    step as isize
7✔
529
}
7✔
530

531
fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
7✔
532
    if rect.width == 0 || rect.height == 0 {
7✔
NEW
533
        return false;
×
534
    }
7✔
535
    let max_x = rect.x.saturating_add(rect.width);
7✔
536
    let max_y = rect.y.saturating_add(rect.height);
7✔
537
    column >= rect.x && column < max_x && row >= rect.y && row < max_y
7✔
538
}
7✔
539

540
#[cfg(test)]
541
mod tests {
542
    use super::*;
543
    use crossterm::event::KeyModifiers;
544

545
    #[derive(Debug)]
546
    struct TestHost {
547
        controller: SelectionController,
548
        viewport: Rect,
549
        h_scroll: Vec<isize>,
550
        v_scroll: Vec<isize>,
551
    }
552

553
    impl TestHost {
554
        fn new(viewport: Rect) -> Self {
4✔
555
            Self {
4✔
556
                controller: SelectionController::new(),
4✔
557
                viewport,
4✔
558
                h_scroll: Vec::new(),
4✔
559
                v_scroll: Vec::new(),
4✔
560
            }
4✔
561
        }
4✔
562

563
        fn controller(&self) -> &SelectionController {
11✔
564
            &self.controller
11✔
565
        }
11✔
566
    }
567

568
    impl SelectionViewport for TestHost {
569
        fn selection_viewport(&self) -> Rect {
12✔
570
            self.viewport
12✔
571
        }
12✔
572

573
        fn selection_viewport_offsets(&self) -> (usize, usize) {
3✔
574
            // Simulate the viewport starting at column 0, row 0 within a larger
575
            // content area so horizontal scrolling is possible in tests.
576
            (0, 0)
3✔
577
        }
3✔
578

579
        fn selection_content_size(&self) -> (usize, usize) {
3✔
580
            // Make the logical content significantly wider than the viewport
581
            // to allow horizontal auto-scrolling in test scenarios.
582
            (
3✔
583
                self.viewport.width as usize + 50,
3✔
584
                self.viewport.height as usize,
3✔
585
            )
3✔
586
        }
3✔
587

588
        fn logical_position_from_point(
7✔
589
            &mut self,
7✔
590
            column: u16,
7✔
591
            row: u16,
7✔
592
        ) -> Option<LogicalPosition> {
7✔
593
            let col = column.saturating_sub(self.viewport.x) as usize;
7✔
594
            let row = row.saturating_sub(self.viewport.y) as usize;
7✔
595
            Some(LogicalPosition::new(row, col))
7✔
596
        }
7✔
597

NEW
598
        fn scroll_selection_vertical(&mut self, delta: isize) {
×
NEW
599
            self.v_scroll.push(delta);
×
NEW
600
        }
×
601

602
        fn scroll_selection_horizontal(&mut self, delta: isize) {
1✔
603
            self.h_scroll.push(delta);
1✔
604
        }
1✔
605
    }
606

607
    impl SelectionHost for TestHost {
608
        fn selection_controller(&mut self) -> &mut SelectionController {
23✔
609
            &mut self.controller
23✔
610
        }
23✔
611
    }
612

613
    fn mouse(column: u16, row: u16, kind: MouseEventKind) -> MouseEvent {
7✔
614
        MouseEvent {
7✔
615
            column,
7✔
616
            row,
7✔
617
            kind,
7✔
618
            modifiers: KeyModifiers::NONE,
7✔
619
        }
7✔
620
    }
7✔
621

622
    #[test]
623
    fn normalized_swaps_when_needed() {
1✔
624
        let range = SelectionRange {
1✔
625
            start: LogicalPosition::new(2, 5),
1✔
626
            end: LogicalPosition::new(1, 3),
1✔
627
        };
1✔
628
        let normalized = range.normalized();
1✔
629
        assert_eq!(normalized.start.row, 1);
1✔
630
        assert_eq!(normalized.start.column, 3);
1✔
631
        assert_eq!(normalized.end.row, 2);
1✔
632
        assert_eq!(normalized.end.column, 5);
1✔
633
    }
1✔
634

635
    #[test]
636
    fn controller_tracks_drag_state() {
1✔
637
        let mut controller = SelectionController::new();
1✔
638
        controller.begin_drag(LogicalPosition::new(0, 0));
1✔
639
        controller.update_drag(LogicalPosition::new(0, 5));
1✔
640
        let range = controller.finish_drag().expect("selection should exist");
1✔
641
        assert_eq!(range.normalized().end.column, 5);
1✔
642
        assert!(controller.has_selection());
1✔
643
    }
1✔
644

645
    #[test]
646
    fn controller_clears_empty_selection() {
1✔
647
        let mut controller = SelectionController::new();
1✔
648
        controller.begin_drag(LogicalPosition::new(0, 0));
1✔
649
        controller.update_drag(LogicalPosition::new(0, 0));
1✔
650
        assert!(controller.finish_drag().is_none());
1✔
651
        assert!(!controller.has_selection());
1✔
652
    }
1✔
653

654
    #[test]
655
    fn edge_scroll_step_scales_and_clamps() {
1✔
656
        assert_eq!(edge_scroll_step(1, 2, 12), 1);
1✔
657
        assert!(edge_scroll_step(6, 2, 12) >= 3);
1✔
658
        assert_eq!(edge_scroll_step(50, 2, 12), 12);
1✔
659
        assert_eq!(edge_scroll_step(10, 1, 48), 10);
1✔
660
        assert_eq!(edge_scroll_step(100, 1, 48), 48);
1✔
661
    }
1✔
662

663
    #[test]
664
    fn mouse_up_clears_button_state() {
1✔
665
        let mut host = TestHost::new(Rect::new(0, 0, 10, 5));
1✔
666
        assert!(handle_selection_mouse(
1✔
667
            &mut host,
1✔
668
            true,
669
            &mouse(1, 1, MouseEventKind::Down(MouseButton::Left))
1✔
670
        ));
671
        assert!(host.controller().is_dragging());
1✔
672
        assert!(host.controller().button_down());
1✔
673

674
        assert!(handle_selection_mouse(
1✔
675
            &mut host,
1✔
676
            true,
677
            &mouse(1, 1, MouseEventKind::Up(MouseButton::Left))
1✔
678
        ));
679
        assert!(!host.controller().is_dragging());
1✔
680
        assert!(!host.controller().button_down());
1✔
681
    }
1✔
682

683
    #[test]
684
    fn moved_event_treats_drag_as_complete() {
1✔
685
        let mut host = TestHost::new(Rect::new(0, 0, 10, 5));
1✔
686
        assert!(handle_selection_mouse(
1✔
687
            &mut host,
1✔
688
            true,
689
            &mouse(2, 2, MouseEventKind::Down(MouseButton::Left))
1✔
690
        ));
691
        assert!(handle_selection_mouse(
1✔
692
            &mut host,
1✔
693
            true,
694
            &mouse(4, 2, MouseEventKind::Drag(MouseButton::Left))
1✔
695
        ));
696
        assert!(host.controller().button_down());
1✔
697

698
        // Moved with our internal button-down state should be treated like
699
        // a Drag (do not finalize). Ensure drag remains active.
700
        let continued =
1✔
701
            handle_selection_mouse(&mut host, true, &mouse(6, 2, MouseEventKind::Moved));
1✔
702
        assert!(continued);
1✔
703
        assert!(host.controller().is_dragging());
1✔
704
        assert!(host.controller().button_down());
1✔
705
    }
1✔
706

707
    #[test]
708
    fn maintain_stops_when_button_released() {
1✔
709
        let mut host = TestHost::new(Rect::new(0, 0, 10, 5));
1✔
710
        assert!(handle_selection_mouse(
1✔
711
            &mut host,
1✔
712
            true,
713
            &mouse(1, 1, MouseEventKind::Down(MouseButton::Left))
1✔
714
        ));
715
        host.selection_controller().set_pointer(0, 0);
1✔
716
        host.selection_controller().set_button_down(false);
1✔
717

718
        let changed = maintain_selection_drag(&mut host);
1✔
719
        assert!(!changed);
1✔
720
        assert!(!host.controller().is_dragging());
1✔
721
        assert!(!host.controller().button_down());
1✔
722
    }
1✔
723

724
    #[test]
725
    fn maintain_scrolls_when_button_down() {
1✔
726
        let mut host = TestHost::new(Rect::new(5, 5, 10, 5));
1✔
727
        assert!(handle_selection_mouse(
1✔
728
            &mut host,
1✔
729
            true,
730
            &mouse(6, 6, MouseEventKind::Down(MouseButton::Left))
1✔
731
        ));
732
        // Simulate pointer beyond the right edge to trigger horizontal scrolling.
733
        host.selection_controller().set_pointer(20, 6);
1✔
734
        host.selection_controller().set_button_down(true);
1✔
735

736
        let changed = maintain_selection_drag(&mut host);
1✔
737
        assert!(changed);
1✔
738
        assert!(host.controller().is_dragging());
1✔
739
        assert!(host.controller().button_down());
1✔
740
        assert!(!host.h_scroll.is_empty());
1✔
741
        assert_eq!(host.h_scroll[0], 6);
1✔
742
    }
1✔
743
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc