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

jzombie / term-wm / 20884612585

10 Jan 2026 09:24PM UTC coverage: 56.953% (+9.9%) from 47.071%
20884612585

Pull #20

github

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

2039 of 3194 new or added lines in 26 files covered. (63.84%)

76 existing lines in 15 files now uncovered.

6782 of 11908 relevant lines covered (56.95%)

9.61 hits per line

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

84.99
/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 {
18✔
26
        Self { row, column }
18✔
27
    }
18✔
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 {
9✔
52
        self.start != self.end
9✔
53
    }
9✔
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) {
7✔
184
        if self.state.phase == Phase::Dragging {
7✔
185
            self.state.cursor = Some(pos);
7✔
186
        }
7✔
187
    }
7✔
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> {
5✔
192
        if self.state.phase != Phase::Dragging {
5✔
NEW
193
            return None;
×
194
        }
5✔
195
        self.state.phase = Phase::Idle;
5✔
196
        self.clear_pointer();
5✔
197
        self.state.button_down = false;
5✔
198
        let range = self.selection_range();
5✔
199
        if range.is_some_and(|r| r.is_non_empty()) {
5✔
200
            range
2✔
201
        } else {
202
            self.clear();
3✔
203
            None
3✔
204
        }
205
    }
5✔
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> {
17✔
219
        match (self.state.anchor, self.state.cursor) {
17✔
220
            (Some(start), Some(end)) => Some(SelectionRange { start, end }),
9✔
221
            _ => None,
8✔
222
        }
223
    }
17✔
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) {
9✔
232
        self.state.pointer = Some((column, row));
9✔
233
        self.touch_pointer_clock();
9✔
234
    }
9✔
235

236
    pub fn clear_pointer(&mut self) {
5✔
237
        self.state.pointer = None;
5✔
238
        self.state.last_pointer_event = None;
5✔
239
    }
5✔
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) {
18✔
254
        self.state.last_pointer_event = Some(Instant::now());
18✔
255
    }
18✔
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
                if selection
1✔
333
                    .selection_range()
1✔
334
                    .is_some_and(|r| r.is_non_empty())
1✔
335
                {
336
                    let controller = host.selection_controller();
1✔
337
                    controller.set_button_down(false);
1✔
338
                    let _ = controller.finish_drag();
1✔
339
                    return true;
1✔
NEW
340
                }
×
341

342
                // selection empty: update pointer and cursor like Drag
NEW
343
                {
×
NEW
344
                    let selection = host.selection_controller();
×
NEW
345
                    selection.set_pointer(mouse.column, mouse.row);
×
NEW
346
                    selection.set_button_down(true);
×
NEW
347
                }
×
NEW
348
                auto_scroll_selection(host, mouse.column, mouse.row);
×
NEW
349
                if let Some(pos) = host.logical_position_from_point(mouse.column, mouse.row) {
×
NEW
350
                    host.selection_controller().update_drag(pos);
×
NEW
351
                }
×
NEW
352
                return true;
×
NEW
353
            }
×
354

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

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

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

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

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

410
    let left = area.x;
3✔
411
    let right_edge = area.x.saturating_add(area.width).saturating_sub(1);
3✔
412

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

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

445
    scrolled
3✔
446
}
3✔
447

448
const DRAG_IDLE_TIMEOUT_BASE: Duration = Duration::from_millis(220);
449
const DRAG_IDLE_TIMEOUT_VERTICAL: Duration = Duration::from_millis(600);
450
const DRAG_IDLE_TIMEOUT_HORIZONTAL: Duration = Duration::from_millis(900);
451

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

463
    let Some((column, row)) = pointer else {
2✔
NEW
464
        let _ = host.selection_controller().finish_drag();
×
NEW
465
        return false;
×
466
    };
467

468
    let timeout = drag_idle_timeout(host.selection_viewport(), column, row);
2✔
469
    let stale = {
2✔
470
        let selection = host.selection_controller();
2✔
471
        if !selection.button_down() {
2✔
472
            true
1✔
473
        } else {
474
            selection.pointer_stale(Instant::now(), timeout)
1✔
475
        }
476
    };
477

478
    if stale {
2✔
479
        let controller = host.selection_controller();
1✔
480
        controller.set_button_down(false);
1✔
481
        let _ = controller.finish_drag();
1✔
482
        return false;
1✔
483
    }
1✔
484

485
    maintain_selection_drag_active(host)
1✔
486
}
23✔
487

488
fn maintain_selection_drag_active<H: SelectionHost>(host: &mut H) -> bool {
1✔
489
    if !host.selection_controller().is_dragging() {
1✔
NEW
490
        return false;
×
491
    }
1✔
492

493
    let pointer = host.selection_controller().pointer();
1✔
494
    let Some((column, row)) = pointer else {
1✔
NEW
495
        let _ = host.selection_controller().finish_drag();
×
NEW
496
        return false;
×
497
    };
498

499
    let mut changed = auto_scroll_selection(host, column, row);
1✔
500
    if let Some(pos) = host.logical_position_from_point(column, row) {
1✔
501
        host.selection_controller().update_drag(pos);
1✔
502
        changed = true;
1✔
503
    }
1✔
504
    changed
1✔
505
}
1✔
506

507
fn drag_idle_timeout(area: Rect, column: u16, row: u16) -> Duration {
2✔
508
    if area.width == 0 || area.height == 0 {
2✔
NEW
509
        return DRAG_IDLE_TIMEOUT_BASE;
×
510
    }
2✔
511
    let horiz_outside = column < area.x || column >= area.x.saturating_add(area.width);
2✔
512
    let vert_outside = row < area.y || row >= area.y.saturating_add(area.height);
2✔
513

514
    let mut timeout = DRAG_IDLE_TIMEOUT_BASE;
2✔
515
    if vert_outside {
2✔
NEW
516
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_VERTICAL);
×
517
    }
