nandi/jolt-nativepublic Fork 0
c2d912f670b7a2e5808970d17cecf51c6c87cfbf
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 · 655 lines · 24.2 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
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago13use crate::graphics;
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 17d ago14use crate::screen::{glyph_cols, glyphs, text_cols, Rect};
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago15use crate::tree::{Props, Tag, Tree};
16
17/// How a child that is not filling its cross axis sits in the space it was
18/// given. `:halign` and `:valign` in the props.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Align {
21 Fill,
22 Start,
23 Center,
24 End,
25}
26
27impl Align {
28 pub fn parse(text: &str) -> Self {
29 match text {
30 "start" => Self::Start,
31 "center" | "centre" => Self::Center,
32 "end" => Self::End,
33 _ => Self::Fill,
34 }
35 }
36
37 /// Where a span of `size` sits inside `avail`.
38 fn offset(self, size: u16, avail: u16) -> u16 {
39 let slack = avail.saturating_sub(size);
40 match self {
41 Self::Fill | Self::Start => 0,
42 Self::Center => slack / 2,
43 Self::End => slack,
44 }
45 }
46}
47
48/// Whether a box stacks its children across or down.
49pub fn horizontal(props: &Props) -> bool {
50 props.str("orientation") == "horizontal"
51}
52
53/// The cells a node gives up on each side before its content starts. `:margin`
54/// and `:padding` are one inset here — a terminal cell has no border between
55/// them to tell them apart, and a caller that sets both means both.
56pub fn inset(tag: &Tag, props: &Props) -> u16 {
57 let own = props.cells("margin", 0) + props.cells("padding", 0);
58 // A frame — and an overlay, which is a frame that floats — spends a cell a
59 // side on its border.
60 own + if matches!(tag, Tag::Frame | Tag::Overlay) {
61 1
62 } else {
63 0
64 }
65}
66
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago67/// What a `:reaction` reads as: the glyph, and the tally when there is one.
68/// A pill with no count is the chip you press to put one there — the same
69/// picture the picker offers, which is the point of it being the same node.
70pub fn pill_text(props: &Props) -> String {
71 let glyph = props.str("emoji");
72 match props.cells("count", 0) {
73 0 => glyph.to_owned(),
74 n => format!("{glyph} {n}"),
75 }
76}
77
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago78/// Break `text` to `width` columns, on spaces where it can and mid-word where
79/// it must. Explicit newlines are always breaks.
80pub fn wrap(text: &str, width: u16) -> Vec<String> {
81 if width == 0 {
82 return Vec::new();
83 }
84 let width = width as usize;
85 let mut lines = Vec::new();
86 for paragraph in text.split('\n') {
87 let mut line = String::new();
88 let mut len = 0usize;
89 for word in paragraph.split(' ') {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago90 // In columns, not characters: an emoji is drawn two cells wide, so
91 // a line of them measured by character is twice the width it was
92 // wrapped to and runs off the edge.
93 let word_len = text_cols(word) as usize;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago94 if len > 0 && len + 1 + word_len > width {
95 lines.push(std::mem::take(&mut line));
96 len = 0;
97 }
98 if word_len > width {
99 // Longer than the whole line: break it where the line ends
100 // rather than let it run off the edge.
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 17d ago101 for glyph in glyphs(word) {
102 let cols = glyph_cols(&glyph) as usize;
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago103 if len + cols > width && len > 0 {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago104 lines.push(std::mem::take(&mut line));
105 len = 0;
106 }
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 17d ago107 line.push_str(&glyph);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago108 len += cols;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago109 }
110 continue;
111 }
112 if len > 0 {
113 line.push(' ');
114 len += 1;
115 }
116 line.push_str(word);
117 len += word_len;
118 }
119 lines.push(line);
120 }
121 lines
122}
123
124fn columns(text: &str) -> u16 {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago125 text.split('\n').map(text_cols).max().unwrap_or(0)
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago126}
127
128/// The longest single word — a label cannot usefully be narrower than this.
129fn longest_word(text: &str) -> u16 {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago130 text.split([' ', '\n']).map(text_cols).max().unwrap_or(0)
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago131}
132
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago133/// The cells a picture is given: its own shape, inside the caller's bounds and
134/// inside the room the column has.
135///
136/// Without the protocol to draw one there is no picture, only the note that
137/// says there was — so the box is a line, and the link in the message above it
138/// is what the reader is left with either way.
139pub fn image_cells(props: &Props, avail: u16) -> (u16, u16) {
140 let path = props.str("src");
141 if path.is_empty() {
142 return (0, 0);
143 }
144 if !graphics::supported() {
145 return (text_cols(PICTURE), 1);
146 }
147 let max_cols = match props.cells("max-width", 0) {
148 0 => avail,
149 want => want.min(avail),
150 };
151 let max_rows = match props.cells("max-height", 0) {
152 0 => 16,
153 want => want,
154 };
155 graphics::cells_for(path, max_cols, max_rows)
156}
157
158/// What stands in for a picture where one cannot be drawn.
159pub const PICTURE: &str = "[ picture ]";
160
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago161/// The text an entry shows: its own, or its placeholder when it has none.
162pub fn entry_text(props: &Props) -> String {
163 let text = props.str("text");
164 if text.is_empty() {
165 props.str("placeholder").to_owned()
166 } else {
167 text.to_owned()
168 }
169}
170
171/// A node's content size before its own request or inset is applied.
172fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
173 let tag = tree.tag(id);
174 let props = tree.props(id);
175 let text = props.label();
176 match tag {
177 Tag::Button => columns(text).saturating_add(4),
178 Tag::CheckButton => columns(text).saturating_add(4),
179 Tag::Entry => {
180 let want = columns(&entry_text(&props)).saturating_add(1).max(12);
181 if minimum {
182 want.min(6)
183 } else {
184 want
185 }
186 }
187 Tag::Label | Tag::Title | Tag::DimLabel => {
188 if minimum {
189 longest_word(text)
190 } else {
191 columns(text)
192 }
193 }
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago194 // An unknown tag with nothing under it paints its own text, so it has
195 // to be measured as the label it turns out to be — a widget given no
196 // room is as invisible as one that was never painted.
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago197 //
198 // Unless it names a picture. An `:avatar`'s label is the nick behind
199 // the face and an `:image`'s is its alt text: words for something that
200 // cannot be drawn here, and in frq's case words already on the row
201 // beside it, which is how every sender came out named twice.
202 Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago203 if minimum {
204 longest_word(props.label())
205 } else {
206 columns(props.label())
207 }
208 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago209 Tag::Separator => 1,
210 Tag::Spacer => props.cells("size", 1),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago211 Tag::Emoji => text_cols(props.str("emoji")),
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago212 Tag::Image => image_cells(&props, u16::MAX).0,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago213 Tag::Reaction => text_cols(&pill_text(&props)),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago214 Tag::Progress => {
215 if minimum {
216 4
217 } else {
218 20
219 }
220 }
221 Tag::Spinner => 1,
222 Tag::Listbox => tree
223 .children(id)
224 .iter()
225 .map(|c| intrinsic_width(tree, *c, minimum).saturating_add(2))
226 .max()
227 .unwrap_or(0),
228 // Every container measures its children the same way; only the axis
229 // the sum runs along differs.
230 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
231 let children = tree.children(id);
232 let spacing = props.cells("spacing", 0);
233 let sizes = children
234 .iter()
235 .map(|c| width(tree, *c, minimum))
236 .collect::<Vec<_>>();
237 let content = if horizontal(&props) && matches!(tag, Tag::Box) {
238 let gaps = spacing.saturating_mul(sizes.len().saturating_sub(1) as u16);
239 sizes.iter().fold(gaps, |a, b| a.saturating_add(*b))
240 } else {
241 sizes.into_iter().max().unwrap_or(0)
242 };
243 // A frame's heading sits in its top edge, so it is part of how wide
244 // the frame has to be — a box narrower than its own label reads as
245 // a truncated one.
246 if matches!(tag, Tag::Frame | Tag::Overlay) {
247 content.max(columns(props.label()).saturating_add(2))
248 } else {
249 content
250 }
251 }
252 }
253}
254
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago255/// True for a node whose label describes a picture rather than being text to
256/// paint — an `:avatar`, an `:image`, a live `:feed`.
257fn has_picture(props: &Props) -> bool {
258 props.has("src") || props.has("feed")
259}
260
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago261/// A node's natural or minimum width, requests and insets included.
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago262///
263/// A `width-request` is the width, not a floor under it. In a window it can be
264/// a floor, because a label wraps to whatever it is given and a column's
265/// natural width is therefore whatever the layout decides. Here a label's
266/// natural width is its whole line, so a column that holds one is as wide as
267/// the longest thing anybody ever said in it — and `max` then hands the
268/// sidebar the screen and leaves the conversation beside it ten cells to wrap
269/// in. Asking for a width is the caller saying how wide the column is; nothing
270/// else in a terminal can say it for them.
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago271pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
272 let props = tree.props(id);
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago273 let requested = props.cells("width-request", 0);
274 if requested > 0 {
275 return requested;
276 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago277 let pad = inset(&tree.tag(id), &props).saturating_mul(2);
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago278 intrinsic_width(tree, id, minimum).saturating_add(pad)
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago279}
280
281/// How tall `id` is when laid out `avail` columns wide.
282///
283/// Height depends on width — that is what wrapping means — so there is no
284/// natural height to ask for on its own.
285pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
286 let tag = tree.tag(id);
287 let props = tree.props(id);
288 let pad = inset(&tag, &props);
289 let inner = avail.saturating_sub(pad.saturating_mul(2));
290 let content = match tag {
291 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago292 Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago293 wrap(props.label(), inner).len() as u16
294 }
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago295 Tag::Button
296 | Tag::CheckButton
297 | Tag::Separator
298 | Tag::Progress
299 | Tag::Spinner
300 | Tag::Reaction
301 | Tag::Emoji => 1,
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago302 Tag::Image => image_cells(&props, inner).1,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago303 Tag::Entry => props.cells("rows", 1).max(1),
304 Tag::Spacer => props.cells("size", 1),
305 Tag::Listbox => tree.child_count(id) as u16,
306 Tag::Box | Tag::Window | Tag::Frame | Tag::Scroll | Tag::Overlay | Tag::Unknown(_) => {
307 let children = tree.children(id);
308 let spacing = props.cells("spacing", 0);
309 if horizontal(&props) && matches!(tag, Tag::Box) {
310 // 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 ago311 let shares = share(tree, id, inner, true, 0);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago312 children
313 .iter()
314 .zip(shares)
315 .map(|(c, w)| height_for_width(tree, *c, w))
316 .max()
317 .unwrap_or(0)
318 } else {
319 let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
320 children
321 .iter()
322 .map(|c| height_for_width(tree, *c, inner))
323 .fold(gaps, |a, b| a.saturating_add(b))
324 }
325 }
326 };
327 content
328 .saturating_add(pad.saturating_mul(2))
329 .max(props.cells("height-request", 0))
330}
331
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago332/// The least `id` can be squeezed to at `avail` columns wide.
333///
334/// Down the page almost nothing can be shorter than it is: a label wrapped to
335/// four lines needs four. A `:scroll` is the exception, and the reason there is
336/// one — it is a viewport, so its height is whatever it is given and its
337/// content moves inside it.
338///
339/// It has to recurse, because the viewport is rarely the child being measured.
340/// In frq's chat screen the backlog is a scroll inside a column inside a row
341/// inside the screen, and a column that reported its natural height all the
342/// way up gave the layout nothing to take: the backlog kept every row it asked
343/// for and the separator and compose bar under it were painted past the bottom
344/// edge — a conversation you cannot type into.
345pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
346 let tag = tree.tag(id);
347 let props = tree.props(id);
348 let pad = inset(&tag, &props);
349 let inner = avail.saturating_sub(pad.saturating_mul(2));
350 let content = match tag {
351 Tag::Scroll => 1,
352 Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)
353 if tree.child_count(id) > 0 =>
354 {
355 let children = tree.children(id);
356 let spacing = props.cells("spacing", 0);
357 if horizontal(&props) && matches!(tag, Tag::Box) {
358 let shares = share(tree, id, inner, true, 0);
359 children
360 .iter()
361 .zip(shares)
362 .map(|(c, w)| min_height_for_width(tree, *c, w))
363 .max()
364 .unwrap_or(0)
365 } else {
366 let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
367 children
368 .iter()
369 .map(|c| min_height_for_width(tree, *c, inner))
370 .fold(gaps, |a, b| a.saturating_add(b))
371 }
372 }
373 // Everything else is as short as it is tall.
374 _ => return height_for_width(tree, id, avail),
375 };
376 content
377 .saturating_add(pad.saturating_mul(2))
378 .max(props.cells("height-request", 0))
379}
380
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago381/// Share `avail` out among the children of `id` along one axis.
382///
383/// `across` picks the axis: true for a horizontal box sharing columns, false
384/// for a vertical one sharing rows. The rule is the same either way — natural
385/// sizes first, shrink proportionally toward the minimums when short, and the
386/// 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 ago387///
388/// `cross` is the extent on the *other* axis, and sharing rows out cannot be
389/// done without it: how tall a child is depends on how wide it is, because
390/// that is what wrapping means. Passing the rows in its place measures every
391/// label at a column count of two or three, wraps it to a paragraph, and the
392/// overrun is then taken off the end — which paints a box's first child and
393/// drops every sibling after it. Unused when `across`, where a width does not
394/// depend on a height.
395pub 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 ago396 let children = tree.children(id);
397 if children.is_empty() {
398 return Vec::new();
399 }
400 let props = tree.props(id);
401 let spacing = props.cells("spacing", 0);
402 let gaps = spacing.saturating_mul((children.len() - 1) as u16);
403 let room = avail.saturating_sub(gaps) as i64;
404
405 let measure = |child: u32, minimum: bool| -> i64 {
406 if across {
407 width(tree, child, minimum) as i64
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago408 } else if minimum {
409 min_height_for_width(tree, child, cross) as i64
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago410 } else {
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago411 // A child's height depends on the width it gets, which the caller
412 // 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 ago413 height_for_width(tree, child, cross) as i64
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago414 }
415 };
416
417 let nat: Vec<i64> = children.iter().map(|c| measure(*c, false)).collect();
418 let min: Vec<i64> = children
419 .iter()
420 .zip(&nat)
421 .map(|(c, n)| measure(*c, true).min(*n))
422 .collect();
423 let total: i64 = nat.iter().sum();
424 let mut out = nat.clone();
425
426 if total > room {
427 // Short: take the overrun out of whatever each child is willing to give
428 // up, in proportion to how much that is.
429 let mut over = total - room;
430 let slack: i64 = nat.iter().zip(&min).map(|(n, m)| n - m).sum();
431 if slack > 0 {
432 for i in 0..out.len() {
433 let give = ((nat[i] - min[i]) * over.min(slack)) / slack;
434 out[i] -= give;
435 }
436 over -= nat.iter().zip(&out).map(|(n, o)| n - o).sum::<i64>();
437 }
438 // Rounding, and children with no slack at all: take the rest off the
439 // end, which is where a terminal clips anyway.
440 let mut i = out.len();
441 while over > 0 && i > 0 {
442 i -= 1;
443 let give = (out[i] - min[i]).min(over);
444 out[i] -= give;
445 over -= give;
446 }
447 } else if total < room {
448 let key = if across { "hexpand" } else { "vexpand" };
449 let greedy: Vec<usize> = children
450 .iter()
451 .enumerate()
452 .filter(|(_, c)| tree.props(**c).bool(key, false))
453 .map(|(i, _)| i)
454 .collect();
455 if !greedy.is_empty() {
456 let extra = room - total;
457 let each = extra / greedy.len() as i64;
458 let mut rest = extra % greedy.len() as i64;
459 for i in greedy {
460 out[i] += each + if rest > 0 { 1 } else { 0 };
461 rest -= 1;
462 }
463 }
464 }
465 out.into_iter()
466 .map(|n| n.clamp(0, u16::MAX as i64) as u16)
467 .collect()
468}
469
470/// The rect a child of `size` gets inside `avail` on its cross axis.
471pub fn place(align: Align, size: u16, avail: u16) -> (u16, u16) {
472 match align {
473 Align::Fill => (0, avail),
474 other => {
475 let size = size.min(avail);
476 (other.offset(size, avail), size)
477 }
478 }
479}
480
481/// Lay the children of a box out inside `area`.
482pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
483 let props = tree.props(id);
484 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
485 let spacing = props.cells("spacing", 0);
486 let children = tree.children(id);
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago487 let shares = share(
488 tree,
489 id,
490 if across { area.w } else { area.h },
491 across,
492 if across { area.h } else { area.w },
493 );
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago494
495 let mut out = Vec::with_capacity(children.len());
496 let mut at = 0u16;
497 for (child, main) in children.iter().zip(shares) {
498 let cprops = tree.props(*child);
499 let rect = if across {
500 let want = height_for_width(tree, *child, main).max(cprops.cells("height-request", 0));
501 let (dy, h) = place(Align::parse(cprops.str("valign")), want, area.h);
502 Rect::new(
503 area.x.saturating_add(at),
504 area.y.saturating_add(dy),
505 main,
506 h,
507 )
508 } else {
509 let want = width(tree, *child, false);
510 let (dx, w) = place(Align::parse(cprops.str("halign")), want, area.w);
511 Rect::new(
512 area.x.saturating_add(dx),
513 area.y.saturating_add(at),
514 w,
515 main,
516 )
517 };
518 out.push(rect);
519 at = at.saturating_add(main).saturating_add(spacing);
520 }
521 out
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::tree::Value;
528
529 fn label(tree: &mut Tree, parent: u32, text: &str) -> u32 {
530 let id = tree.new_node("label");
531 tree.set(id, "label", Value::Str(text.into()));
532 tree.append(parent, id);
533 id
534 }
535
536 #[test]
537 fn wrapping_breaks_on_spaces_and_mid_word_when_it_must() {
538 assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
539 assert_eq!(
540 wrap("antidisestablishment", 6),
541 vec!["antidi", "sestab", "lishme", "nt"]
542 );
543 assert_eq!(wrap("a\nb", 10), vec!["a", "b"]);
544 }
545
546 #[test]
547 fn a_label_is_as_wide_as_its_text_and_as_narrow_as_its_longest_word() {
548 let mut tree = Tree::new();
549 let root = tree.root();
550 let id = label(&mut tree, root, "one two three");
551 assert_eq!(width(&tree, id, false), 13);
552 assert_eq!(width(&tree, id, true), 5);
553 assert_eq!(height_for_width(&tree, id, 7), 2);
554 }
555
556 #[test]
557 fn a_width_request_is_a_floor_on_both_sizes() {
558 let mut tree = Tree::new();
559 let root = tree.root();
560 let id = label(&mut tree, root, "hi");
561 tree.set(id, "width-request", Value::Num(20.0));
562 assert_eq!(width(&tree, id, false), 20);
563 assert_eq!(width(&tree, id, true), 20);
564 }
565
566 #[test]
567 fn a_height_request_of_four_rows_gets_four_rows() {
568 let mut tree = Tree::new();
569 let root = tree.root();
570 let id = label(&mut tree, root, "hi");
571 tree.set(id, "height-request", Value::Num(4.0));
572 assert_eq!(height_for_width(&tree, id, 10), 4);
573 }
574
575 #[test]
576 fn a_horizontal_box_gives_the_surplus_to_whoever_expands() {
577 let mut tree = Tree::new();
578 let row = tree.new_node("hbox");
579 tree.set(row, "orientation", Value::Str("horizontal".into()));
580 let root = tree.root();
581 tree.append(root, row);
582 let a = label(&mut tree, row, "aa");
583 let b = label(&mut tree, row, "bb");
584 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 ago585 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 ago586 let _ = a;
587 }
588
589 #[test]
590 fn a_short_box_shrinks_toward_the_minimums_rather_than_clipping_the_first_child() {
591 let mut tree = Tree::new();
592 let row = tree.new_node("hbox");
593 tree.set(row, "orientation", Value::Str("horizontal".into()));
594 let root = tree.root();
595 tree.append(root, row);
596 label(&mut tree, row, "one two");
597 label(&mut tree, row, "three four");
598 // 17 natural, 10 offered: both give up some, neither goes under its
599 // longest word.
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 18d ago600 let shares = share(&tree, row, 10, true, 1);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago601 assert_eq!(shares.iter().sum::<u16>(), 10);
602 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
603 }
604
605 #[test]
606 fn spacing_comes_off_the_room_before_it_is_shared() {
607 let mut tree = Tree::new();
608 let row = tree.new_node("hbox");
609 tree.set(row, "orientation", Value::Str("horizontal".into()));
610 tree.set(row, "spacing", Value::Num(2.0));
611 let root = tree.root();
612 tree.append(root, row);
613 let a = label(&mut tree, row, "aa");
614 let b = label(&mut tree, row, "bb");
615 tree.set(a, "hexpand", Value::Bool(true));
616 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 ago617 assert_eq!(share(&tree, row, 12, true, 1), vec![5, 5]);
618 }
619
620 #[test]
621 fn a_column_shares_its_rows_out_at_the_width_it_has() {
622 // A column two rows tall and thirty columns wide holds two labels, and
623 // each is one row at that width. Measured against the rows instead —
624 // as this did — "the second line" wraps to five, the overrun comes off
625 // the end, and the second child is handed nothing: a box that paints
626 // its first child and drops the rest, which is what the chats list did
627 // to every Open button in it.
628 let mut tree = Tree::new();
629 let col = tree.new_node("vbox");
630 tree.set(col, "orientation", Value::Str("vertical".into()));
631 let root = tree.root();
632 tree.append(root, col);
633 label(&mut tree, col, "the first line");
634 label(&mut tree, col, "the second line");
635 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 ago636 }
637
638 #[test]
639 fn a_centred_child_sits_in_the_middle_of_its_row() {
640 assert_eq!(place(Align::Center, 4, 10), (3, 4));
641 assert_eq!(place(Align::End, 4, 10), (6, 4));
642 assert_eq!(place(Align::Fill, 4, 10), (0, 10));
643 }
644
645 #[test]
646 fn a_frame_spends_a_cell_a_side_on_its_border() {
647 let mut tree = Tree::new();
648 let frame = tree.new_node("frame");
649 let root = tree.root();
650 tree.append(root, frame);
651 label(&mut tree, frame, "hi");
652 assert_eq!(width(&tree, frame, false), 4);
653 assert_eq!(height_for_width(&tree, frame, 4), 3);
654 }
655}