1use crate::{
4 BlockStyle, Canvas, CanvasContext, CanvasItem, CellContribution, Composition, Grapheme, Length,
5 Position, PositionedCell, PrintableText, ScrollbarRole, TextStyle, View,
6};
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
14pub enum ScrollbarOrientation {
15 #[default]
17 Vertical,
18 Horizontal,
20}
21
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
24pub enum ScrollbarThumbSizing {
25 #[default]
27 Proportional,
28 Marker,
30}
31
32#[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 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 #[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#[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 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 #[must_use]
124 pub fn track(mut self, track: Option<&str>) -> Self {
125 self.track = track.map(checked_glyph);
126 self
127 }
128
129 #[must_use]
136 pub fn begin(mut self, begin: Option<&str>) -> Self {
137 self.begin = begin.map(checked_glyph);
138 self
139 }
140
141 #[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#[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 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 #[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 #[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 #[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 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}