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

jzombie / term-wm / 20885255367

10 Jan 2026 10:20PM UTC coverage: 57.056% (+10.0%) from 47.071%
20885255367

Pull #20

github

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

2045 of 3183 new or added lines in 26 files covered. (64.25%)

76 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

85.6
/src/components/text_renderer.rs
1
use std::collections::HashMap;
2
use std::fmt;
3

4
use crossterm::event::MouseEvent;
5
use ratatui::buffer::Buffer;
6
use ratatui::layout::Rect;
7
use ratatui::style::{Color, Style};
8
use ratatui::text::{Line, Span, Text};
9
use ratatui::widgets::{Paragraph, Widget, Wrap};
10

11
use crate::component_context::{ViewportContext, ViewportHandle};
12
use crate::components::{
13
    Component, ComponentContext,
14
    selectable_text::{
15
        LogicalPosition, SelectionController, SelectionHost, SelectionViewport,
16
        handle_selection_mouse, maintain_selection_drag,
17
    },
18
};
19
use crate::linkifier::LinkifiedText;
20
use crate::ui::UiFrame;
21

22
pub struct TextRendererComponent {
23
    text: Text<'static>,
24
    wrap: bool,
25
    link_map: Vec<Vec<Option<String>>>,
26
    selection: SelectionController,
27
    selection_enabled: bool,
28
    last_area: Rect,
29
    viewport_handle: Option<ViewportHandle>,
30
    viewport_cache: ViewportContext,
31
    content_area: Rect,
32
    content_width: usize,
33
    content_height: usize,
34
}
35

36
impl fmt::Debug for TextRendererComponent {
NEW
37
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
×
NEW
38
        f.debug_struct("TextRendererComponent").finish()
×
NEW
39
    }
×
40
}
41

