Skip to main content

max / quasi

Give the axis a constructor, and let a control ask for a value Node::Timeline was the last variant with no constructor of its own, so goingson's day view wrote the struct literal by hand and no production could reach it. Node::timeline, Node::placed and Node::focus are that gap closed, on stats/figure's accreting shape: a caller building entries one at a time has nowhere to hold the list. The form grows a timeline member whose body holds `at <placement> <row>`, which is the first node member holding members rather than settings. The keyword is `cell at <arg>`'s, and the container is the whole of how the two are told apart, exactly as it is for beside and for include: a timeline holds nothing but entries and a row of cells holds nothing but cells, so no body can be read both ways. The row may be written out or included, because the day view draws the axis and the all-day strip from one row shape. A field inside an act is Act::asking, which the vocabulary had and the form could not say. Placing a task asks which slot, and asks how long it takes only when the task carries no estimate, so the second question is a guarded field inside a control. `since` and `until` join the node-member table. They are Node::since and Node::until, the two clocks a renderer ticks, written out as the struct variant at four sites in the timer band. quasi-router 0.101.12. Additions only, so consumers pinning 0.101 are owed no forward fix; the MNW server checks clean against it.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 17:45 UTC
Signed with PGP, not checked
Commit: 79b7b8596766ee3af479982960254d0350a363d4
Parent: ca63458
6 files changed, +144 insertions, -8 deletions
M Cargo.lock +5 -5
@@ -3563,7 +3563,7 @@
3563 3563
3564 3564 [[package]]
3565 3565 name = "quasi-router"
3566 - version = "0.101.11"
3566 + version = "0.101.12"
3567 3567 dependencies = [
3568 3568 "makeover-layout",
3569 3569 ]
@@ -6279,6 +6279,10 @@
6279 6279 "winnow 1.0.4",
6280 6280 ]
6281 6281
6282 + [[patch.unused]]
6283 + name = "quasi-type"
6284 + version = "0.1.3"
6285 +
6282 6286 [[patch.unused]]
6283 6287 name = "synckit-client"
6284 6288 version = "0.10.0"
@@ -6298,7 +6302,3 @@
6298 6302 [[patch.unused]]
6299 6303 name = "tagtree"
6300 6304 version = "0.4.1"
6301 -
6302 - [[patch.unused]]
6303 - name = "quasi-type"
6304 - version = "0.1.3"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "quasi-router"
3 - version = "0.101.11"
3 + version = "0.101.12"
4 4 description = "Host-agnostic router: a request in, a renderer-agnostic description out"
5 5 edition.workspace = true
6 6 rust-version.workspace = true
@@ -264,6 +264,15 @@
264 264 priority: Ident,
265 265 inner: Box<Emission>,
266 266 },
267 + /// `at <arg> <emission>` -- one thing on an axis, and where it sits.
268 + ///
269 + /// Only inside a `timeline`, where the argument is a `Placement` and the
270 + /// emission is the row that sits at it. The keyword is `cell at <arg>`'s,
271 + /// and the container is the whole of how the two are told apart, exactly as
272 + /// it is for `beside` and for `include`. A timeline holds nothing but
273 + /// entries and a row of cells holds nothing but cells, so no body can be
274 + /// read both ways.
275 + At { at: Arg, inner: Box<Emission> },
267 276 /// `<emission> when <predicate>` / `unless <predicate>`.
268 277 ///
269 278 /// A guard suppresses the emission and nothing else. R9: the holes inside
@@ -52,6 +52,8 @@
52 52 Canvas,
53 53 /// One tag: its body says what it is like, and it emits nothing.
54 54 Tag,
55 + /// An axis: its members are `at`, each a placement and the row on it.
56 + Timeline,
55 57 }
56 58
57 59 pub fn declaration(declaration: &Declaration) -> Result<TokenStream> {
@@ -686,6 +688,51 @@
686 688 emission_span(other),
687 689 "a column holds no members: its body says how it narrows",
688 690 )),
691 + (Container::Timeline, Emission::At { at, inner }) => {
692 + let at = arg(at)?;
693 + let row = match &**inner {
694 + Emission::Row { primary, body } => self::row(primary, body)?,
695 + // A supplier, the same courtesy `include` is everywhere else:
696 + // goingson's day view draws the axis and the all-day strip from
697 + // one row shape, so the row is a shape away from both.
698 + Emission::Include(supplier) => {
699 + let supplier = hole(supplier)?;
700 + quote!(::std::convert::Into::into(#supplier))
701 + }
702 + other => {
703 + return Err(syn::Error::new(
704 + emission_span(other),
705 + "an axis holds rows: write `at <placement> row <primary> { .. }`",
706 + ));
707 + }
708 + };
709 + Ok((
710 + quote!(::quasi_router::screen::Placed {
711 + placement: #at,
712 + row: #row,
713 + }),
714 + quote!(.placed(#held)),
715 + ))
716 + }
717 + (Container::Timeline, other) => Err(syn::Error::new(
718 + emission_span(other),
719 + "an axis holds what sits on it: write `at <placement> row <primary> { .. }`",
720 + )),
721 + (
722 + Container::Act,
723 + Emission::Field {
724 + kind,
725 + name,
726 + label,
727 + body,
728 + },
729 + ) => {
730 + // `Act::asking`: a control that wants a value before it acts.
731 + // goingson's day view is the site -- placing a task asks which slot
732 + // and, when the task carries no estimate, how long it takes.
733 + let asked = field(kind, name, label, body)?;
734 + Ok((asked, quote!(.asking(#held))))
735 + }
689 736 (Container::Act | Container::Field, other) => Err(syn::Error::new(
690 737 emission_span(other),
691 738 "this holds no members: its body says what it is like",
@@ -840,6 +887,18 @@
840 887 )?;
841 888 Ok(quote!(::quasi_router::Node::Canvas(::std::boxed::Box::new(#canvas))))
842 889 }
890 + Emission::Simple { member, args, body } if member == "timeline" => {
891 + // The fifth member whose body is not `Node`'s own, and the only one
892 + // that holds members rather than settings: an axis is told what
893 + // sits on it and where. `Node::timeline` answers with the variant
894 + // already, so nothing is wrapped afterwards.
895 + let args = args.iter().map(self::arg).collect::<Result<Vec<_>>>()?;
896 + accrete(
897 + body,
898 + Container::Timeline,
899 + &quote!(::quasi_router::Node::timeline(#(#args),*)),
900 + )
901 + }
843 902 Emission::Simple { member, args, body } => {
844 903 let args = args.iter().map(self::arg).collect::<Result<Vec<_>>>()?;
845 904 accrete(
@@ -938,6 +997,10 @@
938 997 priority.span(),
939 998 "`beside` needs a run in scope, which is rule R3",
940 999 )),
1000 + Emission::At { inner, .. } => Err(syn::Error::new(
1001 + emission_span(inner),
1002 + "`at` places a row on an axis; it needs a `timeline` in scope",
1003 + )),
941 1004 }
942 1005 }
943 1006
@@ -1492,6 +1555,7 @@
1492 1555 },
1493 1556 Emission::Across { fallback, .. } => fallback.span(),
1494 1557 Emission::Beside { priority, .. } => priority.span(),
1558 + Emission::At { inner, .. } => emission_span(inner),
1495 1559 Emission::Table(_) | Emission::Cells(_) => Span::call_site(),
1496 1560 Emission::Column { .. } | Emission::Cell { .. } => Span::call_site(),
1497 1561 Emission::Activate(action) => action.verb.span(),
@@ -56,8 +56,8 @@
56 56 /// without a production is a parse error naming it, which is the failure worth
57 57 /// having.
58 58 const MEMBERS: &[&str] = &[
59 - "act", "across", "activate", "beside", "cell", "cells", "chip", "column", "field", "form",
60 - "given", "include", "link", "offering", "region", "row", "screen", "table",
59 + "act", "across", "activate", "at", "beside", "cell", "cells", "chip", "column", "field",
60 + "form", "given", "include", "link", "offering", "region", "row", "screen", "table",
61 61 ];
62 62
63 63 /// The members that are one of `Node`'s own constructors, called by its name.
@@ -95,6 +95,11 @@
95 95 /// `badge` is the third and `chip` the fourth, and `chip` is not on this list
96 96 /// because it carries an action: it has a parse arm of its own. Both accrete
97 97 /// onto a `Tag` under `Container::Tag` and the emitter wraps `Node::token`.
98 + ///
99 + /// `timeline` is the fifth, and the only one whose body holds members rather
100 + /// than settings: an axis is told what sits on it and where, which is the `at`
101 + /// member under `Container::Timeline`. It is a `Node` either way, so nothing is
102 + /// wrapped afterwards.
98 103 const NODE_MEMBERS: &[&str] = &[
99 104 "text",
100 105 "toned",
@@ -110,9 +115,12 @@
110 115 "rich",
111 116 "literal",
112 117 "code",
118 + "since",
119 + "until",
113 120 "picture",
114 121 "canvas",
115 122 "badge",
123 + "timeline",
116 124 ];
117 125
118 126 /// The arrangements a screen may be laid out in.
@@ -491,6 +499,22 @@
491 499 },
492 500 })
493 501 }
502 + "at" => {
503 + let at: Arg = input.parse()?;
504 + let inner: Self = input.parse()?;
505 + // A guard on a placed entry guards the placing, exactly as
506 + // `beside`'s does: the timeline is what may hold nothing.
507 + Ok(match inner {
508 + Self::Guarded { guard, inner } => Self::Guarded {
509 + guard,
510 + inner: Box::new(Self::At { at, inner }),
511 + },
512 + inner => Self::At {
513 + at,
514 + inner: Box::new(inner),
515 + },
516 + })
517 + }
494 518 "region" => {
495 519 let name: Arg = input.parse()?;
496 520 input.parse::<Token![as]>()?;
@@ -8942,6 +8942,45 @@
8942 8942 self
8943 8943 }
8944 8944
8945 + /// An axis with nothing on it yet.
8946 + ///
8947 + /// [`Timeline`](Self::Timeline) was the last variant with no constructor of
8948 + /// its own, and its entries accrete for [`figure`](Self::figure)'s reason:
8949 + /// a caller building them one at a time, or conditionally, has nowhere to
8950 + /// hold the list. goingson's day view is the site.
8951 + #[must_use]
8952 + pub const fn timeline(track: layout::Track) -> Self {
8953 + Self::Timeline {
8954 + track,
8955 + entries: Vec::new(),
8956 + focus: None,
8957 + }
8958 + }
8959 +
8960 + /// One more thing on the axis, beside [`timeline`](Self::timeline).
8961 + ///
8962 + /// Does nothing to a node that is not a [`Timeline`](Self::Timeline), on
8963 + /// [`figure`](Self::figure)'s rule.
8964 + #[must_use]
8965 + pub fn placed(mut self, entry: Placed) -> Self {
8966 + if let Self::Timeline { entries, .. } = &mut self {
8967 + entries.push(entry);
8968 + }
8969 + self
8970 + }
8971 +
8972 + /// The moment the axis should bring into view, in minutes from its start.
8973 + ///
8974 + /// Does nothing to a node that is not a [`Timeline`](Self::Timeline), on
8975 + /// [`figure`](Self::figure)'s rule.
8976 + #[must_use]
8977 + pub fn focus(mut self, at: u16) -> Self {
8978 + if let Self::Timeline { focus, .. } = &mut self {
8979 + *focus = Some(at);
8980 + }
8981 + self
8982 + }
8983 +
8945 8984 /// A tag standing on its own, rather than inside a row or a cell.
8946 8985 ///
8947 8986 /// [`Token`](Self::Token) was the last node variant with no constructor of