Skip to main content

urushi/component/
scrollbar.rs

1//! A finite-viewport position and its selectable terminal presentation.
2
3use crate::{
4    BlockStyle, Canvas, CanvasContext, CanvasItem, CellContribution, Composition, Grapheme, Length,
5    Position, PositionedCell, PrintableText, ScrollbarRole, TextStyle, View,
6};
7
8/// The axis along which a [`Scrollbar`] describes a viewport.
9///
10/// Orientation does not choose an edge. A parent `View` places a vertical
11/// scrollbar on the left or right, or a horizontal scrollbar above or below
12/// its content.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
14pub enum ScrollbarOrientation {
15    /// The viewport moves through rows.
16    #[default]
17    Vertical,
18    /// The viewport moves through columns.
19    Horizontal,
20}
21
22/// How a [`ScrollbarPresentation`] represents the visible viewport on its track.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
24pub enum ScrollbarThumbSizing {
25    /// Size the thumb in proportion to the visible share of the content.
26    #[default]
27    Proportional,
28    /// Draw one cell that represents position without representing viewport size.
29    Marker,
30}
31
32/// Immutable semantic input for one finite-viewport position indicator.
33///
34/// Lengths and positions use caller-defined content units. They may be rows,
35/// columns, items, or another uniform unit, provided all three values use the
36/// same unit. Rendering clamps `position` to the last origin at which the
37/// viewport remains within the content.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct Scrollbar {
40    orientation: ScrollbarOrientation,
41    content_length: usize,
42    viewport_length: usize,
43    position: usize,
44}
45
46impl Scrollbar {
47    /// Creates a scrollbar at the beginning of its content.
48    pub const fn new(
49        orientation: ScrollbarOrientation,
50        content_length: usize,
51        viewport_length: usize,
52    ) -> Self {
53        Self {
54            orientation,
55            content_length,
56            viewport_length,
57            position: 0,
58        }
59    }
60
61    /// Replaces the requested viewport origin.
62    ///
63    /// The presentation clamps an out-of-range value after accounting for the
64    /// current content and viewport lengths.
65    #[must_use]
66    pub const fn position(mut self, position: usize) -> Self {
67        self.position = position;
68        self
69    }
70
71    pub const fn orientation(&self) -> ScrollbarOrientation {
72        self.orientation
73    }
74
75    pub const fn content_length(&self) -> usize {
76        self.content_length
77    }
78
79    pub const fn viewport_length(&self) -> usize {
80        self.viewport_length
81    }
82
83    pub const fn get_position(&self) -> usize {
84        self.position
85    }
86}
87
88/// The four glyphs used along one scrollbar orientation.
89///
90/// The thumb is required. Track and endpoint glyphs are optional, allowing a
91/// presentation to range from a conventional arrowed rail to a thumb-only
92/// indicator. Every present glyph must be exactly one printable, one-cell
93/// grapheme.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct ScrollbarGlyphs {
96    thumb: String,
97    track: Option<String>,
98    begin: Option<String>,
99    end: Option<String>,
100}
101
102impl ScrollbarGlyphs {
103    /// Creates a thumb-only glyph set.
104    ///
105    /// # Panics
106    ///
107    /// Panics unless `thumb` is exactly one printable, one-cell grapheme.
108    pub fn new(thumb: impl Into<String>) -> Self {
109        Self {
110            thumb: checked_glyph(thumb),
111            track: None,
112            begin: None,
113            end: None,
114        }
115    }
116
117    /// Replaces or removes the track glyph.
118    ///
119    /// # Panics
120    ///
121    /// Panics unless a present glyph is exactly one printable, one-cell
122    /// grapheme.
123    #[must_use]
124    pub fn track(mut self, track: Option<&str>) -> Self {
125        self.track = track.map(checked_glyph);
126        self
127    }
128
129    /// Replaces or removes the beginning glyph.
130    ///
131    /// # Panics
132    ///
133    /// Panics unless a present glyph is exactly one printable, one-cell
134    /// grapheme.
135    #[must_use]
136    pub fn begin(mut self, begin: Option<&str>) -> Self {
137        self.begin = begin.map(checked_glyph);
138        self
139    }
140
141    /// Replaces or removes the ending glyph.
142    ///
143    /// # Panics
144    ///
145    /// Panics unless a present glyph is exactly one printable, one-cell
146    /// grapheme.
147    #[must_use]
148    pub fn end(mut self, end: Option<&str>) -> Self {
149        self.end = end.map(checked_glyph);
150        self
151    }
152
153    pub fn get_thumb(&self) -> &str {
154        &self.thumb
155    }
156
157    pub fn get_track(&self) -> Option<&str> {
158        self.track.as_deref()
159    }
160
161    pub fn get_begin(&self) -> Option<&str> {
162        self.begin.as_deref()
163    }
164
165    pub fn get_end(&self) -> Option<&str> {
166        self.end.as_deref()
167    }
168}
169
170fn checked_glyph(glyph: impl Into<String>) -> String {
171    let glyph = glyph.into();
172    assert!(
173        !glyph.chars().any(char::is_control),
174        "a scrollbar glyph must not contain control characters: {glyph:?}"
175    );
176    let mut graphemes = PrintableText::new(&glyph).graphemes();
177    let first = graphemes.next();
178    assert!(
179        first.is_some() && graphemes.next().is_none(),
180        "a scrollbar glyph must be exactly one grapheme: {glyph:?}"
181    );
182    assert_eq!(
183        first.map(Grapheme::width),
184        Some(1),
185        "a scrollbar glyph must occupy exactly one terminal cell: {glyph:?}"
186    );
187    drop(graphemes);
188    glyph
189}
190
191/// Selectable visual and geometric policy for a [`Scrollbar`].
192///
193/// The presentation stores separate glyph sets for both orientations so the
194/// same value can compose vertical and horizontal semantic scrollbars. A
195/// caller can clone a Theme-owned presentation and replace either set or any
196/// logical part style without changing the semantic value.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ScrollbarPresentation {
199    vertical_glyphs: ScrollbarGlyphs,
200    horizontal_glyphs: ScrollbarGlyphs,
201    styles: [TextStyle; 4],
202    thumb_sizing: ScrollbarThumbSizing,
203}
204
205impl ScrollbarPresentation {
206    /// Creates a proportional presentation with conventional thin rails.
207    pub fn new(thumb_style: TextStyle, track_style: TextStyle) -> Self {
208        Self {
209            vertical_glyphs: ScrollbarGlyphs::new("█")
210                .track(Some("│"))
211                .begin(Some("↑"))
212                .end(Some("↓")),
213            horizontal_glyphs: ScrollbarGlyphs::new("█")
214                .track(Some("─"))
215                .begin(Some("←"))
216                .end(Some("→")),
217            styles: [
218                thumb_style,
219                track_style.clone(),
220                track_style.clone(),
221                track_style,
222            ],
223            thumb_sizing: ScrollbarThumbSizing::Proportional,
224        }
225    }
226
227    pub fn get_glyphs(&self, orientation: ScrollbarOrientation) -> &ScrollbarGlyphs {
228        match orientation {
229            ScrollbarOrientation::Vertical => &self.vertical_glyphs,
230            ScrollbarOrientation::Horizontal => &self.horizontal_glyphs,
231        }
232    }
233
234    /// Replaces the glyph set for one orientation.
235    #[must_use]
236    pub fn glyphs(mut self, orientation: ScrollbarOrientation, glyphs: ScrollbarGlyphs) -> Self {
237        match orientation {
238            ScrollbarOrientation::Vertical => self.vertical_glyphs = glyphs,
239            ScrollbarOrientation::Horizontal => self.horizontal_glyphs = glyphs,
240        }
241        self
242    }
243
244    pub const fn get_thumb_sizing(&self) -> ScrollbarThumbSizing {
245        self.thumb_sizing
246    }
247
248    /// Replaces the policy that maps viewport state onto the track.
249    #[must_use]
250    pub const fn thumb_sizing(mut self, thumb_sizing: ScrollbarThumbSizing) -> Self {
251        self.thumb_sizing = thumb_sizing;
252        self
253    }
254
255    pub fn get_style(&self, role: ScrollbarRole) -> &TextStyle {
256        &self.styles[role.index()]
257    }
258
259    /// Replaces one complete logical part style.
260    #[must_use]
261    pub fn style(mut self, role: ScrollbarRole, style: TextStyle) -> Self {
262        self.styles[role.index()] = style;
263        self
264    }
265
266    #[must_use]
267    pub fn thumb_style(self, style: TextStyle) -> Self {
268        self.style(ScrollbarRole::Thumb, style)
269    }
270
271    #[must_use]
272    pub fn track_style(self, style: TextStyle) -> Self {
273        self.style(ScrollbarRole::Track, style)
274    }
275
276    #[must_use]
277    pub fn begin_style(self, style: TextStyle) -> Self {
278        self.style(ScrollbarRole::Begin, style)
279    }
280
281    #[must_use]
282    pub fn end_style(self, style: TextStyle) -> Self {
283        self.style(ScrollbarRole::End, style)
284    }
285
286    /// Composes the semantic viewport state into a one-cell-cross-axis View.
287    ///
288    /// The main axis remains area-dependent. Resolving a vertical scrollbar
289    /// therefore requires a finite height, and resolving a horizontal one
290    /// requires a finite width.
291    pub fn compose(&self, scrollbar: &Scrollbar) -> View {
292        let item = ScrollbarItem {
293            scrollbar: *scrollbar,
294            glyphs: self.get_glyphs(scrollbar.orientation).clone(),
295            styles: self.styles.clone(),
296            thumb_sizing: self.thumb_sizing,
297        };
298        let canvas = View::canvas(Canvas::new().item(item));
299        let style = match scrollbar.orientation {
300            ScrollbarOrientation::Vertical => BlockStyle::new()
301                .width(Length::Cells(1))
302                .height(Length::fill(1)),
303            ScrollbarOrientation::Horizontal => BlockStyle::new()
304                .width(Length::fill(1))
305                .height(Length::Cells(1)),
306        };
307        View::block(style, canvas)
308    }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
312struct ScrollbarItem {
313    scrollbar: Scrollbar,
314    glyphs: ScrollbarGlyphs,
315    styles: [TextStyle; 4],
316    thumb_sizing: ScrollbarThumbSizing,
317}
318
319impl CanvasItem for ScrollbarItem {
320    fn draw(&self, context: &mut CanvasContext) {
321        if self.scrollbar.content_length == 0 {
322            return;
323        }
324        let extent = match self.scrollbar.orientation {
325            ScrollbarOrientation::Vertical => context.size().height(),
326            ScrollbarOrientation::Horizontal => context.size().width(),
327        };
328        if extent == 0 {
329            return;
330        }
331
332        let endpoint_count = usize::from(self.glyphs.begin.is_some())
333            .saturating_add(usize::from(self.glyphs.end.is_some()));
334        let show_endpoints = extent >= endpoint_count.saturating_add(1);
335        let begin = show_endpoints
336            .then_some(self.glyphs.begin.as_deref())
337            .flatten();
338        let end = show_endpoints
339            .then_some(self.glyphs.end.as_deref())
340            .flatten();
341        let track_origin = usize::from(begin.is_some());
342        let track_length = extent
343            .saturating_sub(track_origin)
344            .saturating_sub(usize::from(end.is_some()));
345        if track_length == 0 {
346            return;
347        }
348
349        if let Some(begin) = begin {
350            self.draw_part(context, 0, begin, ScrollbarRole::Begin);
351        }
352        if let Some(track) = self.glyphs.track.as_deref() {
353            let cells = (0..track_length).map(|offset| {
354                self.cell(
355                    track_origin.saturating_add(offset),
356                    track,
357                    ScrollbarRole::Track,
358                )
359            });
360            context.cells_with(cells, Composition::Replace);
361        }
362        if let Some(end) = end {
363            self.draw_part(
364                context,
365                track_origin.saturating_add(track_length),
366                end,
367                ScrollbarRole::End,
368            );
369        }
370
371        let (thumb_start, thumb_length) = self.thumb_geometry(track_length);
372        let cells = (0..thumb_length).map(|offset| {
373            self.cell(
374                track_origin
375                    .saturating_add(thumb_start)
376                    .saturating_add(offset),
377                &self.glyphs.thumb,
378                ScrollbarRole::Thumb,
379            )
380        });
381        context.cells_with(cells, Composition::Replace);
382    }
383}
384
385impl ScrollbarItem {
386    fn thumb_geometry(&self, track_length: usize) -> (usize, usize) {
387        let content = self.scrollbar.content_length;
388        let viewport = self.scrollbar.viewport_length.min(content);
389        let maximum = content.saturating_sub(viewport);
390        let position = self.scrollbar.position.min(maximum);
391
392        match self.thumb_sizing {
393            ScrollbarThumbSizing::Proportional => {
394                if maximum == 0 {
395                    return (0, track_length);
396                }
397                let length = scale_round(viewport, track_length, content).clamp(1, track_length);
398                let travel = track_length.saturating_sub(length);
399                (scale_round(position, travel, maximum), length)
400            }
401            ScrollbarThumbSizing::Marker => {
402                let travel = track_length.saturating_sub(1);
403                let start = if maximum == 0 {
404                    0
405                } else {
406                    scale_round(position, travel, maximum)
407                };
408                (start, 1)
409            }
410        }
411    }
412
413    fn draw_part(
414        &self,
415        context: &mut CanvasContext,
416        offset: usize,
417        glyph: &str,
418        role: ScrollbarRole,
419    ) {
420        context.cells_with([self.cell(offset, glyph, role)], Composition::Replace);
421    }
422
423    fn cell(&self, offset: usize, glyph: &str, role: ScrollbarRole) -> PositionedCell {
424        let position = match self.scrollbar.orientation {
425            ScrollbarOrientation::Vertical => Position::new(0, coordinate(offset)),
426            ScrollbarOrientation::Horizontal => Position::new(coordinate(offset), 0),
427        };
428        PositionedCell::new(
429            position,
430            CellContribution::new()
431                .symbol(Grapheme::new(glyph))
432                .style(self.styles[role.index()].clone()),
433        )
434    }
435}
436
437fn scale_round(value: usize, scale: usize, denominator: usize) -> usize {
438    debug_assert!(denominator > 0);
439    let numerator = (value as u128).saturating_mul(scale as u128);
440    let rounded = numerator.saturating_add((denominator / 2) as u128) / denominator as u128;
441    usize::try_from(rounded).unwrap_or(usize::MAX)
442}
443
444fn coordinate(value: usize) -> i64 {
445    i64::try_from(value).unwrap_or(i64::MAX)
446}