42
impl Component for TextRendererComponent {
NEW
43
    fn resize(&mut self, area: Rect, _ctx: &ComponentContext) {
×
NEW
44
        self.last_area = area;
×
UNCOV
45
    }
×
46

47
    fn render(&mut self, frame: &mut UiFrame<'_>, area: Rect, ctx: &ComponentContext) {
21✔
48
        self.apply_focus_state(ctx.focused());
21✔
49
        if area.width == 0 || area.height == 0 {
21✔
NEW
50
            self.last_area = Rect::default();
×
NEW
51
            self.content_area = Rect::default();
×
UNCOV
52
            return;
×
53
        }
21✔
54

55
        self.last_area = area;
21✔
56
        self.content_area = area;
21✔
57
        self.viewport_cache = ctx.viewport();
21✔
58
        self.viewport_handle = ctx.viewport_handle();
21✔
59

60
        let viewport_cache = self.viewport_cache;
21✔
61
        let viewport_handle = &self.viewport_handle;
21✔
62

63
        // Calculate Metrics
64
        let usable_width = area.width.max(1) as usize;
21✔
65
        let content_height = if self.wrap {
21✔
66
            compute_display_lines(&self.text, usable_width as u16)
17✔
67
        } else {
68
            self.text.lines.len().max(1)
4✔
69
        };
70

71
        let content_width = if self.wrap {
21✔
72
            usable_width
17✔
73
        } else {
74
            self.text
4✔
75
                .lines
4✔
76
                .iter()
4✔
77
                .map(|line| line.width())
22✔
78
                .max()
4✔
79
                .unwrap_or(0)
4✔
80
        };
81

82
        self.content_height = content_height;
21✔
83
        self.content_width = content_width;
21✔
84

85
        if let Some(handle) = &viewport_handle {
21✔
86
            handle.set_content_size(content_width, content_height);
21✔
87
        }
21✔
88

89
        maintain_selection_drag(self);
21✔
90

91
        let v_off = viewport_cache.offset_y as u16;
21✔
92
        let h_off = viewport_cache.offset_x as u16;
21✔
93

94
        use crate::ui::safe_set_string;
95

96
        const RULE_PLACEHOLDER: &str = "\0RULE\0";
97
        let usable = usable_width;
21✔
98

99
        // Optimization: Pre-calculate just enough to skip invisible lines?
100
        // Or reuse existing logic.
101

102
        let mut visual_heights: Vec<usize> = Vec::with_capacity(self.text.lines.len());
21✔
103
        for line in &self.text.lines {
385✔
104
            let w = line.width();
364✔
105
            let vh = if w == 0 {
364✔
106
                1
115✔
107
            } else if self.wrap {
249✔
108
                (w + usable - 1).div_euclid(usable)
227✔
109
            } else {
110
                1
22✔
111
            };
112
            visual_heights.push(vh);
364✔
113
        }
114

115
        let mut cum_visual = 0usize;
21✔
116
        let mut y_cursor = area.y;
21✔
117
        let mut remaining = area.height as usize;
21✔
118

119
        for (idx, line) in self.text.lines.iter().enumerate() {
169✔
120
            let line_vh = visual_heights.get(idx).copied().unwrap_or(1);
169✔
121
            if cum_visual + line_vh <= v_off as usize {
169✔
122
                cum_visual += line_vh;
4✔
123
                continue;
4✔
124
            }
165✔
125

126
            let start_in_line = (v_off as usize).saturating_sub(cum_visual);
165✔
127
            let rows_available = line_vh.saturating_sub(start_in_line);
165✔
128
            if rows_available == 0 {
165✔
129
                cum_visual += line_vh;
×
130
                continue;
×
131
            }
165✔
132

133
            if remaining == 0 {
165✔
134
                break;
17✔
135
            }
148✔
136

137
            let rows_to_render = rows_available.min(remaining);
148✔
138
            let is_rule = line.spans.iter().any(|s| s.content == RULE_PLACEHOLDER);
249✔
139

140
            if is_rule {
148✔
141
                if start_in_line == 0 && rows_to_render > 0 {
1✔
142
                    let sep = "─".repeat(area.width as usize);
1✔
143
                    safe_set_string(
1✔
144
                        frame.buffer_mut(),
1✔
145
                        area,
1✔
146
                        area.x,
1✔
147
                        y_cursor,
1✔
148
                        &sep,
1✔
149
                        Style::default(),
1✔
150
                    );
1✔
151
                    y_cursor = y_cursor.saturating_add(1);
1✔
152
                    remaining = remaining.saturating_sub(1);
1✔
153
                }
1✔
154
                cum_visual += line_vh;
1✔
155
                continue;
1✔
156
            }
147✔
157

158
            let single_text = Text::from(vec![line.clone()]);
147✔
159
            let mut paragraph = Paragraph::new(single_text);
147✔
160
            if self.wrap {
147✔
161
                paragraph = paragraph.wrap(Wrap { trim: false });
135✔
162
            }
135✔
163
            paragraph = paragraph.scroll((start_in_line as u16, h_off));
147✔
164
            frame.render_widget(
147✔
165
                paragraph,
147✔
166
                Rect {
147✔
167
                    x: area.x,
147✔
168
                    y: y_cursor,
147✔
169
                    width: area.width,
147✔
170
                    height: rows_to_render as u16,
147✔
171
                },
147✔
172
            );
173

174
            y_cursor = y_cursor.saturating_add(rows_to_render as u16);
147✔
175
            remaining = remaining.saturating_sub(rows_to_render);
147✔
176
            cum_visual += line_vh;
147✔
177
        }
178
        self.render_selection_overlay(frame);
21✔
179
    }
21✔
180

181
    fn handle_event(&mut self, event: &crossterm::event::Event, ctx: &ComponentContext) -> bool {
4✔
182
        self.viewport_cache = ctx.viewport();
4✔
183
        if let Some(handle) = ctx.viewport_handle() {
4✔
184
            self.viewport_handle = Some(handle);
3✔
185
        }
3✔
186

187
        match event {
4✔
188
            crossterm::event::Event::Mouse(mouse) => {
2✔
189
                handle_selection_mouse(self, self.selection_enabled, mouse)
2✔
190
            }
191
            crossterm::event::Event::Key(_) => {
192
                self.selection.clear();
2✔
193
                false
2✔
194
            }
UNCOV
195
            _ => false,
×
196
        }
197
    }
4✔
198
}
199

200
// removed usage of ScrollAreaContent
201

