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

jzombie / term-wm / 20884874829

10 Jan 2026 09:47PM UTC coverage: 57.049% (+10.0%) from 47.071%
20884874829

Pull #20

github

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

2043 of 3181 new or added lines in 26 files covered. (64.23%)

76 existing lines in 15 files now uncovered.

6786 of 11895 relevant lines covered (57.05%)

9.62 hits per line

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

87.12
/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::{EDGE_PAD_HORIZONTAL, EDGE_PAD_VERTICAL};
14
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
15
use ratatui::layout::Rect;
16

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

24
impl LogicalPosition {
25
    pub fn new(row: usize, column: usize) -> Self {
19✔
26
        Self { row, column }
19✔
27
    }
19✔
28
}
29

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

127
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128
enum Phase {
129
    Idle,
130
    Dragging,
131
}
132

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

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

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

163
impl SelectionController {
164
    pub fn new() -> Self {
37✔
165
        Self::default()
37✔
166
    }
37✔
167

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

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

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

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

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

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

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

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

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

236
    pub fn clear_pointer(&mut self) {
4✔
237
        self.state.pointer = None;
4✔
238
        self.state.last_pointer_event = None;
4✔
239
    }
4✔
240

241
    pub fn pointer(&self) -> Option<(u16, u16)> {
3✔
242
        self.state.pointer
3✔
243
    }
3✔
244

245
    pub fn set_button_down(&mut self, pressed: bool) {
12✔
246
        self.state.button_down = pressed;
12✔
247
    }
12✔
248

249
    pub fn button_down(&self) -> bool {
9✔
250
        self.state.button_down
9✔
251
    }
9✔
252

253
    fn touch_pointer_clock(&mut self) {
19✔
254
        self.state.last_pointer_event = Some(Instant::now());
19✔
255
    }
19✔
256

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

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

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

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

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

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

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

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

404
    let left = area.x;
4✔
405
    let right_edge = area.x.saturating_add(area.width).saturating_sub(1);
4✔
406

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

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

439
    scrolled
4✔
440
}
4✔
441

442
const DRAG_IDLE_TIMEOUT_BASE: Duration = Duration::from_millis(220);
443
const DRAG_IDLE_TIMEOUT_VERTICAL: Duration = Duration::from_millis(600);
444
const DRAG_IDLE_TIMEOUT_HORIZONTAL: Duration = Duration::from_millis(900);
445

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

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

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

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

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

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

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

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

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

508
    let mut timeout = DRAG_IDLE_TIMEOUT_BASE;
2✔
509
    if vert_outside {
2✔
NEW
510
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_VERTICAL);
×
511
    }
2✔
512
    if horiz_outside {
2✔
513
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_HORIZONTAL);
1✔
514
    }
1✔
515
    timeout
2✔
516
}
2✔
517

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

735
        let changed = maintain_selection_drag(&mut host);
1✔
736
        assert!(changed);
1✔
737
        assert!(host.controller().is_dragging());
1✔
738
        assert!(host.controller().button_down());
1✔
739
        assert!(!host.h_scroll.is_empty());
1✔
740
        assert_eq!(host.h_scroll[0], 6);
1✔
741
    }
1✔
742
}
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