| 22 |
22 |
|
//! *intent* — this region is a well — and each renderer chooses an expression
|
| 23 |
23 |
|
//! it can actually produce, including dropping half of one.
|
| 24 |
24 |
|
//!
|
| 25 |
|
- |
//! # Scope of this first cut
|
|
25 |
+ |
//! # Scope
|
| 26 |
26 |
|
//!
|
| 27 |
|
- |
//! Depth only: the bevel and the surfaces it shapes. That much is settled,
|
| 28 |
|
- |
//! and settled the hard way — the vocabulary here was read off audiofiles'
|
|
27 |
+ |
//! Depth came first: the bevel and the surfaces it shapes. That much was
|
|
28 |
+ |
//! settled the hard way — the vocabulary here was read off audiofiles'
|
| 29 |
29 |
|
//! `ui::theme` and `ui::widgets`, which are the only implementation written
|
| 30 |
30 |
|
//! by a consumer with no CSS, then checked against both webview apps. All
|
| 31 |
31 |
|
//! three agreed once Balanced Breakfast's fills were corrected.
|
| 32 |
32 |
|
//!
|
| 33 |
|
- |
//! Deliberately absent, because each is a naming decision rather than a
|
| 34 |
|
- |
//! transcription: badge versus chip, toast versus banner, the list row's
|
| 35 |
|
- |
//! parts, heading levels, segmented controls, and whether a description names
|
| 36 |
|
- |
//! loading state at all. Those are tracked as subtasks of the extraction task
|
| 37 |
|
- |
//! and land as they are settled. Guessing at them now is how a description
|
| 38 |
|
- |
//! becomes a framework.
|
|
33 |
+ |
//! 0.2.0 adds the rest of the description, each member drawn the same way,
|
|
34 |
+ |
//! from what the three apps already hand-write rather than from a taxonomy:
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! - Components. [`Token`] (badge against chip), [`Notice`] (toast against
|
|
37 |
+ |
//! banner), [`RowPart`], [`Heading`], [`Selector`], [`Readiness`], and
|
|
38 |
+ |
//! [`Tone`], which is the one intent family they share.
|
|
39 |
+ |
//! - Schemas. [`Field`] for forms and [`Column`] for lists and tables.
|
|
40 |
+ |
//! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how
|
|
41 |
+ |
//! a screen is put together.
|
|
42 |
+ |
//!
|
|
43 |
+ |
//! Two things are absent on purpose rather than pending. **Validation** is not
|
|
44 |
+ |
//! part of [`Field`]: neither app has a shared story, and a schema describing
|
|
45 |
+ |
//! fields but not constraints acquires a constraint layer per app, which is how
|
|
46 |
+ |
//! the divergence this crate exists to end got started. **The bespoke widgets**
|
|
47 |
+ |
//! (a day-plan timeline, a kanban board, a calendar) are not here and are not
|
|
48 |
+ |
//! queued: a description expressive enough to produce them is not a description
|
|
49 |
+ |
//! any more. Generate the boring 80% so the bespoke 20% gets the attention.
|
| 39 |
50 |
|
|
| 40 |
51 |
|
#![forbid(unsafe_code)]
|
| 41 |
52 |
|
|
| 195 |
206 |
|
}
|
| 196 |
207 |
|
}
|
| 197 |
208 |
|
|
|
209 |
+ |
/// What a region is saying, when it is saying something.
|
|
210 |
+ |
///
|
|
211 |
+ |
/// The one intent family shared by badges, notices and nothing else. Kept
|
|
212 |
+ |
/// separate from [`Fill`] because a surface is where a thing sits and a tone is
|
|
213 |
+ |
/// what it means, and the three apps agree on the four statuses:
|
|
214 |
+ |
/// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
|
|
215 |
+ |
/// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
|
|
216 |
+ |
/// `.toast.error` in Balanced Breakfast.
|
|
217 |
+ |
///
|
|
218 |
+ |
/// The per-tag palette (`category-one` through `category-six`) is deliberately
|
|
219 |
+ |
/// not here. Which colour a *particular* tag takes is app domain, and both
|
|
220 |
+ |
/// webview apps already carry it as a `data-color` attribute.
|
|
221 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
222 |
+ |
pub enum Tone {
|
|
223 |
+ |
/// No status. Reads as ordinary de-emphasised content.
|
|
224 |
+ |
Neutral,
|
|
225 |
+ |
/// Something worth knowing and nothing to do about it.
|
|
226 |
+ |
Info,
|
|
227 |
+ |
/// Something finished and it worked.
|
|
228 |
+ |
Success,
|
|
229 |
+ |
/// Something the user should look at before continuing.
|
|
230 |
+ |
Warning,
|
|
231 |
+ |
/// Something broken, or something about to be destroyed.
|
|
232 |
+ |
Danger,
|
|
233 |
+ |
}
|
|
234 |
+ |
|
|
235 |
+ |
impl Intent for Tone {
|
|
236 |
+ |
fn token(self) -> &'static str {
|
|
237 |
+ |
match self {
|
|
238 |
+ |
// Neutral has no status token of its own. It takes the muted
|
|
239 |
+ |
// content intent, which is what both webview apps already spell as
|
|
240 |
+ |
// `data-color="muted"`.
|
|
241 |
+ |
Self::Neutral => "content-muted",
|
|
242 |
+ |
Self::Info => "info",
|
|
243 |
+ |
Self::Success => "success",
|
|
244 |
+ |
Self::Warning => "warning",
|
|
245 |
+ |
Self::Danger => "danger",
|
|
246 |
+ |
}
|
|
247 |
+ |
}
|
|
248 |
+ |
}
|
|
249 |
+ |
|
|
250 |
+ |
/// A small labelled thing that sits inside something else.
|
|
251 |
+ |
///
|
|
252 |
+ |
/// Two members, because the three apps drew three taxonomies and only one line
|
|
253 |
+ |
/// runs through all of them: does it answer a click. audiofiles has
|
|
254 |
+ |
/// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
|
|
255 |
+ |
/// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
|
|
256 |
+ |
/// `.badge` against `.tag-chip`. goingson is the one that has to move: its
|
|
257 |
+ |
/// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
|
|
258 |
+ |
/// to decide which of the two it always was.
|
|
259 |
+ |
///
|
|
260 |
+ |
/// The evidence that a chip is a real concept rather than a badge with a
|
|
261 |
+ |
/// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
|
|
262 |
+ |
/// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
|
|
263 |
+ |
/// holds itself down", which is exactly what [`Depth::pressed`] already says.
|
|
264 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
265 |
+ |
pub enum Token {
|
|
266 |
+ |
/// Non-interactive status or count. Answers no click.
|
|
267 |
+ |
Badge,
|
|
268 |
+ |
/// An interactive or removable token. Answers a click, and latches if it
|
|
269 |
+ |
/// stands for a filter that is either on or off.
|
|
270 |
+ |
Chip {
|
|
271 |
+ |
/// Whether it carries its own remove affordance.
|
|
272 |
+ |
removable: bool,
|
|
273 |
+ |
},
|
|
274 |
+ |
}
|
|
275 |
+ |
|
|
276 |
+ |
impl Token {
|
|
277 |
+ |
/// Whether this answers a click.
|
|
278 |
+ |
///
|
|
279 |
+ |
/// The whole difference between the two members, and the reason a renderer
|
|
280 |
+ |
/// with no hover (a touch surface, a terminal) can still tell them apart.
|
|
281 |
+ |
#[must_use]
|
|
282 |
+ |
pub const fn interactive(self) -> bool {
|
|
283 |
+ |
matches!(self, Self::Chip { .. })
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
/// How it sits, given whether it is currently latched down.
|
|
287 |
+ |
///
|
|
288 |
+ |
/// A badge is flat: it is a label, and giving it an edge would say it can
|
|
289 |
+ |
/// be pressed. A chip is raised, and inset while latched.
|
|
290 |
+ |
#[must_use]
|
|
291 |
+ |
pub const fn depth(self, latched: bool) -> Depth {
|
|
292 |
+ |
match self {
|
|
293 |
+ |
Self::Badge => Depth::Flat,
|
|
294 |
+ |
Self::Chip { .. } if latched => Depth::Well,
|
|
295 |
+ |
Self::Chip { .. } => Depth::Raised,
|
|
296 |
+ |
}
|
|
297 |
+ |
}
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
/// Something the app is telling the user, unprompted.
|
|
301 |
+ |
///
|
|
302 |
+ |
/// Two concepts, not one with a placement. They differ in more than where they
|
|
303 |
+ |
/// sit: a toast is transient, stacked and self-dismissing, and a banner is
|
|
304 |
+ |
/// persistent, in flow, one per region, and dismissed by fixing the condition
|
|
305 |
+ |
/// it reports. Folding them into one member with a placement parameter would
|
|
306 |
+ |
/// make lifetime, stacking and dismissal all placement-dependent, which is the
|
|
307 |
+ |
/// description leaking renderer policy.
|
|
308 |
+ |
///
|
|
309 |
+ |
/// All three apps have banners: `info_banner` and `warning_banner` in
|
|
310 |
+ |
/// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
|
|
311 |
+ |
/// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
|
|
312 |
+ |
/// webview apps also have toasts. So neither member is speculative, and no app
|
|
313 |
+ |
/// gains a concept it lacks except audiofiles, whose renderer may legitimately
|
|
314 |
+ |
/// decline to draw a toast at all.
|
|
315 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
316 |
+ |
pub enum Notice {
|
|
317 |
+ |
/// Transient, stacked, dismisses itself.
|
|
318 |
+ |
Toast,
|
|
319 |
+ |
/// Persistent, in flow, one per region, dismissed by fixing the cause.
|
|
320 |
+ |
Banner,
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
impl Notice {
|
|
324 |
+ |
/// Whether it goes away on its own.
|
|
325 |
+ |
#[must_use]
|
|
326 |
+ |
pub const fn transient(self) -> bool {
|
|
327 |
+ |
matches!(self, Self::Toast)
|
|
328 |
+ |
}
|
|
329 |
+ |
|
|
330 |
+ |
/// How it sits.
|
|
331 |
+ |
///
|
|
332 |
+ |
/// A toast floats above the page rather than resting on it, which is
|
|
333 |
+ |
/// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
|
|
334 |
+ |
/// flow. Both are raised, and they are raised off different things.
|
|
335 |
+ |
#[must_use]
|
|
336 |
+ |
pub const fn fill(self) -> Fill {
|
|
337 |
+ |
match self {
|
|
338 |
+ |
Self::Toast => Fill::Overlay,
|
|
339 |
+ |
Self::Banner => Fill::Raised,
|
|
340 |
+ |
}
|
|
341 |
+ |
}
|
|
342 |
+ |
}
|
|
343 |
+ |
|
|
344 |
+ |
/// The parts of a list row.
|
|
345 |
+ |
///
|
|
346 |
+ |
/// Four, taken from Balanced Breakfast, which is the only consumer that had all
|
|
347 |
+ |
/// of them (`row-primary`, `row-secondary`, `row-meta`, `row-actions`).
|
|
348 |
+ |
/// audiofiles has two and no slot structure at all, so it gains meta and
|
|
349 |
+ |
/// actions as real work rather than a rename; goingson moves off
|
|
350 |
+ |
/// `task-row` / `task-cell`.
|
|
351 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
352 |
+ |
pub enum RowPart {
|
|
353 |
+ |
/// The thing itself. What the row is called.
|
|
354 |
+ |
Primary,
|
|
355 |
+ |
/// Supporting text under the primary.
|
|
356 |
+ |
Secondary,
|
|
357 |
+ |
/// A short trailing fact: a count, a size, a date.
|
|
358 |
+ |
Meta,
|
|
359 |
+ |
/// Controls that act on this row.
|
|
360 |
+ |
Actions,
|
|
361 |
+ |
}
|
|
362 |
+ |
|
|
363 |
+ |
impl RowPart {
|
|
364 |
+ |
/// Whether the part stays hidden until the row is hovered or focused.
|
|
365 |
+ |
///
|
|
366 |
+ |
/// Behaviour of the part, not app policy: Balanced Breakfast and goingson
|
|
367 |
+ |
/// grew the same hover-reveal on their actions independently and neither
|
|
368 |
+ |
/// applies it to anything else.
|
|
369 |
+ |
///
|
|
370 |
+ |
/// A renderer with no hover shows it always. That is a renderer decision
|
|
371 |
+ |
/// and this returning `true` does not forbid it.
|
|
372 |
+ |
#[must_use]
|
|
373 |
+ |
pub const fn revealed_on_hover(self) -> bool {
|
|
374 |
+ |
matches!(self, Self::Actions)
|
|
375 |
+ |
}
|
|
376 |
+ |
|
|
377 |
+ |
/// The content intent the part takes.
|
|
378 |
+ |
#[must_use]
|
|
379 |
+ |
pub const fn intent(self) -> &'static str {
|
|
380 |
+ |
match self {
|
|
381 |
+ |
Self::Primary => "content",
|
|
382 |
+ |
Self::Secondary => "content-secondary",
|
|
383 |
+ |
Self::Meta => "content-muted",
|
|
384 |
+ |
// Actions carry controls rather than text, so they inherit.
|
|
385 |
+ |
Self::Actions => "content",
|
|
386 |
+ |
}
|
|
387 |
+ |
}
|
|
388 |
+ |
}
|
|
389 |
+ |
|
|
390 |
+ |
/// How far down the heading tree a title sits.
|
|
391 |
+ |
///
|
|
392 |
+ |
/// Three, and only the three that are actually headings. The bands those used
|
|
393 |
+ |
/// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header`
|
|
394 |
+ |
/// and `.detail-header`) are arrangement, not type, and live at
|
|
395 |
+ |
/// [`Region::Band`]. One of them contains no text at all.
|
|
396 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
397 |
+ |
pub enum Heading {
|
|
398 |
+ |
/// Names the whole screen. One per screen.
|
|
399 |
+ |
Page,
|
|
400 |
+ |
/// Names a block within the screen.
|
|
401 |
+ |
Section,
|
|
402 |
+ |
/// Names a sub-block inside an already-named section.
|
|
403 |
+ |
Subsection,
|
|
404 |
+ |
}
|
|
405 |
+ |
|
|
406 |
+ |
impl Heading {
|
|
407 |
+ |
/// Whether a rule follows the heading.
|
|
408 |
+ |
///
|
|
409 |
+ |
/// audiofiles' `section_header` draws a separator and its
|
|
410 |
+ |
/// `subsection_label` deliberately does not, which is the only thing
|
|
411 |
+ |
/// distinguishing the two once weight and colour are deferred.
|
|
412 |
+ |
#[must_use]
|
|
413 |
+ |
pub const fn separated(self) -> bool {
|
|
414 |
+ |
matches!(self, Self::Section)
|
|
415 |
+ |
}
|
|
416 |
+ |
}
|
|
417 |
+ |
|
|
418 |
+ |
/// A control that picks between things.
|
|
419 |
+ |
///
|
|
420 |
+ |
/// Three, because three distinct behaviours are in play and collapsing any two
|
|
421 |
+ |
/// loses something. A segmented control picks a value; a tab picks a pane; a
|
|
422 |
+ |
/// toggle picks nothing and simply holds itself on or off.
|
|
423 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
424 |
+ |
pub enum Selector {
|
|
425 |
+ |
/// Exactly one of N, and the options abut.
|
|
426 |
+ |
Segmented,
|
|
427 |
+ |
/// Independent on or off, on its own.
|
|
428 |
+ |
Toggle,
|
|
429 |
+ |
/// Navigation between panes. The folder semantic.
|
|
430 |
+ |
Tabs,
|
|
431 |
+ |
}
|
|
432 |
+ |
|
|
433 |
+ |
impl Selector {
|
|
434 |
+ |
/// How the chosen option sits.
|
|
435 |
+ |
///
|
|
436 |
+ |
/// Held in for a segmented control and a toggle, which is the same shape
|
|
437 |
+ |
/// pressing produces and the whole economy of the idiom: one appearance,
|
|
438 |
+ |
/// two reasons to wear it. A tab is the exception, because the selected
|
|
439 |
+ |
/// folder tab comes *forward* to join the pane it opens.
|
|
440 |
+ |
#[must_use]
|
|
441 |
+ |
pub const fn chosen(self) -> Depth {
|
|
442 |
+ |
match self {
|
|
443 |
+ |
Self::Segmented | Self::Toggle => Depth::Well,
|
|
444 |
+ |
Self::Tabs => Depth::Raised,
|
|
445 |
+ |
}
|
|
446 |
+ |
}
|
|
447 |
+ |
|
|
448 |
+ |
/// Whether the options touch.
|
|
449 |
+ |
///
|
|
450 |
+ |
/// The gap is the entire difference between a segmented control and a row
|
|
451 |
+ |
/// of buttons that happen to sit near each other, which is what audiofiles'
|
|
452 |
+ |
/// `segmented_control` says in its own comment and why it zeroes the
|
|
453 |
+ |
/// spacing by hand.
|
|
454 |
+ |
#[must_use]
|
|
455 |
+ |
pub const fn abutting(self) -> bool {
|
|
456 |
+ |
matches!(self, Self::Segmented | Self::Tabs)
|
|
457 |
+ |
}
|
|
458 |
+ |
}
|
|
459 |
+ |
|
|
460 |
+ |
/// Whether the content of a region has arrived.
|
|
461 |
+ |
///
|
|
462 |
+ |
/// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
|
|
463 |
+ |
/// nothing at all is renderer policy, the same class of decision that got
|
|
464 |
+ |
/// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast
|
|
465 |
+ |
/// each grew a skeleton with differently-named parts; both keep them, as the
|
|
466 |
+ |
/// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
|
|
467 |
+ |
/// and needs none, because an immediate-mode renderer simply repaints.
|
|
468 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
469 |
+ |
pub enum Readiness {
|
|
470 |
+ |
/// The content is here.
|
|
471 |
+ |
Ready,
|
|
472 |
+ |
/// The content is on its way.
|
|
473 |
+ |
Pending,
|
|
474 |
+ |
}
|
|
475 |
+ |
|
|
476 |
+ |
/// A named part of a screen.
|
|
477 |
+ |
///
|
|
478 |
+ |
/// The thing `makeover-geometry` deliberately does not name: it names the space
|
|
479 |
+ |
/// *between* things by relationship, and nothing named the things. Six members,
|
|
480 |
+ |
/// taken from what the two webview apps actually use. Both apps' `layout.css`
|
|
481 |
+ |
/// currently names exactly two things, `.raised` and `.well`, so this layer is
|
|
482 |
+ |
/// absent rather than divergent, which makes it the cheapest of the schemas to
|
|
483 |
+ |
/// add and the easiest to over-build.
|
|
484 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
485 |
+ |
pub enum Region {
|
|
486 |
+ |
/// A full-width strip with a title slot and an actions cluster, either of
|
|
487 |
+ |
/// which may be empty. goingson's `.page-header`, Balanced Breakfast's
|
|
488 |
+ |
/// `.header` and `.detail-header` are all this, differing only in which
|
|
489 |
+ |
/// slots they fill.
|
|
490 |
+ |
Band,
|
|
491 |
+ |
/// A persistent column beside the content, holding navigation.
|
|
492 |
+ |
Sidebar,
|
|
493 |
+ |
/// A region of content with its own scroll.
|
|
494 |
+ |
Pane,
|
|
495 |
+ |
/// Two panes side by side, where the left chooses what the right shows.
|
|
496 |
+ |
Split,
|
|
497 |
+ |
/// A set of panes, one visible at a time, with a [`Selector::Tabs`] above.
|
|
498 |
+ |
TabGroup,
|
|
499 |
+ |
/// Content over a scrim, taking input until dismissed.
|
|
500 |
+ |
Modal,
|
|
501 |
+ |
}
|
|
502 |
+ |
|
|
503 |
+ |
impl Region {
|
|
504 |
+ |
/// How the region sits on what is behind it.
|
|
505 |
+ |
#[must_use]
|
|
506 |
+ |
pub const fn depth(self) -> Depth {
|
|
507 |
+ |
match self {
|
|
508 |
+ |
Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat,
|
|
509 |
+ |
// A pane is looked into, the same as a table body or a tag tree.
|
|
510 |
+ |
Self::Pane => Depth::Well,
|
|
511 |
+ |
Self::Modal => Depth::Raised,
|
|
512 |
+ |
}
|
|
513 |
+ |
}
|
|
514 |
+ |
}
|
|
515 |
+ |
|
|
516 |
+ |
/// How a screen is laid out.
|
|
517 |
+ |
///
|
|
518 |
+ |
/// Two, and the second is not a variant of the first. goingson is list-detail,
|
|
519 |
+ |
/// Balanced Breakfast is sidebar plus content, and neither app has a third.
|
|
520 |
+ |
/// The tab group is a modifier rather than a member, because goingson uses it
|
|
521 |
+ |
/// *inside* the same content region rather than instead of one.
|
|
522 |
+ |
///
|
|
523 |
+ |
/// This exists at all because the router has to be able to express a screen
|
|
524 |
+ |
/// rather than only a control. Discovering the arrangement layer missing after
|
|
525 |
+ |
/// the renderers exist is a redesign; naming two now is a morning.
|
|
526 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
527 |
+ |
pub enum Arrangement {
|
|
528 |
+ |
/// A list that chooses what the detail beside it shows.
|
|
529 |
+ |
ListDetail {
|
|
530 |
+ |
/// Whether the detail side is a [`Region::TabGroup`].
|
|
531 |
+ |
tabbed: bool,
|
|
532 |
+ |
},
|
|
533 |
+ |
/// Navigation down the side, content filling the rest.
|
|
534 |
+ |
SidebarContent,
|
|
535 |
+ |
}
|
|
536 |
+ |
|
|
537 |
+ |
/// What kind of value a form field takes.
|
|
538 |
+ |
///
|
|
539 |
+ |
/// The union of the two vocabularies that diverged, which is what triggered
|
|
540 |
+ |
/// this crate. They have since converged on their own: both apps now have a
|
|
541 |
+ |
/// `renderFormField` emitting the same anatomy, and what is left differing is
|
|
542 |
+ |
/// the kind set, the error shape, and whether the return is a string or a node.
|
|
543 |
+ |
///
|
|
544 |
+ |
/// Validation is deliberately absent. Neither app has a shared story (goingson
|
|
545 |
+ |
/// validates after collecting the form data, with per-field transform hooks;
|
|
546 |
+ |
/// Balanced Breakfast has `required` and nothing else), and a schema that
|
|
547 |
+ |
/// describes fields but not constraints acquires a constraint layer per app,
|
|
548 |
+ |
/// which is exactly how the current divergence started. Naming it absent is a
|
|
549 |
+ |
/// decision; leaving it unmentioned would not be.
|
|
550 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
551 |
+ |
pub enum FieldKind {
|
|
552 |
+ |
/// A single line of text.
|
|
553 |
+ |
Text,
|
|
554 |
+ |
/// A single line of text that must never be echoed, logged or round-tripped
|
|
555 |
+ |
/// through anything that might persist it.
|
|
556 |
+ |
Secret,
|
|
557 |
+ |
/// A number.
|
|
558 |
+ |
Number,
|
|
559 |
+ |
/// Several lines of text.
|
|
560 |
+ |
Textarea,
|
|
561 |
+ |
/// One of a fixed set.
|
|
562 |
+ |
Select,
|
|
563 |
+ |
/// On or off.
|
|
564 |
+ |
Checkbox,
|
|
565 |
+ |
/// Carried through the form and never shown.
|
|
566 |
+ |
Hidden,
|
|
567 |
+ |
}
|
|
568 |
+ |
|
|
569 |
+ |
impl FieldKind {
|
|
570 |
+ |
/// Whether the field is drawn at all.
|
|
571 |
+ |
#[must_use]
|
|
572 |
+ |
pub const fn visible(self) -> bool {
|
|
573 |
+ |
!matches!(self, Self::Hidden)
|
|
574 |
+ |
}
|
|
575 |
+ |
|
|
576 |
+ |
/// Whether the value must be kept out of logs and diagnostics.
|
|
577 |
+ |
#[must_use]
|
|
578 |
+ |
pub const fn confidential(self) -> bool {
|
|
579 |
+ |
matches!(self, Self::Secret)
|
|
580 |
+ |
}
|
|
581 |
+ |
|
|
582 |
+ |
/// Where the field's own label sits.
|
|
583 |
+ |
///
|
|
584 |
+ |
/// A checkbox labels itself on the right of the box; everything else takes
|
|
585 |
+ |
/// a label above. Both webview apps already do this and both special-case
|
|
586 |
+ |
/// it inline, which is the tell that it belongs in the description.
|
|
587 |
+ |
#[must_use]
|
|
588 |
+ |
pub const fn labels_itself(self) -> bool {
|
|
589 |
+ |
matches!(self, Self::Checkbox)
|
|
590 |
+ |
}
|
|
591 |
+ |
}
|
|
592 |
+ |
|
|
593 |
+ |
/// One field of a form.
|
|
594 |
+ |
///
|
|
595 |
+ |
/// Borrowed rather than owned: a description is built, read once by a renderer,
|
|
596 |
+ |
/// and dropped. Nothing here outlives the screen it describes.
|
|
597 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
598 |
+ |
pub struct Field<'a> {
|
|
599 |
+ |
/// What kind of value it takes.
|
|
600 |
+ |
pub kind: FieldKind,
|
|
601 |
+ |
/// The name the value is submitted under.
|
|
602 |
+ |
pub name: &'a str,
|
|
603 |
+ |
/// What the user is asked for.
|
|
604 |
+ |
pub label: &'a str,
|
|
605 |
+ |
/// Standing help, shown whether or not anything is wrong.
|
|
606 |
+ |
pub hint: Option<&'a str>,
|
|
607 |
+ |
/// What is currently wrong with the value.
|
|
608 |
+ |
pub error: Option<&'a str>,
|
|
609 |
+ |
/// Whether the form refuses to submit without it.
|
|
610 |
+ |
pub required: bool,
|
|
611 |
+ |
/// Whether the field lives behind a "more options" disclosure.
|
|
612 |
+ |
pub extended: bool,
|
|
613 |
+ |
}
|
|
614 |
+ |
|
|
615 |
+ |
impl<'a> Field<'a> {
|
|
616 |
+ |
/// A plain required-nothing field of the given kind.
|
|
617 |
+ |
#[must_use]
|
|
618 |
+ |
pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
|
|
619 |
+ |
Self {
|
|
620 |
+ |
kind,
|
|
621 |
+ |
name,
|
|
622 |
+ |
label,
|
|
623 |
+ |
hint: None,
|
|
624 |
+ |
error: None,
|
|
625 |
+ |
required: false,
|
|
626 |
+ |
extended: false,
|
|
627 |
+ |
}
|
|
628 |
+ |
}
|
|
629 |
+ |
|
|
630 |
+ |
/// Whether the field is currently reporting a problem.
|
|
631 |
+ |
///
|
|
632 |
+ |
/// Read this rather than testing `error.is_some()` at each renderer: the
|
|
633 |
+ |
/// error state has to mark the field's whole group and not only the
|
|
634 |
+ |
/// message, because a renderer with no descendant selectors (egui, a
|
|
635 |
+ |
/// terminal) cannot find the group from the message. goingson already marks
|
|
636 |
+ |
/// the group and Balanced Breakfast does not, so goingson's shape is the
|
|
637 |
+ |
/// one taken here.
|
|
638 |
+ |
#[must_use]
|
|
639 |
+ |
pub const fn invalid(&self) -> bool {
|
|
640 |
+ |
self.error.is_some()
|
|
641 |
+ |
}
|
|
642 |
+ |
}
|
|
643 |
+ |
|
|
644 |
+ |
/// How much room a column asks for.
|
|
645 |
+ |
///
|
|
646 |
+ |
/// An intent, so the actual floor stays with `makeover-geometry`. goingson's
|
|
647 |
+ |
/// task table spells these as `minmax(200px, 1fr)`, `140px` and content-sized;
|
|
648 |
+ |
/// only the first three words of that survive deferral.
|
|
649 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
650 |
+ |
pub enum Width {
|
|
651 |
+ |
/// Takes what it needs and no more.
|
|
652 |
+ |
Content,
|
|
653 |
+ |
/// A fixed share, the same at every width.
|
|
654 |
+ |
Fixed,
|
|
655 |
+ |
/// Absorbs whatever is left over.
|
|
656 |
+ |
Fill,
|
|
657 |
+ |
}
|
|
658 |
+ |
|
|
659 |
+ |
/// What a column is worth when there is not room for all of them.
|
|
660 |
+ |
///
|
|
661 |
+ |
/// Ordered: [`Priority::Optional`] drops first, [`Priority::Essential`] never
|
|
662 |
+ |
/// drops. This replaces addressing columns by position, which is what both
|
|
663 |
+ |
/// webview apps do today and is a live bug rather than only verbosity. goingson
|
|
664 |
+ |
/// hides mobile columns with `nth-child(n+5)` against a seven-column table, so
|
|
665 |
+ |
/// inserting a column silently hides the wrong one.
|