202
impl TextRendererComponent {
203
    pub fn new() -> Self {
31✔
204
        Self {
31✔
205
            text: Text::from(vec![Line::from(String::new())]),
31✔
206
            wrap: true,
31✔
207
            link_map: Vec::new(),
31✔
208
            selection: SelectionController::new(),
31✔
209
            selection_enabled: false,
31✔
210
            last_area: Rect::default(),
31✔
211
            viewport_handle: None,
31✔
212
            viewport_cache: ViewportContext::default(),
31✔
213
            content_area: Rect::default(),
31✔
214
            content_width: 0,
31✔
215
            content_height: 0,
31✔
216
        }
31✔
217
    }
31✔
218

219
    pub fn set_text(&mut self, text: Text<'static>) {
3✔
220
        self.text = text;
3✔
221
        self.link_map.clear();
3✔
222
    }
3✔
223

224
    pub fn set_linkified_text(&mut self, linkified: LinkifiedText) {
11✔
225
        self.text = linkified.text;
11✔
226
        self.link_map = linkified.link_map;
11✔
227
    }
11✔
228

229
    pub fn set_wrap(&mut self, wrap: bool) {
29✔
230
        self.wrap = wrap;
29✔
231
    }
29✔
232

233
    pub fn set_selection_enabled(&mut self, enabled: bool) {
16✔
234
        if self.selection_enabled == enabled {
16✔
235
            return;
13✔
236
        }
3✔
237
        self.selection_enabled = enabled;
3✔
238
        if !enabled {
3✔
NEW
239
            self.selection.clear();
×
240
        }
3✔
241
    }
16✔
242

243
    pub fn jump_to_logical_line(&mut self, line_idx: usize, area: Rect) {
1✔
244
        if self.text.lines.is_empty() || area.width == 0 {
1✔
NEW
245
            if let Some(handle) = &self.viewport_handle {
×
NEW
246
                handle.scroll_vertical_to(0);
×
UNCOV
247
            }
×
NEW
248
            return;
×
249
        }
1✔
250

251
        let usable = area.width.max(1) as usize;
1✔
252
        let mut offset = 0;
1✔
253
        for (i, line) in self.text.lines.iter().enumerate() {
7✔
254
            if i >= line_idx {
7✔
255
                break;
1✔
256
            }
6✔
257
            if self.wrap {
6✔
258
                let w = line.width();
6✔
259
                if w == 0 {
6✔
260
                    offset += 1;
3✔
261
                } else {
3✔
262
                    offset += (w + usable - 1).div_euclid(usable);
3✔
263
                }
3✔
264
            } else {
×
265
                offset += 1;
×
266
            }
×
267
        }
268
        if let Some(handle) = &self.viewport_handle {
1✔
269
            handle.scroll_vertical_to(offset);
1✔
270
        }
1✔
271
    }
1✔
272

273
    pub fn scroll_vertical_to(&mut self, offset: usize) {
1✔
274
        if let Some(handle) = &self.viewport_handle {
1✔
275
            handle.scroll_vertical_to(offset);
1✔
276
        }
1✔
277
    }
1✔
278

UNCOV
279
    pub fn text_ref(&self) -> &Text<'static> {
×
UNCOV
280
        &self.text
×
UNCOV
281
    }
×
282

283
    pub fn rendered_lines(&self) -> Vec<String> {
2✔
284
        self.text
2✔
285
            .lines
2✔
286
            .iter()
2✔
287
            .map(|line| {
18✔
288
                line.spans
18✔
289
                    .iter()
18✔
290
                    .map(|span| span.content.to_string())
24✔
291
                    .collect::<String>()
18✔
292
            })
18✔
293
            .collect()
2✔
294
    }
2✔
295

296
    // Internal helper methods
297

298
    fn apply_focus_state(&mut self, focused: bool) {
22✔
299
        if !focused {
22✔
300
            self.selection.clear();
4✔
301
        }
18✔
302
    }
22✔
303

304
    fn logical_position_from_point_impl(&self, column: u16, row: u16) -> Option<LogicalPosition> {
2✔
305
        if self.content_area.width == 0 || self.content_area.height == 0 {
2✔
NEW
306
            return None;
×
307
        }
2✔
308
        let max_x = self
2✔
309
            .content_area
2✔
310
            .x
2✔
311
            .saturating_add(self.content_area.width)
2✔
312
            .saturating_sub(1);
2✔
313
        let max_y = self
2✔
314
            .content_area
2✔
315
            .y
2✔
316
            .saturating_add(self.content_area.height)
2✔
317
            .saturating_sub(1);
2✔
318
        let clamped_col = column.clamp(self.content_area.x, max_x);
2✔
319
        let clamped_row = row.clamp(self.content_area.y, max_y);
2✔
320
        let local_col = clamped_col.saturating_sub(self.content_area.x) as usize;
2✔
321
        let local_row = clamped_row.saturating_sub(self.content_area.y) as usize;
2✔
322
        let row_base = self.viewport_cache.offset_y;
2✔
323
        let col_base = self.viewport_cache.offset_x;
2✔
324
        Some(LogicalPosition::new(
2✔
325
            row_base.saturating_add(local_row),
2✔
326
            col_base.saturating_add(local_col),
2✔
327
        ))
2✔
328
    }
