Skip to main content

saorsa_core/layout/
scroll.rs

1//! Scroll region management.
2//!
3//! Tracks scroll state for widgets with `overflow: scroll` or `overflow: auto`,
4//! computing visible content regions within viewports.
5
6use std::collections::HashMap;
7
8use crate::focus::WidgetId;
9use crate::geometry::Rect;
10use crate::tcss::cascade::ComputedStyle;
11use crate::tcss::property::PropertyName;
12use crate::tcss::value::CssValue;
13
14/// Overflow behavior for a single axis.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum OverflowBehavior {
17    /// Content is not clipped and may overflow.
18    #[default]
19    Visible,
20    /// Content is clipped at the boundary.
21    Hidden,
22    /// Content is clipped and scrollbars are shown.
23    Scroll,
24    /// Content is clipped; scrollbars shown only when needed.
25    Auto,
26}
27
28/// Scroll state for a single widget.
29#[derive(Clone, Debug, Default, PartialEq)]
30pub struct ScrollState {
31    /// Horizontal scroll offset in cells.
32    pub offset_x: u16,
33    /// Vertical scroll offset in cells.
34    pub offset_y: u16,
35    /// Total content width in cells.
36    pub content_width: u16,
37    /// Total content height in cells.
38    pub content_height: u16,
39    /// Viewport width in cells.
40    pub viewport_width: u16,
41    /// Viewport height in cells.
42    pub viewport_height: u16,
43}
44
45impl ScrollState {
46    /// Create a new scroll state.
47    pub const fn new(
48        content_width: u16,
49        content_height: u16,
50        viewport_width: u16,
51        viewport_height: u16,
52    ) -> Self {
53        Self {
54            offset_x: 0,
55            offset_y: 0,
56            content_width,
57            content_height,
58            viewport_width,
59            viewport_height,
60        }
61    }
62
63    /// Whether horizontal scrolling is possible.
64    pub const fn can_scroll_x(&self) -> bool {
65        self.content_width > self.viewport_width
66    }
67
68    /// Whether vertical scrolling is possible.
69    pub const fn can_scroll_y(&self) -> bool {
70        self.content_height > self.viewport_height
71    }
72
73    /// Maximum horizontal scroll offset.
74    pub fn max_offset_x(&self) -> u16 {
75        self.content_width.saturating_sub(self.viewport_width)
76    }
77
78    /// Maximum vertical scroll offset.
79    pub fn max_offset_y(&self) -> u16 {
80        self.content_height.saturating_sub(self.viewport_height)
81    }
82
83    /// The visible content rectangle (in content coordinates).
84    pub fn visible_rect(&self) -> Rect {
85        Rect::new(
86            self.offset_x,
87            self.offset_y,
88            self.viewport_width,
89            self.viewport_height,
90        )
91    }
92}
93
94/// Manages scroll regions for all scrollable widgets.
95pub struct ScrollManager {
96    regions: HashMap<WidgetId, ScrollState>,
97}
98
99impl ScrollManager {
100    /// Create a new scroll manager.
101    pub fn new() -> Self {
102        Self {
103            regions: HashMap::new(),
104        }
105    }
106
107    /// Register a scrollable region for a widget.
108    pub fn register(
109        &mut self,
110        widget_id: WidgetId,
111        content_width: u16,
112        content_height: u16,
113        viewport_width: u16,
114        viewport_height: u16,
115    ) {
116        self.regions.insert(
117            widget_id,
118            ScrollState::new(
119                content_width,
120                content_height,
121                viewport_width,
122                viewport_height,
123            ),
124        );
125    }
126
127    /// Scroll by a relative offset, clamping to valid range.
128    pub fn scroll_by(&mut self, widget_id: WidgetId, dx: i16, dy: i16) {
129        if let Some(state) = self.regions.get_mut(&widget_id) {
130            let new_x = i32::from(state.offset_x) + i32::from(dx);
131            let new_y = i32::from(state.offset_y) + i32::from(dy);
132            state.offset_x = clamp_offset(new_x, state.max_offset_x());
133            state.offset_y = clamp_offset(new_y, state.max_offset_y());
134        }
135    }
136
137    /// Scroll to an absolute position, clamping to valid range.
138    pub fn scroll_to(&mut self, widget_id: WidgetId, x: u16, y: u16) {
139        if let Some(state) = self.regions.get_mut(&widget_id) {
140            state.offset_x = x.min(state.max_offset_x());
141            state.offset_y = y.min(state.max_offset_y());
142        }
143    }
144
145    /// Get the scroll state for a widget.
146    pub fn get(&self, widget_id: WidgetId) -> Option<&ScrollState> {
147        self.regions.get(&widget_id)
148    }
149
150    /// Check if a widget can scroll horizontally.
151    pub fn can_scroll_x(&self, widget_id: WidgetId) -> bool {
152        self.regions
153            .get(&widget_id)
154            .is_some_and(|s| s.can_scroll_x())
155    }
156
157    /// Check if a widget can scroll vertically.
158    pub fn can_scroll_y(&self, widget_id: WidgetId) -> bool {
159        self.regions
160            .get(&widget_id)
161            .is_some_and(|s| s.can_scroll_y())
162    }
163
164    /// Get the visible content rectangle for a scrollable widget.
165    pub fn visible_rect(&self, widget_id: WidgetId) -> Option<Rect> {
166        self.regions.get(&widget_id).map(|s| s.visible_rect())
167    }
168
169    /// Remove a scroll region.
170    pub fn remove(&mut self, widget_id: WidgetId) {
171        self.regions.remove(&widget_id);
172    }
173}
174
175impl Default for ScrollManager {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181/// Extract overflow behavior from a computed style.
182///
183/// Returns `(overflow_x, overflow_y)`.
184pub fn extract_overflow(style: &ComputedStyle) -> (OverflowBehavior, OverflowBehavior) {
185    let base = style
186        .get(&PropertyName::Overflow)
187        .map(keyword_to_overflow)
188        .unwrap_or_default();
189    let ox = style
190        .get(&PropertyName::OverflowX)
191        .map(keyword_to_overflow)
192        .unwrap_or(base);
193    let oy = style
194        .get(&PropertyName::OverflowY)
195        .map(keyword_to_overflow)
196        .unwrap_or(base);
197    (ox, oy)
198}
199
200/// Convert a CSS keyword to [`OverflowBehavior`].
201fn keyword_to_overflow(value: &CssValue) -> OverflowBehavior {
202    match value {
203        CssValue::Keyword(k) => match k.to_ascii_lowercase().as_str() {
204            "visible" => OverflowBehavior::Visible,
205            "hidden" => OverflowBehavior::Hidden,
206            "scroll" => OverflowBehavior::Scroll,
207            "auto" => OverflowBehavior::Auto,
208            _ => OverflowBehavior::Visible,
209        },
210        _ => OverflowBehavior::Visible,
211    }
212}
213
214/// Clamp a signed offset to `[0, max]`.
215fn clamp_offset(value: i32, max: u16) -> u16 {
216    if value < 0 {
217        0
218    } else if value > i32::from(max) {
219        max
220    } else {
221        value as u16
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn wid(n: u64) -> WidgetId {
230        n
231    }
232
233    #[test]
234    fn scroll_state_creation() {
235        let state = ScrollState::new(100, 200, 80, 24);
236        assert_eq!(state.content_width, 100);
237        assert_eq!(state.content_height, 200);
238        assert_eq!(state.viewport_width, 80);
239        assert_eq!(state.viewport_height, 24);
240        assert_eq!(state.offset_x, 0);
241        assert_eq!(state.offset_y, 0);
242    }
243
244    #[test]
245    fn scroll_state_can_scroll() {
246        let state = ScrollState::new(100, 200, 80, 24);
247        assert!(state.can_scroll_x());
248        assert!(state.can_scroll_y());
249
250        let no_scroll = ScrollState::new(40, 10, 80, 24);
251        assert!(!no_scroll.can_scroll_x());
252        assert!(!no_scroll.can_scroll_y());
253    }
254
255    #[test]
256    fn scroll_state_max_offsets() {
257        let state = ScrollState::new(100, 200, 80, 24);
258        assert_eq!(state.max_offset_x(), 20);
259        assert_eq!(state.max_offset_y(), 176);
260    }
261
262    #[test]
263    fn scroll_state_visible_rect() {
264        let mut state = ScrollState::new(100, 200, 80, 24);
265        assert_eq!(state.visible_rect(), Rect::new(0, 0, 80, 24));
266        state.offset_x = 5;
267        state.offset_y = 10;
268        assert_eq!(state.visible_rect(), Rect::new(5, 10, 80, 24));
269    }
270
271    #[test]
272    fn manager_register_and_get() {
273        let mut mgr = ScrollManager::new();
274        mgr.register(wid(1), 100, 200, 80, 24);
275        let state = mgr.get(wid(1));
276        assert!(state.is_some());
277        let state = match state {
278            Some(s) => s,
279            None => unreachable!(),
280        };
281        assert_eq!(state.content_width, 100);
282    }
283
284    #[test]
285    fn manager_scroll_by_clamps() {
286        let mut mgr = ScrollManager::new();
287        mgr.register(wid(1), 100, 200, 80, 24);
288
289        mgr.scroll_by(wid(1), 10, 20);
290        let state = mgr.get(wid(1));
291        assert!(state.is_some());
292        let state = match state {
293            Some(s) => s,
294            None => unreachable!(),
295        };
296        assert_eq!(state.offset_x, 10);
297        assert_eq!(state.offset_y, 20);
298
299        // Scroll beyond max
300        mgr.scroll_by(wid(1), 100, 1000);
301        let state = match mgr.get(wid(1)) {
302            Some(s) => s,
303            None => unreachable!(),
304        };
305        assert_eq!(state.offset_x, 20); // max is 20
306        assert_eq!(state.offset_y, 176); // max is 176
307
308        // Scroll negative past zero
309        mgr.scroll_by(wid(1), -100, -1000);
310        let state = match mgr.get(wid(1)) {
311            Some(s) => s,
312            None => unreachable!(),
313        };
314        assert_eq!(state.offset_x, 0);
315        assert_eq!(state.offset_y, 0);
316    }
317
318    #[test]
319    fn manager_scroll_to() {
320        let mut mgr = ScrollManager::new();
321        mgr.register(wid(1), 100, 200, 80, 24);
322
323        mgr.scroll_to(wid(1), 15, 100);
324        let state = match mgr.get(wid(1)) {
325            Some(s) => s,
326            None => unreachable!(),
327        };
328        assert_eq!(state.offset_x, 15);
329        assert_eq!(state.offset_y, 100);
330
331        // Clamp to max
332        mgr.scroll_to(wid(1), 500, 500);
333        let state = match mgr.get(wid(1)) {
334            Some(s) => s,
335            None => unreachable!(),
336        };
337        assert_eq!(state.offset_x, 20);
338        assert_eq!(state.offset_y, 176);
339    }
340
341    #[test]
342    fn manager_can_scroll() {
343        let mut mgr = ScrollManager::new();
344        mgr.register(wid(1), 100, 200, 80, 24);
345        assert!(mgr.can_scroll_x(wid(1)));
346        assert!(mgr.can_scroll_y(wid(1)));
347        assert!(!mgr.can_scroll_x(wid(999)));
348        assert!(!mgr.can_scroll_y(wid(999)));
349    }
350
351    #[test]
352    fn manager_visible_rect() {
353        let mut mgr = ScrollManager::new();
354        mgr.register(wid(1), 100, 200, 80, 24);
355        let rect = mgr.visible_rect(wid(1));
356        assert_eq!(rect, Some(Rect::new(0, 0, 80, 24)));
357        assert_eq!(mgr.visible_rect(wid(999)), None);
358    }
359
360    #[test]
361    fn manager_remove() {
362        let mut mgr = ScrollManager::new();
363        mgr.register(wid(1), 100, 200, 80, 24);
364        mgr.remove(wid(1));
365        assert!(mgr.get(wid(1)).is_none());
366    }
367
368    #[test]
369    fn extract_overflow_default() {
370        let style = ComputedStyle::new();
371        let (ox, oy) = extract_overflow(&style);
372        assert_eq!(ox, OverflowBehavior::Visible);
373        assert_eq!(oy, OverflowBehavior::Visible);
374    }
375
376    #[test]
377    fn extract_overflow_shorthand() {
378        let mut style = ComputedStyle::new();
379        style.set(PropertyName::Overflow, CssValue::Keyword("hidden".into()));
380        let (ox, oy) = extract_overflow(&style);
381        assert_eq!(ox, OverflowBehavior::Hidden);
382        assert_eq!(oy, OverflowBehavior::Hidden);
383    }
384
385    #[test]
386    fn extract_overflow_xy_separate() {
387        let mut style = ComputedStyle::new();
388        style.set(PropertyName::OverflowX, CssValue::Keyword("scroll".into()));
389        style.set(PropertyName::OverflowY, CssValue::Keyword("hidden".into()));
390        let (ox, oy) = extract_overflow(&style);
391        assert_eq!(ox, OverflowBehavior::Scroll);
392        assert_eq!(oy, OverflowBehavior::Hidden);
393    }
394
395    #[test]
396    fn extract_overflow_auto() {
397        let mut style = ComputedStyle::new();
398        style.set(PropertyName::Overflow, CssValue::Keyword("auto".into()));
399        let (ox, oy) = extract_overflow(&style);
400        assert_eq!(ox, OverflowBehavior::Auto);
401        assert_eq!(oy, OverflowBehavior::Auto);
402    }
403
404    #[test]
405    fn overflow_behavior_default() {
406        assert_eq!(OverflowBehavior::default(), OverflowBehavior::Visible);
407    }
408
409    #[test]
410    fn scroll_state_no_scroll_max_offset_zero() {
411        let state = ScrollState::new(40, 10, 80, 24);
412        assert_eq!(state.max_offset_x(), 0);
413        assert_eq!(state.max_offset_y(), 0);
414    }
415}