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

jzombie / term-wm / 20874362201

10 Jan 2026 06:38AM UTC coverage: 57.201% (+10.1%) from 47.071%
20874362201

Pull #20

github

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

2017 of 3005 new or added lines in 26 files covered. (67.12%)

75 existing lines in 14 files now uncovered.

6748 of 11797 relevant lines covered (57.2%)

9.67 hits per line

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

87.64
/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 {
8✔
52
        self.start != self.end
8✔
53
    }
8✔
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 {
45✔
145
        Self {
45✔
146
            anchor: None,
45✔
147
            cursor: None,
45✔
148
            phase: Phase::Idle,
45✔
149
            pointer: None,
45✔
150
            last_pointer_event: None,
45✔
151
            button_down: false,
45✔
152
        }
45✔
153
    }
45✔
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 {
36✔
165
        Self::default()
36✔
166
    }
36✔
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> {
16✔
219
        match (self.state.anchor, self.state.cursor) {
16✔
220
            (Some(start), Some(end)) => Some(SelectionRange { start, end }),
8✔
221
            _ => None,
8✔
222
        }
223
    }
16✔
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 controller = host.selection_controller();
1✔
323
            if !controller.is_dragging() || !controller.button_down() {
1✔
NEW
324
                return false;
×
325
            }
1✔
326
            controller.set_button_down(false);
1✔
327
            let _ = controller.finish_drag();
1✔
328
            true
1✔
329
        }
NEW
330
        _ => false,
×
331
    }
332
}
9✔
333

334
fn auto_scroll_selection<V: SelectionViewport>(viewport: &mut V, column: u16, row: u16) -> bool {
3✔
335
    let area = viewport.selection_viewport();
3✔
336
    if area.width == 0 || area.height == 0 {
3✔
NEW
337
        return false;
×
338
    }
3✔
339

340
    let (offset_x, offset_y) = viewport.selection_viewport_offsets();
3✔
341
    let (content_w, content_h) = viewport.selection_content_size();
3✔
342
    let view_w = area.width as usize;
3✔
343
    let view_h = area.height as usize;
3✔
344
    let max_off_x = content_w.saturating_sub(view_w);
3✔
345
    let max_off_y = content_h.saturating_sub(view_h);
3✔
346
    let mut scrolled = false;
3✔
347

348
    let top = area.y;
3✔
349
    let bottom_edge = area.y.saturating_add(area.height).saturating_sub(1);
3✔
350
    let mut scroll_up_dist = 0;
3✔
351
    if row < top {
3✔
NEW
352
        scroll_up_dist = top.saturating_sub(row);
×
353
    } else if row <= top.saturating_add(EDGE_PAD_VERTICAL) {
3✔
354
        scroll_up_dist = top.saturating_add(EDGE_PAD_VERTICAL).saturating_sub(row);
2✔
355
    }
2✔
356
    if scroll_up_dist > 0 && offset_y > 0 {
3✔
NEW
357
        let delta = edge_scroll_step(scroll_up_dist, 2, 12);
×
NEW
358
        if delta != 0 {
×
NEW
359
            viewport.scroll_selection_vertical(-delta);
×
NEW
360
            scrolled = true;
×
NEW
361
        }
×
362
    }
3✔
363

364
    let mut scroll_down_dist = 0;
3✔
365
    if row > bottom_edge {
3✔
NEW
366
        scroll_down_dist = row.saturating_sub(bottom_edge);
×
367
    } else if row.saturating_add(EDGE_PAD_VERTICAL) >= bottom_edge
3✔
368
        && row >= bottom_edge.saturating_sub(EDGE_PAD_VERTICAL)
1✔
369
    {
1✔
370
        scroll_down_dist = row.saturating_sub(bottom_edge.saturating_sub(EDGE_PAD_VERTICAL));
1✔
371
    }
2✔
372
    if scroll_down_dist > 0 && offset_y < max_off_y {
3✔
NEW
373
        let delta = edge_scroll_step(scroll_down_dist, 2, 12);
×
NEW
374
        if delta != 0 {
×
NEW
375
            viewport.scroll_selection_vertical(delta);
×
NEW
376
            scrolled = true;
×
NEW
377
        }
×
378
    }
3✔
379

380
    let left = area.x;
3✔
381
    let right_edge = area.x.saturating_add(area.width).saturating_sub(1);
3✔
382

383
    let mut scroll_left_dist = 0;
3✔
384
    if column < left {
3✔
NEW
385
        scroll_left_dist = left.saturating_sub(column);
×
386
    } else if column <= left.saturating_add(EDGE_PAD_HORIZONTAL) {
3✔
387
        scroll_left_dist = left
1✔
388
            .saturating_add(EDGE_PAD_HORIZONTAL)
1✔
389
            .saturating_sub(column);
1✔
390
    }
2✔
391
    if scroll_left_dist > 0 && offset_x > 0 {
3✔
392
        let delta = edge_scroll_step(scroll_left_dist, 1, 80);
1✔
393
        if delta != 0 {
1✔
394
            viewport.scroll_selection_horizontal(-delta);
1✔
395
            scrolled = true;
1✔
396
        }
1✔
397
    }
2✔
398

399
    let mut scroll_right_dist = 0;
3✔
400
    if column > right_edge {
3✔
401
        scroll_right_dist = column.saturating_sub(right_edge);
1✔
402
    } else if column.saturating_add(EDGE_PAD_HORIZONTAL) >= right_edge
2✔
NEW
403
        && column >= right_edge.saturating_sub(EDGE_PAD_HORIZONTAL)
×
NEW
404
    {
×
NEW
405
        scroll_right_dist = column.saturating_sub(right_edge.saturating_sub(EDGE_PAD_HORIZONTAL));
×
406
    }
2✔
407
    if scroll_right_dist > 0 && offset_x < max_off_x {
3✔
408
        let delta = edge_scroll_step(scroll_right_dist, 1, 80);
1✔
409
        if delta != 0 {
1✔
410
            viewport.scroll_selection_horizontal(delta);
1✔
411
            scrolled = true;
1✔
412
        }
1✔
413
    }
2✔
414

415
    scrolled
3✔
416
}
3✔
417