2✔
329

330
    fn render_selection_overlay(&mut self, frame: &mut UiFrame<'_>) {
21✔
331
        if !self.selection_enabled {
21✔
332
            return;
16✔
333
        }
5✔
334
        let Some(range) = self
5✔
335
            .selection
5✔
336
            .selection_range()
5✔
337
            .filter(|r| r.is_non_empty())
5✔
338
            .map(|r| r.normalized())
5✔
339
        else {
340
            return;
5✔
341
        };
NEW
342
        let mut bounds = self.content_area;
×
NEW
343
        let buffer = frame.buffer_mut();
×
NEW
344
        bounds = bounds.intersection(buffer.area);
×
NEW
345
        if bounds.width == 0 || bounds.height == 0 {
×
NEW
346
            return;
×
NEW
347
        }
×
NEW
348
        let row_base = self.viewport_cache.offset_y;
×
NEW
349
        let col_base = self.viewport_cache.offset_x;
×
NEW
350
        for y in bounds.y..bounds.y.saturating_add(bounds.height) {
×
NEW
351
            let local_row = y.saturating_sub(self.content_area.y) as usize;
×
NEW
352
            for x in bounds.x..bounds.x.saturating_add(bounds.width) {
×
NEW
353
                let local_col = x.saturating_sub(self.content_area.x) as usize;
×
NEW
354
                let pos = LogicalPosition::new(
×
NEW
355
                    row_base.saturating_add(local_row),
×
NEW
356
                    col_base.saturating_add(local_col),
×
357
                );
NEW
358
                if range.contains(pos)
×
NEW
359
                    && let Some(cell) = buffer.cell_mut((x, y))
×
NEW
360
                {
×
NEW
361
                    let style = cell
×
NEW
362
                        .style()
×
NEW
363
                        .bg(crate::theme::selection_bg())
×
NEW
364
                        .fg(crate::theme::selection_fg());
×
NEW
365
                    cell.set_style(style);
×
NEW
366
                }
×
367
            }
368
        }
369
    }
21✔
370

371
    fn build_hit_test_palette(&self) -> Option<HitTestPalette> {
1✔
372
        let mut url_ids: HashMap<String, u32> = HashMap::new();
1✔
373
        let mut urls: Vec<String> = Vec::new();
1✔
374
        let mut has_links = false;
1✔
375
        let mut lines: Vec<Line<'static>> = Vec::with_capacity(self.text.lines.len());
1✔
376

377
        for (line_idx, line) in self.text.lines.iter().enumerate() {
10✔
378
            let mut spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
10✔
379
            let line_links = self.link_map.get(line_idx);
10✔
380
            for (span_idx, span) in line.spans.iter().enumerate() {
10✔
381
                let mut new_span = span.clone();
9✔
382
                if let Some(link) = line_links
9✔
383
                    .and_then(|entries| entries.get(span_idx))
9✔
384
                    .and_then(|opt| opt.clone())
9✔
385
                {
386
                    has_links = true;
1✔
387
                    let id = *url_ids.entry(link.clone()).or_insert_with(|| {
1✔
388
                        urls.push(link.clone());
1✔
389
                        urls.len() as u32
1✔
390
                    });
1✔
391
                    new_span.style = new_span.style.fg(encode_link_color(id));
1✔
392
                }
8✔
393
                spans.push(new_span);
9✔
394
            }
395
            lines.push(Line::from(spans));
10✔
396
        }
397

398
        if !has_links {
1✔
399
            return None;
×
400
        }
1✔
401

402
        Some(HitTestPalette {
1✔
403
            text: Text::from(lines),
1✔
404
            urls,
1✔
405
        })
1✔
406
    }
1✔
407

