rat_scrolled/
scroll_area.rs

1use crate::event::ScrollOutcome;
2use crate::{Scroll, ScrollState, ScrollbarPolicy};
3use rat_event::{HandleEvent, MouseOnly, ct_event, flow};
4use ratatui::buffer::Buffer;
5use ratatui::layout::{Position, Rect};
6use ratatui::style::Style;
7use ratatui::widgets::{Block, Padding, ScrollbarOrientation, StatefulWidget, Widget};
8use std::cmp::max;
9
10/// Utility widget for rendering a combination of a Block and
11/// one or two Scroll(bars). Any of these can be None.
12#[derive(Debug, Default, Clone)]
13pub struct ScrollArea<'a> {
14    style: Style,
15    ignore_block: bool,
16    block: Option<&'a Block<'a>>,
17    ignore_scroll: bool,
18    h_scroll: Option<&'a Scroll<'a>>,
19    v_scroll: Option<&'a Scroll<'a>>,
20}
21
22/// Temporary state for ScrollArea.
23///
24/// This state is not meant to keep, it just repackages the
25/// widget state for use by ScrollArea.
26#[derive(Debug, Default)]
27pub struct ScrollAreaState<'a> {
28    /// This area is only used for event-handling.
29    /// Populate before calling the event-handler.
30    area: Rect,
31    /// Horizontal scroll state.
32    h_scroll: Option<&'a mut ScrollState>,
33    /// Vertical scroll state.
34    v_scroll: Option<&'a mut ScrollState>,
35}
36
37impl<'a> ScrollArea<'a> {
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Set the base style.
43    pub fn style(mut self, style: Style) -> Self {
44        self.style = style;
45        self
46    }
47
48    /// Sets the block.
49    pub fn block(mut self, block: Option<&'a Block<'a>>) -> Self {
50        self.block = block;
51        self
52    }
53
54    /// Ignores the block when rendering and renders only the scrollbars.
55    /// The block is still considered when calculating everything.
56    pub fn ignore_block(mut self) -> Self {
57        self.ignore_block = true;
58        self
59    }
60
61    /// Sets the horizontal scroll.
62    pub fn h_scroll(mut self, scroll: Option<&'a Scroll<'a>>) -> Self {
63        self.h_scroll = scroll;
64        self
65    }
66
67    /// Sets the vertical scroll.
68    pub fn v_scroll(mut self, scroll: Option<&'a Scroll<'a>>) -> Self {
69        self.v_scroll = scroll;
70        self
71    }
72
73    /// Ignores the scrollbars when rendering and renders only the block.
74    /// The scrollbars are still considered when calculating everything.
75    pub fn ignore_scroll(mut self) -> Self {
76        self.ignore_scroll = true;
77        self
78    }
79
80    /// What is the combined Padding.
81    pub fn padding(&self) -> Padding {
82        let mut padding = block_padding(&self.block);
83        if let Some(h_scroll) = self.h_scroll {
84            let scroll_pad = h_scroll.padding();
85            padding.top = max(padding.top, scroll_pad.top);
86            padding.bottom = max(padding.bottom, scroll_pad.bottom);
87        }
88        if let Some(v_scroll) = self.v_scroll {
89            let scroll_pad = v_scroll.padding();
90            padding.left = max(padding.left, scroll_pad.left);
91            padding.right = max(padding.right, scroll_pad.right);
92        }
93        padding
94    }
95
96    /// Calculate the size of the inner area.
97    pub fn inner(
98        &self,
99        area: Rect,
100        hscroll_state: Option<&ScrollState>,
101        vscroll_state: Option<&ScrollState>,
102    ) -> Rect {
103        layout(
104            self.block,
105            self.h_scroll,
106            self.v_scroll,
107            area,
108            hscroll_state,
109            vscroll_state,
110        )
111        .0
112    }
113}
114
115/// Get the padding the block imposes as Padding.
116fn block_padding(block: &Option<&Block<'_>>) -> Padding {
117    let area = Rect::new(0, 0, 20, 20);
118    let inner = if let Some(block) = block {
119        block.inner(area)
120    } else {
121        area
122    };
123    Padding {
124        left: inner.left() - area.left(),
125        right: area.right() - inner.right(),
126        top: inner.top() - area.top(),
127        bottom: area.bottom() - inner.bottom(),
128    }
129}
130
131impl<'a> StatefulWidget for ScrollArea<'a> {
132    type State = ScrollAreaState<'a>;
133
134    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
135        render_scroll_area(&self, area, buf, state);
136    }
137}
138
139impl<'a> StatefulWidget for &ScrollArea<'a> {
140    type State = ScrollAreaState<'a>;
141
142    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
143        render_scroll_area(self, area, buf, state);
144    }
145}
146
147fn render_scroll_area(
148    widget: &ScrollArea<'_>,
149    area: Rect,
150    buf: &mut Buffer,
151    state: &mut ScrollAreaState<'_>,
152) {
153    let (_, hscroll_area, vscroll_area) = layout(
154        widget.block,
155        widget.h_scroll,
156        widget.v_scroll,
157        area,
158        state.h_scroll.as_deref(),
159        state.v_scroll.as_deref(),
160    );
161
162    if !widget.ignore_block {
163        if let Some(block) = widget.block {
164            block.render(area, buf);
165        } else {
166            buf.set_style(area, widget.style);
167        }
168    }
169    if !widget.ignore_scroll {
170        if let Some(h) = widget.h_scroll {
171            if let Some(hstate) = &mut state.h_scroll {
172                h.render(hscroll_area, buf, hstate);
173            } else {
174                panic!("no horizontal scroll state");
175            }
176        }
177        if let Some(v) = widget.v_scroll {
178            if let Some(vstate) = &mut state.v_scroll {
179                v.render(vscroll_area, buf, vstate)
180            } else {
181                panic!("no vertical scroll state");
182            }
183        }
184    }
185}
186
187/// Calculate the layout for the given scrollbars.
188/// This prevents overlaps in the corners, if both scrollbars are
189/// visible, and tries to fit in the given block.
190///
191/// Returns (inner, h_area, v_area).
192///
193/// __Panic__
194///
195/// Panics if the orientation doesn't match,
196/// - h_scroll doesn't accept ScrollBarOrientation::Vertical* and
197/// - v_scroll doesn't accept ScrollBarOrientation::Horizontal*.
198///
199/// __Panic__
200///
201/// if the state doesn't contain the necessary scroll-states.
202fn layout<'a>(
203    block: Option<&Block<'a>>,
204    hscroll: Option<&Scroll<'a>>,
205    vscroll: Option<&Scroll<'a>>,
206    area: Rect,
207    hscroll_state: Option<&ScrollState>,
208    vscroll_state: Option<&ScrollState>,
209) -> (Rect, Rect, Rect) {
210    let mut inner = area;
211
212    if let Some(block) = block {
213        inner = block.inner(area);
214    }
215
216    if let Some(hscroll) = hscroll {
217        let show = match hscroll.get_policy() {
218            ScrollbarPolicy::Always => true,
219            ScrollbarPolicy::Minimize => true,
220            ScrollbarPolicy::Collapse => {
221                if let Some(hscroll_state) = hscroll_state {
222                    hscroll_state.max_offset > 0
223                } else {
224                    true
225                }
226            }
227        };
228        if show {
229            match hscroll.get_orientation() {
230                ScrollbarOrientation::VerticalRight => {
231                    unimplemented!(
232                        "ScrollbarOrientation::VerticalRight not supported for horizontal scrolling."
233                    );
234                }
235                ScrollbarOrientation::VerticalLeft => {
236                    unimplemented!(
237                        "ScrollbarOrientation::VerticalLeft not supported for horizontal scrolling."
238                    );
239                }
240                ScrollbarOrientation::HorizontalBottom => {
241                    if inner.bottom() == area.bottom() {
242                        inner.height = inner.height.saturating_sub(1);
243                    }
244                }
245                ScrollbarOrientation::HorizontalTop => {
246                    if inner.top() == area.top() {
247                        inner.y += 1;
248                        inner.height = inner.height.saturating_sub(1);
249                    }
250                }
251            }
252        }
253    }
254
255    if let Some(vscroll) = vscroll {
256        let show = match vscroll.get_policy() {
257            ScrollbarPolicy::Always => true,
258            ScrollbarPolicy::Minimize => true,
259            ScrollbarPolicy::Collapse => {
260                if let Some(vscroll_state) = vscroll_state {
261                    vscroll_state.max_offset > 0
262                } else {
263                    true
264                }
265            }
266        };
267        if show {
268            match vscroll.get_orientation() {
269                ScrollbarOrientation::VerticalRight => {
270                    if inner.right() == area.right() {
271                        inner.width = inner.width.saturating_sub(1);
272                    }
273                }
274                ScrollbarOrientation::VerticalLeft => {
275                    if inner.left() == area.left() {
276                        inner.x += 1;
277                        inner.width = inner.width.saturating_sub(1);
278                    }
279                }
280                ScrollbarOrientation::HorizontalBottom => {
281                    unimplemented!(
282                        "ScrollbarOrientation::HorizontalBottom not supported for vertical scrolling."
283                    );
284                }
285                ScrollbarOrientation::HorizontalTop => {
286                    unimplemented!(
287                        "ScrollbarOrientation::HorizontalTop not supported for vertical scrolling."
288                    );
289                }
290            }
291        }
292    }
293
294    // horizontal
295    let h_area = if let Some(hscroll) = hscroll {
296        let show = match hscroll.get_policy() {
297            ScrollbarPolicy::Always => true,
298            ScrollbarPolicy::Minimize => true,
299            ScrollbarPolicy::Collapse => {
300                if let Some(hscroll_state) = hscroll_state {
301                    hscroll_state.max_offset > 0
302                } else {
303                    true
304                }
305            }
306        };
307        if show {
308            match hscroll.get_orientation() {
309                ScrollbarOrientation::HorizontalBottom => Rect::new(
310                    inner.x + hscroll.get_start_margin(),
311                    area.bottom().saturating_sub(1),
312                    inner
313                        .width
314                        .saturating_sub(hscroll.get_start_margin() + hscroll.get_end_margin()),
315                    if area.height > 0 { 1 } else { 0 },
316                ),
317                ScrollbarOrientation::HorizontalTop => Rect::new(
318                    inner.x + hscroll.get_start_margin(),
319                    area.y,
320                    inner
321                        .width
322                        .saturating_sub(hscroll.get_start_margin() + hscroll.get_end_margin()),
323                    if area.height > 0 { 1 } else { 0 },
324                ),
325                _ => unreachable!(),
326            }
327        } else {
328            Rect::new(area.x, area.y, 0, 0)
329        }
330    } else {
331        Rect::new(area.x, area.y, 0, 0)
332    };
333
334    // vertical
335    let v_area = if let Some(vscroll) = vscroll {
336        let show = match vscroll.get_policy() {
337            ScrollbarPolicy::Always => true,
338            ScrollbarPolicy::Minimize => true,
339            ScrollbarPolicy::Collapse => {
340                if let Some(vscroll_state) = vscroll_state {
341                    vscroll_state.max_offset > 0
342                } else {
343                    true
344                }
345            }
346        };
347        if show {
348            match vscroll.get_orientation() {
349                ScrollbarOrientation::VerticalRight => Rect::new(
350                    area.right().saturating_sub(1),
351                    inner.y + vscroll.get_start_margin(),
352                    if area.width > 0 { 1 } else { 0 },
353                    inner
354                        .height
355                        .saturating_sub(vscroll.get_start_margin() + vscroll.get_end_margin()),
356                ),
357                ScrollbarOrientation::VerticalLeft => Rect::new(
358                    area.x,
359                    inner.y + vscroll.get_start_margin(),
360                    if area.width > 0 { 1 } else { 0 },
361                    inner
362                        .height
363                        .saturating_sub(vscroll.get_start_margin() + vscroll.get_end_margin()),
364                ),
365                _ => unreachable!(),
366            }
367        } else {
368            Rect::new(area.x, area.y, 0, 0)
369        }
370    } else {
371        Rect::new(area.x, area.y, 0, 0)
372    };
373
374    (inner, h_area, v_area)
375}
376
377impl<'a> ScrollAreaState<'a> {
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    pub fn area(mut self, area: Rect) -> Self {
383        self.area = area;
384        self
385    }
386
387    pub fn v_scroll(mut self, v_scroll: &'a mut ScrollState) -> Self {
388        self.v_scroll = Some(v_scroll);
389        self
390    }
391
392    pub fn v_scroll_opt(mut self, v_scroll: Option<&'a mut ScrollState>) -> Self {
393        self.v_scroll = v_scroll;
394        self
395    }
396
397    pub fn h_scroll(mut self, h_scroll: &'a mut ScrollState) -> Self {
398        self.h_scroll = Some(h_scroll);
399        self
400    }
401
402    pub fn h_scroll_opt(mut self, h_scroll: Option<&'a mut ScrollState>) -> Self {
403        self.h_scroll = h_scroll;
404        self
405    }
406}
407
408///
409/// Handle scrolling for the whole area spanned by the two scroll-states.
410///
411impl HandleEvent<crossterm::event::Event, MouseOnly, ScrollOutcome> for ScrollAreaState<'_> {
412    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: MouseOnly) -> ScrollOutcome {
413        if let Some(h_scroll) = &mut self.h_scroll {
414            flow!(match event {
415                // right scroll with ALT down. shift doesn't work?
416                ct_event!(scroll ALT down for column, row) => {
417                    if self.area.contains(Position::new(*column, *row)) {
418                        ScrollOutcome::Right(h_scroll.scroll_by())
419                    } else {
420                        ScrollOutcome::Continue
421                    }
422                }
423                // left scroll with ALT up. shift doesn't work?
424                ct_event!(scroll ALT up for column, row) => {
425                    if self.area.contains(Position::new(*column, *row)) {
426                        ScrollOutcome::Left(h_scroll.scroll_by())
427                    } else {
428                        ScrollOutcome::Continue
429                    }
430                }
431                _ => ScrollOutcome::Continue,
432            });
433            flow!(h_scroll.handle(event, MouseOnly));
434        }
435        if let Some(v_scroll) = &mut self.v_scroll {
436            flow!(match event {
437                ct_event!(scroll down for column, row) => {
438                    if self.area.contains(Position::new(*column, *row)) {
439                        ScrollOutcome::Down(v_scroll.scroll_by())
440                    } else {
441                        ScrollOutcome::Continue
442                    }
443                }
444                ct_event!(scroll up for column, row) => {
445                    if self.area.contains(Position::new(*column, *row)) {
446                        ScrollOutcome::Up(v_scroll.scroll_by())
447                    } else {
448                        ScrollOutcome::Continue
449                    }
450                }
451                _ => ScrollOutcome::Continue,
452            });
453            flow!(v_scroll.handle(event, MouseOnly));
454        }
455
456        ScrollOutcome::Continue
457    }
458}