418
const DRAG_IDLE_TIMEOUT_BASE: Duration = Duration::from_millis(220);
419
const DRAG_IDLE_TIMEOUT_VERTICAL: Duration = Duration::from_millis(600);
420
const DRAG_IDLE_TIMEOUT_HORIZONTAL: Duration = Duration::from_millis(900);
421

422
/// Continue scrolling/selection updates using the last drag pointer, even when
423
/// no new mouse events arrive (e.g., cursor held outside the viewport).
424
pub fn maintain_selection_drag<H: SelectionHost>(host: &mut H) -> bool {
23✔
425
    let pointer = {
2✔
426
        let selection = host.selection_controller();
23✔
427
        if !selection.is_dragging() {
23✔
428
            return false;
21✔
429
        }
2✔
430
        selection.pointer()
2✔
431
    };
432

433
    let Some((column, row)) = pointer else {
2✔
NEW
434
        let _ = host.selection_controller().finish_drag();
×
NEW
435
        return false;
×
436
    };
437

438
    let timeout = drag_idle_timeout(host.selection_viewport(), column, row);
2✔
439
    let stale = {
2✔
440
        let selection = host.selection_controller();
2✔
441
        if !selection.button_down() {
2✔
442
            true
1✔
443
        } else {
444
            selection.pointer_stale(Instant::now(), timeout)
1✔
445
        }
446
    };
447

448
    if stale {
2✔
449
        let controller = host.selection_controller();
1✔
450
        controller.set_button_down(false);
1✔
451
        let _ = controller.finish_drag();
1✔
452
        return false;
1✔
453
    }
1✔
454

455
    maintain_selection_drag_active(host)
1✔
456
}
23✔
457

458
fn maintain_selection_drag_active<H: SelectionHost>(host: &mut H) -> bool {
1✔
459
    if !host.selection_controller().is_dragging() {
1✔
NEW
460
        return false;
×
461
    }
1✔
462

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

469
    let mut changed = auto_scroll_selection(host, column, row);
1✔
470
    if let Some(pos) = host.logical_position_from_point(column, row) {
1✔
471
        host.selection_controller().update_drag(pos);
1✔
472
        changed = true;
1✔
473
    }
1✔
474
    changed
1✔
475
}
1✔
476

477
fn drag_idle_timeout(area: Rect, column: u16, row: u16) -> Duration {
2✔
478
    if area.width == 0 || area.height == 0 {
2✔
NEW
479
        return DRAG_IDLE_TIMEOUT_BASE;
×
480
    }
2✔
481
    let horiz_outside = column < area.x || column >= area.x.saturating_add(area.width);
2✔
482
    let vert_outside = row < area.y || row >= area.y.saturating_add(area.height);
2✔
483

484
    let mut timeout = DRAG_IDLE_TIMEOUT_BASE;
2✔
485
    if vert_outside {
2✔
NEW
486
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_VERTICAL);
×
487
    }