408
    pub fn link_at(&self, area: Rect, mouse: MouseEvent) -> Option<String> {
1✔
409
        if self.link_map.is_empty() {
1✔
410
            return None;
×
411
        }
1✔
412
        if area.width == 0 || area.height == 0 {
1✔
413
            return None;
×
414
        }
1✔
415
        if mouse.column < area.x
1✔
416
            || mouse.column >= area.x.saturating_add(area.width)
1✔
417
            || mouse.row < area.y
1✔
418
            || mouse.row >= area.y.saturating_add(area.height)
1✔
419
        {
420
            return None;
×
421
        }
1✔
422

423
        let content_width = area.width;
1✔
424
        if content_width == 0 {
1✔
425
            return None;
×
426
        }
1✔
427

428
        let local_x = mouse.column.saturating_sub(area.x);
1✔
429
        let local_y = mouse.row.saturating_sub(area.y);
1✔
430
        if local_x >= content_width || local_y >= area.height {
1✔
431
            return None;
×
432
        }
1✔
433

434
        let hit_palette = self.build_hit_test_palette()?;
1✔
435
        let HitTestPalette { mut text, urls } = hit_palette;
1✔
436
        {
437
            use std::borrow::Cow;
438
            const RULE_PLACEHOLDER: &str = "\0RULE\0";
439
            let repeat_len = content_width as usize;
1✔
440
            if repeat_len > 0 {
1✔
441
                let mut sep = String::with_capacity(repeat_len * 3);
1✔
442
                for i in 0..repeat_len {
39✔
443
                    sep.push('─');
39✔
444
                    if i + 1 < repeat_len {
39✔
445
                        sep.push('\u{2060}');
38✔
446
                    }
38✔
447
                }
448
                for line in text.lines.iter_mut() {
10✔
449
                    for span in line.spans.iter_mut() {
10✔
450
                        if span.content == RULE_PLACEHOLDER {
9✔
451
                            span.content = Cow::Owned(sep.clone());
×
452
                        }
9✔
453
                    }
454
                }
455
            }
×
456
        }
457
        if urls.is_empty() {
1✔
458
            return None;
×
459
        }
1✔
460

461
        let mut paragraph = Paragraph::new(text);
1✔
462
        if self.wrap {
1✔
463
            paragraph = paragraph.wrap(Wrap { trim: false });
1✔
464
        }
1✔
465
        let viewport = self.viewport_cache;
1✔
466
        paragraph = paragraph.scroll((viewport.offset_y as u16, viewport.offset_x as u16));
1✔
467

468
        let mut buffer = Buffer::empty(Rect {
1✔
469
            x: 0,
1✔
470
            y: 0,
1✔
471
            width: content_width,
1✔
472
            height: area.height,
1✔
473
        });
1✔
474
        paragraph.render(
1✔
475
            Rect {
1✔
476
                x: 0,
1✔
477
                y: 0,
1✔
478
                width: content_width,
1✔
479
                height: area.height,
1✔
480
            },
1✔
481
            &mut buffer,
1✔
482
        );
483

484
        if let Some(cell) = buffer.cell((local_x, local_y))
1✔
485
            && let Some(id) = decode_link_color(cell.fg)
1✔
486
            && let Some(idx) = id.checked_sub(1)
1✔
487
            && let Some(url) = urls.get(idx as usize)
1✔
488
        {
489
            return Some(url.clone());
1✔
490
        }
×
491

492
        None
×
493
    }
1✔
494

495
    pub fn reset(&mut self) {
3✔
496
        if let Some(handle) = &self.viewport_handle {
3✔
NEW
497
            handle.scroll_vertical_to(0);
×
NEW
498
            handle.scroll_horizontal_to(0);
×
499
        }
3✔
500
    }
3✔
501
}
502

