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}