Skip to main content

concinnity_asset/
scroll_panel.rs

1// Scrollable UI panel schema.
2
3use crate::{AssetId, de_opt_asset_ref};
4use alloc::string::String;
5use alloc::vec::Vec;
6
7/// Runtime model that makes a band of UI rows scrollable and (optionally)
8/// collapsible.
9///
10/// A `ScrollPanel` is emitted by the build (e.g. by a settings menu) and read
11/// by the UI at runtime; it is not hand-authored. It names a content band (a
12/// fixed rectangle in the menu's reference canvas), the ordered rows that live
13/// inside it, the collapsible groups some rows belong to, and the scrollbar
14/// thumb/track sprites. The UI lays the rows out each frame: a collapsed group's
15/// body rows hide and the rows below them move up; when the visible stack is
16/// taller than the band it scrolls (mouse wheel or thumb drag) and rows outside
17/// the band are clipped.
18///
19/// All pixel fields are in the same reference-space coordinates as the Screen's
20/// other UI (see the overlay scaling notes on [MainMenu](#mainmenu)).
21#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
22#[serde(default)]
23pub struct ScrollPanel {
24    /// [Screen](#screen) this panel belongs to. Resolved automatically from
25    /// the `<screen>_*` naming convention; you don't set this directly. The
26    /// panel is only live while its screen is active.
27    #[serde(deserialize_with = "de_opt_asset_ref")]
28    pub screen: Option<AssetId>,
29    /// Left edge of the content band in reference pixels.
30    pub x: f32,
31    /// Top edge of the content band in reference pixels.
32    pub y: f32,
33    /// Width of the content band in reference pixels.
34    pub width: f32,
35    /// Height of the content band (the visible window) in reference pixels.
36    pub height: f32,
37    /// The rows in the band, top to bottom.
38    pub rows: Vec<ScrollRow>,
39    /// Collapsible groups, referenced by index from [ScrollRow::group].
40    pub groups: Vec<ScrollGroup>,
41    /// Scrollbar thumb [Sprite](#sprite) the UI moves and resizes. `None` for a
42    /// panel with no scrollbar.
43    #[serde(deserialize_with = "de_opt_asset_ref")]
44    pub thumb: Option<AssetId>,
45    /// Scrollbar track [Sprite](#sprite). Hidden along with the thumb when the
46    /// content fits the band.
47    #[serde(deserialize_with = "de_opt_asset_ref")]
48    pub track: Option<AssetId>,
49    /// Left edge of the scrollbar track in reference pixels.
50    pub track_x: f32,
51    /// Top edge of the scrollbar track in reference pixels.
52    pub track_y: f32,
53    /// Width of the scrollbar track in reference pixels.
54    pub track_w: f32,
55    /// Height of the scrollbar track in reference pixels (the thumb travels
56    /// within it).
57    pub track_h: f32,
58}
59
60/// One row inside a [ScrollPanel](#scrollpanel): the elements that move
61/// together, the row's height, and the collapsible group it belongs to.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63#[serde(default)]
64pub struct ScrollRow {
65    /// The [Sprite](#sprite)/[TextLabel](#textlabel) ids that make up this row
66    /// and move (and clip) together. Click regions are matched to their row by
67    /// position, so they are not listed here.
68    pub elements: Vec<AssetId>,
69    /// The row's authored top edge in reference pixels (its build-time, all
70    /// groups expanded, unscrolled position).
71    pub base_y: f32,
72    /// The row's height in reference pixels (its vertical pitch in the stack).
73    pub height: f32,
74    /// Index into [ScrollPanel::groups] of the group whose collapsed state
75    /// hides this row, or `-1` for a row that is always shown (a group header
76    /// or an ungrouped row).
77    pub group: i32,
78}
79
80impl Default for ScrollRow {
81    fn default() -> Self {
82        Self {
83            elements: Vec::new(),
84            base_y: 0.0,
85            height: 0.0,
86            group: -1,
87        }
88    }
89}
90
91/// A collapsible group of rows inside a [ScrollPanel](#scrollpanel).
92#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
93#[serde(default)]
94pub struct ScrollGroup {
95    /// Whether the group starts collapsed (its body rows hidden).
96    pub collapsed: bool,
97    /// The header [TextLabel](#textlabel) whose text gets a `+`/`-` prefix to
98    /// reflect the collapsed state. `None` leaves the header text unchanged.
99    #[serde(deserialize_with = "de_opt_asset_ref")]
100    pub header: Option<AssetId>,
101    /// The header's base title (e.g. `"Advanced"`); the UI shows `"+ Advanced"`
102    /// when collapsed and `"- Advanced"` when expanded.
103    pub title: String,
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn a_blank_row_belongs_to_no_group() {
112        let r = ScrollRow::default();
113        assert!(r.elements.is_empty());
114        assert_eq!((r.base_y, r.height), (0.0, 0.0));
115        // -1 is the ungrouped marker; 0 would put every row in group 0.
116        assert_eq!(r.group, -1);
117    }
118
119    #[test]
120    fn a_blank_group_starts_expanded_with_no_header() {
121        let g = ScrollGroup::default();
122        assert!(!g.collapsed);
123        assert!(g.header.is_none());
124        assert!(g.title.is_empty());
125    }
126
127    #[test]
128    fn a_blank_panel_has_no_rows_and_no_scrollbar() {
129        let p = ScrollPanel::default();
130        assert!(p.rows.is_empty());
131        assert!(p.groups.is_empty());
132        assert!(p.screen.is_none());
133        assert!(p.thumb.is_none());
134        assert!(p.track.is_none());
135        assert_eq!((p.width, p.height), (0.0, 0.0));
136        assert_eq!((p.track_w, p.track_h), (0.0, 0.0));
137    }
138
139    #[test]
140    fn an_authored_panel_parses_its_rows_groups_and_scrollbar() {
141        crate::test_support::install_resolvers();
142        let p: ScrollPanel = serde_json::from_str(
143            r#"{"screen":"settings","x":20,"y":40,"width":600,"height":400,
144                "rows":[{"elements":["row_a","row_b"],"base_y":10,"height":48,"group":0}],
145                "groups":[{"collapsed":true,"header":"adv_header","title":"Advanced"}],
146                "thumb":"bar","track":"bar_bg","track_x":600,"track_y":40,
147                "track_w":8,"track_h":400}"#,
148        )
149        .unwrap();
150        assert_eq!(p.screen, Some(AssetId(8)));
151        assert_eq!(p.rows[0].elements, [AssetId(5), AssetId(5)]);
152        assert_eq!(p.rows[0].group, 0);
153        assert!(p.groups[0].collapsed);
154        assert_eq!(p.groups[0].header, Some(AssetId(10)));
155        assert_eq!(p.groups[0].title, "Advanced");
156        assert_eq!(p.thumb, Some(AssetId(3)));
157        assert_eq!(p.track, Some(AssetId(6)));
158
159        let bytes = postcard::to_allocvec(&p).unwrap();
160        let back: ScrollPanel = postcard::from_bytes(&bytes).unwrap();
161        assert_eq!(back.rows[0].base_y, 10.0);
162        assert_eq!(back.rows[0].height, 48.0);
163        assert_eq!(back.groups[0].title, "Advanced");
164        assert_eq!((back.track_x, back.track_y), (600.0, 40.0));
165        assert_eq!((back.track_w, back.track_h), (8.0, 400.0));
166    }
167}