503
impl SelectionViewport for TextRendererComponent {
504
    fn selection_viewport(&self) -> Rect {
3✔
505
        self.content_area
3✔
506
    }
3✔
507

508
    fn logical_position_from_point(&mut self, column: u16, row: u16) -> Option<LogicalPosition> {
2✔
509
        self.logical_position_from_point_impl(column, row)
2✔
510
    }
2✔
511

NEW
512
    fn scroll_selection_vertical(&mut self, delta: isize) {
×
NEW
513
        if delta == 0 {
×
NEW
514
            return;
×
NEW
515
        }
×
NEW
516
        if let Some(handle) = &self.viewport_handle {
×
NEW
517
            handle.scroll_vertical_by(delta);
×
NEW
518
        }
×
NEW
519
    }
×
520

521
    fn scroll_selection_horizontal(&mut self, delta: isize) {
1✔
522
        if delta == 0 {
1✔
NEW
523
            return;
×
524
        }
1✔
525
        if let Some(handle) = &self.viewport_handle {
1✔
526
            handle.scroll_horizontal_by(delta);
1✔
527
        }
1✔
528
    }
1✔
529

530
    fn selection_viewport_offsets(&self) -> (usize, usize) {
1✔
531
        (self.viewport_cache.offset_x, self.viewport_cache.offset_y)
1✔
532
    }
1✔
533

534
    fn selection_content_size(&self) -> (usize, usize) {
1✔
535
        (self.content_width, self.content_height)
1✔
536
    }
1✔
537
}
538

539
impl SelectionHost for TextRendererComponent {
540
    fn selection_controller(&mut self) -> &mut SelectionController {
33✔
541
        &mut self.selection
33✔
542
    }
33✔
543
}
544

545
fn compute_display_lines(text: &Text<'_>, width: u16) -> usize {
17✔
546
    let usable = width.max(1) as usize;
17✔
547
    text.lines
17✔
548
        .iter()
17✔
549
        .map(|line| {
342✔
550
            let w = line.width();
342✔
551
            if w == 0 {
342✔
552
                1
115✔
553
            } else {
554
                (w + usable - 1).div_euclid(usable)
227✔
555
            }
556
        })
342✔
557
        .sum::<usize>()
17✔
558
        .max(1)
17✔
559
}
17✔
560

561
impl Default for TextRendererComponent {
562
    fn default() -> Self {
×
563
        Self::new()
×
564
    }
×
565
}
566