2✔
518
    if horiz_outside {
2✔
519
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_HORIZONTAL);
1✔
520
    }
1✔
521
    timeout
2✔
522
}
2✔
523

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

536
fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
5✔
537
    if rect.width == 0 || rect.height == 0 {
5✔
NEW
538
        return false;
×
539
    }
5✔
540
    let max_x = rect.x.saturating_add(rect.width);
5✔
541
    let max_y = rect.y.saturating_add(rect.height);
5✔
542
    column >= rect.x && column < max_x && row >= rect.y && row < max_y
5✔
543
}
5✔
544

545
#[cfg(test)]
546
mod tests {
547
    use super::*;
548
    use crossterm::event::KeyModifiers;
549

550
    #[derive(Debug)]
551
    struct TestHost {
552
        controller: SelectionController,
553
        viewport: Rect,
554
        h_scroll: Vec<isize>,
555
        v_scroll: Vec<isize>,
556
    }
557

558
    impl TestHost {
559
        fn new(viewport: Rect) -> Self {
4✔
560
            Self {
4✔
561
                controller: SelectionController::new(),
4✔
562
                viewport,
4✔
563
                h_scroll: Vec::new(),
4✔
564
                v_scroll: Vec::new(),
4✔
565
            }
4✔
566
        }
4✔
567

568
        fn controller(&self) -> &SelectionController {
11✔
569
            &self.controller
11✔
570
        }
11✔
571
    }
572

573
    impl SelectionViewport for TestHost {
574
        fn selection_viewport(&self) -> Rect {
11✔
575
            self.viewport
11✔
576
        }
11✔
577

578
        fn selection_viewport_offsets(&self) -> (usize, usize) {
2✔
579
            // Simulate the viewport starting at column 0, row 0 within a larger
580
            // content area so horizontal scrolling is possible in tests.
581
            (0, 0)
2✔
582
        }
2✔
583

584
        fn selection_content_size(&self) -> (usize, usize) {
2✔
585
            // Make the logical content significantly wider than the viewport
586
            // to allow horizontal auto-scrolling in test scenarios.
587
            (
2✔
588
                self.viewport.width as usize + 50,
2✔
589
                self.viewport.height as usize,
2✔
590
            )
2✔
591
        }
2✔
592

593
        fn logical_position_from_point(
6✔
594
            &mut self,
6✔
595
            column: u16,
6✔
596
            row: u16,
6✔
597
        ) -> Option<LogicalPosition> {
6✔
598
            let col = column.saturating_sub(self.viewport.x) as usize;
6✔
599
            let row = row.saturating_sub(self.viewport.y) as usize;
6✔
600
            Some(LogicalPosition::new(row, col))
6✔
601
        }
6✔
602

NEW
603
        fn scroll_selection_vertical(&mut self, delta: isize) {
×
NEW
604
            self.v_scroll.push(delta);
×
NEW
605
        }
×
606

607
        fn scroll_selection_horizontal(&mut self, delta: isize) {
1✔
608
            self.h_scroll.push(delta);
1✔
609
        }
1✔
610
    }
611

612
    impl SelectionHost for TestHost {
613
        fn selection_controller(&mut self) -> &mut SelectionController {
22✔
614
            &mut self.controller
22✔
615
        }
22✔
616
    }
617

618
    fn mouse(column: u16, row: u16, kind: MouseEventKind) -> MouseEvent {
7✔
619
        MouseEvent {
7✔
620
            column,
7✔
621
            row,
7✔
622
            kind,
7✔
623
            modifiers: KeyModifiers::NONE,
7✔
624
        }
7✔
625
    }
7✔
626

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

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

650
    #[test]
651
    fn controller_clears_empty_selection() {
1✔
652
        let mut controller = SelectionController::new();
1✔
653
        controller.begin_drag(LogicalPosition::new(0, 0));
1✔
654
        controller.update_drag(LogicalPosition::new(0, 0));
1✔
655
        assert!(controller.finish_drag().is_none());
1✔
656
        assert!(!controller.has_selection());
1✔
657
    }
1✔
658

659
    #[test]
660
    fn edge_scroll_step_scales_and_clamps() {
1✔
661
        assert_eq!(edge_scroll_step(1, 2, 12), 1);
1✔
662
        assert!(edge_scroll_step(6, 2, 12) >= 3);
1✔
663
        assert_eq!(edge_scroll_step(50, 2, 12), 12);
1✔
664
        assert_eq!(edge_scroll_step(10, 1, 48), 10);
1✔
665
        assert_eq!(edge_scroll_step(100, 1, 48), 48);
1✔
666
    }
1✔
667

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

679
        assert!(handle_selection_mouse(
1✔
680
            &mut host,
1✔
681
            true,
682
            &mouse(1, 1, MouseEventKind::Up(MouseButton::Left))
1✔
683
        ));
684
        assert!(!host.controller().is_dragging());
1✔
685
        assert!(!host.controller().button_down());
1✔
686
    }
1✔
687

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

703
        let finished = handle_selection_mouse(&mut host, true, &mouse(6, 2, MouseEventKind::Moved));
1✔
704
        assert!(finished);
1✔
705
        assert!(!host.controller().is_dragging());
1✔
706
        assert!(!host.controller().button_down());
1✔
707
    }
1✔
708

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

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

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

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