nandi/jolt-nativepublic Fork 0
5acc801872977305e0f29a8dc4aca4ddca240f98
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 · 535 lines · 19.0 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d 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 }
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago158 // An unknown tag with nothing under it paints its own text, so it has
159 // to be measured as the label it turns out to be — a widget given no
160 // room is as invisible as one that was never painted.
161 Tag::Unknown(_) if tree.child_count(id) == 0 => {
162 if minimum {
163 longest_word(props.label())
164 } else {
165 columns(props.label())
166 }
167 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago168 Tag::Separator => 1,
169 Tag::Spacer => props.cells("size", 1),
170 Tag::Progress => {
171 if minimum {
172 4
173 } else {
174 20
175 }
176 }
177 Tag::Spinner => 1,
178 Tag::Listbox => tree
179 .children(id)
180 .iter()
181 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
182 .max()
183 .unwrap_or(0),
184 // Every container measures its children the same way; only the axis
185 // the sum runs along differs.
186 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
187 let children = tree.children(id);
188 let spacing = props.cells("spacing", 0);
189 let sizes = children
190 .iter()
191 .map(|c| width(tree, *c, minimum))
192 .collect::<Vec<_>>();
193 let content = if horizontal(&props) && matches!(tag, Tag::Box) {
194 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
195 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
196 } else {
197 sizes.into_iter().max().unwrap_or(0)
198 };
199 // A frame's heading sits in its top edge, so it is part of how wide
200 // the frame has to be — a box narrower than its own label reads as
201 // a truncated one.
202 if matches!(tag, Tag::Frame | Tag::Overlay) {
203 content.max(columns(props.label()).saturating_add(2))
204 } else {
205 content
206 }
207 }
208 }
209}
210
211/// A node's natural or minimum width, requests and insets included.
212pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
213 let props = tree.props(id);
214 let pad = inset(&tree.tag(id), &props).saturating_mul(2);
215 let content = intrinsic_width(tree, id, minimum).saturating_add(pad);
216 content.max(props.cells("width-request", 0))
217}
218
219/// How tall `id` is when laid out `avail` columns wide.
220///
221/// Height depends on width — that is what wrapping means — so there is no
222/// natural height to ask for on its own.
223pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
224 let tag = tree.tag(id);
225 let props = tree.props(id);
226 let pad = inset(&tag, &props);
227 let inner = avail.saturating_sub(pad.saturating_mul(2));
228 let content = match tag {
229 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago230 Tag::Unknown(_) if tree.child_count(id) == 0 => {
231 wrap(props.label(), inner).len() as u16
232 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago233 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
234 Tag::Entry => props.cells("rows", 1).max(1),
235 Tag::Spacer => props.cells("size", 1),
236 Tag::Listbox => tree.child_count(id) as u16,
237 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
238 let children = tree.children(id);
239 let spacing = props.cells("spacing", 0);
240 if horizontal(&props) && matches!(tag, Tag::Box) {
241 // Across: each child is measured at the width it will get.
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago242 let shares = share(tree, id, inner, true, 0);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago243 children
244 .iter()
245 .zip(shares)
246 .map(|(c, w)| height_for_width(tree, *c, w))
247 .max()
248 .unwrap_or(0)
249 } else {
250 let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
251 children
252 .iter()
253 .map(|c| height_for_width(tree, *c, inner))
254 .fold(gaps, |a, b| a.saturating_add(b))
255 }
256 }
257 };
258 content
259 .saturating_add(pad.saturating_mul(2))
260 .max(props.cells("height-request", 0))
261}
262
263/// Share `avail` out among the children of `id` along one axis.
264///
265/// `across` picks the axis: true for a horizontal box sharing columns, false
266/// for a vertical one sharing rows. The rule is the same either way — natural
267/// sizes first, shrink proportionally toward the minimums when short, and the
268/// surplus to whoever set `:hexpand` / `:vexpand`.
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago269///
270/// `cross` is the extent on the *other* axis, and sharing rows out cannot be
271/// done without it: how tall a child is depends on how wide it is, because
272/// that is what wrapping means. Passing the rows in its place measures every
273/// label at a column count of two or three, wraps it to a paragraph, and the
274/// overrun is then taken off the end — which paints a box's first child and
275/// drops every sibling after it. Unused when `across`, where a width does not
276/// depend on a height.
277pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago278 let children = tree.children(id);
279 if children.is_empty() {
280 return Vec::new();
281 }
282 let props = tree.props(id);
283 let spacing = props.cells("spacing", 0);
284 let gaps = spacing.saturating_mul((children.len() - 1) as u16);
285 let room = avail.saturating_sub(gaps) as i64;
286
287 let measure = |child: u32, minimum: bool| -> i64 {
288 if across {
289 width(tree, child, minimum) as i64
290 } else {
291 // Down the page a child's height depends on the width it gets,
292 // which the caller has already fixed by the time it asks.
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago293 height_for_width(tree, child, cross) as i64
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago294 }
295 };
296
297 let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect();
298 let min: Vec<i64> = children
299 .iter()
300 .zip(&nat)
301 .map(|(c, n)| measure(*c, true).min(*n))
302 .collect();
303 let total: i64 = nat.iter().sum();
304 let mut out = nat.clone();
305
306 if total > room {
307 // Short: take the overrun out of whatever each child is willing to give
308 // up, in proportion to how much that is.
309 let mut over = total - room;
310 let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum();
311 if slack > 0 {
312 for i in 0..out.len() {
313 let give = ((nat[i] - min[i]) * over.min(slack)) / slack;
314 out[i] -= give;
315 }
316 over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>();
317 }
318 // Rounding, and children with no slack at all: take the rest off the
319 // end, which is where a terminal clips anyway.
320 let mut i = out.len();
321 while over > 0 && i > 0 {
322 i -= 1;
323 let give = (out[i] - min[i]).min(over);
324 out[i] -= give;
325 over -= give;
326 }
327 } else if total < room {
328 let key = if across { "hexpand" } else { "vexpand" };
329 let greedy: Vec<usize> = children
330 .iter()
331 .enumerate()
332 .filter(|(_, c)| tree.props(**c).bool(key, false))
333 .map(|(i, _)| i)
334 .collect();
335 if !greedy.is_empty() {
336 let extra = room - total;
337 let each = extra / greedy.len() as i64;
338 let mut rest = extra % greedy.len() as i64;
339 for i in greedy {
340 out[i] += each + if rest > 0 { 1 } else { 0 };
341 rest -= 1;
342 }
343 }
344 }
345 out.into_iter()
346 .map(|n| n.clamp(0, u16::MAX as i64) as u16)
347 .collect()
348}
349
350/// The rect a child of `size` gets inside `avail` on its cross axis.
351pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
352 match align {
353 Align::Fill => (0, avail),
354 other => {
355 let size = size.min(avail);
356 (other.offset(size, avail), size)
357 }
358 }
359}
360
361/// Lay the children of a box out inside `area`.
362pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
363 let props = tree.props(id);
364 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
365 let spacing = props.cells("spacing", 0);
366 let children = tree.children(id);
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago367 let shares = share(
368 tree,
369 id,
370 if across { area.w } else { area.h },
371 across,
372 if across { area.h } else { area.w },
373 );
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago374
375 let mut out = Vec::with_capacity(children.len());
376 let mut at = 0u16;
377 for (child, main) in children.iter().zip(shares) {
378 let cprops = tree.props(*child);
379 let rect = if across {
380 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
381 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
382 Rect::new(
383 area.x.saturating_add(at),
384 area.y.saturating_add(dy),
385 main,
386 h,
387 )
388 } else {
389 let want = width(tree, *child, false);
390 let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w);
391 Rect::new(
392 area.x.saturating_add(dx),
393 area.y.saturating_add(at),
394 w,
395 main,
396 )
397 };
398 out.push(rect);
399 at = at.saturating_add(main).saturating_add(spacing);
400 }
401 out
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::tree::Value;
408
409 fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 {
410 let id = tree.new_node("label");
411 tree.set(id, "label", Value::Str(text.into()));
412 tree.append(parent, id);
413 id
414 }
415
416 #[test]
417 fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() {
418 assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
419 assert_eq!(
420 wrap("antidisestablishment", 6),
421 vec!["antidi", "sestab", "lishme", "nt"]
422 );
423 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
424 }
425
426 #[test]
427 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
428 let mut tree = Tree::new();
429 let root = tree.root();
430 let id = label(&mut tree, root, "one two three");
431 assert_eq!(width(&tree, id, false), 13);
432 assert_eq!(width(&tree, id, true), 5);
433 assert_eq!(height_for_width(&tree, id, 7), 2);
434 }
435
436 #[test]
437 fn a_width_request_is_a_floor_on_both_sizes() {
438 let mut tree = Tree::new();
439 let root = tree.root();
440 let id = label(&mut tree, root, "hi");
441 tree.set(id, "width-request", Value::Num(20.0));
442 assert_eq!(width(&tree, id, false), 20);
443 assert_eq!(width(&tree, id, true), 20);
444 }
445
446 #[test]
447 fn a_height_request_of_four_rows_gets_four_rows() {
448 let mut tree = Tree::new();
449 let root = tree.root();
450 let id = label(&mut tree, root, "hi");
451 tree.set(id, "height-request", Value::Num(4.0));
452 assert_eq!(height_for_width(&tree, id, 10), 4);
453 }
454
455 #[test]
456 fn a_horizontal_box_gives_the_surplus_to_whoever_expands() {
457 let mut tree = Tree::new();
458 let row = tree.new_node("hbox");
459 tree.set(row, "orientation", Value::Str("horizontal".into()));
460 let root = tree.root();
461 tree.append(root, row);
462 let a = label(&mut tree, row, "aa");
463 let b = label(&mut tree, row, "bb");
464 tree.set(b, "hexpand", Value::Bool(true));
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago465 assert_eq!(share(&tree, row, 20, true, 1), vec![2, 18]);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago466 let _ = a;
467 }
468
469 #[test]
470 fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() {
471 let mut tree = Tree::new();
472 let row = tree.new_node("hbox");
473 tree.set(row, "orientation", Value::Str("horizontal".into()));
474 let root = tree.root();
475 tree.append(root, row);
476 label(&mut tree, row, "one two");
477 label(&mut tree, row, "three four");
478 // 17 natural, 10 offered: both give up some, neither goes under its
479 // longest word.
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago480 let shares = share(&tree, row, 10, true, 1);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago481 assert_eq!(shares.iter().sum::<u16>(), 10);
482 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
483 }
484
485 #[test]
486 fn spacing_comes_off_the_room_before_it_is_shared() {
487 let mut tree = Tree::new();
488 let row = tree.new_node("hbox");
489 tree.set(row, "orientation", Value::Str("horizontal".into()));
490 tree.set(row, "spacing", Value::Num(2.0));
491 let root = tree.root();
492 tree.append(root, row);
493 let a = label(&mut tree, row, "aa");
494 let b = label(&mut tree, row, "bb");
495 tree.set(a, "hexpand", Value::Bool(true));
496 tree.set(b, "hexpand", Value::Bool(true));
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago497 assert_eq!(share(&tree, row, 12, true, 1), vec![5, 5]);
498 }
499
500 #[test]
501 fn a_column_shares_its_rows_out_at_the_width_it_has() {
502 // A column two rows tall and thirty columns wide holds two labels, and
503 // each is one row at that width. Measured against the rows instead —
504 // as this did — "the second line" wraps to five, the overrun comes off
505 // the end, and the second child is handed nothing: a box that paints
506 // its first child and drops the rest, which is what the chats list did
507 // to every Open button in it.
508 let mut tree = Tree::new();
509 let col = tree.new_node("vbox");
510 tree.set(col, "orientation", Value::Str("vertical".into()));
511 let root = tree.root();
512 tree.append(root, col);
513 label(&mut tree, col, "the first line");
514 label(&mut tree, col, "the second line");
515 assert_eq!(share(&tree, col, 2, false, 30), vec![1, 1]);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago516 }
517
518 #[test]
519 fn a_centred_child_sits_in_the_middle_of_its_row() {
520 assert_eq!(place(Align::Center, 4, 10), (3, 4));
521 assert_eq!(place(Align::End, 4, 10), (6, 4));
522 assert_eq!(place(Align::Fill, 4, 10), (0, 10));
523 }
524
525 #[test]
526 fn a_frame_spends_a_cell_a_side_on_its_border() {
527 let mut tree = Tree::new();
528 let frame = tree.new_node("frame");
529 let root = tree.root();
530 tree.append(root, frame);
531 label(&mut tree, frame, "hi");
532 assert_eq!(width(&tree, frame, false), 4);
533 assert_eq!(height_for_width(&tree, frame, 4), 3);
534 }
535}