| 40 |
40 |
|
//! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how
|
| 41 |
41 |
|
//! a screen is put together.
|
| 42 |
42 |
|
//!
|
| 43 |
|
- |
//! **Validation** is absent on purpose rather than pending: neither app has a
|
| 44 |
|
- |
//! shared story, and a schema describing fields but not constraints acquires a
|
| 45 |
|
- |
//! constraint layer per app, which is how the divergence this crate exists to
|
| 46 |
|
- |
//! end got started.
|
|
43 |
+ |
//! **Validation** was absent on purpose here, on the grounds that neither app
|
|
44 |
+ |
//! had a shared story. That reasoning is retired — see 0.11.0 below, which is
|
|
45 |
+ |
//! where the constraints arrived and why the argument did not survive contact
|
|
46 |
+ |
//! with what the apps were measured to do.
|
| 47 |
47 |
|
//!
|
| 48 |
48 |
|
//! 0.3.0 closes a gap the first real adoption found, which is what adopting
|
| 49 |
49 |
|
//! against goingson first was for. [`Selector`] described only the *chosen*
|
| 107 |
107 |
|
//! the slot was already straining under a second consumer for a different
|
| 108 |
108 |
|
//! reason.
|
| 109 |
109 |
|
//!
|
|
110 |
+ |
//! 0.10.0 adds [`Meter`], a proportion carried as a pair rather than as a
|
|
111 |
+ |
//! percentage. Its own docs carry the argument; the short form is that the
|
|
112 |
+ |
//! percentage shape had already been tried in goingson and had already needed a
|
|
113 |
+ |
//! companion flag to recover what rounding and clamping threw away.
|
|
114 |
+ |
//!
|
|
115 |
+ |
//! 0.11.0 is four members from the quasi proving ground, batched into one
|
|
116 |
+ |
//! release because pre-1.0 a minor is breaking and a cascade is nine repos.
|
|
117 |
+ |
//! Three findings that arrived with them turned out not to belong here at all:
|
|
118 |
+ |
//! this crate has no notion of an action, a route or a destination, so anything
|
|
119 |
+ |
//! asking what a control *calls* was never the vocabulary's to say.
|
|
120 |
+ |
//!
|
|
121 |
+ |
//! - [`Figure`], a value with a caption. goingson had five of them across five
|
|
122 |
+ |
//! screens with five class vocabularies for the one shape, which is the
|
|
123 |
+ |
//! divergence this crate exists to end, sitting in plain sight and counted for
|
|
124 |
+ |
//! the first time.
|
|
125 |
+ |
//! - [`RowPart::Proportion`], so a [`Meter`] can sit in a row. `Meter` reached
|
|
126 |
+ |
//! two of its seven sites at 0.10.0 and the other five are row-shaped. Exactly
|
|
127 |
+ |
//! [`RowPart::Tokens`]'s problem with a different payload, and it takes
|
|
128 |
+ |
//! `Tokens`' answer: the part carries the description of a bar, not a node.
|
|
129 |
+ |
//! - [`Field::max_length`], [`Field::min`] and [`Field::max`], joining
|
|
130 |
+ |
//! [`Field::required`], which had been sitting here as the sole constraint
|
|
131 |
+ |
//! while the header above claimed there were none. The set stops before
|
|
132 |
+ |
//! `pattern`, which fails the renderer test and is one site in one app.
|
|
133 |
+ |
//! - [`FieldKind::File`]. Every host has an honest answer — a native picker, an
|
|
134 |
+ |
//! `<input type="file">`, a path prompt, an argument — and it carries no
|
|
135 |
+ |
//! accepted-types list because `accept` appears at zero sites in either app.
|
|
136 |
+ |
//!
|
|
137 |
+ |
//! The evidence rule changed under these, and it is worth recording because four
|
|
138 |
+ |
//! earlier decisions were made under the old one. The two-app test said a shape
|
|
139 |
+ |
//! earns a word once a second app wants it. It is backwards: a rule that
|
|
140 |
+ |
//! withholds a word until a second app has duplicated the code guarantees the
|
|
141 |
+ |
//! duplication, and app three writes it a third time. The bar is now generic
|
|
142 |
+ |
//! against bespoke — is this furniture any app would have, or is it this app's
|
|
143 |
+ |
//! own? Bespoke keeps [`Region::Bespoke`], which already carries a completion
|
|
144 |
+ |
//! heatmap and is the right answer for a calendar nobody will build twice.
|
|
145 |
+ |
//!
|
| 110 |
146 |
|
//! # Where the description stops
|
| 111 |
147 |
|
//!
|
| 112 |
148 |
|
//! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
|
| 609 |
645 |
|
/// to pass, and it is why the tone lives on the token rather than on the
|
| 610 |
646 |
|
/// part.
|
| 611 |
647 |
|
Tokens,
|
|
648 |
+ |
/// How much of a set the row's thing has done: a [`Meter`] in the row.
|
|
649 |
+ |
///
|
|
650 |
+ |
/// Added 0.11.0, `da5666ae`, and it is [`Tokens`](Self::Tokens)'s problem
|
|
651 |
+ |
/// again with a different payload. [`Meter`] arrived at 0.10.0 and closed
|
|
652 |
+ |
/// two of the seven sites that asked for it; the other five sit in rows, and
|
|
653 |
+ |
/// a row holds no nodes by the ruling that a row part may not carry an
|
|
654 |
+ |
/// arbitrary node — the door through which a description becomes a
|
|
655 |
+ |
/// templating language. So the part carries the *description of a bar*
|
|
656 |
+ |
/// rather than a node, exactly as `Tokens` carries tags rather than nodes.
|
|
657 |
+ |
///
|
|
658 |
+ |
/// Without it a row flattens the proportion into [`Meta`](Self::Meta) as
|
|
659 |
+ |
/// "3/7 subtasks", which keeps both numbers and loses the reading, the same
|
|
660 |
+ |
/// way a toned status badge read as prose before `Tokens`.
|
|
661 |
+ |
Proportion,
|
| 612 |
662 |
|
}
|
| 613 |
663 |
|
|
| 614 |
664 |
|
impl RowPart {
|
| 637 |
687 |
|
// So do tokens: each one carries its own tone, and a part-level
|
| 638 |
688 |
|
// intent underneath it would fight the token that sits on it.
|
| 639 |
689 |
|
Self::Tokens => "content",
|
|
690 |
+ |
// And so does a proportion, for the same reason: the meter carries
|
|
691 |
+ |
// the tone, and it is about the ratio rather than about the row.
|
|
692 |
+ |
Self::Proportion => "content",
|
| 640 |
693 |
|
}
|
| 641 |
694 |
|
}
|
| 642 |
695 |
|
}
|
| 880 |
933 |
|
}
|
| 881 |
934 |
|
}
|
| 882 |
935 |
|
|
|
936 |
+ |
/// One figure with a caption: a number and what it counts.
|
|
937 |
+ |
///
|
|
938 |
+ |
/// The dashboard shape. A large value over a small caption, several of them in a
|
|
939 |
+ |
/// strip: a current streak, a completion rate, a total. Added 0.11.0,
|
|
940 |
+ |
/// `93c6a174`, after goingson turned out to have five of them across five
|
|
941 |
+ |
/// screens with five class vocabularies for the one shape — `task-overview-stat`,
|
|
942 |
+ |
/// `stat-box`, `month-stat-item`, `contact-summary-stat`, `sync-stat`. Four put
|
|
943 |
+ |
/// the value above the caption and one inverts it, which is drift inside the
|
|
944 |
+ |
/// shape rather than a second shape.
|
|
945 |
+ |
///
|
|
946 |
+ |
/// # Why the value is text
|
|
947 |
+ |
///
|
|
948 |
+ |
/// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already
|
|
949 |
+ |
/// formatted, and the formatting is the app's because only it knows whether the
|
|
950 |
+ |
/// number is a percentage, a duration or a ratio. This carries none of the
|
|
951 |
+ |
/// arithmetic [`Meter`] carries, and that is the difference between them: a
|
|
952 |
+ |
/// meter is a proportion a renderer draws, and a figure is a fact a renderer
|
|
953 |
+ |
/// sets in type.
|
|
954 |
+ |
///
|
|
955 |
+ |
/// # Tone is carried, for [`Meter`]'s reason
|
|
956 |
+ |
///
|
|
957 |
+ |
/// Three of the five sites tone the figure by their own means — `red`/`blue` on
|
|
958 |
+ |
/// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on
|
|
959 |
+ |
/// sync. So tone is carried at every site that needs it and derived at none, and
|
|
960 |
+ |
/// no renderer can work out that a streak of zero is worth colouring.
|
|
961 |
+ |
///
|
|
962 |
+ |
/// # What is not here
|
|
963 |
+ |
///
|
|
964 |
+ |
/// Whether the figure answers a click. One of the five is a control — sync's
|
|
965 |
+ |
/// "Not Applied: 3" opens the list — and an action is not something this crate
|
|
966 |
+ |
/// can name: nothing here knows what a route is. That belongs beside the figure
|
|
967 |
+ |
/// in whatever layer holds the actions, the same way a row's activation sits
|
|
968 |
+ |
/// beside its parts rather than inside them.
|
|
969 |
+ |
///
|
|
970 |
+ |
/// The arrangement is not here either. Several figures in a strip is a set, and
|
|
971 |
+ |
/// a renderer given them one at a time cannot tell it is looking at one; the
|
|
972 |
+ |
/// layer that holds the tree is where the set gets said.
|
|
973 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
974 |
+ |
pub struct Figure<'a> {
|
|
975 |
+ |
/// The number, formatted the way the app means it to read.
|
|
976 |
+ |
pub value: &'a str,
|
|
977 |
+ |
/// What it counts. The caption under the value.
|
|
978 |
+ |
pub caption: &'a str,
|
|
979 |
+ |
/// What the figure means right now. [`Tone::Neutral`] is an ordinary fact.
|
|
980 |
+ |
pub tone: Tone,
|
|
981 |
+ |
}
|
|
982 |
+ |
|
|
983 |
+ |
impl<'a> Figure<'a> {
|
|
984 |
+ |
/// A figure that is an ordinary fact.
|
|
985 |
+ |
#[must_use]
|
|
986 |
+ |
pub const fn new(value: &'a str, caption: &'a str) -> Self {
|
|
987 |
+ |
Self {
|
|
988 |
+ |
value,
|
|
989 |
+ |
caption,
|
|
990 |
+ |
tone: Tone::Neutral,
|
|
991 |
+ |
}
|
|
992 |
+ |
}
|
|
993 |
+ |
|
|
994 |
+ |
/// What the figure means.
|
|
995 |
+ |
#[must_use]
|
|
996 |
+ |
pub const fn tone(mut self, tone: Tone) -> Self {
|
|
997 |
+ |
self.tone = tone;
|
|
998 |
+ |
self
|
|
999 |
+ |
}
|
|
1000 |
+ |
}
|
|
1001 |
+ |
|
| 883 |
1002 |
|
/// A named part of a screen.
|
| 884 |
1003 |
|
///
|
| 885 |
1004 |
|
/// The thing `makeover-geometry` deliberately does not name: it names the space
|
| 1048 |
1167 |
|
Radio,
|
| 1049 |
1168 |
|
/// On or off.
|
| 1050 |
1169 |
|
Checkbox,
|
|
1170 |
+ |
/// A file the user picks from wherever the host keeps files.
|
|
1171 |
+ |
///
|
|
1172 |
+ |
/// Added 0.11.0, `844b5ae0`, from goingson's project-dashboard attachments
|
|
1173 |
+ |
/// column. It was filed as a router finding — a control whose destination is
|
|
1174 |
+ |
/// a host capability rather than an address — and splitting it is what made
|
|
1175 |
+ |
/// it two answers instead of one member satisfying neither. *Opening* a file
|
|
1176 |
+ |
/// is a one-way handoff and needs no new API. *Picking* one returns a value
|
|
1177 |
+ |
/// into a write, which is a form concern, which is this.
|
|
1178 |
+ |
///
|
|
1179 |
+ |
/// The membership test passes on every host and not by a stretch: a Tauri
|
|
1180 |
+ |
/// app opens a native picker, a server renders `<input type="file">`, a
|
|
1181 |
+ |
/// terminal prompts for a path, a CLI takes an argument. That is closer to
|
|
1182 |
+ |
/// [`Email`](Self::Email), which exists because it changes the keyboard,
|
|
1183 |
+ |
/// than to anything bespoke.
|
|
1184 |
+ |
///
|
|
1185 |
+ |
/// It carries no accepted-types list and no multiple flag, and that is
|
|
1186 |
+ |
/// measured rather than deferred: `accept` appears at zero sites in either
|
|
1187 |
+ |
/// app. A member added for a case nobody has is a member designed against
|
|
1188 |
+ |
/// nothing.
|
|
1189 |
+ |
File,
|
| 1051 |
1190 |
|
/// Carried through the form and never shown.
|
| 1052 |
1191 |
|
Hidden,
|
| 1053 |
1192 |
|
}
|
| 1138 |
1277 |
|
/// that carried the value would have to carry a way to write it back, at which
|
| 1139 |
1278 |
|
/// point it is a form model and no longer a description.
|
| 1140 |
1279 |
|
///
|
| 1141 |
|
- |
/// **Validation** is absent for the reason [`FieldKind`] records: [`error`] is
|
| 1142 |
|
- |
/// the *result* of validating, supplied by whoever validated. Nothing here
|
| 1143 |
|
- |
/// decides that a value is wrong.
|
|
1280 |
+ |
/// **Constraints** are here and enforcement is not, which is one line rather
|
|
1281 |
+ |
/// than two. [`required`], [`max_length`], [`min`] and [`max`] are facts about
|
|
1282 |
+ |
/// the *question*, so a renderer can emit its host's idiom for each — an HTML
|
|
1283 |
+ |
/// attribute, a marked label, a clamped spinner — and the platform helps the
|
|
1284 |
+ |
/// user before anything is submitted. Deciding that a value is wrong stays with
|
|
1285 |
+ |
/// whoever validated, and [`error`] is that decision arriving back.
|
|
1286 |
+ |
///
|
|
1287 |
+ |
/// The set stops before `pattern`, and stops there on both tests at once. A
|
|
1288 |
+ |
/// regex has an honest answer in a webview and none anywhere else: egui would
|
|
1289 |
+ |
/// have to run it per keystroke and decide what a half-typed value means, which
|
|
1290 |
+ |
/// is enforcement wearing description's clothes. And it is one site in goingson
|
|
1291 |
+ |
/// and none in Balanced Breakfast, against 8 and 1 for `maxlength`. Measured
|
|
1292 |
+ |
/// 2026-08-09, `2cbad3e2`.
|
| 1144 |
1293 |
|
///
|
| 1145 |
1294 |
|
/// [`error`]: Field::error
|
|
1295 |
+ |
/// [`required`]: Field::required
|
|
1296 |
+ |
/// [`max_length`]: Field::max_length
|
|
1297 |
+ |
/// [`min`]: Field::min
|
|
1298 |
+ |
/// [`max`]: Field::max
|
| 1146 |
1299 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
| 1147 |
1300 |
|
pub struct Field<'a> {
|
| 1148 |
1301 |
|
/// What kind of value it takes.
|
| 1179 |
1332 |
|
pub options: &'a [Choice<'a>],
|
| 1180 |
1333 |
|
/// Whether the form refuses to submit without it.
|
| 1181 |
1334 |
|
pub required: bool,
|
|
1335 |
+ |
/// The longest the value may be, in characters.
|
|
1336 |
+ |
///
|
|
1337 |
+ |
/// Added 0.11.0 with [`min`](Self::min) and [`max`](Self::max), joining
|
|
1338 |
+ |
/// [`required`](Self::required), which had been the only constraint here
|
|
1339 |
+ |
/// since before the crate wrote down that it carried none.
|
|
1340 |
+ |
pub max_length: Option<u32>,
|
|
1341 |
+ |
/// The lowest value accepted, as the host would write it.
|
|
1342 |
+ |
///
|
|
1343 |
+ |
/// Text rather than a number, because the bound is only a number for some
|
|
1344 |
+ |
/// of the kinds that take one. goingson's own sites are `min="1"` on a
|
|
1345 |
+ |
/// duration and `min="2026-08-09T14:30"` on a datetime, and a numeric member
|
|
1346 |
+ |
/// could say the first and not the second. The [`kind`](Self::kind) already
|
|
1347 |
+ |
/// says how to read it, the same way it does for the value.
|
|
1348 |
+ |
pub min: Option<&'a str>,
|
|
1349 |
+ |
/// The highest value accepted, as the host would write it. See
|
|
1350 |
+ |
/// [`min`](Self::min).
|
|
1351 |
+ |
pub max: Option<&'a str>,
|
| 1182 |
1352 |
|
/// Whether the field lives behind a "more options" disclosure.
|
| 1183 |
1353 |
|
pub extended: bool,
|
| 1184 |
1354 |
|
}
|
| 1196 |
1366 |
|
placeholder: None,
|
| 1197 |
1367 |
|
options: &[],
|
| 1198 |
1368 |
|
required: false,
|
|
1369 |
+ |
max_length: None,
|
|
1370 |
+ |
min: None,
|
|
1371 |
+ |
max: None,
|
| 1199 |
1372 |
|
extended: false,
|
| 1200 |
1373 |
|
}
|
| 1201 |
1374 |
|
}
|
| 1331 |
1504 |
|
mod tests {
|
| 1332 |
1505 |
|
use super::*;
|
| 1333 |
1506 |
|
|
|
1507 |
+ |
#[test]
|
|
1508 |
+ |
fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() {
|
|
1509 |
+ |
// Three of goingson's five sites tone the figure by their own means, so
|
|
1510 |
+ |
// tone is carried at every site that needs it and derived at none. The
|
|
1511 |
+ |
// same reasoning `Meter` reached, from a different direction.
|
|
1512 |
+ |
let streak = Figure::new("0", "Current Streak").tone(Tone::Warning);
|
|
1513 |
+ |
assert_eq!(streak.tone, Tone::Warning);
|
|
1514 |
+ |
assert_eq!(Figure::new("17", "Total").tone, Tone::Neutral);
|
|
1515 |
+ |
}
|
|
1516 |
+ |
|
|
1517 |
+ |
#[test]
|
|
1518 |
+ |
fn a_figures_value_is_text_because_only_the_app_knows_what_it_is() {
|
|
1519 |
+ |
// "84%", "12/30", "3d". A figure is whatever the app computed, already
|
|
1520 |
+ |
// formatted, and that is the line between this and `Meter`: a meter is
|
|
1521 |
+ |
// a proportion a renderer draws, a figure is a fact it sets in type.
|
|
1522 |
+ |
for value in ["84%", "12/30", "3d"] {
|
|
1523 |
+ |
assert_eq!(Figure::new(value, "Rate").value, value);
|
|
1524 |
+ |
}
|
|
1525 |
+ |
}
|
|
1526 |
+ |
|
|
1527 |
+ |
#[test]
|
|
1528 |
+ |
fn a_proportion_is_a_row_part_and_takes_no_intent_of_its_own() {
|
|
1529 |
+ |
// The meter carries the tone, so a part-level intent underneath would
|
|
1530 |
+ |
// fight it. Same answer `Tokens` needed, for the same reason.
|
|
1531 |
+ |
assert_eq!(RowPart::Proportion.intent(), RowPart::Tokens.intent());
|
|
1532 |
+ |
assert!(!RowPart::Proportion.revealed_on_hover());
|
|
1533 |
+ |
}
|
|
1534 |
+ |
|
|
1535 |
+ |
#[test]
|
|
1536 |
+ |
fn a_file_field_is_drawn_and_offers_no_options() {
|
|
1537 |
+ |
// It is a control the user operates, unlike `Hidden`, and it does not
|
|
1538 |
+ |
// pick from a list the description carries, unlike `Select`.
|
|
1539 |
+ |
assert!(FieldKind::File.visible());
|
|
1540 |
+ |
assert!(!FieldKind::File.offers_options());
|
|
1541 |
+ |
assert!(!FieldKind::File.confidential());
|
|
1542 |
+ |
}
|
|
1543 |
+ |
|
|
1544 |
+ |
#[test]
|
|
1545 |
+ |
fn a_constraint_is_a_fact_about_the_question_and_not_a_verdict() {
|
|
1546 |
+ |
// The whole model: the description carries the rule, the renderer emits
|
|
1547 |
+ |
// its host's idiom, and `error` is what arrives back when someone
|
|
1548 |
+ |
// validated. Nothing here decides a value is wrong.
|
|
1549 |
+ |
let field = Field {
|
|
1550 |
+ |
max_length: Some(100),
|
|
1551 |
+ |
min: Some("1"),
|
|
1552 |
+ |
max: Some("240"),
|
|
1553 |
+ |
required: true,
|
|
1554 |
+ |
..Field::new(FieldKind::Number, "minutes", "Minutes")
|
|
1555 |
+ |
};
|
|
1556 |
+ |
assert!(!field.invalid());
|
|
1557 |
+ |
|
|
1558 |
+ |
// A bound is text because it is only a number for some of the kinds
|
|
1559 |
+ |
// that take one. goingson has both shapes live.
|
|
1560 |
+ |
let when = Field {
|
|
1561 |
+ |
min: Some("2026-08-09T14:30"),
|
|
1562 |
+ |
..Field::new(FieldKind::Text, "starts", "Starts")
|
|
1563 |
+ |
};
|
|
1564 |
+ |
assert_eq!(when.min, Some("2026-08-09T14:30"));
|
|
1565 |
+ |
}
|
|
1566 |
+ |
|
| 1334 |
1567 |
|
#[test]
|
| 1335 |
1568 |
|
fn a_meter_keeps_the_over_run_the_percentage_throws_away() {
|
| 1336 |
1569 |
|
// The whole reason this is a pair. goingson's `Task::time_progress`
|