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}