max / quasi
6 files changed,
+392 insertions,
-165 deletions
| @@ -77,11 +77,22 @@ | |||
| 77 | 77 | Blocks, | |
| 78 | 78 | /// A homogeneous sequence whose element type the container fixes. | |
| 79 | 79 | Collection(Of), | |
| 80 | - | /// The app fills it; the description names the place and stops. | |
| 80 | + | /// The app fills it, so what is finally there is not the description's to | |
| 81 | + | /// know. | |
| 81 | 82 | /// | |
| 82 | 83 | /// What [`RegionKind::Bespoke`](crate::RegionKind::Bespoke) always was. | |
| 83 | 84 | /// Under the enumeration it was an exception to a rule; here it is an | |
| 84 | 85 | /// answer to the same question every other element answers. | |
| 86 | + | /// | |
| 87 | + | /// Not the same as holding nothing, which is what this said until `Slot` | |
| 88 | + | /// was migrated and the renderer was read against it: a bespoke region | |
| 89 | + | /// draws the blocks the description put in it and *then* the host's fill, | |
| 90 | + | /// which is a deliberate arrangement -- a heading the description owns | |
| 91 | + | /// above a canvas it does not. So the claim is about knowledge rather than | |
| 92 | + | /// about emptiness. A renderer cannot answer "what is in this region" from | |
| 93 | + | /// the description alone, which is the property every consumer of this | |
| 94 | + | /// actually needs, and it is why the answer is off the ladder rather than | |
| 95 | + | /// on top of it. | |
| 85 | 96 | Opaque, | |
| 86 | 97 | } | |
| 87 | 98 | ||
| @@ -214,10 +225,11 @@ | |||
| 214 | 225 | impl Element for Slot { | |
| 215 | 226 | fn containment(&self) -> Containment { | |
| 216 | 227 | // The one place `Opaque` is reached, and the reason it exists. A | |
| 217 | - | // bespoke region is filled by the host, so the description names the | |
| 218 | - | // place and says nothing about what goes in it. Every other region | |
| 219 | - | // holds blocks, regions included, which is the nesting that was always | |
| 220 | - | // accepted and that the row rule never touched. | |
| 228 | + | // bespoke region is filled by the host, so what is finally in it is not | |
| 229 | + | // answerable from the description -- not that the description put | |
| 230 | + | // nothing there, which is a different claim and the wrong one. Every | |
| 231 | + | // other region holds blocks, regions included, which is the nesting | |
| 232 | + | // that was always accepted and that the row rule never touched. | |
| 221 | 233 | if matches!(self.kind, RegionKind::Bespoke { .. }) { | |
| 222 | 234 | Containment::Opaque | |
| 223 | 235 | } else { | |
| @@ -377,7 +389,9 @@ | |||
| 377 | 389 | #[test] | |
| 378 | 390 | fn a_run_holds_no_blocks() { | |
| 379 | 391 | // The rule stated the other way round, over the members that are runs. | |
| 380 | - | // A row and a cell hold parts on a line; neither may hold a list. | |
| 392 | + | // A row and a cell hold parts on a line; neither may hold a list. Both | |
| 393 | + | // are runs in fact and not only by declaration since `Row` moved: | |
| 394 | + | // `Row::part` and `Cell::part` assert this same answer at a call site. | |
| 381 | 395 | for run in [Row::new("r").containment(), Cell::new("c").containment()] { | |
| 382 | 396 | assert_eq!(run, Containment::Inlines); | |
| 383 | 397 | assert_eq!(run.level(), Some(Level::Run)); | |
| @@ -409,6 +423,24 @@ | |||
| 409 | 423 | assert!(choosing.kind.offers_options()); | |
| 410 | 424 | } | |
| 411 | 425 | ||
| 426 | + | #[test] | |
| 427 | + | fn a_region_holds_leaves_as_well_as_blocks() { | |
| 428 | + | // Going down the ladder is what containment is for, so a block holding | |
| 429 | + | // a leaf needs no permission and gets no assertion. `Cell::part` and | |
| 430 | + | // `Row::part` guard the one direction that has to be guarded, which is | |
| 431 | + | // a run reaching up at a block; there is no upward violation a slot can | |
| 432 | + | // commit, so `Slot::with` checks nothing. | |
| 433 | + | let pane = Slot::new("facts", RegionKind::Pane) | |
| 434 | + | .with(Node::section("Facts")) | |
| 435 | + | .with(Node::text("Two files")) | |
| 436 | + | .with(Node::Token(Tag::badge("beta"))); | |
| 437 | + | ||
| 438 | + | assert_eq!(pane.containment(), Containment::Blocks); | |
| 439 | + | for node in &pane.body { | |
| 440 | + | assert!(node.containment().level() <= pane.containment().level()); | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | ||
| 412 | 444 | #[test] | |
| 413 | 445 | fn a_bespoke_region_is_opaque_rather_than_an_exception() { | |
| 414 | 446 | // Under the enumeration this was a hole in the rule. Here it answers |
| @@ -113,7 +113,7 @@ | |||
| 113 | 113 | pub use crate::router::{Handler, Router}; | |
| 114 | 114 | pub use crate::screen::{ | |
| 115 | 115 | Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node, | |
| 116 | - | Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag, | |
| 116 | + | Part, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag, | |
| 117 | 117 | }; | |
| 118 | 118 | ||
| 119 | 119 | #[cfg(test)] |
| @@ -1064,6 +1064,28 @@ | |||
| 1064 | 1064 | /// [`Node::StandIn`]. | |
| 1065 | 1065 | pub readiness: layout::Readiness, | |
| 1066 | 1066 | /// What is in it. | |
| 1067 | + | /// | |
| 1068 | + | /// Blocks, regions included, which is the nesting that was always accepted: | |
| 1069 | + | /// a region inside a region is a nested rect on every host. Leaves are | |
| 1070 | + | /// admitted too, and deliberately -- a fact under a heading is a | |
| 1071 | + | /// [`Node::Text`] straight in a pane, and it is the commonest thing in the | |
| 1072 | + | /// tree. | |
| 1073 | + | /// | |
| 1074 | + | /// # Why there is no bound here | |
| 1075 | + | /// | |
| 1076 | + | /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a | |
| 1077 | + | /// leaf, and this does not, which looks like an oversight and is the model | |
| 1078 | + | /// working. The ladder forbids reaching *up*: a run may not hold a block, | |
| 1079 | + | /// because a run has to be drawable on one wrapped line. A block holding a | |
| 1080 | + | /// leaf is going down, and going down is what containment is for. There is | |
| 1081 | + | /// no upward violation for [`with`](Self::with) to catch, so an assertion | |
| 1082 | + | /// here would be a runtime check that can never fire. | |
| 1083 | + | /// | |
| 1084 | + | /// A region whose whole content is one badge is the case that made this | |
| 1085 | + | /// look like a question. It is describable, and it should be: a status pane | |
| 1086 | + | /// is a real screen. Whether it is a *good* screen is a judgement about | |
| 1087 | + | /// that screen rather than a property of the vocabulary, and the bound is | |
| 1088 | + | /// not the place to hold opinions about taste. | |
| 1067 | 1089 | pub body: Vec<Node>, | |
| 1068 | 1090 | } | |
| 1069 | 1091 | ||
| @@ -1238,36 +1260,69 @@ | |||
| 1238 | 1260 | } | |
| 1239 | 1261 | } | |
| 1240 | 1262 | ||
| 1263 | + | /// One part of a row's run, and the role it takes. | |
| 1264 | + | /// | |
| 1265 | + | /// A cell's run entries carry no role because their kind already says which | |
| 1266 | + | /// part they are: text is the value, a [`Node::Link`] is the link, a | |
| 1267 | + | /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's | |
| 1268 | + | /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot | |
| 1269 | + | /// tell those apart, so a row says which one it means. | |
| 1270 | + | /// | |
| 1271 | + | /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged | |
| 1272 | + | /// by the containment model: it says how a part is drawn, not what may sit in | |
| 1273 | + | /// it, and that is the half of it worth keeping. | |
| 1274 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 1275 | + | pub struct Part { | |
| 1276 | + | /// Which of the row's roles this part takes. | |
| 1277 | + | pub role: layout::RowPart, | |
| 1278 | + | /// What is in it. A leaf, since a row is an inline run. | |
| 1279 | + | pub node: Node, | |
| 1280 | + | } | |
| 1281 | + | ||
| 1241 | 1282 | /// One row of a list. | |
| 1242 | 1283 | /// | |
| 1243 | - | /// The four parts are [`layout::RowPart`]'s four, which is where they came | |
| 1244 | - | /// from: a row that needed a fifth would be a table. | |
| 1284 | + | /// # The run | |
| 1285 | + | /// | |
| 1286 | + | /// A row's content is an inline run of [`Part`]s, in the order the description | |
| 1287 | + | /// says them, the same way a [`Cell`]'s is. It was six members before | |
| 1288 | + | /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter` | |
| 1289 | + | /// -- each of which arrived as a counted-sites argument, a member here, a | |
| 1290 | + | /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at | |
| 1291 | + | /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0 | |
| 1292 | + | /// for a bar in one. A link in a row was simply not sayable, and a figure in | |
| 1293 | + | /// one was not either. Under the run both are already sayable and cost nothing. | |
| 1294 | + | /// | |
| 1295 | + | /// The bound is that every part is a leaf, so a row is drawable on one wrapped | |
| 1296 | + | /// line without a renderer knowing what is in it. [`Row::part`] is where that | |
| 1297 | + | /// bites at a call site. | |
| 1298 | + | /// | |
| 1299 | + | /// Order is the description's. The old members were drawn in a fixed sequence | |
| 1300 | + | /// whatever order they were built in, so a row that wanted a tag between two | |
| 1301 | + | /// facts got the tag hoisted to the end; now it draws where it was put. | |
| 1302 | + | /// | |
| 1303 | + | /// # What stayed a field | |
| 1304 | + | /// | |
| 1305 | + | /// [`activate`](Self::activate), [`current`](Self::current), | |
| 1306 | + | /// [`selected`](Self::selected), [`menu`](Self::menu) and | |
| 1307 | + | /// [`toggle`](Self::toggle) are facts *about* the row rather than content in | |
| 1308 | + | /// it. A run of things on a line is not where "this row is the one the detail | |
| 1309 | + | /// pane is showing" belongs. | |
| 1310 | + | /// | |
| 1311 | + | /// # The cost | |
| 1312 | + | /// | |
| 1313 | + | /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which | |
| 1314 | + | /// is what let a constrained renderer right-align a row cheaply. It answers the | |
| 1315 | + | /// text of the primary parts joined, and a row built the ordinary way still has | |
| 1316 | + | /// exactly one. | |
| 1245 | 1317 | #[derive(Debug, Clone, PartialEq, Eq, Default)] | |
| 1246 | 1318 | pub struct Row { | |
| 1247 | - | /// The thing itself. What the row is called. | |
| 1248 | - | pub primary: String, | |
| 1249 | - | /// Supporting text under the primary, plain or markdown. | |
| 1319 | + | /// What is in the row, in order. | |
| 1250 | 1320 | /// | |
| 1251 | - | /// [`Prose`] rather than a `String` since the row-prose decision: the part | |
| 1252 | - | /// is still a string and still not a node, and it now says which of the two | |
| 1253 | - | /// kinds of string it is. | |
| 1254 | - | pub secondary: Option<Prose>, | |
| 1255 | - | /// A short trailing fact: a count, a size, a date. | |
| 1256 | - | pub meta: Option<String>, | |
| 1257 | - | /// Small labelled things belonging to the row: badges, chips, tags. | |
| 1258 | - | /// | |
| 1259 | - | /// Against `makeover-layout`'s `RowPart::Tokens`, which arrived at 0.9.0 for | |
| 1260 | - | /// this. Before it, a row that carried two trailing facts had to join them | |
| 1261 | - | /// into [`meta`](Self::meta) as text, which kept both and lost what the | |
| 1262 | - | /// second one was: a toned status read as prose rather than as colour. | |
| 1263 | - | /// | |
| 1264 | - | /// [`meta`](Self::meta) is still the right place for a plain fact. The line | |
| 1265 | - | /// is whether the thing has its own standing — a tone of its own, or a click | |
| 1266 | - | /// to answer. "3 files" is meta; an amber status and a clickable tag are | |
| 1267 | - | /// tokens. | |
| 1268 | - | pub tokens: Vec<Tag>, | |
| 1269 | - | /// Controls that act on this row. | |
| 1270 | - | pub actions: Vec<Act>, | |
| 1321 | + | /// Built by the same constructors that named the old members: | |
| 1322 | + | /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta), | |
| 1323 | + | /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all | |
| 1324 | + | /// still mean what they meant, so no builder call site moved. | |
| 1325 | + | pub parts: Vec<Part>, | |
| 1271 | 1326 | /// The route that selects this row, if selecting it does anything. | |
| 1272 | 1327 | pub activate: Option<Action>, | |
| 1273 | 1328 | /// Whether this is the row the detail side is currently showing. | |
| @@ -1292,23 +1347,10 @@ | |||
| 1292 | 1347 | /// [`current`](Self::current). goingson's contacts and tasks screens both | |
| 1293 | 1348 | /// drive bulk actions from it. | |
| 1294 | 1349 | pub selected: Option<bool>, | |
| 1295 | - | /// How much of a set this row's thing has done. | |
| 1296 | - | /// | |
| 1297 | - | /// `da5666ae`, and [`RowPart::Proportion`](layout::RowPart::Proportion) is | |
| 1298 | - | /// the part it renders into. [`layout::Meter`] arrived at 0.10.0 and closed | |
| 1299 | - | /// two of the seven sites that wanted it; the other five sit in rows, and a | |
| 1300 | - | /// row holds no nodes by the 2026-08-08 ruling — the door through which a | |
| 1301 | - | /// description becomes a templating language. So this carries the | |
| 1302 | - | /// *description of a bar* rather than a node, exactly as | |
| 1303 | - | /// [`tokens`](Self::tokens) carries tags rather than nodes. | |
| 1304 | - | /// | |
| 1305 | - | /// Owned rather than borrowed for the reason everything here is. Without it | |
| 1306 | - | /// a row flattens the proportion into [`meta`](Self::meta) as "3/7 | |
| 1307 | - | /// subtasks", keeping both numbers and losing the reading. | |
| 1308 | - | pub meter: Option<Meter>, | |
| 1309 | 1350 | /// Everything else that can be done to this row. | |
| 1310 | 1351 | /// | |
| 1311 | - | /// `5e02fbce`. [`actions`](Self::actions) is what the row shows; this is | |
| 1352 | + | /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run | |
| 1353 | + | /// are what the row shows; this is | |
| 1312 | 1354 | /// what it *offers*, reached by right-click on a pointer host, long-press on | |
| 1313 | 1355 | /// a touch one, and a key in a terminal. That split is the whole reason it | |
| 1314 | 1356 | /// belongs in the description rather than in a renderer: one description has | |
| @@ -1319,9 +1361,9 @@ | |||
| 1319 | 1361 | /// 680 lines of generic menu machinery between `components.js` and | |
| 1320 | 1362 | /// `context-menus.js`. | |
| 1321 | 1363 | /// | |
| 1322 | - | /// A `Vec<Act>` rather than a new part, because that is what a menu is: the | |
| 1323 | - | /// acts already carry their own tone, state, confirmation and key. The | |
| 1324 | - | /// 2026-08-08 row ruling is untouched — these are acts, not nodes. | |
| 1364 | + | /// A field rather than a role in the run, because a menu is not on the | |
| 1365 | + | /// line. The run is what the row draws; this is what it holds back until | |
| 1366 | + | /// the host asks, and no renderer draws it in sequence with the primary. | |
| 1325 | 1367 | pub menu: Vec<Act>, | |
| 1326 | 1368 | /// What ticking this row calls, if ticking it is the write. | |
| 1327 | 1369 | /// | |
| @@ -1344,9 +1386,21 @@ | |||
| 1344 | 1386 | ||
| 1345 | 1387 | impl Row { | |
| 1346 | 1388 | /// A row with only its primary text. | |
| 1389 | + | /// | |
| 1390 | + | /// An empty string is an empty run rather than a run holding an empty | |
| 1391 | + | /// string, so `Row::new("")` and [`Row::default`] are the same value. Same | |
| 1392 | + | /// rule as [`Cell::new`], and for the same reason. | |
| 1347 | 1393 | pub fn new(primary: impl Into<String>) -> Self { | |
| 1394 | + | let primary = primary.into(); | |
| 1348 | 1395 | Self { | |
| 1349 | - | primary: primary.into(), | |
| 1396 | + | parts: if primary.is_empty() { | |
| 1397 | + | Vec::new() | |
| 1398 | + | } else { | |
| 1399 | + | vec![Part { | |
| 1400 | + | role: layout::RowPart::Primary, | |
| 1401 | + | node: Node::text(primary), | |
| 1402 | + | }] | |
| 1403 | + | }, | |
| 1350 | 1404 | ..Self::default() | |
| 1351 | 1405 | } | |
| 1352 | 1406 | } | |
| @@ -1361,7 +1415,7 @@ | |||
| 1361 | 1415 | /// How much of this row's set is done. | |
| 1362 | 1416 | #[must_use] | |
| 1363 | 1417 | pub fn meter(mut self, meter: Meter) -> Self { | |
| 1364 | - | self.meter = Some(meter); | |
| 1418 | + | self.set(layout::RowPart::Proportion, Node::Meter(meter)); | |
| 1365 | 1419 | self | |
| 1366 | 1420 | } | |
| 1367 | 1421 | ||
| @@ -1381,21 +1435,28 @@ | |||
| 1381 | 1435 | /// Supporting text under the primary. | |
| 1382 | 1436 | #[must_use] | |
| 1383 | 1437 | pub fn secondary(mut self, text: impl Into<Prose>) -> Self { | |
| 1384 | - | self.secondary = Some(text.into()); | |
| 1438 | + | let node = match text.into() { | |
| 1439 | + | Prose::Text(text) => Node::text(text), | |
| 1440 | + | Prose::Rich(source) => Node::rich(source), | |
| 1441 | + | }; | |
| 1442 | + | self.set(layout::RowPart::Secondary, node); | |
| 1385 | 1443 | self | |
| 1386 | 1444 | } | |
| 1387 | 1445 | ||
| 1388 | 1446 | /// A short trailing fact. | |
| 1389 | 1447 | #[must_use] | |
| 1390 | 1448 | pub fn meta(mut self, text: impl Into<String>) -> Self { | |
| 1391 | - | self.meta = Some(text.into()); | |
| 1449 | + | self.set(layout::RowPart::Meta, Node::text(text)); | |
| 1392 | 1450 | self | |
| 1393 | 1451 | } | |
| 1394 | 1452 | ||
| 1395 | 1453 | /// Add a token, chaining. | |
| 1396 | 1454 | #[must_use] | |
| 1397 | 1455 | pub fn token(mut self, tag: Tag) -> Self { | |
| 1398 | - | self.tokens.push(tag); | |
| 1456 | + | self.parts.push(Part { | |
| 1457 | + | role: layout::RowPart::Tokens, | |
| 1458 | + | node: Node::Token(tag), | |
| 1459 | + | }); | |
| 1399 | 1460 | self | |
| 1400 | 1461 | } | |
| 1401 | 1462 | ||
| @@ -1419,9 +1480,78 @@ | |||
| 1419 | 1480 | /// A control acting on this row. | |
| 1420 | 1481 | #[must_use] | |
| 1421 | 1482 | pub fn act(mut self, act: Act) -> Self { | |
| 1422 | - | self.actions.push(act); | |
| 1483 | + | self.parts.push(Part { | |
| 1484 | + | role: layout::RowPart::Actions, | |
| 1485 | + | node: Node::Act(act), | |
| 1486 | + | }); | |
| 1423 | 1487 | self | |
| 1424 | 1488 | } | |
| 1489 | + | ||
| 1490 | + | /// Anything in this row, under the role it takes. | |
| 1491 | + | /// | |
| 1492 | + | /// The general form the constructors above are shorthands for, and the | |
| 1493 | + | /// point of the model: a link in a row and a figure in a row became | |
| 1494 | + | /// sayable at once, where each was previously a | |
| 1495 | + | /// [`layout::RowPart`] variant, a member here, a renderer arm and a | |
| 1496 | + | /// release. | |
| 1497 | + | /// | |
| 1498 | + | /// Appends rather than replacing, so a row can hold two of a role. The | |
| 1499 | + | /// named constructors keep the single-valued roles single-valued, which is | |
| 1500 | + | /// what their call sites already meant. | |
| 1501 | + | /// | |
| 1502 | + | /// # Panics | |
| 1503 | + | /// | |
| 1504 | + | /// If the node is not a leaf. A row is an inline run, so what goes in it | |
| 1505 | + | /// has to be drawable on one wrapped line without the renderer knowing what | |
| 1506 | + | /// it is -- the constrained-consumer bound, biting at a call site rather | |
| 1507 | + | /// than in a doc comment. Same assertion as [`Cell::part`]. | |
| 1508 | + | #[must_use] | |
| 1509 | + | pub fn part(mut self, role: layout::RowPart, node: Node) -> Self { | |
| 1510 | + | assert!( | |
| 1511 | + | node.containment() == Containment::Text, | |
| 1512 | + | "a row is an inline run and holds leaves; {node:?} holds {:?}", | |
| 1513 | + | node.containment() | |
| 1514 | + | ); | |
| 1515 | + | self.parts.push(Part { role, node }); | |
| 1516 | + | self | |
| 1517 | + | } | |
| 1518 | + | ||
| 1519 | + | /// Set the one part taking a role, replacing it if it is already there. | |
| 1520 | + | /// | |
| 1521 | + | /// For the roles that are single-valued at every call site that has ever | |
| 1522 | + | /// existed: the primary, the supporting line, the trailing fact, the bar. | |
| 1523 | + | /// Building a row that calls `.meta` twice meant the second one won when | |
| 1524 | + | /// `meta` was an `Option`, and it still does. | |
| 1525 | + | fn set(&mut self, role: layout::RowPart, node: Node) { | |
| 1526 | + | match self.parts.iter_mut().find(|part| part.role == role) { | |
| 1527 | + | Some(part) => part.node = node, | |
| 1528 | + | None => self.parts.push(Part { role, node }), | |
| 1529 | + | } | |
| 1530 | + | } | |
| 1531 | + | ||
| 1532 | + | /// The parts taking one role, in order. | |
| 1533 | + | pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> { | |
| 1534 | + | self.parts | |
| 1535 | + | .iter() | |
| 1536 | + | .filter(move |part| part.role == role) | |
| 1537 | + | .map(|part| &part.node) | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | /// The row's primary text. | |
| 1541 | + | /// | |
| 1542 | + | /// What every consumer of the old `primary` member wanted. A row built the | |
| 1543 | + | /// ordinary way has one primary part and answers its string; one that was | |
| 1544 | + | /// given two answers both, joined, in order. | |
| 1545 | + | #[must_use] | |
| 1546 | + | pub fn primary(&self) -> String { | |
| 1547 | + | self.role(layout::RowPart::Primary) | |
| 1548 | + | .filter_map(|node| match node { | |
| 1549 | + | Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()), | |
| 1550 | + | _ => None, | |
| 1551 | + | }) | |
| 1552 | + | .collect::<Vec<_>>() | |
| 1553 | + | .join(" ") | |
| 1554 | + | } | |
| 1425 | 1555 | } | |
| 1426 | 1556 | ||
| 1427 | 1557 | /// One cell of a table row. |
| @@ -48,7 +48,7 @@ | |||
| 48 | 48 | }; | |
| 49 | 49 | use makeover_webview::meter::meter_html; | |
| 50 | 50 | use makeover_webview::placeholder::placeholder_html; | |
| 51 | - | use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Prose, Row, Slot, Tag}; | |
| 51 | + | use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag}; | |
| 52 | 52 | use quasi_router::{Action, Method, Params}; | |
| 53 | 53 | ||
| 54 | 54 | /// Write a `class="..."` attribute, prefixed. | |
| @@ -140,32 +140,6 @@ | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | /// A row part's prose into markup. | |
| 144 | - | /// | |
| 145 | - | /// [`Prose::Text`] escapes, exactly as it did when the part was a `String`. | |
| 146 | - | /// [`Prose::Rich`] renders through docengine's `phrase` preset, which is the | |
| 147 | - | /// preset this call site asked for: markdown with no block structure at all and | |
| 148 | - | /// no links, keeping the inline emphasis. A heading, a list and a quote each | |
| 149 | - | /// contribute their words without claiming a block of a row that has no room | |
| 150 | - | /// for one, and not even a paragraph survives. | |
| 151 | - | /// | |
| 152 | - | /// Links go because a row usually carries [`Row::activate`], so the row itself | |
| 153 | - | /// is already a target and an anchor inside it is a second target inside the | |
| 154 | - | /// first: ambiguous to click, worse to reach by keyboard, and pointing somewhere | |
| 155 | - | /// a one-line summary cannot usefully send anyone. Their text stays. | |
| 156 | - | /// | |
| 157 | - | /// This is the renderer deciding, which is the point of the description | |
| 158 | - | /// carrying the kind rather than a flattened string. A terminal renderer facing | |
| 159 | - | /// the same [`Prose::Rich`] can emit bold instead, and one that wants neither | |
| 160 | - | /// can call `docengine::render_plain`. None of them has to be told by the | |
| 161 | - | /// screen author which to do. | |
| 162 | - | fn row_prose_html(prose: &Prose) -> String { | |
| 163 | - | match prose { | |
| 164 | - | Prose::Text(text) => escape(text), | |
| 165 | - | Prose::Rich(source) => docengine::render_phrase(source), | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ||
| 169 | 143 | /// Markdown source into markup, for [`Node::Rich`]. | |
| 170 | 144 | /// | |
| 171 | 145 | /// The strict preset, which is a deliberate difference from the | |
| @@ -534,49 +508,28 @@ | |||
| 534 | 508 | out.push_str(" aria-label=\"Select\">"); | |
| 535 | 509 | } | |
| 536 | 510 | ||
| 537 | - | // The primary is a control when selecting the row does something, and plain | |
| 538 | - | // text when it does not. Emitting a button either way would give a screen | |
| 539 | - | // reader an affordance that answers nothing. | |
| 540 | - | match &row.activate { | |
| 541 | - | Some(action) => { | |
| 542 | - | let (open, close) = control_tag(action); | |
| 543 | - | out.push_str(open); | |
| 544 | - | class_attr(&["row-activate"], opts, out); | |
| 545 | - | action_attrs(action, Fires::Click, None, morphs, out); | |
| 546 | - | out.push('>'); | |
| 547 | - | out.push_str(&escape(&row.primary)); | |
| 548 | - | out.push_str(close); | |
| 549 | - | } | |
| 550 | - | None => { | |
| 551 | - | out.push_str("<span"); | |
| 552 | - | class_attr(&[part_class(layout::RowPart::Primary)], opts, out); | |
| 553 | - | out.push('>'); | |
| 554 | - | out.push_str(&escape(&row.primary)); | |
| 555 | - | out.push_str("</span>"); | |
| 556 | - | } | |
| 511 | + | // A span per run of consecutive parts sharing a role, rather than a fixed | |
| 512 | + | // sequence of members. The old shape drew primary, secondary, meta, bar, | |
| 513 | + | // tokens, actions in that order however the description was built; the run | |
| 514 | + | // draws what it was given where it was put, and a row with a tag between | |
| 515 | + | // two facts now says so. | |
| 516 | + | // | |
| 517 | + | // Consecutive same-role parts share one wrapping span for the reason a | |
| 518 | + | // cell's tokens do: the part class carries the gap between siblings, so a | |
| 519 | + | // span each would space two badges as though they were unrelated. | |
| 520 | + | let mut rest = row.parts.as_slice(); | |
| 521 | + | while let Some(head) = rest.first() { | |
| 522 | + | let role = head.role; | |
| 523 | + | let taken = rest.iter().take_while(|part| part.role == role).count(); | |
| 524 | + | let (group, tail) = rest.split_at(taken); | |
| 525 | + | row_part_html(row, role, group, morphs, opts, out); | |
| 526 | + | rest = tail; | |
| 557 | 527 | } | |
| 558 | 528 | ||
| 559 | - | if let Some(secondary) = &row.secondary { | |
| 560 | - | out.push_str("<span"); | |
| 561 | - | class_attr(&[part_class(layout::RowPart::Secondary)], opts, out); | |
| 562 | - | out.push('>'); | |
| 563 | - | out.push_str(&row_prose_html(secondary)); | |
| 564 | - | out.push_str("</span>"); | |
| 565 | - | } | |
| 566 | - | ||
| 567 | - | if let Some(meta) = &row.meta { | |
| 568 | - | out.push_str("<span"); | |
| 569 | - | class_attr(&[part_class(layout::RowPart::Meta)], opts, out); | |
| 570 | - | out.push('>'); | |
| 571 | - | out.push_str(&escape(meta)); | |
| 572 | - | out.push_str("</span>"); | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | // The menu before the bar, because it is not a fact about the row at all: | |
| 576 | - | // it is the set of things that can be done to it, and it renders as a | |
| 577 | - | // container the host opens its own way. A webview hangs a context menu off | |
| 578 | - | // it, a touch host an action sheet, a terminal a key-driven list; all three | |
| 579 | - | // read the same acts. | |
| 529 | + | // After the run, because a menu is not on the line: it is the set of things | |
| 530 | + | // that can be done to the row, and it renders as a container the host opens | |
| 531 | + | // its own way. A webview hangs a context menu off it, a touch host an action | |
| 532 | + | // sheet, a terminal a key-driven list; all three read the same acts. | |
| 580 | 533 | if !row.menu.is_empty() { | |
| 581 | 534 | out.push_str("<div"); | |
| 582 | 535 | class_attr(&["row-menu"], opts, out); | |
| @@ -589,45 +542,83 @@ | |||
| 589 | 542 | out.push_str("</div>"); | |
| 590 | 543 | } | |
| 591 | 544 | ||
| 592 | - | // The bar sits with the plain trailing facts and before the tokens, which | |
| 593 | - | // is where a proportion belongs in the emphasis order: it is a fact about | |
| 594 | - | // the row rather than a thing with a standing of its own. The trough and | |
| 595 | - | // its tones are makeover-webview's, unchanged, same as everywhere else. | |
| 596 | - | if let Some(meter) = &row.meter { | |
| 597 | - | out.push_str("<span"); | |
| 598 | - | class_attr(&[part_class(layout::RowPart::Proportion)], opts, out); | |
| 599 | - | out.push('>'); | |
| 600 | - | out.push_str(&meter_html(&meter.as_layout(), opts)); | |
| 601 | - | out.push_str("</span>"); | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | // Tokens after meta and before actions, which is emphasis order: the row's | |
| 605 | - | // own name, then what supports it, then the plain trailing fact, then the | |
| 606 | - | // things with a standing of their own, then the controls. `RowPart`'s | |
| 607 | - | // `intent` falls off in the same order and never rises again. | |
| 608 | - | if !row.tokens.is_empty() { | |
| 609 | - | out.push_str("<span"); | |
| 610 | - | class_attr(&[part_class(layout::RowPart::Tokens)], opts, out); | |
| 611 | - | out.push('>'); | |
| 612 | - | for tag in &row.tokens { | |
| 613 | - | tag_html(tag, morphs, opts, out); | |
| 614 | - | } | |
| 615 | - | out.push_str("</span>"); | |
| 616 | - | } | |
| 617 | - | ||
| 618 | - | if !row.actions.is_empty() { | |
| 619 | - | out.push_str("<span"); | |
| 620 | - | class_attr(&[part_class(layout::RowPart::Actions)], opts, out); | |
| 621 | - | out.push('>'); | |
| 622 | - | for act in &row.actions { | |
| 623 | - | act_html(act, morphs, opts, out); | |
| 624 | - | } | |
| 625 | - | out.push_str("</span>"); | |
| 626 | - | } | |
| 627 | - | ||
| 628 | 545 | out.push_str("</li>"); | |
| 629 | 546 | } | |
| 630 | 547 | ||
| 548 | + | /// One run of consecutive row parts sharing a role. | |
| 549 | + | fn row_part_html( | |
| 550 | + | row: &Row, | |
| 551 | + | role: layout::RowPart, | |
| 552 | + | group: &[quasi_router::Part], | |
| 553 | + | morphs: bool, | |
| 554 | + | opts: &Emit, | |
| 555 | + | out: &mut String, | |
| 556 | + | ) { | |
| 557 | + | // The primary is a control when selecting the row does something, and plain | |
| 558 | + | // text when it does not. Emitting a button either way would give a screen | |
| 559 | + | // reader an affordance that answers nothing. `activate` stayed a field | |
| 560 | + | // through the run migration, so this is still the row's own answer rather | |
| 561 | + | // than something recomputed from a part. | |
| 562 | + | let activates = role == layout::RowPart::Primary && row.activate.is_some(); | |
| 563 | + | ||
| 564 | + | let close = if let (true, Some(action)) = (activates, row.activate.as_ref()) { | |
| 565 | + | let (open, close) = control_tag(action); | |
| 566 | + | out.push_str(open); | |
| 567 | + | class_attr(&["row-activate"], opts, out); | |
| 568 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 569 | + | out.push('>'); | |
| 570 | + | close | |
| 571 | + | } else { | |
| 572 | + | out.push_str("<span"); | |
| 573 | + | class_attr(&[part_class(role)], opts, out); | |
| 574 | + | out.push('>'); | |
| 575 | + | "</span>" | |
| 576 | + | }; | |
| 577 | + | ||
| 578 | + | for part in group { | |
| 579 | + | row_inline_html(&part.node, morphs, opts, out); | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | out.push_str(close); | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | /// One leaf inside a row's run. | |
| 586 | + | /// | |
| 587 | + | /// Markdown is the one place a run entry is not just `node_html`. A | |
| 588 | + | /// [`Node::Rich`] standing on its own is a block and renders as one; inside a | |
| 589 | + | /// row it goes through docengine's `phrase` preset instead, which is markdown | |
| 590 | + | /// with no block structure at all and no links, keeping the inline emphasis. A | |
| 591 | + | /// heading, a list and a quote each contribute their words without claiming a | |
| 592 | + | /// block of a row that has no room for one, and not even a paragraph survives. | |
| 593 | + | /// | |
| 594 | + | /// Links go because a row usually carries [`Row::activate`], so the row itself | |
| 595 | + | /// is already a target and an anchor inside it is a second target inside the | |
| 596 | + | /// first: ambiguous to click, worse to reach by keyboard, and pointing | |
| 597 | + | /// somewhere a one-line summary cannot usefully send anyone. Their text stays. | |
| 598 | + | /// | |
| 599 | + | /// This is the renderer deciding, which is the point of the description | |
| 600 | + | /// carrying the kind rather than a flattened string. A terminal renderer facing | |
| 601 | + | /// the same source can emit bold instead, and one that wants neither can call | |
| 602 | + | /// `docengine::render_plain`. None of them has to be told by the screen author | |
| 603 | + | /// which to do. | |
| 604 | + | fn row_inline_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) { | |
| 605 | + | match node { | |
| 606 | + | Node::Text { text, .. } => out.push_str(&escape(text)), | |
| 607 | + | Node::Rich { source } => out.push_str(&docengine::render_phrase(source)), | |
| 608 | + | Node::Token(tag) => tag_html(tag, morphs, opts, out), | |
| 609 | + | Node::Act(act) => act_html(act, morphs, opts, out), | |
| 610 | + | Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)), | |
| 611 | + | // Every other leaf the model admits into a run. A link in a row and a | |
| 612 | + | // figure in a row were the two gaps the enumeration left open and could | |
| 613 | + | // not close without a `RowPart` variant each; here they arrive by | |
| 614 | + | // already being leaves. | |
| 615 | + | // | |
| 616 | + | // No fills: a bespoke region is a block and cannot reach a run, which | |
| 617 | + | // is what the containment bound guarantees. | |
| 618 | + | other => node_html(other, morphs, opts, &HashMap::new(), out), | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | ||
| 631 | 622 | /// One cell's inline run. | |
| 632 | 623 | /// | |
| 633 | 624 | /// A part per inline rather than a part per cell, which is the half of the |
| @@ -398,11 +398,85 @@ | |||
| 398 | 398 | assert!(html.contains(r#"aria-label="3 of 7 subtasks""#)); | |
| 399 | 399 | } | |
| 400 | 400 | ||
| 401 | + | #[test] | |
| 402 | + | fn a_link_and_a_figure_in_a_row_cost_no_release() { | |
| 403 | + | // The two gaps the enumeration left open on the row side. Each was a | |
| 404 | + | // `RowPart` variant, a member on `Row`, a renderer arm and a | |
| 405 | + | // makeover-layout release away, which is what put the third of the seven | |
| 406 | + | // matrix pairings out of reach. Under the run they are leaves, and a leaf | |
| 407 | + | // in a run is already sayable. | |
| 408 | + | let html = fragment(&Node::list([Row::new("Invoice 41") | |
| 409 | + | .part( | |
| 410 | + | layout::RowPart::Meta, | |
| 411 | + | Node::Link { | |
| 412 | + | text: "makenot.work".into(), | |
| 413 | + | action: Action::get("/sites/1"), | |
| 414 | + | }, | |
| 415 | + | ) | |
| 416 | + | .part( | |
| 417 | + | layout::RowPart::Tokens, | |
| 418 | + | Node::Figure(Figure::new("41", "days")), | |
| 419 | + | )])); | |
| 420 | + | ||
| 421 | + | assert!(html.contains("makenot.work"), "{html}"); | |
| 422 | + | assert!(html.contains("/sites/1"), "{html}"); | |
| 423 | + | assert!(html.contains("41"), "{html}"); | |
| 424 | + | // Each takes the class of the role it was given, and the leaves inside keep | |
| 425 | + | // makeover's own classes for what they are. | |
| 426 | + | assert!(html.contains("class=\"row-meta\""), "{html}"); | |
| 427 | + | assert!(html.contains("class=\"row-tokens\""), "{html}"); | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | #[test] | |
| 431 | + | #[should_panic(expected = "a row is an inline run and holds leaves")] | |
| 432 | + | fn a_row_refuses_a_block() { | |
| 433 | + | // The bound, at the call site. A row is drawable on one wrapped line, and a | |
| 434 | + | // list inside one is the door the 2026-08-08 rider was trying to shut with | |
| 435 | + | // a doc comment. This is the same rule, checked. | |
| 436 | + | let _ = Row::new("Ship it").part( | |
| 437 | + | layout::RowPart::Meta, | |
| 438 | + | Node::List { | |
| 439 | + | rows: Vec::new(), | |
| 440 | + | more: None, | |
| 441 | + | }, | |
| 442 | + | ); | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | #[test] | |
| 446 | + | fn a_rows_parts_draw_in_the_order_the_description_says_them() { | |
| 447 | + | // The members were drawn in a fixed sequence whatever order they were built | |
| 448 | + | // in, so a row wanting a tag between two facts got the tag hoisted to the | |
| 449 | + | // end. The run says the order itself. | |
| 450 | + | let html = fragment(&Node::list([Row::new("Ship it") | |
| 451 | + | .token(Tag::badge("beta")) | |
| 452 | + | .meta("2 files")])); | |
| 453 | + | ||
| 454 | + | let tokens = html.find("row-tokens").expect("a tokens strip"); | |
| 455 | + | let meta = html.find("row-meta").expect("a meta part"); | |
| 456 | + | assert!(tokens < meta, "{html}"); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn consecutive_parts_of_one_role_share_a_strip() { | |
| 461 | + | // Two badges are a strip and not two unrelated spans: the part class | |
| 462 | + | // carries the gap between siblings, which is the same reason a cell's | |
| 463 | + | // tokens group. Two badges with a fact between them are two strips, and | |
| 464 | + | // that is the description saying so. | |
| 465 | + | let html = fragment(&Node::list([Row::new("Ship it") | |
| 466 | + | .token(Tag::badge("beta")) | |
| 467 | + | .token(Tag::badge("draft"))])); | |
| 468 | + | ||
| 469 | + | assert_eq!(html.matches("row-tokens").count(), 1, "{html}"); | |
| 470 | + | assert!(html.contains("beta"), "{html}"); | |
| 471 | + | assert!(html.contains("draft"), "{html}"); | |
| 472 | + | } | |
| 473 | + | ||
| 401 | 474 | #[test] | |
| 402 | 475 | fn a_proportion_in_a_row_is_a_bar_and_not_flattened_text() { | |
| 403 | 476 | // `da5666ae`. `Meter` closed two of its seven sites at 0.10.0 and could not | |
| 404 | - | // reach the five that sit in rows, because a row holds no nodes. The row | |
| 405 | - | // carries the description of a bar instead, the way it carries tags. | |
| 477 | + | // reach the five that sit in rows, because a row held no nodes. The row | |
| 478 | + | // carried the description of a bar instead; under the run it carries the | |
| 479 | + | // meter as the leaf it always was, and `Row::meter` still builds it. | |
| 406 | 480 | let html = fragment(&Node::list([ | |
| 407 | 481 | Row::new("Ship it").meter(Meter::new(3, 7).label("subtasks")) | |
| 408 | 482 | ])); |
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | use super::*; | |
| 9 | 9 | use crate::router; | |
| 10 | 10 | use quasi_router::layout::Readiness; | |
| 11 | - | use quasi_router::{Outcome, Request}; | |
| 11 | + | use quasi_router::{Outcome, Request, Row}; | |
| 12 | 12 | ||
| 13 | 13 | fn state() -> AppState { | |
| 14 | 14 | AppState::in_memory().expect("in-memory state") | |
| @@ -53,7 +53,7 @@ | |||
| 53 | 53 | slot.body | |
| 54 | 54 | .iter() | |
| 55 | 55 | .find_map(|node| match node { | |
| 56 | - | Node::List { rows, .. } => Some(rows.iter().map(|row| row.primary.clone()).collect()), | |
| 56 | + | Node::List { rows, .. } => Some(rows.iter().map(Row::primary).collect()), | |
| 57 | 57 | _ => None, | |
| 58 | 58 | }) | |
| 59 | 59 | .unwrap_or_default() | |
| @@ -220,7 +220,7 @@ | |||
| 220 | 220 | Node::List { rows, .. } => Some( | |
| 221 | 221 | rows.iter() | |
| 222 | 222 | .filter(|row| row.current) | |
| 223 | - | .map(|row| row.primary.clone()) | |
| 223 | + | .map(Row::primary) | |
| 224 | 224 | .collect::<Vec<_>>(), | |
| 225 | 225 | ), | |
| 226 | 226 | _ => None, |