Skip to main content

rusty_bubbles/
viewport.rs

1//! Cleanroom Rust port of upstream Go source file: `viewport/viewport.go`
2//! Cleanroom Rust port of upstream Go source file: `viewport/keymap.go`
3//! Cleanroom Rust port of upstream Go source file: `viewport/highlight.go`
4//! Upstream Target Tag / Version: `v2.1.0`
5//!
6//! <public-docs>
7//! # Viewport
8//!
9//! A component for rendering a viewport in a Bubble Tea application.
10//! </public-docs>
11
12use crate::key::{self, Binding};
13use rusty_bubbletea::key::KeyPressMsg;
14use rusty_bubbletea::model::{Cmd, Msg};
15use rusty_bubbletea::mouse::{MouseButton, MouseWheelMsg};
16use rusty_lipgloss::{self, ranges::Range, Style};
17use rusty_x_ansi;
18use std::collections::HashMap;
19
20/// defaultHorizontalStep is the number of columns the viewport moves
21/// horizontally by default.
22const DEFAULT_HORIZONTAL_STEP: usize = 6;
23
24/// Option is a configuration option that works in conjunction with [`new`].
25/// For example:
26///
27/// ```rust
28/// # use rusty_bubbles::viewport;
29/// let viewport = viewport::new(vec![viewport::with_width(10), viewport::with_height(5)]);
30/// ```
31pub type Option = Box<dyn FnOnce(&mut Model)>; // (std::option::Option is used for optionals)
32
33/// WithWidth is an initialization option that sets the width of the
34/// viewport. Pass as an argument to [`new`].
35pub fn with_width(w: usize) -> Option {
36    Box::new(move |m: &mut Model| {
37        m.width = w;
38    })
39}
40
41/// WithHeight is an initialization option that sets the height of the
42/// viewport. Pass as an argument to [`new`].
43pub fn with_height(h: usize) -> Option {
44    Box::new(move |m: &mut Model| {
45        m.height = h;
46    })
47}
48
49/// New returns a new model with the given width and height as well as
50/// default key mappings.
51impl Default for KeyMap {
52    fn default() -> Self {
53        default_key_map()
54    }
55}
56
57/// WithKeyMap sets the keymap used by the viewport.
58pub fn with_key_map(km: KeyMap) -> Option {
59    Box::new(move |m: &mut Model| {
60        m.key_map = km.clone();
61    })
62}
63
64pub fn new(opts: Vec<Option>) -> Model {
65    let mut m = Model {
66        width: 0,
67        height: 0,
68        key_map: default_key_map(),
69        soft_wrap: false,
70        fill_height: false,
71        mouse_wheel_enabled: true,
72        mouse_wheel_delta: 3,
73        y_offset: 0,
74        x_offset: 0,
75        horizontal_step: DEFAULT_HORIZONTAL_STEP,
76        y_position: 0,
77        style: Style::new(),
78        left_gutter_func: None,
79        initialized: false,
80        lines: vec![],
81        longest_line_width: 0,
82        highlight_style: Style::new(),
83        selected_highlight_style: Style::new(),
84        style_line_func: None,
85        highlights: vec![],
86        hi_idx: -1,
87        clone_hack: std::marker::PhantomData,
88    };
89
90    for opt in opts {
91        opt(&mut m);
92    }
93    m.set_initial_values();
94    m
95}
96
97/// GutterContext provides context to a [`GutterFunc`].
98#[derive(Debug, Clone, Copy)]
99pub struct GutterContext {
100    /// Index is the line index of the line which the gutter is being
101    /// rendered for.
102    pub index: usize,
103
104    /// TotalLines is the total number of lines in the viewport.
105    pub total_lines: usize,
106
107    /// Soft is whether or not the line is soft wrapped.
108    pub soft: bool,
109}
110
111/// GutterFunc can be implemented and set into [`Model::left_gutter_func`].
112///
113/// Example implementation showing line numbers:
114///
115/// ```rust
116/// # use rusty_bubbles::viewport::{self, GutterContext};
117/// fn line_numbers(info: GutterContext) -> String {
118///     if info.soft {
119///         return "     │ ".to_string();
120///     }
121///     if info.index >= info.total_lines {
122///         return "   ~ │ ".to_string();
123///     }
124///     format!("{:4} │ ", info.index + 1)
125/// }
126/// ```
127pub type GutterFunc = Box<dyn Fn(GutterContext) -> String + Send + Sync>;
128
129/// Model is the Bubble Tea model for this viewport element.
130pub struct Model {
131    width: usize,
132    height: usize,
133    /// The key mappings for the viewport.
134    pub key_map: KeyMap,
135
136    /// Whether or not to wrap text. If false, it'll allow horizontal
137    /// scrolling instead.
138    pub soft_wrap: bool,
139
140    /// Whether or not to fill to the height of the viewport with empty
141    /// lines.
142    pub fill_height: bool,
143
144    /// Whether or not to respond to the mouse. The mouse must be enabled in
145    /// Bubble Tea for this to work.
146    pub mouse_wheel_enabled: bool,
147
148    /// The number of lines the mouse wheel will scroll. By default, this is
149    /// 3.
150    pub mouse_wheel_delta: usize,
151
152    /// y_offset is the vertical scroll position.
153    y_offset: usize,
154
155    /// x_offset is the horizontal scroll position.
156    x_offset: usize,
157
158    /// horizontal_step is the number of columns we move left or right
159    /// during a default horizontal scroll.
160    horizontal_step: usize,
161
162    /// YPosition is the position of the viewport in relation to the terminal
163    /// window. It's used in high performance rendering only.
164    pub y_position: usize,
165
166    /// Style applies a lipgloss style to the viewport. Realistically, it's
167    /// most useful for setting borders, margins and padding.
168    pub style: Style,
169
170    /// LeftGutterFunc allows to define a [`GutterFunc`] that adds a column
171    /// into the left of the viewport, which is kept when horizontal
172    /// scrolling.
173    pub left_gutter_func: std::option::Option<GutterFunc>,
174
175    #[doc(hidden)]
176    #[allow(dead_code)]
177    clone_hack: std::marker::PhantomData<()>,
178
179    initialized: bool,
180    lines: Vec<String>,
181    longest_line_width: usize,
182
183    /// HighlightStyle highlights the ranges set with [`set_highlights`](Self::set_highlights).
184    pub highlight_style: Style,
185
186    /// SelectedHighlightStyle highlights the highlight range focused during
187    /// navigation.
188    pub selected_highlight_style: Style,
189
190    /// StyleLineFunc allows to return a [`Style`] for each line. The
191    /// argument is the line index.
192    pub style_line_func: std::option::Option<Box<dyn Fn(usize) -> Style + Send + Sync>>,
193
194    highlights: Vec<HighlightInfo>,
195    hi_idx: isize,
196}
197
198impl Clone for Model {
199    fn clone(&self) -> Self {
200        Model {
201            width: self.width,
202            height: self.height,
203            key_map: self.key_map.clone(),
204            soft_wrap: self.soft_wrap,
205            fill_height: self.fill_height,
206            mouse_wheel_enabled: self.mouse_wheel_enabled,
207            mouse_wheel_delta: self.mouse_wheel_delta,
208            y_offset: self.y_offset,
209            x_offset: self.x_offset,
210            horizontal_step: self.horizontal_step,
211            y_position: self.y_position,
212            style: self.style.clone(),
213            left_gutter_func: None,
214            initialized: self.initialized,
215            lines: self.lines.clone(),
216            longest_line_width: self.longest_line_width,
217            highlight_style: self.highlight_style.clone(),
218            selected_highlight_style: self.selected_highlight_style.clone(),
219            style_line_func: None,
220            highlights: self.highlights.clone(),
221            hi_idx: self.hi_idx,
222            clone_hack: std::marker::PhantomData,
223        }
224    }
225}
226
227impl std::fmt::Debug for Model {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("viewport::Model")
230            .field("width", &self.width)
231            .field("height", &self.height)
232            .field("y_offset", &self.y_offset)
233            .field("lines", &self.lines.len())
234            .finish()
235    }
236}
237
238impl Model {
239    fn set_initial_values(&mut self) {
240        self.mouse_wheel_enabled = true;
241        self.mouse_wheel_delta = 3;
242        self.horizontal_step = DEFAULT_HORIZONTAL_STEP;
243        self.initialized = true;
244    }
245
246    /// Height returns the height of the viewport.
247    pub fn height(&self) -> usize {
248        self.height
249    }
250
251    /// SetHeight sets the height of the viewport.
252    pub fn set_height(&mut self, h: usize) {
253        self.height = h;
254    }
255
256    /// Width returns the width of the viewport.
257    pub fn width(&self) -> usize {
258        self.width
259    }
260
261    /// SetWidth sets the width of the viewport.
262    pub fn set_width(&mut self, w: usize) {
263        self.width = w;
264    }
265
266    /// AtTop returns whether or not the viewport is at the very top
267    /// position.
268    pub fn at_top(&self) -> bool {
269        self.y_offset() == 0
270    }
271
272    /// AtBottom returns whether or not the viewport is at or past the very
273    /// bottom position.
274    pub fn at_bottom(&self) -> bool {
275        self.y_offset() >= self.max_y_offset()
276    }
277
278    /// PastBottom returns whether or not the viewport is scrolled beyond the
279    /// last line. This can happen when adjusting the viewport height.
280    pub fn past_bottom(&self) -> bool {
281        self.y_offset() > self.max_y_offset()
282    }
283
284    /// ScrollPercent returns the amount scrolled as a float between 0 and 1.
285    pub fn scroll_percent(&self) -> f64 {
286        let (total, _, _) = self.calculate_line(0);
287        if self.height() >= total {
288            return 1.0;
289        }
290        let y = self.y_offset() as f64;
291        let h = self.height() as f64;
292        let t = total as f64;
293        let v = y / (t - h);
294        clamp(v, 0.0, 1.0)
295    }
296
297    /// HorizontalScrollPercent returns the amount horizontally scrolled as a
298    /// float between 0 and 1.
299    pub fn horizontal_scroll_percent(&self) -> f64 {
300        if self.x_offset >= self.longest_line_width.saturating_sub(self.width()) {
301            return 1.0;
302        }
303        let y = self.x_offset as f64;
304        let h = self.width() as f64;
305        let t = self.longest_line_width as f64;
306        let v = y / (t - h);
307        clamp(v, 0.0, 1.0)
308    }
309
310    /// SetContent set the pager's text content. Line endings will be
311    /// normalized to '\n'.
312    pub fn set_content(&mut self, s: &str) {
313        self.set_content_lines(&s.split('\n').map(|x| x.to_string()).collect::<Vec<_>>());
314    }
315
316    /// SetContentLines allows to set the lines to be shown instead of the
317    /// content. If a given line has a \n in it, it will still be split into
318    /// multiple lines similar to that of [`set_content`](Self::set_content).
319    pub fn set_content_lines(&mut self, lines: &[String]) {
320        // if there's no content, set content to actual nil instead of one
321        // empty line.
322        self.lines = lines.to_vec();
323        if self.lines.len() == 1 && rusty_x_ansi::string_width(&self.lines[0]) == 0 {
324            self.lines.clear();
325        } else {
326            // iterate in reverse, so we can safely modify the slice.
327            let mut sub_lines: Vec<String>;
328            let mut i = self.lines.len();
329            while i > 0 {
330                i -= 1;
331                if !self.lines[i].contains('\r') && !self.lines[i].contains('\n') {
332                    continue;
333                }
334
335                self.lines[i] = self.lines[i].replace("\r\n", "\n"); // normalize line endings
336                sub_lines = self.lines[i].split('\n').map(|x| x.to_string()).collect();
337                if sub_lines.len() > 1 {
338                    self.lines
339                        .splice(i + 1..i + 1, sub_lines[1..].iter().cloned());
340                    self.lines[i] = sub_lines[0].clone();
341                }
342            }
343        }
344
345        self.longest_line_width = max_line_width(&self.lines);
346        self.clear_highlights();
347
348        if self.y_offset() > self.max_y_offset() {
349            self.goto_bottom();
350        }
351    }
352
353    /// GetContent returns the entire content as a single string.
354    /// Line endings are normalized to '\n'.
355    pub fn get_content(&self) -> String {
356        self.lines.join("\n")
357    }
358
359    /// calculateLine taking soft wrapping into account, returns the total
360    /// viewable lines and the real-line index for the given yoffset, as well
361    /// as the virtual line offset.
362    fn calculate_line(&self, yoffset: usize) -> (usize, usize, usize) {
363        if !self.soft_wrap {
364            let total = self.lines.len();
365            let ridx = yoffset.min(self.lines.len());
366            return (total, ridx, 0);
367        }
368
369        let max_width = self.max_width() as f64;
370        let mut total = 0usize;
371        let mut ridx = self.lines.len();
372        let mut voffset = 0usize;
373
374        for (i, line) in self.lines.iter().enumerate() {
375            let line_height =
376                1usize.max((rusty_x_ansi::string_width(line) as f64 / max_width).ceil() as usize);
377
378            if yoffset >= total && yoffset < total + line_height {
379                ridx = i;
380                voffset = yoffset - total;
381            }
382            total += line_height;
383        }
384
385        if yoffset >= total {
386            ridx = self.lines.len();
387            voffset = 0;
388        }
389
390        (total, ridx, voffset)
391    }
392
393    /// maxYOffset returns the maximum possible value of the y-offset based
394    /// on the viewport's content and set height.
395    fn max_y_offset(&self) -> usize {
396        let (total, _, _) = self.calculate_line(0);
397        total
398            .saturating_sub(self.height())
399            .saturating_add(self.style.get_vertical_frame_size())
400    }
401
402    /// maxXOffset returns the maximum possible value of the x-offset based
403    /// on the viewport's content and set width.
404    fn max_x_offset(&self) -> usize {
405        self.longest_line_width.saturating_sub(self.width())
406    }
407
408    /// maxWidth returns the maximum width of the viewport. It accounts for
409    /// the frame size, in addition to the gutter size.
410    fn max_width(&self) -> usize {
411        let mut gutter_size = 0;
412        if let Some(g) = &self.left_gutter_func {
413            gutter_size = rusty_x_ansi::string_width(&g(GutterContext {
414                index: 0,
415                total_lines: 0,
416                soft: false,
417            }));
418        }
419        self.width()
420            .saturating_sub(self.style.get_horizontal_frame_size())
421            .saturating_sub(gutter_size)
422    }
423
424    /// maxHeight returns the maximum height of the viewport. It accounts for
425    /// the frame size.
426    fn max_height(&self) -> usize {
427        self.height()
428            .saturating_sub(self.style.get_vertical_frame_size())
429    }
430
431    /// visibleLines returns the lines that should currently be visible in
432    /// the viewport.
433    fn visible_lines(&self) -> Vec<String> {
434        let max_height = self.max_height();
435        let max_width = self.max_width();
436
437        if max_height == 0 || max_width == 0 {
438            return vec![];
439        }
440
441        let (total, ridx, voffset) = self.calculate_line(self.y_offset());
442        let mut lines: Vec<String> = vec![];
443        if total > 0 {
444            let bottom = clamp(ridx + max_height, ridx, self.lines.len());
445            lines = self.style_lines(self.lines[ridx..bottom].to_vec(), ridx);
446            lines = self.highlight_lines(lines, ridx);
447        }
448
449        while self.fill_height && lines.len() < max_height {
450            lines.push(String::new());
451        }
452
453        // if longest line fit within width, no need to do anything else.
454        if (self.x_offset == 0 && self.longest_line_width <= max_width) || max_width == 0 {
455            let out = self.setup_gutter(lines, total, ridx);
456            return out;
457        }
458
459        if self.soft_wrap {
460            return self.soft_wrap_lines(lines, max_width, max_height, total, ridx, voffset);
461        }
462
463        // Cut the lines to the viewport width.
464        for line in lines.iter_mut() {
465            *line = rusty_x_ansi::cut(line, self.x_offset, self.x_offset + max_width);
466        }
467        self.setup_gutter(lines, total, ridx)
468    }
469
470    /// styleLines styles the lines using [`Model::style_line_func`].
471    fn style_lines(&self, lines: Vec<String>, offset: usize) -> Vec<String> {
472        match &self.style_line_func {
473            Some(f) => lines
474                .iter()
475                .enumerate()
476                .map(|(i, l)| f(i + offset).render(l))
477                .collect(),
478            None => lines,
479        }
480    }
481
482    /// highlightLines highlights the lines with [`Model::highlight_style`]
483    /// and [`Model::selected_highlight_style`].
484    fn highlight_lines(&self, lines: Vec<String>, offset: usize) -> Vec<String> {
485        if self.highlights.is_empty() {
486            return lines;
487        }
488        lines
489            .iter()
490            .enumerate()
491            .map(|(i, line)| {
492                let ranges =
493                    make_highlight_ranges(&self.highlights, i + offset, &self.highlight_style);
494                if self.hi_idx >= 0 {
495                    let sel = &self.highlights[self.hi_idx as usize];
496                    if let Some(hi) = sel.lines.get(&(i + offset)) {
497                        // Upstream re-styles the line with ONLY the selected
498                        // range, replacing the normal highlight ranges.
499                        return rusty_lipgloss::ranges::style_ranges(
500                            line,
501                            &[rusty_lipgloss::ranges::new_range(
502                                hi.0,
503                                hi.1,
504                                self.selected_highlight_style.clone(),
505                            )],
506                        );
507                    }
508                }
509                rusty_lipgloss::ranges::style_ranges(line, &ranges)
510            })
511            .collect()
512    }
513
514    fn soft_wrap_lines(
515        &self,
516        lines: Vec<String>,
517        max_width: usize,
518        max_height: usize,
519        total: usize,
520        ridx: usize,
521        voffset: usize,
522    ) -> Vec<String> {
523        let mut wrapped_lines: Vec<String> = Vec::with_capacity(max_height);
524
525        let mut idx: usize;
526        let mut line_width: usize;
527        let mut truncated_line: String;
528
529        for (i, line) in lines.iter().enumerate() {
530            // If the line is less than or equal to the max width, it can be
531            // added as is.
532            line_width = rusty_x_ansi::string_width(line);
533
534            if line_width <= max_width {
535                if let Some(g) = &self.left_gutter_func {
536                    let gutter = g(GutterContext {
537                        index: i + ridx,
538                        total_lines: total,
539                        soft: false,
540                    });
541                    wrapped_lines.push(gutter + line);
542                } else {
543                    wrapped_lines.push(line.clone());
544                }
545                continue;
546            }
547
548            idx = 0;
549            while line_width > idx {
550                truncated_line = rusty_x_ansi::cut(line, idx, max_width + idx);
551                if let Some(g) = &self.left_gutter_func {
552                    let gutter = g(GutterContext {
553                        index: i + ridx,
554                        total_lines: total,
555                        soft: idx > 0,
556                    });
557                    wrapped_lines.push(gutter + &truncated_line);
558                } else {
559                    wrapped_lines.push(truncated_line);
560                }
561                idx += max_width;
562            }
563        }
564
565        wrapped_lines[voffset..(voffset + max_height).min(wrapped_lines.len())].to_vec()
566    }
567
568    /// setupGutter sets up the left gutter using [`Model::left_gutter_func`].
569    fn setup_gutter(&self, lines: Vec<String>, total: usize, ridx: usize) -> Vec<String> {
570        match &self.left_gutter_func {
571            None => lines,
572            Some(g) => lines
573                .iter()
574                .enumerate()
575                .map(|(i, l)| {
576                    let gutter = g(GutterContext {
577                        index: i + ridx,
578                        total_lines: total,
579                        soft: false,
580                    });
581                    gutter + l
582                })
583                .collect(),
584        }
585    }
586
587    /// SetYOffset sets the Y offset.
588    pub fn set_y_offset(&mut self, n: usize) {
589        self.y_offset = clamp(n, 0, self.max_y_offset());
590    }
591
592    /// YOffset returns the current Y offset - the vertical scroll position.
593    pub fn y_offset(&self) -> usize {
594        self.y_offset
595    }
596
597    /// EnsureVisible ensures that the given line and column are in the
598    /// viewport.
599    pub fn ensure_visible(&mut self, line: usize, colstart: usize, colend: usize) {
600        let max_width = self.max_width();
601        if colend <= max_width {
602            self.set_x_offset(0);
603        } else {
604            self.set_x_offset(colstart.saturating_sub(self.horizontal_step)); // put one step to the left, feels more natural
605        }
606
607        if line < self.y_offset() || line >= self.y_offset() + self.max_height() {
608            self.set_y_offset(line);
609        }
610    }
611
612    /// PageDown moves the view down by the number of lines in the viewport.
613    pub fn page_down(&mut self) {
614        if self.at_bottom() {
615            return;
616        }
617        self.scroll_down(self.height());
618    }
619
620    /// PageUp moves the view up by one height of the viewport.
621    pub fn page_up(&mut self) {
622        if self.at_top() {
623            return;
624        }
625        self.scroll_up(self.height());
626    }
627
628    /// HalfPageDown moves the view down by half the height of the viewport.
629    pub fn half_page_down(&mut self) {
630        if self.at_bottom() {
631            return;
632        }
633        self.scroll_down(self.height() / 2);
634    }
635
636    /// HalfPageUp moves the view up by half the height of the viewport.
637    pub fn half_page_up(&mut self) {
638        if self.at_top() {
639            return;
640        }
641        self.scroll_up(self.height() / 2);
642    }
643
644    /// ScrollDown moves the view down by the given number of lines.
645    pub fn scroll_down(&mut self, n: usize) {
646        if self.at_bottom() || n == 0 || self.lines.is_empty() {
647            return;
648        }
649        // Make sure the number of lines by which we're going to scroll isn't
650        // greater than the number of lines we actually have left before we
651        // reach the bottom.
652        self.set_y_offset(self.y_offset() + n);
653        self.hi_idx = self.find_nearest_match();
654    }
655
656    /// ScrollUp moves the view up by the given number of lines.
657    pub fn scroll_up(&mut self, n: usize) {
658        if self.at_top() || n == 0 || self.lines.is_empty() {
659            return;
660        }
661        // Make sure the number of lines by which we're going to scroll isn't
662        // greater than the number of lines we are from the top.
663        self.set_y_offset(self.y_offset() - n);
664        self.hi_idx = self.find_nearest_match();
665    }
666
667    /// SetHorizontalStep sets the amount of cells that the viewport moves in
668    /// the default viewport keymapping. If set to 0 or less, horizontal
669    /// scrolling is disabled.
670    pub fn set_horizontal_step(&mut self, n: usize) {
671        self.horizontal_step = n;
672    }
673
674    /// XOffset returns the current X offset - the horizontal scroll
675    /// position.
676    pub fn x_offset(&self) -> usize {
677        self.x_offset
678    }
679
680    /// SetXOffset sets the X offset.
681    /// No-op when soft wrap is enabled.
682    pub fn set_x_offset(&mut self, n: usize) {
683        if self.soft_wrap {
684            return;
685        }
686        self.x_offset = clamp(n, 0, self.max_x_offset());
687    }
688
689    /// ScrollLeft moves the viewport to the left by the given number of
690    /// columns.
691    pub fn scroll_left(&mut self, n: usize) {
692        // Upstream uses signed ints and clamps the resulting offset to 0;
693        // saturating subtraction mirrors that without overflowing.
694        self.set_x_offset(self.x_offset.saturating_sub(n));
695    }
696
697    /// ScrollRight moves viewport to the right by the given number of
698    /// columns.
699    pub fn scroll_right(&mut self, n: usize) {
700        self.set_x_offset(self.x_offset + n);
701    }
702
703    /// TotalLineCount returns the total number of lines (both hidden and
704    /// visible) within the viewport.
705    pub fn total_line_count(&self) -> usize {
706        let (total, _, _) = self.calculate_line(0);
707        total
708    }
709
710    /// VisibleLineCount returns the number of the visible lines within the
711    /// viewport.
712    pub fn visible_line_count(&self) -> usize {
713        self.visible_lines().len()
714    }
715
716    /// GotoTop sets the viewport to the top position.
717    pub fn goto_top(&mut self) -> Vec<String> {
718        if self.at_top() {
719            return vec![];
720        }
721        self.set_y_offset(0);
722        self.hi_idx = self.find_nearest_match();
723        self.visible_lines()
724    }
725
726    /// GotoBottom sets the viewport to the bottom position.
727    pub fn goto_bottom(&mut self) -> Vec<String> {
728        self.set_y_offset(self.max_y_offset());
729        self.hi_idx = self.find_nearest_match();
730        self.visible_lines()
731    }
732
733    /// SetHighlights sets ranges of characters to highlight.
734    /// For instance, `[[2, 10], [20, 30]]` will highlight characters 2 to 10
735    /// and 20 to 30.
736    /// Note that highlights are not expected to transpose each other, and
737    /// are also expected to be in order.
738    pub fn set_highlights(&mut self, matches: &[Vec<usize>]) {
739        if matches.is_empty() || self.lines.is_empty() {
740            return;
741        }
742        self.highlights = parse_matches(&self.get_content(), matches);
743        self.hi_idx = self.find_nearest_match();
744        self.show_highlight();
745    }
746
747    /// Highlights returns the currently set highlight ranges.
748    ///
749    /// This is exposed so integration tests can assert on highlight ranges
750    /// the same way the upstream in-package tests do.
751    pub fn highlights(&self) -> &[HighlightInfo] {
752        &self.highlights
753    }
754
755    /// ClearHighlights clears previously set highlights.
756    pub fn clear_highlights(&mut self) {
757        self.highlights.clear();
758        self.hi_idx = -1;
759    }
760
761    fn show_highlight(&mut self) {
762        if self.hi_idx == -1 {
763            return;
764        }
765        let (line, colstart, colend) = self.highlights[self.hi_idx as usize].coords();
766        self.ensure_visible(line, colstart, colend);
767    }
768
769    /// HighlightNext highlights the next match.
770    pub fn highlight_next(&mut self) {
771        if self.highlights.is_empty() {
772            return;
773        }
774        self.hi_idx = (self.hi_idx + 1) % self.highlights.len() as isize;
775        self.show_highlight();
776    }
777
778    /// HighlightPrevious highlights the previous match.
779    pub fn highlight_previous(&mut self) {
780        if self.highlights.is_empty() {
781            return;
782        }
783        self.hi_idx =
784            (self.hi_idx - 1 + self.highlights.len() as isize) % self.highlights.len() as isize;
785        self.show_highlight();
786    }
787
788    fn find_nearest_match(&self) -> isize {
789        for (i, m) in self.highlights.iter().enumerate() {
790            if m.line_start >= self.y_offset() {
791                return i as isize;
792            }
793        }
794        -1
795    }
796
797    /// Update handles standard message-based viewport updates.
798    pub fn update(&mut self, msg: &dyn Msg) -> Cmd {
799        self.update_as_model(msg);
800        None
801    }
802
803    fn update_as_model(&mut self, msg: &dyn Msg) {
804        if !self.initialized {
805            self.set_initial_values();
806        }
807
808        if let Some(m) = msg.as_any().downcast_ref::<KeyPressMsg>() {
809            let k = &m.0;
810            if key::matches(k, std::slice::from_ref(&self.key_map.page_down)) {
811                self.page_down();
812            } else if key::matches(k, std::slice::from_ref(&self.key_map.page_up)) {
813                self.page_up();
814            } else if key::matches(k, std::slice::from_ref(&self.key_map.half_page_down)) {
815                self.half_page_down();
816            } else if key::matches(k, std::slice::from_ref(&self.key_map.half_page_up)) {
817                self.half_page_up();
818            } else if key::matches(k, std::slice::from_ref(&self.key_map.down)) {
819                self.scroll_down(1);
820            } else if key::matches(k, std::slice::from_ref(&self.key_map.up)) {
821                self.scroll_up(1);
822            } else if key::matches(k, std::slice::from_ref(&self.key_map.left)) {
823                self.scroll_left(self.horizontal_step);
824            } else if key::matches(k, std::slice::from_ref(&self.key_map.right)) {
825                self.scroll_right(self.horizontal_step);
826            }
827            return;
828        }
829
830        if let Some(m) = msg.as_any().downcast_ref::<MouseWheelMsg>() {
831            if !self.mouse_wheel_enabled {
832                return;
833            }
834            let mouse = &m.0;
835            match mouse.button {
836                MouseButton::MouseWheelDown => {
837                    // NOTE: some terminal emulators don't send the shift
838                    // event for mouse actions.
839                    if mouse.mod_keys.contains(rusty_bubbletea::key::KeyMod::SHIFT) {
840                        self.scroll_right(self.horizontal_step);
841                        return;
842                    }
843                    self.scroll_down(self.mouse_wheel_delta);
844                }
845                MouseButton::MouseWheelUp => {
846                    // NOTE: some terminal emulators don't send the shift
847                    // event for mouse actions.
848                    if mouse.mod_keys.contains(rusty_bubbletea::key::KeyMod::SHIFT) {
849                        self.scroll_left(self.horizontal_step);
850                        return;
851                    }
852                    self.scroll_up(self.mouse_wheel_delta);
853                }
854                MouseButton::MouseWheelLeft => {
855                    self.scroll_left(self.horizontal_step);
856                }
857                MouseButton::MouseWheelRight => {
858                    self.scroll_right(self.horizontal_step);
859                }
860                _ => {}
861            }
862        }
863    }
864
865    /// View renders the viewport into a string.
866    pub fn view(&self) -> String {
867        let mut w = self.width();
868        let mut h = self.height();
869        let sw = self.style.get_width();
870        if sw != 0 {
871            w = w.min(sw);
872        }
873        let sh = self.style.get_height();
874        if sh != 0 {
875            h = h.min(sh);
876        }
877
878        if w == 0 || h == 0 {
879            return String::new();
880        }
881
882        let content_width = w - self.style.get_horizontal_frame_size();
883        let content_height = h - self.style.get_vertical_frame_size();
884        let vl = self.visible_lines();
885        let contents = rusty_lipgloss::new_style()
886            .width(content_width) // pad to width.
887            .height(content_height) // pad to height.
888            .render(&vl.join("\n"));
889        self.style
890            .clone()
891            .unset_width()
892            .unset_height() // Style size already applied in contents.
893            .render(&contents)
894    }
895}
896
897/// KeyMap defines the keybindings for the viewport.
898#[derive(Debug, Clone)]
899pub struct KeyMap {
900    /// Page down binding.
901    pub page_down: Binding,
902    /// Page up binding.
903    pub page_up: Binding,
904    /// Half page up binding.
905    pub half_page_up: Binding,
906    /// Half page down binding.
907    pub half_page_down: Binding,
908    /// Down binding.
909    pub down: Binding,
910    /// Up binding.
911    pub up: Binding,
912    /// Left binding.
913    pub left: Binding,
914    /// Right binding.
915    pub right: Binding,
916}
917
918/// DefaultKeyMap returns a set of pager-like default keybindings.
919pub fn default_key_map() -> KeyMap {
920    KeyMap {
921        page_down: key::new_binding(vec![
922            key::with_keys(&["pgdown", "space", "f"]),
923            key::with_help("f/pgdn", "page down"),
924        ]),
925        page_up: key::new_binding(vec![
926            key::with_keys(&["pgup", "b"]),
927            key::with_help("b/pgup", "page up"),
928        ]),
929        half_page_up: key::new_binding(vec![
930            key::with_keys(&["u", "ctrl+u"]),
931            key::with_help("u", "½ page up"),
932        ]),
933        half_page_down: key::new_binding(vec![
934            key::with_keys(&["d", "ctrl+d"]),
935            key::with_help("d", "½ page down"),
936        ]),
937        up: key::new_binding(vec![
938            key::with_keys(&["up", "k"]),
939            key::with_help("↑/k", "up"),
940        ]),
941        down: key::new_binding(vec![
942            key::with_keys(&["down", "j"]),
943            key::with_help("↓/j", "down"),
944        ]),
945        left: key::new_binding(vec![
946            key::with_keys(&["left", "h"]),
947            key::with_help("←/h", "move left"),
948        ]),
949        right: key::new_binding(vec![
950            key::with_keys(&["right", "l"]),
951            key::with_help("→/l", "move right"),
952        ]),
953    }
954}
955
956/// HighlightInfo holds the highlight ranges for a set of matches.
957///
958/// This is exposed so integration tests can assert on highlight ranges the
959/// same way the upstream in-package tests do.
960#[derive(Debug, Clone, PartialEq)]
961pub struct HighlightInfo {
962    /// in which line this highlight starts and ends
963    pub line_start: usize,
964    /// in which line this highlight ends
965    pub line_end: usize,
966    /// the grapheme highlight ranges for each of these lines
967    pub lines: HashMap<usize, (usize, usize)>,
968}
969
970impl HighlightInfo {
971    /// coords returns the line x column of this highlight.
972    fn coords(&self) -> (usize, usize, usize) {
973        for i in self.line_start..=self.line_end {
974            if let Some(hl) = self.lines.get(&i) {
975                return (i, hl.0, hl.1);
976            }
977        }
978        (self.line_start, 0, 0)
979    }
980}
981
982/// parseMatches converts the given matches into highlight ranges.
983///
984/// Assumptions:
985/// - matches are measured in bytes, e.g. what a regex match would return
986/// - matches were made against the given content
987/// - matches are in order
988/// - matches do not overlap
989/// - content is line terminated with \n only
990fn parse_matches(content: &str, matches: &[Vec<usize>]) -> Vec<HighlightInfo> {
991    if matches.is_empty() {
992        return vec![];
993    }
994
995    // NOTE: matches are byte ranges into the raw (unstyled) content, so the
996    // walk below must index by *byte* position, decoding each UTF-8 char as
997    // it goes (the upstream Go code indexes a []byte directly).
998    let stripped: Vec<u8> = rusty_x_ansi::strip(content).as_bytes().to_vec();
999
1000    let mut highlights: Vec<HighlightInfo> = Vec::with_capacity(matches.len());
1001
1002    for m in matches {
1003        let (byte_start, byte_end) = (m[0], m[1]);
1004
1005        // highlight for this match:
1006        let mut hi = HighlightInfo {
1007            line_start: 0,
1008            line_end: 0,
1009            lines: HashMap::new(),
1010        };
1011
1012        let mut line = 0usize;
1013        let mut grapheme_pos = 0usize;
1014        let mut previous_lines_offset = 0usize;
1015        let mut byte_pos = 0usize;
1016
1017        // find the beginning of this byte range, setup current line and
1018        // grapheme position.
1019        while byte_start > byte_pos && byte_pos < stripped.len() {
1020            let c = char_at(&stripped, byte_pos);
1021            if c == '\n' {
1022                previous_lines_offset = grapheme_pos + 1;
1023                line += 1;
1024            }
1025            grapheme_pos += 1usize.max(char_width(c));
1026            byte_pos += char_len(c);
1027        }
1028
1029        hi.line_start = line;
1030        hi.line_end = line;
1031
1032        let grapheme_start = grapheme_pos;
1033
1034        // loop until we find the end
1035        while byte_end > byte_pos && byte_pos < stripped.len() {
1036            let c = char_at(&stripped, byte_pos);
1037            // if it ends with a new line, add the range, increase line, and
1038            // continue
1039            if c == '\n' {
1040                let colstart = grapheme_start.saturating_sub(previous_lines_offset);
1041                let colend = (grapheme_pos.saturating_sub(previous_lines_offset) + 1).max(colstart); // +1 its \n itself
1042
1043                if colend > colstart {
1044                    hi.lines.insert(line, (colstart, colend));
1045                    hi.line_end = line;
1046                }
1047
1048                previous_lines_offset = grapheme_pos + 1;
1049                line += 1;
1050            }
1051
1052            grapheme_pos += 1usize.max(char_width(c));
1053            byte_pos += char_len(c);
1054        }
1055
1056        // we found it!, add highlight and continue
1057        if byte_pos == byte_end {
1058            let colstart = grapheme_start.saturating_sub(previous_lines_offset);
1059            let colend = (grapheme_pos.saturating_sub(previous_lines_offset)).max(colstart);
1060
1061            if colend > colstart {
1062                hi.lines.insert(line, (colstart, colend));
1063                hi.line_end = line;
1064            }
1065        }
1066
1067        highlights.push(hi);
1068    }
1069
1070    highlights
1071}
1072
1073/// CharAt decodes the UTF-8 character at the given byte offset. The offset
1074/// must lie on a character boundary (all offsets used here do).
1075fn char_at(s: &[u8], byte_pos: usize) -> char {
1076    std::str::from_utf8(&s[byte_pos..])
1077        .ok()
1078        .and_then(|r| r.chars().next())
1079        .unwrap_or('\u{FFFD}')
1080}
1081
1082fn make_highlight_ranges(highlights: &[HighlightInfo], line: usize, style: &Style) -> Vec<Range> {
1083    let mut result: Vec<Range> = vec![];
1084    for hi in highlights {
1085        if let Some(lihi) = hi.lines.get(&line) {
1086            if *lihi == (0, 0) {
1087                continue;
1088            }
1089            result.push(rusty_lipgloss::ranges::new_range(
1090                lihi.0,
1091                lihi.1,
1092                style.clone(),
1093            ));
1094        }
1095    }
1096    result
1097}
1098
1099fn char_width(c: char) -> usize {
1100    unicode_width::UnicodeWidthChar::width(c).unwrap_or(0)
1101}
1102
1103fn char_len(c: char) -> usize {
1104    c.len_utf8()
1105}
1106
1107fn clamp<T: PartialOrd + Copy>(v: T, low: T, high: T) -> T {
1108    if high < low {
1109        return low;
1110    }
1111    if v < low {
1112        low
1113    } else if v > high {
1114        high
1115    } else {
1116        v
1117    }
1118}
1119
1120fn max_line_width(lines: &[String]) -> usize {
1121    let mut result = 0;
1122    for line in lines {
1123        result = result.max(rusty_x_ansi::string_width(line));
1124    }
1125    result
1126}