max / quasi
4 files changed,
+384 insertions,
-9 deletions
| @@ -375,7 +375,15 @@ | |||
| 375 | 375 | // something that is not on the screen. Recorded rather than papered over: | |
| 376 | 376 | // fixing it means the navigation walk knowing the width the drawing knows, | |
| 377 | 377 | // which is a wider change than the member that revealed it. | |
| 378 | - | for placed in &slot.body { | |
| 378 | + | // The row the region says its members share, then its body, which is the | |
| 379 | + | // order they are drawn in. The caret and the drawing agreeing about that | |
| 380 | + | // order is the invariant this walk exists to keep. | |
| 381 | + | for placed in slot | |
| 382 | + | .run | |
| 383 | + | .iter() | |
| 384 | + | .flat_map(|run| run.members.iter()) | |
| 385 | + | .chain(slot.body.iter()) | |
| 386 | + | { | |
| 379 | 387 | node_spots(&placed.node, &slot.id, found); | |
| 380 | 388 | } | |
| 381 | 389 | } |
| @@ -117,6 +117,89 @@ | |||
| 117 | 117 | } | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | + | /// How much of a row a node wants, in cells. | |
| 121 | + | /// | |
| 122 | + | /// The counterpart to [`height`] for the one axis a column never had to think | |
| 123 | + | /// about. A region that says its members share a row (`Slot::across`) needs to | |
| 124 | + | /// know how wide each one is before it can put two of them side by side, and | |
| 125 | + | /// every measurement here is derived from what the node holds rather than | |
| 126 | + | /// authored: a label's characters, a tag's, a meter's line. | |
| 127 | + | /// | |
| 128 | + | /// [`Want::Rest`] is the honest answer for two different things, and both of | |
| 129 | + | /// them mean "do not try to measure me": a control the description said should | |
| 130 | + | /// absorb what is left ([`layout::Width::Fill`]), and a node whose shape is a | |
| 131 | + | /// block rather than a line -- a list, a table, a form -- which has no business | |
| 132 | + | /// being a member of a row and is given the whole of one if it is. | |
| 133 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 134 | + | pub(crate) enum Want { | |
| 135 | + | /// This many cells, which is what the node holds. | |
| 136 | + | Cells(u16), | |
| 137 | + | /// Whatever the line has left. | |
| 138 | + | Rest, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// The cells `node` wants as a member of a row. | |
| 142 | + | pub(crate) fn want(tui: &Tui, node: &Node) -> Want { | |
| 143 | + | /// The cells a line of spans occupies, unwrapped. | |
| 144 | + | fn spans_wide(line: &Line<'_>) -> u16 { | |
| 145 | + | u16::try_from( | |
| 146 | + | line.spans | |
| 147 | + | .iter() | |
| 148 | + | .map(|span| span.content.chars().count()) | |
| 149 | + | .sum::<usize>(), | |
| 150 | + | ) | |
| 151 | + | .unwrap_or(u16::MAX) | |
| 152 | + | } | |
| 153 | + | fn chars_wide(text: &str) -> u16 { | |
| 154 | + | u16::try_from(text.chars().count()).unwrap_or(u16::MAX) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | match node { | |
| 158 | + | Node::Heading { text, .. } | Node::Text { text, .. } | Node::Notice { text, .. } => { | |
| 159 | + | Want::Cells(chars_wide(text)) | |
| 160 | + | } | |
| 161 | + | Node::Link { text, .. } => Want::Cells(chars_wide(text)), | |
| 162 | + | // A control with questions on it is not a line: the questions stand | |
| 163 | + | // above it here, which is what `height`'s arm says, so it takes a row | |
| 164 | + | // of its own rather than a place in one. | |
| 165 | + | Node::Act(act) if act.asks.is_empty() => { | |
| 166 | + | Want::Cells(spans_wide(&act_line(tui, act, false))) | |
| 167 | + | } | |
| 168 | + | Node::Token(tag) => Want::Cells(spans_wide(&Line::from(tag_span(tui, tag, false)))), | |
| 169 | + | Node::Figure(figure) => { | |
| 170 | + | Want::Cells(chars_wide(&figure.value) + 1 + chars_wide(&figure.caption)) | |
| 171 | + | } | |
| 172 | + | Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => Want::Cells(CLOCK_CELLS), | |
| 173 | + | Node::Select { options, .. } => Want::Cells(spans_wide(&Line::from(select_spans( | |
| 174 | + | tui, | |
| 175 | + | options, | |
| 176 | + | None, | |
| 177 | + | &[], | |
| 178 | + | )))), | |
| 179 | + | Node::Meter(meter) => Want::Cells(spans_wide(&meter_line(tui, meter))), | |
| 180 | + | // What the description said about this one, which is the only place a | |
| 181 | + | // width is stated rather than derived. `Fill` is the search box in a | |
| 182 | + | // toolbar; `Content` and `Fixed` are a box that should be as wide as | |
| 183 | + | // what goes in it, and a terminal has no better number for that than | |
| 184 | + | // the label plus room to type. | |
| 185 | + | Node::Field(field) => match field.width { | |
| 186 | + | layout::Width::Fill => Want::Rest, | |
| 187 | + | _ => Want::Cells(chars_wide(&field.label) + FIELD_BOX_CELLS), | |
| 188 | + | }, | |
| 189 | + | _ => Want::Rest, | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// The cells a clock readout is given. | |
| 194 | + | /// | |
| 195 | + | /// `height`'s arm has the argument: the readout is sized without asking what it | |
| 196 | + | /// says, or the row would shuffle under the reader once a minute. | |
| 197 | + | const CLOCK_CELLS: u16 = 16; | |
| 198 | + | ||
| 199 | + | /// The cells a box gets beyond its label, when the description did not say the | |
| 200 | + | /// box absorbs the line. | |
| 201 | + | const FIELD_BOX_CELLS: u16 = 12; | |
| 202 | + | ||
| 120 | 203 | /// What a node this renderer has not learned yet draws instead of itself. | |
| 121 | 204 | /// | |
| 122 | 205 | /// `Node` is `#[non_exhaustive]` so that a new member is not a lockstep release |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | ||
| 22 | 22 | use makeover_layout as layout; | |
| 23 | 23 | use makeover_tui::{frame, text}; | |
| 24 | - | use quasi_router::{Node, Ranked, RegionKind, Screen, Slot}; | |
| 24 | + | use quasi_router::{Node, Ranked, RegionKind, Run, Screen, Slot}; | |
| 25 | 25 | use ratatui::buffer::Buffer; | |
| 26 | 26 | use ratatui::layout::Rect; | |
| 27 | 27 | use ratatui::style::{Modifier, Style}; | |
| @@ -261,6 +261,124 @@ | |||
| 261 | 261 | .collect() | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | + | /// How a region's row packs at this width: the members on each line, and how | |
| 265 | + | /// tall the line is. | |
| 266 | + | /// | |
| 267 | + | /// Ruling: wiki `layout-room-and-fallback`. **This renderer drew none of a | |
| 268 | + | /// region's row until 2026-08-23**, the way quasi-immediate did not: `draw` and | |
| 269 | + | /// `height` both walked `body`, nothing walked `run`, so a description that | |
| 270 | + | /// said `across` and then `beside` contributed members that were never painted, | |
| 271 | + | /// never counted in the region's height, and never reached by the caret. | |
| 272 | + | /// | |
| 273 | + | /// Packing is left to right, one space between members, wrapping to a new line | |
| 274 | + | /// when what is left of this one cannot hold the next member. Every width is | |
| 275 | + | /// derived -- [`crate::node::want`] measures a member from what it holds -- so | |
| 276 | + | /// nothing here is authored and there is no breakpoint. | |
| 277 | + | /// | |
| 278 | + | /// # What each fallback gets | |
| 279 | + | /// | |
| 280 | + | /// [`Wrap`](layout::Fallback::Wrap) and [`Stack`](layout::Fallback::Stack) keep | |
| 281 | + | /// every member and wrap. They differ in a webview by whether a wrapped member | |
| 282 | + | /// fills its line; a terminal has no such distinction to draw, so both answer | |
| 283 | + | /// alike rather than this renderer inventing one. | |
| 284 | + | /// | |
| 285 | + | /// [`Shed`](layout::Fallback::Shed) drops members by [`layout::Priority`] at | |
| 286 | + | /// the cutoff the region's body already reads, which is the half a webview | |
| 287 | + | /// cannot do at all. | |
| 288 | + | /// | |
| 289 | + | /// [`Menu`](layout::Fallback::Menu) **wraps rather than shedding**, and that is | |
| 290 | + | /// this renderer's answer rather than a shortfall. Menu says the shed members | |
| 291 | + | /// stay reachable behind one control; a terminal has no anchored menu to put | |
| 292 | + | /// them behind -- `quasi-tui`'s own header says a menu here is a key -- and a | |
| 293 | + | /// marker that shows a count nobody can open is the `by_host` failure, a thing | |
| 294 | + | /// drawn, reachable and doing nothing. Keeping every member on a second line | |
| 295 | + | /// honours the half that matters and states the half it cannot. | |
| 296 | + | fn packed<'a>(tui: &Tui, run: &'a Run, width: u16) -> Vec<Vec<(&'a Node, u16)>> { | |
| 297 | + | let kept: Vec<&Ranked> = match run.fallback { | |
| 298 | + | layout::Fallback::Shed => run.kept_at(cutoff(width)), | |
| 299 | + | _ => run.members.iter().collect(), | |
| 300 | + | }; | |
| 301 | + | ||
| 302 | + | let mut lines: Vec<Vec<(&Node, u16)>> = Vec::new(); | |
| 303 | + | let mut line: Vec<(&Node, u16)> = Vec::new(); | |
| 304 | + | let mut left = width; | |
| 305 | + | for placed in kept { | |
| 306 | + | let wants = match crate::node::want(tui, &placed.node) { | |
| 307 | + | crate::node::Want::Cells(cells) => cells.min(width), | |
| 308 | + | crate::node::Want::Rest => left.max(MEMBER_FLOOR).min(width), | |
| 309 | + | }; | |
| 310 | + | let gap = u16::from(!line.is_empty()); | |
| 311 | + | if !line.is_empty() && wants + gap > left { | |
| 312 | + | lines.push(std::mem::take(&mut line)); | |
| 313 | + | left = width; | |
| 314 | + | } | |
| 315 | + | let gap = u16::from(!line.is_empty()); | |
| 316 | + | let given = wants.min(left.saturating_sub(gap)); | |
| 317 | + | line.push((&placed.node, given)); | |
| 318 | + | left = left.saturating_sub(given + gap); | |
| 319 | + | } | |
| 320 | + | if !line.is_empty() { | |
| 321 | + | lines.push(line); | |
| 322 | + | } | |
| 323 | + | lines | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// The narrowest a member is given before the row wraps instead. | |
| 327 | + | /// | |
| 328 | + | /// Only reached by a member that asked for what is left of the line and found | |
| 329 | + | /// almost nothing there. Below this a control is not readable, so it takes the | |
| 330 | + | /// next line whole rather than a sliver of this one. | |
| 331 | + | const MEMBER_FLOOR: u16 = 8; | |
| 332 | + | ||
| 333 | + | /// The rows a region's leading row takes at this width. | |
| 334 | + | fn run_height(tui: &Tui, slot: &Slot, width: u16) -> u16 { | |
| 335 | + | let Some(run) = slot.run.as_ref() else { | |
| 336 | + | return 0; | |
| 337 | + | }; | |
| 338 | + | packed(tui, run, width) | |
| 339 | + | .iter() | |
| 340 | + | .map(|line| { | |
| 341 | + | line.iter() | |
| 342 | + | .map(|(node, cells)| crate::node::height(tui, node, *cells)) | |
| 343 | + | .max() | |
| 344 | + | .unwrap_or(0) | |
| 345 | + | }) | |
| 346 | + | .sum() | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | /// Draw a region's leading row, and answer the rows it used. | |
| 350 | + | fn run_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 { | |
| 351 | + | let Some(run) = slot.run.as_ref() else { | |
| 352 | + | return 0; | |
| 353 | + | }; | |
| 354 | + | let lines = packed(pass.tui, run, inner.width); | |
| 355 | + | let mut used = 0; | |
| 356 | + | for line in lines { | |
| 357 | + | let area = below(inner, used); | |
| 358 | + | if area.height == 0 { | |
| 359 | + | break; | |
| 360 | + | } | |
| 361 | + | let mut column = 0; | |
| 362 | + | let mut tall = 0; | |
| 363 | + | for (node, cells) in line { | |
| 364 | + | let cell = Rect { | |
| 365 | + | x: area.x + column, | |
| 366 | + | width: cells.min(area.width.saturating_sub(column)), | |
| 367 | + | ..area | |
| 368 | + | }; | |
| 369 | + | if cell.width == 0 { | |
| 370 | + | break; | |
| 371 | + | } | |
| 372 | + | tall = tall.max(crate::node::draw(pass, node, cell, buf)); | |
| 373 | + | // One space between members, which is the only separator a | |
| 374 | + | // terminal row needs and the same one a strip of labels uses. | |
| 375 | + | column += cell.width + 1; | |
| 376 | + | } | |
| 377 | + | used += tall.max(1); | |
| 378 | + | } | |
| 379 | + | used.min(inner.height) | |
| 380 | + | } | |
| 381 | + | ||
| 264 | 382 | /// The rows a region wants at `width`. | |
| 265 | 383 | pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 { | |
| 266 | 384 | let inner = width.saturating_sub(2); | |
| @@ -295,7 +413,7 @@ | |||
| 295 | 413 | }; | |
| 296 | 414 | ||
| 297 | 415 | // Two rows for the frame, when the region has one. | |
| 298 | - | body + filled + if framed(slot.kind.depth()) { 2 } else { 0 } | |
| 416 | + | run_height(tui, slot, inner) + body + filled + if framed(slot.kind.depth()) { 2 } else { 0 } | |
| 299 | 417 | } | |
| 300 | 418 | ||
| 301 | 419 | /// Draw one region, and answer the rows it used. | |
| @@ -307,7 +425,12 @@ | |||
| 307 | 425 | ||
| 308 | 426 | if area.width == 0 || area.height == 0 { | |
| 309 | 427 | if !pending { | |
| 310 | - | for placed in &slot.body { | |
| 428 | + | for placed in slot | |
| 429 | + | .run | |
| 430 | + | .iter() | |
| 431 | + | .flat_map(|run| run.members.iter()) | |
| 432 | + | .chain(slot.body.iter()) | |
| 433 | + | { | |
| 311 | 434 | crate::node::draw(pass, &placed.node, area, buf); | |
| 312 | 435 | } | |
| 313 | 436 | } | |
| @@ -351,11 +474,17 @@ | |||
| 351 | 474 | // blocks and then stopped, whatever the host had to put in it. The | |
| 352 | 475 | // ordering is the arrangement `Containment::Opaque` describes -- a heading | |
| 353 | 476 | // the description owns above a canvas it does not. | |
| 354 | - | let mut used = if slot.showing.selective() { | |
| 355 | - | showing_body(pass, slot, inner, buf) | |
| 356 | - | } else { | |
| 357 | - | body(pass, slot, &slot.body, inner, buf) | |
| 358 | - | }; | |
| 477 | + | // The row the description said its members share, above the body. Above, | |
| 478 | + | // because that is the order the webview emits them in and the order a | |
| 479 | + | // toolbar over a list reads in. | |
| 480 | + | let row = run_body(pass, slot, inner, buf); | |
| 481 | + | let rest = below(inner, row); | |
| 482 | + | let mut used = row | |
| 483 | + | + if slot.showing.selective() { | |
| 484 | + | showing_body(pass, slot, rest, buf) | |
| 485 | + | } else { | |
| 486 | + | body(pass, slot, &slot.body, rest, buf) | |
| 487 | + | }; | |
| 359 | 488 | ||
| 360 | 489 | if let RegionKind::Bespoke { .. } = slot.kind | |
| 361 | 490 | && let Some(fill) = tui.fill(&slot.id) |
| @@ -103,6 +103,161 @@ | |||
| 103 | 103 | rows(&buf) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// A band whose members were said to share one row. | |
| 107 | + | fn run_of(fallback: layout::Fallback, members: &[(&str, layout::Priority)]) -> Screen { | |
| 108 | + | let mut band = Slot::new("bar", RegionKind::Band).across(fallback); | |
| 109 | + | for (label, priority) in members { | |
| 110 | + | band = band.beside( | |
| 111 | + | Node::Act(Act::new(*label, Action::post(format!("/{label}")))), | |
| 112 | + | *priority, | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | Screen::sidebar_content("Test").with(band) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// The three toolbar controls, all essential. | |
| 119 | + | const THREE: [(&str, layout::Priority); 3] = [ | |
| 120 | + | ("Import", layout::Priority::Essential), | |
| 121 | + | ("Export", layout::Priority::Essential), | |
| 122 | + | ("Settings", layout::Priority::Essential), | |
| 123 | + | ]; | |
| 124 | + | ||
| 125 | + | #[test] | |
| 126 | + | fn a_member_of_a_run_is_drawn_at_all() { | |
| 127 | + | // The defect: `draw` and `height` both walked `body` and nothing walked | |
| 128 | + | // `run`, so a description that said `across` and then `beside` contributed | |
| 129 | + | // members that were never painted and never counted. Silent, and the same | |
| 130 | + | // shape quasi-immediate had until quasi@98a7276. | |
| 131 | + | let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6); | |
| 132 | + | for label in ["Import", "Export", "Settings"] { | |
| 133 | + | assert!( | |
| 134 | + | out.iter().any(|row| row.contains(label)), | |
| 135 | + | "{label} was never drawn: {out:?}" | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn a_run_puts_its_members_across_rather_than_down() { | |
| 142 | + | let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 60, 6); | |
| 143 | + | let row = out | |
| 144 | + | .iter() | |
| 145 | + | .find(|row| row.contains("Import")) | |
| 146 | + | .expect("no row holds the first member"); | |
| 147 | + | assert!( | |
| 148 | + | row.contains("Export") && row.contains("Settings"), | |
| 149 | + | "the members went down the screen instead of across it: {out:?}" | |
| 150 | + | ); | |
| 151 | + | let (first, second) = (row.find("Import").unwrap(), row.find("Export").unwrap()); | |
| 152 | + | assert!(first < second, "the members are out of order: {row:?}"); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[test] | |
| 156 | + | fn a_run_wraps_when_the_line_cannot_hold_the_next_member() { | |
| 157 | + | // Derived from what the members hold, at the width the region was given. | |
| 158 | + | // No breakpoint is authored anywhere in this path. | |
| 159 | + | let out = shown(&run_of(layout::Fallback::Wrap, &THREE), 16, 6); | |
| 160 | + | let lines: Vec<&String> = out | |
| 161 | + | .iter() | |
| 162 | + | .filter(|row| THREE.iter().any(|(label, _)| row.contains(label))) | |
| 163 | + | .collect(); | |
| 164 | + | assert!(lines.len() > 1, "a narrow row did not wrap: {out:?}"); | |
| 165 | + | for label in ["Import", "Export", "Settings"] { | |
| 166 | + | assert!( | |
| 167 | + | out.iter().any(|row| row.contains(label)), | |
| 168 | + | "wrapping lost {label}: {out:?}" | |
| 169 | + | ); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn a_region_is_as_tall_as_its_row_plus_its_body() { | |
| 175 | + | // The half that is invisible until something scrolls: `height` is what the | |
| 176 | + | // scroll arithmetic trusts, so a row it did not count is a region that | |
| 177 | + | // scrolls short by however many lines the row took. | |
| 178 | + | let bare = Slot::new("bar", RegionKind::Band).with(Node::text("body")); | |
| 179 | + | let rowed = Slot::new("bar", RegionKind::Band) | |
| 180 | + | .with(Node::text("body")) | |
| 181 | + | .across(layout::Fallback::Wrap) | |
| 182 | + | .beside( | |
| 183 | + | Node::Act(Act::new("Import", Action::post("/import"))), | |
| 184 | + | layout::Priority::Essential, | |
| 185 | + | ); | |
| 186 | + | ||
| 187 | + | let tui = tui(); | |
| 188 | + | assert_eq!( | |
| 189 | + | crate::region::height(&tui, &rowed, 60), | |
| 190 | + | crate::region::height(&tui, &bare, 60) + 1, | |
| 191 | + | "the row was not counted" | |
| 192 | + | ); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[test] | |
| 196 | + | fn a_run_that_sheds_drops_by_priority_and_keeps_the_essential() { | |
| 197 | + | let members = [ | |
| 198 | + | ("Import", layout::Priority::Essential), | |
| 199 | + | ("Export", layout::Priority::Secondary), | |
| 200 | + | ("Settings", layout::Priority::Optional), | |
| 201 | + | ]; | |
| 202 | + | let screen = run_of(layout::Fallback::Shed, &members); | |
| 203 | + | ||
| 204 | + | let narrow = shown(&screen, 30, 6); | |
| 205 | + | assert!(narrow.iter().any(|row| row.contains("Import"))); | |
| 206 | + | assert!( | |
| 207 | + | !narrow.iter().any(|row| row.contains("Export")), | |
| 208 | + | "a shed row kept what it said it would drop: {narrow:?}" | |
| 209 | + | ); | |
| 210 | + | ||
| 211 | + | // And nothing is dropped where there is room, or the cutoff would be a | |
| 212 | + | // permanent narrowing rather than a measurement. | |
| 213 | + | let wide = shown(&screen, 120, 6); | |
| 214 | + | assert!(wide.iter().any(|row| row.contains("Settings")), "{wide:?}"); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn a_run_that_menus_keeps_every_member_because_a_terminal_has_nowhere_to_put_them() { | |
| 219 | + | // This renderer's answer rather than a shortfall, and the header says so: a | |
| 220 | + | // menu here is a key, and a marker showing a count nobody can open is a | |
| 221 | + | // control drawn, reachable and doing nothing. | |
| 222 | + | let members = [ | |
| 223 | + | ("Import", layout::Priority::Essential), | |
| 224 | + | ("Export", layout::Priority::Secondary), | |
| 225 | + | ("Settings", layout::Priority::Optional), | |
| 226 | + | ]; | |
| 227 | + | let narrow = shown(&run_of(layout::Fallback::Menu, &members), 30, 6); | |
| 228 | + | for label in ["Import", "Export", "Settings"] { | |
| 229 | + | assert!( | |
| 230 | + | narrow.iter().any(|row| row.contains(label)), | |
| 231 | + | "Menu shed {label} with nowhere to put it: {narrow:?}" | |
| 232 | + | ); | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn the_caret_reaches_a_row_s_members_before_the_body() { | |
| 238 | + | // The invariant holding the two walks together: the caret lights the | |
| 239 | + | // control the drawing put first, or Enter calls something the reader is not | |
| 240 | + | // looking at. | |
| 241 | + | let screen = Screen::sidebar_content("Test").with( | |
| 242 | + | Slot::new("bar", RegionKind::Band) | |
| 243 | + | .across(layout::Fallback::Wrap) | |
| 244 | + | .beside( | |
| 245 | + | Node::Act(Act::new("Import", Action::post("/import"))), | |
| 246 | + | layout::Priority::Essential, | |
| 247 | + | ) | |
| 248 | + | .with(Node::Act(Act::new("Below", Action::post("/below")))), | |
| 249 | + | ); | |
| 250 | + | ||
| 251 | + | let reached: Vec<String> = crate::focus::reaches(&screen) | |
| 252 | + | .iter() | |
| 253 | + | .filter_map(|reach| match &reach.spot { | |
| 254 | + | Spot::Act { action, .. } => action.destination.route().map(ToOwned::to_owned), | |
| 255 | + | _ => None, | |
| 256 | + | }) | |
| 257 | + | .collect(); | |
| 258 | + | assert_eq!(reached, ["/import", "/below"]); | |
| 259 | + | } | |
| 260 | + | ||
| 106 | 261 | /// The row a piece of text was drawn on. | |
| 107 | 262 | fn row_of(rows: &[String], text: &str) -> Option<usize> { | |
| 108 | 263 | rows.iter().position(|row| row.contains(text)) |