nandi/jolt-nativepublic Fork 0
a7f62025fc9a5a5db4edb9a6ba6808dc31f7596b
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

layout.rs · 490 lines · 17.0 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! Two sizes per node, and how a box shares out what it has.
2//!
3//! Every node answers a *natural* size — what it would like — and a *minimum*
4//! — what it can survive on. A container hands out its natural sizes when there
5//! is room, shrinks them proportionally toward the minimums when there is not,
6//! and gives the surplus to whoever asked to expand. `:width-request` and
7//! `:height-request` are a floor on both numbers, so asking for four rows gets
8//! four rows even when space is short.
9//!
10//! Nothing here touches a terminal or the screen grid: sizes are arithmetic on
11//! the tree, which is why the layout tests below need no TTY.
12
13use crate::screen::Rect;
14use crate::tree::{Props, Tag, Tree};
15
16/// How a child that is not filling its cross axis sits in the space it was
17/// given. `:halign` and `:valign` in the props.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Align {
20 Fill,
21 Start,
22 Center,
23 End,
24}
25
26impl Align {
27 pub fn parse(text: &str) -> Self {
28 match text {
29 "start" => Self::Start,
30 "center" | "centre" => Self::Center,
31 "end" => Self::End,
32 _ => Self::Fill,
33 }
34 }
35
36 /// Where a span of `size` sits inside `avail`.
37 fn offset(self, size: u16, avail: u16) -> u16 {
38 let slack = avail.saturating_sub(size);
39 match self {
40 Self::Fill | Self::Start => 0,
41 Self::Center => slack / 2,
42 Self::End => slack,
43 }
44 }
45}
46
47/// Whether a box stacks its children across or down.
48pub fn horizontal(props: &Props) -> bool {
49 props.str("orientation") == "horizontal"
50}
51
52/// The cells a node gives up on each side before its content starts. `:margin`
53/// and `:padding` are one inset here — a terminal cell has no border between
54/// them to tell them apart, and a caller that sets both means both.
55pub fn inset(tag: &Tag, props: &Props) -> u16 {
56 let own = props.cells("margin", 0) + props.cells("padding", 0);
57 // A frame — and an overlay, which is a frame that floats — spends a cell a
58 // side on its border.
59 own + if matches!(tag, Tag::Frame | Tag::Overlay) {
60 1
61 } else {
62 0
63 }
64}
65
66/// Break `text` to `width` columns, on spaces where it can and mid-word where
67/// it must. Explicit newlines are always breaks.
68pub fn wrap(text: &str, width: u16) -> Vec<String> {
69 if width == 0 {
70 return Vec::new();
71 }
72 let width = width as usize;
73 let mut lines = Vec::new();
74 for paragraph in text.split('\n') {
75 let mut line = String::new();
76 let mut len = 0usize;
77 for word in paragraph.split(' ') {
78 let word_len = word.chars().count();
79 if len > 0 && len + 1 + word_len > width {
80 lines.push(std::mem::take(&mut line));
81 len = 0;
82 }
83 if word_len > width {
84 // Longer than the whole line: break it where the line ends
85 // rather than let it run off the edge.
86 for ch in word.chars() {
87 if len == width {
88 lines.push(std::mem::take(&mut line));
89 len = 0;
90 }
91 line.push(ch);
92 len += 1;
93 }
94 continue;
95 }
96 if len > 0 {
97 line.push(' ');
98 len += 1;
99 }
100 line.push_str(word);
101 len += word_len;
102 }
103 lines.push(line);
104 }
105 lines
106}
107
108fn columns(text: &str) -> u16 {
109 text.split('\n')
110 .map(|line| line.chars().count())
111 .max()
112 .unwrap_or(0)
113 .min(u16::MAX as usize) as u16
114}
115
116/// The longest single word — a label cannot usefully be narrower than this.
117fn longest_word(text: &str) -> u16 {
118 text.split([' ', '\n'])
119 .map(|w| w.chars().count())
120 .max()
121 .unwrap_or(0)
122 .min(u16::MAX as usize) as u16
123}
124
125/// The text an entry shows: its own, or its placeholder when it has none.
126pub fn entry_text(props: &Props) -> String {
127 let text = props.str("text");
128 if text.is_empty() {
129 props.str("placeholder").to_owned()
130 } else {
131 text.to_owned()
132 }
133}
134
135/// A node's content size before its own request or inset is applied.
136fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
137 let tag = tree.tag(id);
138 let props = tree.props(id);
139 let text = props.label();
140 match tag {
141 Tag::Button => columns(text).saturating_add(4),
142 Tag::CheckButton => columns(text).saturating_add(4),
143 Tag::Entry => {
144 let want = columns(&entry_text(&props)).saturating_add(1).max(12);
145 if minimum {
146 want.min(6)
147 } else {
148 want
149 }
150 }
151 Tag::Label | Tag::Title | Tag::DimLabel => {
152 if minimum {
153 longest_word(text)
154 } else {
155 columns(text)
156 }
157 }
158 Tag::Separator => 1,
159 Tag::Spacer => props.cells("size", 1),
160 Tag::Progress => {
161 if minimum {
162 4
163 } else {
164 20
165 }
166 }
167 Tag::Spinner => 1,
168 Tag::Listbox => tree
169 .children(id)
170 .iter()
171 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
172 .max()
173 .unwrap_or(0),
174 // Every container measures its children the same way; only the axis
175 // the sum runs along differs.
176 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
177 let children = tree.children(id);
178 let spacing = props.cells("spacing", 0);
179 let sizes = children
180 .iter()
181 .map(|c| width(tree, *c, minimum))
182 .collect::<Vec<_>>();
183 let content = if horizontal(&props) && matches!(tag, Tag::Box) {
184 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
185 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
186 } else {
187 sizes.into_iter().max().unwrap_or(0)
188 };
189 // A frame's heading sits in its top edge, so it is part of how wide
190 // the frame has to be — a box narrower than its own label reads as
191 // a truncated one.
192 if matches!(tag, Tag::Frame | Tag::Overlay) {
193 content.max(columns(props.label()).saturating_add(2))
194 } else {
195 content
196 }
197 }
198 }
199}
200
201/// A node's natural or minimum width, requests and insets included.
202pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
203 let props = tree.props(id);
204 let pad = inset(&tree.tag(id), &props).saturating_mul(2);
205 let content = intrinsic_width(tree, id, minimum).saturating_add(pad);
206 content.max(props.cells("width-request", 0))
207}
208
209/// How tall `id` is when laid out `avail` columns wide.
210///
211/// Height depends on width — that is what wrapping means — so there is no
212/// natural height to ask for on its own.
213pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
214 let tag = tree.tag(id);
215 let props = tree.props(id);
216 let pad = inset(&tag, &props);
217 let inner = avail.saturating_sub(pad.saturating_mul(2));
218 let content = match tag {
219 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
220 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
221 Tag::Entry => props.cells("rows", 1).max(1),
222 Tag::Spacer => props.cells("size", 1),
223 Tag::Listbox => tree.child_count(id) as u16,
224 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
225 let children = tree.children(id);
226 let spacing = props.cells("spacing", 0);
227 if horizontal(&props) && matches!(tag, Tag::Box) {
228 // Across: each child is measured at the width it will get.
229 let shares = share(tree, id, inner, true);
230 children
231 .iter()
232 .zip(shares)
233 .map(|(c, w)| height_for_width(tree, *c, w))
234 .max()
235 .unwrap_or(0)
236 } else {
237 let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
238 children
239 .iter()
240 .map(|c| height_for_width(tree, *c, inner))
241 .fold(gaps, |a, b| a.saturating_add(b))
242 }
243 }
244 };
245 content
246 .saturating_add(pad.saturating_mul(2))
247 .max(props.cells("height-request", 0))
248}
249
250/// Share `avail` out among the children of `id` along one axis.
251///
252/// `across` picks the axis: true for a horizontal box sharing columns, false
253/// for a vertical one sharing rows. The rule is the same either way — natural
254/// sizes first, shrink proportionally toward the minimums when short, and the
255/// surplus to whoever set `:hexpand` / `:vexpand`.
256pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
257 let children = tree.children(id);
258 if children.is_empty() {
259 return Vec::new();
260 }
261 let props = tree.props(id);
262 let spacing = props.cells("spacing", 0);
263 let gaps = spacing.saturating_mul((children.len() - 1) as u16);
264 let room = avail.saturating_sub(gaps) as i64;
265
266 let measure = |child: u32, minimum: bool| -> i64 {
267 if across {
268 width(tree, child, minimum) as i64
269 } else {
270 // Down the page a child's height depends on the width it gets,
271 // which the caller has already fixed by the time it asks.
272 height_for_width(tree, child, avail) as i64
273 }
274 };
275
276 let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect();
277 let min: Vec<i64> = children
278 .iter()
279 .zip(&nat)
280 .map(|(c, n)| measure(*c, true).min(*n))
281 .collect();
282 let total: i64 = nat.iter().sum();
283 let mut out = nat.clone();
284
285 if total > room {
286 // Short: take the overrun out of whatever each child is willing to give
287 // up, in proportion to how much that is.
288 let mut over = total - room;
289 let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum();
290 if slack > 0 {
291 for i in 0..out.len() {
292 let give = ((nat[i] - min[i]) * over.min(slack)) / slack;
293 out[i] -= give;
294 }
295 over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>();
296 }
297 // Rounding, and children with no slack at all: take the rest off the
298 // end, which is where a terminal clips anyway.
299 let mut i = out.len();
300 while over > 0 && i > 0 {
301 i -= 1;
302 let give = (out[i] - min[i]).min(over);
303 out[i] -= give;
304 over -= give;
305 }
306 } else if total < room {
307 let key = if across { "hexpand" } else { "vexpand" };
308 let greedy: Vec<usize> = children
309 .iter()
310 .enumerate()
311 .filter(|(_, c)| tree.props(**c).bool(key, false))
312 .map(|(i, _)| i)
313 .collect();
314 if !greedy.is_empty() {
315 let extra = room - total;
316 let each = extra / greedy.len() as i64;
317 let mut rest = extra % greedy.len() as i64;
318 for i in greedy {
319 out[i] += each + if rest > 0 { 1 } else { 0 };
320 rest -= 1;
321 }
322 }
323 }
324 out.into_iter()
325 .map(|n| n.clamp(0, u16::MAX as i64) as u16)
326 .collect()
327}
328
329/// The rect a child of `size` gets inside `avail` on its cross axis.
330pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
331 match align {
332 Align::Fill => (0, avail),
333 other => {
334 let size = size.min(avail);
335 (other.offset(size, avail), size)
336 }
337 }
338}
339
340/// Lay the children of a box out inside `area`.
341pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
342 let props = tree.props(id);
343 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
344 let spacing = props.cells("spacing", 0);
345 let children = tree.children(id);
346 let shares = share(tree, id, if across { area.w } else { area.h }, across);
347
348 let mut out = Vec::with_capacity(children.len());
349 let mut at = 0u16;
350 for (child, main) in children.iter().zip(shares) {
351 let cprops = tree.props(*child);
352 let rect = if across {
353 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
354 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
355 Rect::new(
356 area.x.saturating_add(at),
357 area.y.saturating_add(dy),
358 main,
359 h,
360 )
361 } else {
362 let want = width(tree, *child, false);
363 let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w);
364 Rect::new(
365 area.x.saturating_add(dx),
366 area.y.saturating_add(at),
367 w,
368 main,
369 )
370 };
371 out.push(rect);
372 at = at.saturating_add(main).saturating_add(spacing);
373 }
374 out
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::tree::Value;
381
382 fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 {
383 let id = tree.new_node("label");
384 tree.set(id, "label", Value::Str(text.into()));
385 tree.append(parent, id);
386 id
387 }
388
389 #[test]
390 fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() {
391 assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
392 assert_eq!(
393 wrap("antidisestablishment", 6),
394 vec!["antidi", "sestab", "lishme", "nt"]
395 );
396 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
397 }
398
399 #[test]
400 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
401 let mut tree = Tree::new();
402 let root = tree.root();
403 let id = label(&mut tree, root, "one two three");
404 assert_eq!(width(&tree, id, false), 13);
405 assert_eq!(width(&tree, id, true), 5);
406 assert_eq!(height_for_width(&tree, id, 7), 2);
407 }
408
409 #[test]
410 fn a_width_request_is_a_floor_on_both_sizes() {
411 let mut tree = Tree::new();
412 let root = tree.root();
413 let id = label(&mut tree, root, "hi");
414 tree.set(id, "width-request", Value::Num(20.0));
415 assert_eq!(width(&tree, id, false), 20);
416 assert_eq!(width(&tree, id, true), 20);
417 }
418
419 #[test]
420 fn a_height_request_of_four_rows_gets_four_rows() {
421 let mut tree = Tree::new();
422 let root = tree.root();
423 let id = label(&mut tree, root, "hi");
424 tree.set(id, "height-request", Value::Num(4.0));
425 assert_eq!(height_for_width(&tree, id, 10), 4);
426 }
427
428 #[test]
429 fn a_horizontal_box_gives_the_surplus_to_whoever_expands() {
430 let mut tree = Tree::new();
431 let row = tree.new_node("hbox");
432 tree.set(row, "orientation", Value::Str("horizontal".into()));
433 let root = tree.root();
434 tree.append(root, row);
435 let a = label(&mut tree, row, "aa");
436 let b = label(&mut tree, row, "bb");
437 tree.set(b, "hexpand", Value::Bool(true));
438 assert_eq!(share(&tree, row, 20, true), vec![2, 18]);
439 let _ = a;
440 }
441
442 #[test]
443 fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() {
444 let mut tree = Tree::new();
445 let row = tree.new_node("hbox");
446 tree.set(row, "orientation", Value::Str("horizontal".into()));
447 let root = tree.root();
448 tree.append(root, row);
449 label(&mut tree, row, "one two");
450 label(&mut tree, row, "three four");
451 // 17 natural, 10 offered: both give up some, neither goes under its
452 // longest word.
453 let shares = share(&tree, row, 10, true);
454 assert_eq!(shares.iter().sum::<u16>(), 10);
455 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
456 }
457
458 #[test]
459 fn spacing_comes_off_the_room_before_it_is_shared() {
460 let mut tree = Tree::new();
461 let row = tree.new_node("hbox");
462 tree.set(row, "orientation", Value::Str("horizontal".into()));
463 tree.set(row, "spacing", Value::Num(2.0));
464 let root = tree.root();
465 tree.append(root, row);
466 let a = label(&mut tree, row, "aa");
467 let b = label(&mut tree, row, "bb");
468 tree.set(a, "hexpand", Value::Bool(true));
469 tree.set(b, "hexpand", Value::Bool(true));
470 assert_eq!(share(&tree, row, 12, true), vec![5, 5]);
471 }
472
473 #[test]
474 fn a_centred_child_sits_in_the_middle_of_its_row() {
475 assert_eq!(place(Align::Center, 4, 10), (3, 4));
476 assert_eq!(place(Align::End, 4, 10), (6, 4));
477 assert_eq!(place(Align::Fill, 4, 10), (0, 10));
478 }
479
480 #[test]
481 fn a_frame_spends_a_cell_a_side_on_its_border() {
482 let mut tree = Tree::new();
483 let frame = tree.new_node("frame");
484 let root = tree.root();
485 tree.append(root, frame);
486 label(&mut tree, frame, "hi");
487 assert_eq!(width(&tree, frame, false), 4);
488 assert_eq!(height_for_width(&tree, frame, 4), 3);
489 }
490}