2✔
488
    if horiz_outside {
2✔
489
        timeout = timeout.max(DRAG_IDLE_TIMEOUT_HORIZONTAL);
1✔
490
    }
1✔
491
    timeout
2✔
492
}
2✔
493

494
fn edge_scroll_step(distance: u16, divisor: u16, max_step: u16) -> isize {
7✔
495
    if distance == 0 || max_step == 0 {
7✔
NEW
496
        return 0;
×
497
    }
7✔
498
    let div = divisor.max(1);
7✔
499
    let mut step = 1 + distance.saturating_sub(1) / div;
7✔
500
    if step > max_step {
7✔
501
        step = max_step;
2✔
502
    }
5✔
503
    step as isize
7✔
504
}
7✔
505

506
fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
5✔
507
    if rect.width == 0 || rect.height == 0 {
5✔
NEW
508
        return false;
×
509
    }
5✔
510
    let max_x = rect.x.saturating_add(rect.width);
5✔
511
    let max_y = rect.y.saturating_add(rect.height);
5✔
512
    column >= rect.x && column < max_x && row >= rect.y && row < max_y
5✔
513
}
5✔
514

515
#[cfg(test)]
516
mod tests {
517
    use super::*;
518
    use crossterm::event::KeyModifiers;
519

520
    #[derive(Debug)]
521
    struct TestHost {
522
        controller: SelectionController,
523
        viewport: Rect,
524
        h_scroll: Vec<isize>,
525
        v_scroll: Vec<isize>,
526
    }
527

528
    impl TestHost {
529
        fn new(viewport: Rect) -> Self {
4✔
530
            Self {
4✔
531
                controller: SelectionController::new(),
4✔
532
                viewport,
4✔
533
                h_scroll: Vec::new(),
4✔
534
                v_scroll: Vec::new(),
4✔
535
            }
4✔
536
        }
4✔
537

538
        fn controller(&self) -> &SelectionController {
11✔
539
            &self.controller
11✔
540
        }
11✔
541
    }
542

543
    impl SelectionViewport for TestHost {
544
        fn selection_viewport(&self) -> Rect {
11✔
545
            self.viewport
11✔
546
        }
11✔
547

548
        fn selection_viewport_offsets(&self) -> (usize, usize) {
2✔
549
            // Simulate the viewport starting at column 0, row 0 within a larger
550
            // content area so horizontal scrolling is possible in tests.
551
            (0, 0)
2✔
552
        }
2✔
553

554
        fn selection_content_size(&self) -> (usize, usize) {
2✔
555
            // Make the logical content significantly wider than the viewport
556
            // to allow horizontal auto-scrolling in test scenarios.
557
            (
2✔
558
                self.viewport.width as usize + 50,
2✔
559
                self.viewport.height as usize,
2✔
560
            )
2✔
561
        }
2✔
562

563
        fn logical_position_from_point(
6✔
564
            &mut self,
6✔
565
            column: u16,
6✔
566
            row: u16,
6✔
567
        ) -> Option<LogicalPosition> {
6✔
568
            let col = column.saturating_sub(self.viewport.x) as usize;
6✔
569
            let row = row.saturating_sub(self.viewport.y) as usize;
6✔
570
            Some(LogicalPosition::new(row, col))
6✔
571
        }
6✔
572

NEW
573
        fn scroll_selection_vertical(&mut self, delta: isize) {
×
NEW
574
            self.v_scroll.push(delta);
×
NEW
575
        }
×
576

577
        fn scroll_selection_horizontal(&mut self, delta: isize) {
1✔
578
            self.h_scroll.push(delta);
1✔
579
        }
1✔
580
    }
581

582
    impl SelectionHost for TestHost {
583
        fn selection_controller(&mut self) -> &mut SelectionController {
21✔
584
            &mut self.controller
21✔
585
        }
21✔
586
    }
587

588
    fn mouse(column: u16, row: u16, kind: MouseEventKind) -> MouseEvent {
7✔
589
        MouseEvent {
7✔
590
            column,
7✔
591
            row,
7✔
592
            kind,
7✔
593
            modifiers: KeyModifiers::NONE,
7✔
594
        }
7✔
595
    }
7✔
596

597
    #[test]
598
    fn normalized_swaps_when_needed() {
1✔
599
        let range = SelectionRange {
1✔
600
            start: LogicalPosition::new(2, 5),
1✔
601
            end: LogicalPosition::new(1, 3),
1✔
602
        };
1✔
603
        let normalized = range.normalized();
1✔
604
        assert_eq!(normalized.start.row, 1);
1✔
605
        assert_eq!(normalized.start.column, 3);
1✔
606
        assert_eq!(normalized.end.row, 2);
1✔
607
        assert_eq!(normalized.end.column, 5);
1✔
608
    }
1✔
609

610
    #[test]
611
    fn controller_tracks_drag_state() {
1✔
612
        let mut controller = SelectionController::new();
1✔
613
        controller.begin_drag(LogicalPosition::new(0, 0));
1✔
614
        controller.update_drag(LogicalPosition::new(0, 5));
1✔
615
        let range = controller.finish_drag().expect("selection should exist");
1✔
616
        assert_eq!(range.normalized().end.column, 5);
1✔
617
        assert!(controller.has_selection());
1✔
618
    }
1✔
619

620
    #[test]
621
    fn controller_clears_empty_selection() {
1✔
622
        let mut controller = SelectionController::new();
1✔
623
        controller.begin_drag(LogicalPosition::new(0, 0));
1✔
624
        controller.update_drag(LogicalPosition::new(0, 0));
1✔
625
        assert!(controller.finish_drag().is_none());
1✔
626
        assert!(!controller.has_selection());
1✔
627
    }
1✔
628

629
    #[test]
630
    fn edge_scroll_step_scales_and_clamps() {
1✔
631
        assert_eq!(edge_scroll_step(1, 2, 12), 1);
1✔
632
        assert!(edge_scroll_step(6, 2, 12) >= 3);
1✔
633
        assert_eq!(edge_scroll_step(50, 2, 12), 12);
1✔
634
        assert_eq!(edge_scroll_step(10, 1, 48), 10);
1✔
635
        assert_eq!(edge_scroll_step(100, 1, 48), 48);
1✔
636
    }
1✔
637

638
    #[test]
639
    fn mouse_up_clears_button_state() {
1✔
640
        let mut host = TestHost::new(Rect::new(0, 0, 10, 5));
1✔
641
        assert!(handle_selection_mouse(
1✔
642
            &mut host,
1✔
643
            true,
644
            &mouse(1, 1, MouseEventKind::Down(MouseButton::Left))
1✔
645
        ));
646
        assert!(host.controller().is_dragging());
1✔
647
        assert!(host.controller().button_down());
1✔
648

649
        assert!(handle_selection_mouse(
1✔
650
            &mut host,
1✔
651
            true,
652
            &mouse(1, 1, MouseEventKind::Up(MouseButton::Left))
1✔
653
        ));
654
        assert!(!host.controller().is_dragging());
1✔
655
        assert!(!host.controller().button_down());
1✔
656
    }
1✔
657

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

673
        let finished = handle_selection_mouse(&mut host, true, &mouse(6, 2, MouseEventKind::Moved));
1✔
674
        assert!(finished);
1✔
675
        assert!(!host.controller().is_dragging());
1✔
676
        assert!(!host.controller().button_down());
1✔
677
    }
