rat_scrolled/
scroll_area.rs

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