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

jzombie / term-wm / 20885819972

10 Jan 2026 11:10PM UTC coverage: 57.056% (+10.0%) from 47.071%
20885819972

Pull #20

github

web-flow
Merge 8345857e8 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/utils/selectable_text.rs
1
//! Shared selection and clipboard plumbing for text-oriented components.
2
//!
3
//! This module wires together the concepts needed by both the terminal and
4
//! text-renderer components so they can share selection math, clipboard
5
//! extraction, and drag tracking. It intentionally keeps the public surface
6
//! small for now; future commits can extend it with clipboard drivers and
7
//! richer rendering hooks.
8

9
use std::time::{Duration, Instant};
10

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

440
    scrolled
4✔
441
}
4✔
442

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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