1✔
678

679
    #[test]
680
    fn maintain_stops_when_button_released() {
1✔
681
        let mut host = TestHost::new(Rect::new(0, 0, 10, 5));
1✔
682
        assert!(handle_selection_mouse(
1✔
683
            &mut host,
1✔
684
            true,
685
            &mouse(1, 1, MouseEventKind::Down(MouseButton::Left))
1✔
686
        ));
687
        host.selection_controller().set_pointer(0, 0);
1✔
688
        host.selection_controller().set_button_down(false);
1✔
689

690
        let changed = maintain_selection_drag(&mut host);
1✔
691
        assert!(!changed);
1✔
692
        assert!(!host.controller().is_dragging());
1✔
693
        assert!(!host.controller().button_down());
1✔
694
    }
1✔
695

696
    #[test]
697
    fn maintain_scrolls_when_button_down() {
1✔
698
        let mut host = TestHost::new(Rect::new(5, 5, 10, 5));
1✔
699
        assert!(handle_selection_mouse(
1✔
700
            &mut host,
1✔
701
            true,
702
            &mouse(6, 6, MouseEventKind::Down(MouseButton::Left))
1✔
703
        ));
704
        // Simulate pointer beyond the right edge to trigger horizontal scrolling.
705
        host.selection_controller().set_pointer(20, 6);
1✔
706
        host.selection_controller().set_button_down(true);
1✔
707

708
        let changed = maintain_selection_drag(&mut host);
1✔
709
        assert!(changed);
1✔
710
        assert!(host.controller().is_dragging());
1✔
711
        assert!(host.controller().button_down());
1✔
712
        assert!(!host.h_scroll.is_empty());
1✔
713
        assert_eq!(host.h_scroll[0], 6);
1✔
714
    }
1✔
715
}
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