567
#[cfg(test)]
568
mod tests {
569
    use super::*;
570
    use crate::components::ScrollViewComponent;
571
    use crate::ui::UiFrame;
572
    use crossterm::event::{
573
        Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
574
        MouseEventKind,
575
    };
576
    use ratatui::{buffer::Buffer, layout::Rect, text::Text};
577

578
    fn key_event(code: KeyCode) -> KeyEvent {
1✔
579
        let mut ev = KeyEvent::new(code, KeyModifiers::NONE);
1✔
580
        ev.kind = KeyEventKind::Press;
1✔
581
        ev
1✔
582
    }
1✔
583

584
    #[test]
585
    fn key_press_clears_selection() {
1✔
586
        let mut comp = TextRendererComponent::new();
1✔
587
        comp.set_selection_enabled(true);
1✔
588
        {
589
            comp.selection_controller()
1✔
590
                .begin_drag(LogicalPosition::new(0, 0));
1✔
591
            comp.selection_controller()
1✔
592
                .update_drag(LogicalPosition::new(0, 5));
1✔
593
            assert!(comp.selection_controller().has_selection());
1✔
594
        }
595

596
        let handled = comp.handle_event(
1✔
597
            &Event::Key(key_event(KeyCode::Char('a'))),
1✔
598
            &ComponentContext::new(true),
1✔
599
        );
600
        assert!(!handled);
1✔
601
        assert!(!comp.selection_controller().has_selection());
1✔
602
    }
1✔
603

604
    #[test]
605
    fn selection_drag_auto_scrolls_left_at_edge() {
1✔
606
        use ratatui::text::Line;
607
        let mut renderer = TextRendererComponent::new();
1✔
608
        renderer.set_selection_enabled(true);
1✔
609
        renderer.set_wrap(false);
1✔
610
        let long_line = Line::from("0123456789".repeat(20));
1✔
611
        renderer.set_text(Text::from(vec![long_line]));
1✔
612
        let mut scroll_view = ScrollViewComponent::new(renderer);
1✔
613
        let area = Rect::new(0, 0, 20, 3);
1✔
614
        let mut buffer = Buffer::empty(area);
1✔
615
        {
1✔
616
            let mut frame = UiFrame::from_parts(area, &mut buffer);
1✔
617
            scroll_view.render(&mut frame, area, &ComponentContext::new(true));
1✔
618
        }
1✔
619

620
        scroll_view.viewport_handle().scroll_horizontal_to(25);
1✔
621
        let ctx = ComponentContext::new(true);
1✔
622
        let down = Event::Mouse(MouseEvent {
1✔
623
            column: 10,
1✔
624
            row: 1,
1✔
625
            kind: MouseEventKind::Down(MouseButton::Left),
1✔
626
            modifiers: KeyModifiers::NONE,
1✔
627
        });
1✔
628
        scroll_view.handle_event(&down, &ctx);
1✔
629
        let before = scroll_view.viewport_handle().info().offset_x;
1✔
630
        assert!(before > 0);
1✔
631

632
        let drag = Event::Mouse(MouseEvent {
1✔
633
            column: 0,
1✔
634
            row: 1,
1✔
635
            kind: MouseEventKind::Drag(MouseButton::Left),
1✔
636
            modifiers: KeyModifiers::NONE,
1✔
637
        });
1✔
638
        scroll_view.handle_event(&drag, &ctx);
1✔
639
        let after = scroll_view.viewport_handle().info().offset_x;
1✔
640
        assert!(
1✔
641
            after < before,
1✔
NEW
642
            "expected horizontal auto-scroll towards origin"
×
643
        );
644
    }
1✔
645

646
    #[test]
647
    fn blur_clears_selection() {
1✔
648
        let mut comp = TextRendererComponent::new();
1✔
649
        {
650
            comp.selection_controller()
1✔
651
                .begin_drag(LogicalPosition::new(0, 0));
1✔
652
            comp.selection_controller()
1✔
653
                .update_drag(LogicalPosition::new(0, 2));
1✔
654
            assert!(comp.selection_controller().has_selection());
1✔
655
            comp.apply_focus_state(false);
1✔
656
            assert!(!comp.selection_controller().has_selection());
1✔
657
        }
658
    }
1✔
659

660
    #[test]
661
    fn scrollbar_drag_bypasses_selection() {
1✔
662
        let mut renderer = TextRendererComponent::new();
1✔
663
        renderer.set_selection_enabled(true);
1✔
664
        let lines: Vec<Line<'static>> = (0..20)
1✔
665
            .map(|idx| Line::from(format!("line {idx}")))
20✔
666
            .collect();
1✔
667
        renderer.set_text(Text::from(lines));
1✔
668
        let mut scroll_view = ScrollViewComponent::new(renderer);
1✔
669
        let area = Rect::new(0, 0, 20, 5);
1✔
670
        let mut buffer = ratatui::buffer::Buffer::empty(area);
1✔
671
        {
1✔
672
            let mut frame = crate::ui::UiFrame::from_parts(area, &mut buffer);
1✔
673
            scroll_view.render(&mut frame, area, &ComponentContext::new(true));
1✔
674
        }
1✔
675

676
        let scrollbar_x = area.x + area.width.saturating_sub(1);
1✔
677
        let handled = scroll_view.handle_event(
1✔
678
            &Event::Mouse(MouseEvent {
1✔
679
                column: scrollbar_x,
1✔
680
                row: area.y + 1,
1✔
681
                kind: MouseEventKind::Down(MouseButton::Left),
1✔
682
                modifiers: KeyModifiers::NONE,
1✔
683
            }),
1✔
684
            &ComponentContext::new(true),
1✔
685
        );
686

687
        assert!(handled);
1✔
688
        assert!(!scroll_view.content.selection_controller().is_dragging());
1✔
689
    }
1✔
690
}
691

692
#[derive(Debug)]
693
struct HitTestPalette {
694
    text: Text<'static>,
695
    urls: Vec<String>,
696
}
697

698
fn encode_link_color(id: u32) -> Color {
1✔
699
    debug_assert!(id > 0 && id <= 0x00FF_FFFF, "hit-test color id overflow");
1✔
700
    let r = ((id >> 16) & 0xFF) as u8;
1✔
701
    let g = ((id >> 8) & 0xFF) as u8;
1✔
702
    let b = (id & 0xFF) as u8;
1✔
703
    Color::Rgb(r, g, b)
1✔
704
}
1✔
705

706
fn decode_link_color(color: Color) -> Option<u32> {
1✔
707
    match color {
1✔
708
        Color::Rgb(r, g, b) => {
1✔
709
            let id = ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
1✔
710
            if id == 0 { None } else { Some(id) }
1✔
711
        }
712
        _ => None,
×
713
    }
714
}
1✔
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