Skip to main content

concinnity_core/components/
scroll_panel.rs

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