max / audiofiles
9 files changed,
+132 insertions,
-148 deletions
| @@ -4256,7 +4256,7 @@ | |||
| 4256 | 4256 | ||
| 4257 | 4257 | [[package]] | |
| 4258 | 4258 | name = "quasi-immediate" | |
| 4259 | - | version = "0.81.0" | |
| 4259 | + | version = "0.82.0" | |
| 4260 | 4260 | dependencies = [ | |
| 4261 | 4261 | "docengine", | |
| 4262 | 4262 | "egui", | |
| @@ -4267,7 +4267,7 @@ | |||
| 4267 | 4267 | ||
| 4268 | 4268 | [[package]] | |
| 4269 | 4269 | name = "quasi-router" | |
| 4270 | - | version = "0.81.0" | |
| 4270 | + | version = "0.82.0" | |
| 4271 | 4271 | dependencies = [ | |
| 4272 | 4272 | "makeover-layout", | |
| 4273 | 4273 | ] | |
| @@ -7558,19 +7558,19 @@ | |||
| 7558 | 7558 | ||
| 7559 | 7559 | [[patch.unused]] | |
| 7560 | 7560 | name = "quasi-axum" | |
| 7561 | - | version = "0.81.0" | |
| 7561 | + | version = "0.82.0" | |
| 7562 | 7562 | ||
| 7563 | 7563 | [[patch.unused]] | |
| 7564 | 7564 | name = "quasi-basics" | |
| 7565 | - | version = "0.81.0" | |
| 7565 | + | version = "0.82.0" | |
| 7566 | 7566 | ||
| 7567 | 7567 | [[patch.unused]] | |
| 7568 | 7568 | name = "quasi-http" | |
| 7569 | - | version = "0.81.0" | |
| 7569 | + | version = "0.82.0" | |
| 7570 | 7570 | ||
| 7571 | 7571 | [[patch.unused]] | |
| 7572 | 7572 | name = "quasi-notifs" | |
| 7573 | - | version = "0.81.0" | |
| 7573 | + | version = "0.82.0" | |
| 7574 | 7574 | ||
| 7575 | 7575 | [[patch.unused]] | |
| 7576 | 7576 | name = "quasi-store" | |
| @@ -7578,15 +7578,11 @@ | |||
| 7578 | 7578 | ||
| 7579 | 7579 | [[patch.unused]] | |
| 7580 | 7580 | name = "quasi-tauri" | |
| 7581 | - | version = "0.81.0" | |
| 7581 | + | version = "0.82.0" | |
| 7582 | 7582 | ||
| 7583 | 7583 | [[patch.unused]] | |
| 7584 | 7584 | name = "quasi-webview" | |
| 7585 | - | version = "0.81.0" | |
| 7586 | - | ||
| 7587 | - | [[patch.unused]] | |
| 7588 | - | name = "quasi-type" | |
| 7589 | - | version = "0.1.3" | |
| 7585 | + | version = "0.82.0" | |
| 7590 | 7586 | ||
| 7591 | 7587 | [[patch.unused]] | |
| 7592 | 7588 | name = "kberg" | |
| @@ -7599,3 +7595,7 @@ | |||
| 7599 | 7595 | [[patch.unused]] | |
| 7600 | 7596 | name = "painhours" | |
| 7601 | 7597 | version = "0.1.0" | |
| 7598 | + | ||
| 7599 | + | [[patch.unused]] | |
| 7600 | + | name = "quasi-type" | |
| 7601 | + | version = "0.1.3" |
| @@ -26,8 +26,8 @@ | |||
| 26 | 26 | # The described screens, which are audiofiles-browser's screens. By git URL with | |
| 27 | 27 | # a version requirement, per the tree's rule for cross-repo deps. They were | |
| 28 | 28 | # behind a `quasi` feature until 2026-08-25, when the last flip landed. | |
| 29 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.81" } | |
| 30 | - | quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.81" } | |
| 29 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.82" } | |
| 30 | + | quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.82" } | |
| 31 | 31 | egui = { version = "0.35", default-features = false, features = ["default_fonts"] } | |
| 32 | 32 | egui_extras = { version = "0.35", default-features = false } | |
| 33 | 33 | eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] } |
| @@ -199,9 +199,9 @@ | |||
| 199 | 199 | // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a | |
| 200 | 200 | // segmented control spelled out. | |
| 201 | 201 | // | |
| 202 | - | // A field in the form rather than a `Node::Select` beside it, which is what | |
| 203 | - | // it always was: nothing writes when it changes, and `tag` reads it out of | |
| 204 | - | // the payload at submit alongside the tag itself. | |
| 202 | + | // A field in the form rather than a control beside it, which is what it | |
| 203 | + | // always was: nothing writes when it changes, and `tag` reads it out of the | |
| 204 | + | // payload at submit alongside the tag itself. | |
| 205 | 205 | let mode = Field::radio( | |
| 206 | 206 | MODE, | |
| 207 | 207 | "Mode", |
| @@ -550,9 +550,8 @@ | |||
| 550 | 550 | ||
| 551 | 551 | /// The device profile picker. | |
| 552 | 552 | /// | |
| 553 | - | /// A `Field` rather than a `Node::Select` for `settings.rs`'s reason: the choice | |
| 554 | - | /// count is open — profiles are plugins — so it has to be able to fold away, and | |
| 555 | - | /// that is a dropdown. | |
| 553 | + | /// A dropdown for `settings.rs`'s reason: the choice count is open — profiles | |
| 554 | + | /// are plugins — so it has to be able to fold away. | |
| 556 | 555 | fn profile_field(profiles: &[ProfileChoice], chosen: Option<&str>) -> Node { | |
| 557 | 556 | let mut options = vec![Choice::new(String::new(), "None (manual)")]; | |
| 558 | 557 | options.extend(profiles.iter().map(|profile| { | |
| @@ -661,11 +660,9 @@ | |||
| 661 | 660 | ||
| 662 | 661 | /// One of a handful of choices, asked as a question. | |
| 663 | 662 | /// | |
| 664 | - | /// A `Field` and not a `Node::Select`, which is the line the sweep drew: every | |
| 665 | - | /// one of these is a form question wearing a strip -- pick exactly one of a | |
| 666 | - | /// handful, write immediately, no submit -- and only a field has a label, a | |
| 667 | - | /// hint, an error and a note to say it with. `Node::Select` carries a | |
| 668 | - | /// destination per option, which none of these ever used. | |
| 663 | + | /// Every one of these is a form question wearing a strip -- pick exactly one of | |
| 664 | + | /// a handful, write immediately, no submit -- and a field is what has a label, a | |
| 665 | + | /// hint, an error and a note to say it with. | |
| 669 | 666 | /// | |
| 670 | 667 | /// Returns the `Field` rather than a `Node` so a caller can say what the | |
| 671 | 668 | /// answer costs before wrapping it; see [`format_field`]. |
| @@ -429,10 +429,9 @@ | |||
| 429 | 429 | ||
| 430 | 430 | /// A handful of values that do not fold away, asked as a question. | |
| 431 | 431 | /// | |
| 432 | - | /// A `Field` and not a `Node::Select`: five slice counts and three subdivisions | |
| 433 | - | /// are one choice each, written immediately, with no destination per option. | |
| 434 | - | /// Being a field is what gives them a label, which as a strip they had to | |
| 435 | - | /// borrow from a section heading above. | |
| 432 | + | /// Five slice counts and three subdivisions are one choice each, written | |
| 433 | + | /// immediately. Being a field is what gives them a label; a bare strip of | |
| 434 | + | /// options had to borrow one from a section heading above. | |
| 436 | 435 | fn strip( | |
| 437 | 436 | knob: Knob, | |
| 438 | 437 | label: &str, |
| @@ -98,29 +98,24 @@ | |||
| 98 | 98 | //! take. Untidy rather than broken, and it is what shipped: the binding | |
| 99 | 99 | //! predates this flip, which only made it the sole path to the overlay. | |
| 100 | 100 | ||
| 101 | - | use quasi_router::layout::Selector; | |
| 102 | 101 | use quasi_router::{ | |
| 103 | - | Action, Cell, Cells, Choice, Chrome, Column, Node, Outcome, RegionKind, Request, Response, | |
| 104 | - | RouteError, Router, Screen, Slot, | |
| 102 | + | Action, Cell, Cells, Chrome, Column, Node, RegionKind, Request, Response, RouteError, Router, | |
| 103 | + | Screen, Slot, | |
| 105 | 104 | }; | |
| 106 | 105 | ||
| 107 | 106 | use super::{Panel, Panels}; | |
| 108 | 107 | ||
| 109 | 108 | /// The region the overlay answers into. | |
| 110 | 109 | const BODY: &str = "help-body"; | |
| 111 | - | /// The region a tab's contents land in. | |
| 110 | + | /// The region that holds the tabs and shows one of them. | |
| 112 | 111 | const TAB: &str = "help-tab"; | |
| 113 | - | /// The region the grouped key tables sit in. | |
| 112 | + | /// One tab: the grouped key tables. | |
| 114 | 113 | /// | |
| 115 | - | /// A region rather than a bare run because a tab's contents are one node, and | |
| 116 | - | /// grouping turned this tab from one table into a heading and a table per | |
| 117 | - | /// group. | |
| 118 | - | const SHORTCUTS_BODY: &str = "help-shortcuts"; | |
| 119 | - | ||
| 120 | - | /// Which tab is showing. | |
| 121 | - | const SHORTCUTS: &str = "shortcuts"; | |
| 114 | + | /// A region rather than a bare run because grouping turned this tab from one | |
| 115 | + | /// table into a heading and a table per group, and a tab is one child. | |
| 116 | + | const SHORTCUTS: &str = "help-shortcuts"; | |
| 122 | 117 | /// The other one. | |
| 123 | - | const FEATURES: &str = "features"; | |
| 118 | + | const FEATURES: &str = "help-features"; | |
| 124 | 119 | ||
| 125 | 120 | /// The keys that work from every described screen. | |
| 126 | 121 | /// | |
| @@ -236,67 +231,63 @@ | |||
| 236 | 231 | Action::post(format!("/panels/{}", panel.as_str())) | |
| 237 | 232 | } | |
| 238 | 233 | ||
| 239 | - | /// Register the help overlay's routes. | |
| 234 | + | /// Register the help overlay's route. | |
| 235 | + | /// | |
| 236 | + | /// One of them. Switching tabs was `POST /help/tab` until the strip became a | |
| 237 | + | /// region showing one child at a time; see [`screen`] for why both panels now | |
| 238 | + | /// arrive with the overlay. | |
| 240 | 239 | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 241 | - | router.get("/help", index).post("/help/tab", tab) | |
| 240 | + | router.get("/help", index) | |
| 242 | 241 | } | |
| 243 | 242 | ||
| 244 | 243 | /// `GET /help` | |
| 245 | 244 | fn index(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 246 | - | Ok(Response::over(screen(SHORTCUTS))) | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | /// `POST /help/tab` | |
| 250 | - | /// | |
| 251 | - | /// A fragment, because this overlay is already open. See the header: neither | |
| 252 | - | /// outcome that carries a whole screen can replace one layer of a stack. | |
| 253 | - | fn tab(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 254 | - | let chosen = request.payload.get(Node::SELECTED).unwrap_or(SHORTCUTS); | |
| 255 | - | if chosen != SHORTCUTS && chosen != FEATURES { | |
| 256 | - | return Err(RouteError::not_found("no such tab")); | |
| 257 | - | } | |
| 258 | - | Ok(Response::from(Outcome::Fragment { | |
| 259 | - | region: TAB.to_owned(), | |
| 260 | - | node: showing(chosen), | |
| 261 | - | })) | |
| 245 | + | Ok(Response::over(screen())) | |
| 262 | 246 | } | |
| 263 | 247 | ||
| 264 | 248 | /// The overlay. | |
| 265 | - | fn screen(chosen: &str) -> Screen { | |
| 249 | + | /// | |
| 250 | + | /// # Two tabs as one region, not as a control beside one | |
| 251 | + | /// | |
| 252 | + | /// This was a `Node::Select` over a `Slot` the chosen tab's contents were | |
| 253 | + | /// posted into, and it was the last tenant of that member anywhere in the tree | |
| 254 | + | /// (`e9f61b46`, Max: one way to describe a tab strip). A region showing one | |
| 255 | + | /// child at a time says the same thing with nothing beside it: the strip is | |
| 256 | + | /// derived from the children's labels, and which one is up is | |
| 257 | + | /// [`Slot::showing_one`]. | |
| 258 | + | /// | |
| 259 | + | /// Both panels are built here rather than fetched, so no child carries | |
| 260 | + | /// [`Slot::fed_by`] and pressing a tab calls no route. They are two static | |
| 261 | + | /// documents a few kilobytes long; a round trip to reveal bytes already in hand | |
| 262 | + | /// is what every renderer's derivation refuses. That is what retired | |
| 263 | + | /// `POST /help/tab` along with the member. | |
| 264 | + | fn screen() -> Screen { | |
| 266 | 265 | let body = Slot::new(BODY, RegionKind::Pane) | |
| 267 | 266 | .with(Node::page("audiofiles")) | |
| 268 | - | .with(Node::Select { | |
| 269 | - | kind: Selector::Tabs, | |
| 270 | - | options: vec![ | |
| 271 | - | (Choice::new(SHORTCUTS, "Shortcuts"), None), | |
| 272 | - | (Choice::new(FEATURES, "Features"), None), | |
| 273 | - | ], | |
| 274 | - | chosen: Some(chosen.to_owned()), | |
| 275 | - | action: Some(Action::post("/help/tab")), | |
| 276 | - | }) | |
| 277 | 267 | .with(Node::Region( | |
| 278 | - | Slot::new(TAB, RegionKind::Group).with(showing(chosen)), | |
| 268 | + | Slot::new(TAB, RegionKind::TabGroup) | |
| 269 | + | .showing_one(0) | |
| 270 | + | .with(Node::Region(shortcuts().into_iter().fold( | |
| 271 | + | Slot::new(SHORTCUTS, RegionKind::Group).label("Shortcuts"), | |
| 272 | + | Slot::with, | |
| 273 | + | ))) | |
| 274 | + | .with(Node::Region( | |
| 275 | + | Slot::new(FEATURES, RegionKind::Group) | |
| 276 | + | .label("Features") | |
| 277 | + | .with(features()), | |
| 278 | + | )), | |
| 279 | 279 | )); | |
| 280 | 280 | ||
| 281 | 281 | Screen::sidebar_content("Help").with(body) | |
| 282 | 282 | } | |
| 283 | 283 | ||
| 284 | - | /// Whichever tab is chosen. | |
| 285 | - | fn showing(chosen: &str) -> Node { | |
| 286 | - | if chosen == FEATURES { | |
| 287 | - | features() | |
| 288 | - | } else { | |
| 289 | - | shortcuts() | |
| 290 | - | } | |
| 291 | - | } | |
| 292 | - | ||
| 293 | 284 | /// Every key that works, read off the one table. | |
| 294 | 285 | /// | |
| 295 | 286 | /// No filter box. The shipped tab has one because twenty-six rows in a | |
| 296 | 287 | /// fixed-height scroll area need it; narrowing a list a screen was handed is | |
| 297 | 288 | /// what a host does, which is the rule the bulk port's tag completions and | |
| 298 | 289 | /// folder filter both follow. | |
| 299 | - | fn shortcuts() -> Node { | |
| 290 | + | fn shortcuts() -> Vec<Node> { | |
| 300 | 291 | // A heading and a table per group, in the order the app bound them, which | |
| 301 | 292 | // is what `Chrome::grouped` answers. Not sorted here: the reading order is | |
| 302 | 293 | // the shipped tab's and belongs to whoever wrote the table. | |
| @@ -327,11 +318,7 @@ | |||
| 327 | 318 | more: None, | |
| 328 | 319 | }); | |
| 329 | 320 | } | |
| 330 | - | let mut body = Slot::new(SHORTCUTS_BODY, RegionKind::Group); | |
| 331 | - | for node in sections { | |
| 332 | - | body = body.with(node); | |
| 333 | - | } | |
| 334 | - | Node::Region(body) | |
| 321 | + | sections | |
| 335 | 322 | } | |
| 336 | 323 | ||
| 337 | 324 | /// What the app does, in prose. |
| @@ -294,29 +294,6 @@ | |||
| 294 | 294 | field_offers(field, out); | |
| 295 | 295 | } | |
| 296 | 296 | } | |
| 297 | - | Node::Select { | |
| 298 | - | options, action, .. | |
| 299 | - | } => { | |
| 300 | - | // A segmented control is one choice with several labels on the | |
| 301 | - | // shipped side too, so each option is an offer rather than the | |
| 302 | - | // strip being one. | |
| 303 | - | // | |
| 304 | - | // A `Button` and not a `Choice`, which reads backwards until you | |
| 305 | - | // ask what a renderer draws: every host draws a `Node::Select` as a | |
| 306 | - | // strip of pressable segments, and egui reports a selectable label | |
| 307 | - | // as `Role::Button` with nothing to distinguish it from an ordinary | |
| 308 | - | // one. `Choice` stays for `FieldKind::Select` and `Radio`, which are | |
| 309 | - | // a box you open and a set of radios -- genuinely different things | |
| 310 | - | // to operate. Corrected 2026-08-22, when the forge's eight slice | |
| 311 | - | // options came back as buttons from the renderer and choices from | |
| 312 | - | // here. | |
| 313 | - | for (choice, own) in options { | |
| 314 | - | out.push(Role::Button, choice.label.clone(), false); | |
| 315 | - | if let Some(action) = own.as_ref().or(action.as_ref()) { | |
| 316 | - | out.addresses.push(action.destination.as_str().to_owned()); | |
| 317 | - | } | |
| 318 | - | } | |
| 319 | - | } | |
| 320 | 297 | Node::Table { columns, rows, .. } => { | |
| 321 | 298 | for column in columns { | |
| 322 | 299 | // A heading with no address is a heading. Only a sortable one |
| @@ -297,11 +297,9 @@ | |||
| 297 | 297 | ||
| 298 | 298 | /// The theme picker. | |
| 299 | 299 | /// | |
| 300 | - | /// A `Field` and not a `Node::Select`, which is worth saying because the wrong | |
| 301 | - | /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of | |
| 302 | - | /// a handful of choices, and a theme picker is thirty-odd options that has to | |
| 303 | - | /// collapse. That is a dropdown, and as of makeover-layout 0.38.0 it is | |
| 304 | - | /// `FieldKind::Theme` rather than `FieldKind::Select`. | |
| 300 | + | /// `FieldKind::Theme` and not `FieldKind::Select`, as of makeover-layout | |
| 301 | + | /// 0.38.0. Thirty-odd options that have to collapse and carry a swatch each is | |
| 302 | + | /// not the dropdown a plain choice gets. | |
| 305 | 303 | /// | |
| 306 | 304 | /// # The finding in the module header is closed | |
| 307 | 305 | /// |
| @@ -1065,8 +1065,7 @@ | |||
| 1065 | 1065 | .handle(&state, Request::get("/settings")) | |
| 1066 | 1066 | .expect("answered"); | |
| 1067 | 1067 | ||
| 1068 | - | // A field and not a `Node::Select`: `Selector` is a strip of a handful of | |
| 1069 | - | // choices, and thirty-odd themes that fold away is a dropdown. | |
| 1068 | + | // Thirty-odd themes that fold away is a dropdown, not a strip. | |
| 1070 | 1069 | let picker = screen_of(&response) | |
| 1071 | 1070 | .slots | |
| 1072 | 1071 | .iter() | |
| @@ -4318,47 +4317,74 @@ | |||
| 4318 | 4317 | assert!(matches!(response.outcome, Outcome::Over(_))); | |
| 4319 | 4318 | } | |
| 4320 | 4319 | ||
| 4321 | - | #[test] | |
| 4322 | - | fn switching_tabs_answers_a_fragment_so_the_overlay_survives() { | |
| 4323 | - | // Second consumer of the overlay-refresh finding, and a sharper one than | |
| 4324 | - | // the rename preview: a tabbed overlay is not buildable at all without | |
| 4325 | - | // fragments, because both outcomes that carry a screen destroy the layer. | |
| 4326 | - | let response = helping( | |
| 4327 | - | Request::post("/help/tab") | |
| 4328 | - | .sending(Params::new().with(Node::SELECTED.to_owned(), "features".to_owned())), | |
| 4329 | - | ) | |
| 4330 | - | .unwrap(); | |
| 4331 | - | ||
| 4332 | - | let Outcome::Fragment { region, node } = &response.outcome else { | |
| 4333 | - | panic!("expected a fragment, got {:?}", response.outcome); | |
| 4334 | - | }; | |
| 4335 | - | assert_eq!(region, "help-tab"); | |
| 4336 | - | // The features tab is a document, so it is markdown source rather than a | |
| 4337 | - | // tree of headings the description would have to invent structure for. | |
| 4338 | - | assert!(matches!(node, Node::Rich { .. })); | |
| 4320 | + | /// The region holding the overlay's two tabs. | |
| 4321 | + | /// | |
| 4322 | + | /// There is exactly one region showing a single child on this screen, which is | |
| 4323 | + | /// what these tests address it by rather than by its id: the id is a fragment | |
| 4324 | + | /// address and nothing here sends a fragment any more. | |
| 4325 | + | fn tab_strip(screen: &Screen) -> &quasi_router::Slot { | |
| 4326 | + | fn find(slot: &quasi_router::Slot) -> Option<&quasi_router::Slot> { | |
| 4327 | + | if slot.showing.selective() { | |
| 4328 | + | return Some(slot); | |
| 4329 | + | } | |
| 4330 | + | slot.body.iter().find_map(|placed| match &placed.node { | |
| 4331 | + | Node::Region(inner) => find(inner), | |
| 4332 | + | _ => None, | |
| 4333 | + | }) | |
| 4334 | + | } | |
| 4335 | + | screen | |
| 4336 | + | .slots | |
| 4337 | + | .iter() | |
| 4338 | + | .find_map(find) | |
| 4339 | + | .expect("the help overlay has no region showing one child at a time") | |
| 4339 | 4340 | } | |
| 4340 | 4341 | ||
| 4341 | 4342 | #[test] | |
| 4342 | - | fn a_tab_that_is_not_one_of_the_two_is_refused() { | |
| 4343 | - | assert!( | |
| 4344 | - | helping( | |
| 4345 | - | Request::post("/help/tab") | |
| 4346 | - | .sending(Params::new().with(Node::SELECTED.to_owned(), "elsewhere".to_owned())), | |
| 4347 | - | ) | |
| 4348 | - | .is_err() | |
| 4349 | - | ); | |
| 4343 | + | fn switching_tabs_asks_the_app_nothing_because_both_tabs_already_arrived() { | |
| 4344 | + | // This was `POST /help/tab` answering a fragment, and the route is gone: | |
| 4345 | + | // both panels are two static documents built here, so a round trip to | |
| 4346 | + | // reveal bytes the reader already has is ceremony. The overlay-refresh | |
| 4347 | + | // finding it stood as the second consumer of is still checked by the rename | |
| 4348 | + | // preview, which is a fragment over a layer for a reason that has not | |
| 4349 | + | // changed -- it is showing something the app computed. | |
| 4350 | + | let response = helping(Request::get("/help")).unwrap(); | |
| 4351 | + | let strip = tab_strip(overlay(&response)); | |
| 4352 | + | ||
| 4353 | + | assert_eq!(strip.labels(), vec!["Shortcuts", "Features"]); | |
| 4354 | + | for placed in &strip.body { | |
| 4355 | + | let Node::Region(panel) = &placed.node else { | |
| 4356 | + | panic!("a tab is not a region: {:?}", placed.node); | |
| 4357 | + | }; | |
| 4358 | + | assert!( | |
| 4359 | + | panel.fed_by.is_none(), | |
| 4360 | + | "a tab whose contents are already here named a route to fetch them" | |
| 4361 | + | ); | |
| 4362 | + | } | |
| 4350 | 4363 | } | |
| 4351 | 4364 | ||
| 4352 | 4365 | #[test] | |
| 4353 | 4366 | fn the_shortcuts_tab_is_what_a_bare_help_request_answers() { | |
| 4354 | 4367 | let response = helping(Request::get("/help")).unwrap(); | |
| 4355 | - | let screen = overlay(&response); | |
| 4368 | + | let strip = tab_strip(overlay(&response)); | |
| 4356 | 4369 | ||
| 4357 | - | let chosen = nodes(screen).iter().find_map(|node| match node { | |
| 4358 | - | Node::Select { chosen, .. } => chosen.clone(), | |
| 4359 | - | _ => None, | |
| 4360 | - | }); | |
| 4361 | - | assert_eq!(chosen.as_deref(), Some("shortcuts")); | |
| 4370 | + | assert_eq!(strip.current(), Some(0)); | |
| 4371 | + | assert_eq!(strip.labels().first().copied(), Some("Shortcuts")); | |
| 4372 | + | } | |
| 4373 | + | ||
| 4374 | + | #[test] | |
| 4375 | + | fn the_features_tab_is_the_document_it_says_it_is() { | |
| 4376 | + | // The features tab is a document, so it is markdown source rather than a | |
| 4377 | + | // tree of headings the description would have to invent structure for. | |
| 4378 | + | let response = helping(Request::get("/help")).unwrap(); | |
| 4379 | + | let strip = tab_strip(overlay(&response)); | |
| 4380 | + | ||
| 4381 | + | let Node::Region(features) = &strip.body[1].node else { | |
| 4382 | + | panic!("the second tab is not a region"); | |
| 4383 | + | }; | |
| 4384 | + | assert!(matches!( | |
| 4385 | + | features.body.first().map(|placed| &placed.node), | |
| 4386 | + | Some(Node::Rich { .. }) | |
| 4387 | + | )); | |
| 4362 | 4388 | } | |
| 4363 | 4389 | ||
| 4364 | 4390 | /// A row's primary part. |