Skip to main content

max / makeover-layout

0.31.0: what an upload takes, and how many files it takes Task f7261a5a. FieldKind::File arrived at 0.11.0 with neither an accept list nor a multiplicity, and its doc gave the reason: accept appeared at zero sites in either app, and a member designed against nothing is not a member. The count was taken over goingson and Balanced Breakfast; the MNW server is a third consumer with 14 accept lists across 10 templates and 4 of its 16 file inputs marked multiple. The reasoning was right and the measurement went stale. Field::accept and Field::multiple, plus Field::upload and FieldKind::takes_files. Accepted is an enum rather than the comma-joined string the templates hold, because the list is read twice and only one of the readings is filtering: the other is which disclosure the field earns, and a renderer deciding that from raw strings is three renderers each writing a media-type parser. All three shapes are in the measured sites and none can be dropped -- image/* is a Family, image/jpeg and text/csv are a Type, .zip and .tar.gz are a Suffix, and one site carries .csv,text/csv. A suffix names no family and this crate will not infer one. .mp3 is audio in fact, and a table here saying so rots in a published crate and is wrong for the first container format handed to it. A call site that wants the disclosure writes the family or the media type. The other two axes of an upload need nothing and get nothing. Where the bytes go is an address and this crate holds none; how far along it is rides on Awaiting, whose own doc already names an upload's file length as the case it accepts. How the file is handed over -- a drop area, a picker, a typed path -- stays the host's, for the reason no gesture is described here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 21:15 UTC
Signed with PGP, not checked
Commit: 8a43d80bf8d2dce3bab0999c5623631c5557f2da
Parent: 4269eea
2 files changed, +358 insertions, -5 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.30.0"
3 + version = "0.31.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +357 -4
@@ -382,6 +382,41 @@
382 382 //! that one page: audiofiles' library browser and goingson's filters are the
383 383 //! same shape.
384 384 //!
385 + //! 0.31.0 finishes the file field. [`FieldKind::File`] arrived at 0.11.0
386 + //! carrying neither an accepted-types list nor a multiplicity flag, and said so
387 + //! in its own doc: `accept` appeared at zero sites in either app, and a member
388 + //! added for a case nobody has is a member designed against nothing. That count
389 + //! was taken over goingson and Balanced Breakfast, and the MNW server is a third
390 + //! consumer with 14 `accept` lists across 10 templates and 4 of its 16 file
391 + //! inputs marked `multiple`. The reasoning was right and the measurement went
392 + //! stale, so [`Field::accept`] and [`Field::multiple`] arrive now.
393 + //!
394 + //! - [`Accepted`] is an enum rather than the comma-joined string the templates
395 + //! hold, because the list is read twice and only one of the readings is
396 + //! filtering. The other is which disclosure to offer — a preview, a duration,
397 + //! a waveform — and a renderer deciding that from raw strings is three
398 + //! renderers each writing a media-type parser. [`Accepted::family`] answers it
399 + //! once. All three shapes are in the measured sites and none can be dropped:
400 + //! `image/*` is a [`Family`], `image/jpeg` and `text/csv` are a
401 + //! [`Type`](Accepted::Type), and `.zip`, `.tar.gz` and `.clap` are a
402 + //! [`Suffix`](Accepted::Suffix). One site carries `.csv,text/csv`, which is
403 + //! both in one list.
404 + //! - A suffix carries no family and this crate will not infer one. `.mp3` is
405 + //! audio in fact, and a table here saying so is a mapping that rots in a
406 + //! published crate and is wrong for the first container format someone hands
407 + //! it. A call site that wants the disclosure writes the family or the media
408 + //! type, which is what the sites offering previews already do.
409 + //! - There is one upload shape, not one per type. What a media upload shows
410 + //! beyond a plain one is disclosure layered on this shape, which is why the
411 + //! accept list is load-bearing beyond validation and why nothing here names a
412 + //! media upload as its own kind.
413 + //! - Progress is not a member and needs none. An upload in flight is a control
414 + //! in flight with a number attached, so it is [`Awaiting`] with the file's
415 + //! length as its [`amount`](Awaiting::amount) — the case that type's own doc
416 + //! names. Where the bytes go is an address, and this crate holds none; that is
417 + //! the router's [`Action`], which the field already points at when it writes
418 + //! on its own.
419 + //!
385 420 //! # Reach, focus and the focus ring
386 421 //!
387 422 //! Three terms, and no others, for what 0.19.0 moved out of the description.
@@ -3312,10 +3347,34 @@
3312 3347 /// [`Email`](Self::Email), which exists because it changes the keyboard,
3313 3348 /// than to anything bespoke.
3314 3349 ///
3315 - /// It carries no accepted-types list and no multiple flag, and that is
3316 - /// measured rather than deferred: `accept` appears at zero sites in either
3317 - /// app. A member added for a case nobody has is a member designed against
3318 - /// nothing.
3350 + /// # The four things an upload says, and where each of them lives
3351 + ///
3352 + /// | axis | where |
3353 + /// |---|---|
3354 + /// | what it accepts | [`Field::accept`] |
3355 + /// | one file or several | [`Field::multiple`] |
3356 + /// | where the bytes go | the router's action, not here |
3357 + /// | how far along it is | [`Awaiting`] on that action |
3358 + ///
3359 + /// Only the first two are this crate's, and that split is the answer to
3360 + /// "describe an upload in full" rather than a gap in it. A destination is an
3361 + /// address and this crate holds no addresses; progress is a live number and
3362 + /// a description is built once, so the number is the renderer's to observe
3363 + /// against the size [`Awaiting::amount`] carried before the transfer began.
3364 + ///
3365 + /// # How the file is handed over is the host's
3366 + ///
3367 + /// A drop area, a button opening a native picker, a path typed at a prompt:
3368 + /// all three are the same field, and every measured site has the first. It
3369 + /// is not described for the reason no gesture is — this crate owns no
3370 + /// coordinates and no pointer, and a terminal that cannot be dropped on
3371 + /// would be refusing a description it can otherwise honour completely.
3372 + ///
3373 + /// The first two were absent until 0.31.0, and the doc here said why: they
3374 + /// were measured rather than deferred, `accept` appearing at zero sites in
3375 + /// either app. The count was taken over goingson and Balanced Breakfast, and
3376 + /// the MNW server is a third consumer with 14 of them. A member designed
3377 + /// against nothing is still the rule; the measurement is what changed.
3319 3378 File,
3320 3379 /// Carried through the form and never shown.
3321 3380 Hidden,
@@ -3399,6 +3458,145 @@
3399 3458 pub const fn multiline(self) -> bool {
3400 3459 matches!(self, Self::Textarea | Self::Rich)
3401 3460 }
3461 +
3462 + /// Whether the value is a file the host picks rather than a string typed
3463 + /// into a box.
3464 + ///
3465 + /// One member answers yes, which is [`visible`](Self::visible)'s and
3466 + /// [`confidential`](Self::confidential)'s footing rather than a departure
3467 + /// from it: the question gets a name because three renderers ask it before
3468 + /// they can read [`Field::accept`] or [`Field::multiple`], and a `matches!`
3469 + /// per renderer is where a second file-taking kind would go missing.
3470 + #[must_use]
3471 + pub const fn takes_files(self) -> bool {
3472 + matches!(self, Self::File)
3473 + }
3474 + }
3475 +
3476 + /// A family of media a file can belong to.
3477 + ///
3478 + /// Three members, because three is what a media type's own first segment offers
3479 + /// that a renderer can do anything with. `text` and `application` are families
3480 + /// too and neither buys a disclosure — there is no preview of an
3481 + /// `application/octet-stream` — so naming them would be a member added for a
3482 + /// case nobody has.
3483 + ///
3484 + /// It is the answer to "which disclosure", not a validation rule.
3485 + /// [`Field::accept`] is what a host filters on.
3486 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3487 + #[non_exhaustive]
3488 + pub enum Family {
3489 + /// A still picture.
3490 + Image,
3491 + /// Sound.
3492 + Audio,
3493 + /// Moving pictures, with or without sound.
3494 + Video,
3495 + }
3496 +
3497 + impl Family {
3498 + /// The wildcard media type that means the whole family.
3499 + ///
3500 + /// `image/*` and its two siblings, which is what the measured sites write
3501 + /// and what a webview puts in an `accept` attribute. Named here so the three
3502 + /// renderers do not each spell the star.
3503 + #[must_use]
3504 + pub const fn wildcard(self) -> &'static str {
3505 + match self {
3506 + Self::Image => "image/*",
3507 + Self::Audio => "audio/*",
3508 + Self::Video => "video/*",
3509 + }
3510 + }
3511 +
3512 + /// The family a media type's first segment names, if it is one of these.
3513 + ///
3514 + /// Case-insensitive on the segment, because a media type is
3515 + /// case-insensitive and half the tree writes them lowercase by habit rather
3516 + /// than by rule.
3517 + #[must_use]
3518 + pub fn of_type(media_type: &str) -> Option<Self> {
3519 + let (top, _) = media_type.split_once('/')?;
3520 + if top.eq_ignore_ascii_case("image") {
3521 + Some(Self::Image)
3522 + } else if top.eq_ignore_ascii_case("audio") {
3523 + Some(Self::Audio)
3524 + } else if top.eq_ignore_ascii_case("video") {
3525 + Some(Self::Video)
3526 + } else {
3527 + None
3528 + }
3529 + }
3530 + }
3531 +
3532 + /// One entry in a file field's accept list.
3533 + ///
3534 + /// Three shapes rather than a string, and all three are in the measured sites:
3535 + /// the MNW server writes `image/*`, `image/jpeg,image/png,image/webp`,
3536 + /// `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` and, in one place,
3537 + /// `.csv,text/csv`. A single string would carry all of them and answer nothing
3538 + /// about any of them.
3539 + ///
3540 + /// # Why the list is not just a filter
3541 + ///
3542 + /// It is read twice. Once to decide what the picker offers, which any of the
3543 + /// three shapes serves, and once to decide **which disclosure** the field gets:
3544 + /// a preview for a picture, a duration or a waveform for a sound. There is one
3545 + /// upload shape and a media upload is that shape with more of it shown, so the
3546 + /// accept list is what says which more. [`family`](Self::family) is that
3547 + /// question answered once here instead of a media-type parser in each renderer.
3548 + ///
3549 + /// # A suffix names no family, on purpose
3550 + ///
3551 + /// `.mp3` is audio in fact, and nothing here says so. A suffix-to-family table
3552 + /// in a published crate is a mapping that goes stale, disagrees with the host's
3553 + /// own idea of what a file is, and is wrong the first time somebody hands it a
3554 + /// container. A call site that wants a picture's preview writes
3555 + /// [`Family::Image`] or `image/jpeg`; a call site listing installer suffixes
3556 + /// wants no disclosure anyway, which is the measured case.
3557 + ///
3558 + /// Added 0.31.0, `f7261a5a`.
3559 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3560 + #[non_exhaustive]
3561 + pub enum Accepted<'a> {
3562 + /// Every file of a family: `image/*` and its siblings.
3563 + Family(Family),
3564 + /// One media type, written the way a media type is written:
3565 + /// `image/jpeg`, `text/csv`.
3566 + Type(&'a str),
3567 + /// One file-name suffix, written with its leading dot: `.zip`, `.tar.gz`.
3568 + ///
3569 + /// A suffix and not an extension, because `.tar.gz` is a measured site and
3570 + /// is two dots.
3571 + Suffix(&'a str),
3572 + }
3573 +
3574 + impl<'a> Accepted<'a> {
3575 + /// The family this entry belongs to, when it names one.
3576 + ///
3577 + /// [`None`] for a [`Suffix`](Self::Suffix) and for any media type outside
3578 + /// the three families, which is the honest answer rather than a missing
3579 + /// one: the description did not say.
3580 + #[must_use]
3581 + pub fn family(self) -> Option<Family> {
3582 + match self {
3583 + Self::Family(family) => Some(family),
3584 + Self::Type(media_type) => Family::of_type(media_type),
3585 + Self::Suffix(_) => None,
3586 + }
3587 + }
3588 +
3589 + /// How a host that wants one string writes this entry.
3590 + ///
3591 + /// A webview's `accept` attribute takes exactly these spellings, and a
3592 + /// terminal listing what it will take reads the same words.
3593 + #[must_use]
3594 + pub const fn as_str(self) -> &'a str {
3595 + match self {
3596 + Self::Family(family) => family.wildcard(),
3597 + Self::Type(text) | Self::Suffix(text) => text,
3598 + }
3599 + }
3402 3600 }
3403 3601
3404 3602 /// One option offered by a field [`FieldKind::offers_options`] accepts.
@@ -3564,6 +3762,31 @@
3564 3762 /// Which option is *current* is not here. That is the value, and the value
3565 3763 /// is renderer state.
3566 3764 pub options: &'a [Choice<'a>],
3765 + /// What a file field takes, in the order a host offering the list shows it.
3766 + ///
3767 + /// Empty for every kind [`FieldKind::takes_files`] rejects, and empty is
3768 + /// also a real answer for one that accepts it: a field that takes any file
3769 + /// says so by listing nothing, which is what an `<input type="file">` with
3770 + /// no `accept` does and what most of the measured sites are.
3771 + ///
3772 + /// It is a filter and it is the disclosure cue, and [`Accepted`]'s doc
3773 + /// carries which reading is which. Nothing here validates: a host may hand
3774 + /// back a file the list does not cover, exactly as a browser does when the
3775 + /// user switches the picker to "All Files", and deciding a value is wrong
3776 + /// stays with whoever validated.
3777 + ///
3778 + /// Added 0.31.0, `f7261a5a`.
3779 + pub accept: &'a [Accepted<'a>],
3780 + /// Whether more than one file may be picked at once.
3781 + ///
3782 + /// Only [`FieldKind::takes_files`] reads it. A multi-valued answer to any
3783 + /// other question is a different shape — a set of options, a repeated
3784 + /// group — and neither is this flag with a different kind beside it.
3785 + ///
3786 + /// False is the common case: 4 of the MNW server's 16 file inputs carry it.
3787 + ///
3788 + /// Added 0.31.0, `f7261a5a`.
3789 + pub multiple: bool,
3567 3790 /// Whether the form refuses to submit without it.
3568 3791 pub required: bool,
3569 3792 /// The longest the value may be, in characters.
@@ -3615,6 +3838,8 @@
3615 3838 error: None,
3616 3839 placeholder: None,
3617 3840 options: &[],
3841 + accept: &[],
3842 + multiple: false,
3618 3843 required: false,
3619 3844 max_length: None,
3620 3845 min: None,
@@ -3644,6 +3869,27 @@
3644 3869 }
3645 3870 }
3646 3871
3872 + /// A file field, taking the given accept list.
3873 + ///
3874 + /// The fourth under-described kind and it gets a constructor for
3875 + /// [`range`](Self::range)'s reason rather than [`select`](Self::select)'s:
3876 + /// a file field with no accept list is not broken, it is a field that takes
3877 + /// anything, and the hazard is the opposite one. A call site that meant to
3878 + /// restrict and forgot has a picker offering every file on the machine and
3879 + /// a server refusing the upload afterwards, which is the failure the list
3880 + /// exists to move forward. Taking it as an argument is what makes an
3881 + /// accidental omission a deliberate `&[]`.
3882 + ///
3883 + /// [`multiple`](Self::multiple) stays a field. One file is the common case
3884 + /// and the honest default; several is the thing worth saying.
3885 + #[must_use]
3886 + pub const fn upload(name: &'a str, label: &'a str, accept: &'a [Accepted<'a>]) -> Self {
3887 + Self {
3888 + accept,
3889 + ..Self::new(FieldKind::File, name, label)
3890 + }
3891 + }
3892 +
3647 3893 /// A select offering the given options.
3648 3894 ///
3649 3895 /// One of the two kinds under-described by [`Field::new`], so it gets a
@@ -3711,6 +3957,23 @@
3711 3957 pub const fn bounded(&self) -> bool {
3712 3958 self.min.is_some() && self.max.is_some()
3713 3959 }
3960 +
3961 + /// Whether anything in [`accept`](Self::accept) names a media family.
3962 + ///
3963 + /// The question a renderer asks before it decides to keep room for a
3964 + /// preview, and it is deliberately the *whole list* rather than one entry:
3965 + /// the media dropzone this was measured against takes `image/*,video/*`, so
3966 + /// there is no single family to return and there is still a disclosure to
3967 + /// offer. Which one it turns out to be is known once a file is picked, which
3968 + /// is renderer-side and after the description is gone.
3969 + ///
3970 + /// False for an empty list, for a list of suffixes, and for `text/csv`. A
3971 + /// renderer that wants the family of a particular entry reads
3972 + /// [`Accepted::family`].
3973 + #[must_use]
3974 + pub fn accepts_media(&self) -> bool {
3975 + self.accept.iter().any(|one| one.family().is_some())
3976 + }
3714 3977 }
3715 3978
3716 3979 /// How much room a placement asks for.
@@ -4453,6 +4716,96 @@
4453 4716 assert_eq!(FacetValue::of("Ambient").counted(0).count, Some(0));
4454 4717 }
4455 4718
4719 + #[test]
4720 + fn one_kind_takes_files_and_the_two_file_members_are_its_alone() {
4721 + assert!(FieldKind::File.takes_files());
4722 + for kind in [
4723 + FieldKind::Text,
4724 + FieldKind::Textarea,
4725 + FieldKind::Rich,
4726 + FieldKind::Select,
4727 + FieldKind::Checkbox,
4728 + FieldKind::Hidden,
4729 + ] {
4730 + assert!(!kind.takes_files());
4731 + }
4732 + // The default is a field that takes any one file, which is what an
4733 + // input with no accept and no multiple already is.
4734 + let plain = Field::new(FieldKind::File, "cover", "Cover");
4735 + assert!(plain.accept.is_empty());
4736 + assert!(!plain.multiple);
4737 + }
4738 +
4739 + #[test]
4740 + fn an_accept_list_says_which_disclosure_and_a_suffix_says_none() {
4741 + // The three shapes are the MNW server's own three, and the family is
4742 + // the question a renderer asks before it keeps room for a preview.
4743 + assert_eq!(
4744 + Accepted::Family(Family::Image).family(),
4745 + Some(Family::Image)
4746 + );
4747 + assert_eq!(Accepted::Type("image/jpeg").family(), Some(Family::Image));
4748 + assert_eq!(Accepted::Type("audio/flac").family(), Some(Family::Audio));
4749 + assert_eq!(
4750 + Accepted::Type("video/quicktime").family(),
4751 + Some(Family::Video)
4752 + );
4753 + // A media type outside the three families names none, and neither does
4754 + // a suffix. `.mp3` is audio in fact and this crate will not infer it:
4755 + // the table that said so would rot.
4756 + assert_eq!(Accepted::Type("text/csv").family(), None);
4757 + assert_eq!(Accepted::Suffix(".mp3").family(), None);
4758 + assert_eq!(Accepted::Suffix(".tar.gz").family(), None);
4759 + // Media types are case-insensitive and half the tree writes them
4760 + // lowercase by habit rather than by rule.
4761 + assert_eq!(Accepted::Type("IMAGE/PNG").family(), Some(Family::Image));
4762 + }
4763 +
4764 + #[test]
4765 + fn every_accepted_entry_has_one_spelling_a_host_can_write() {
4766 + assert_eq!(Accepted::Family(Family::Image).as_str(), "image/*");
4767 + assert_eq!(Accepted::Family(Family::Audio).as_str(), "audio/*");
4768 + assert_eq!(Accepted::Family(Family::Video).as_str(), "video/*");
4769 + assert_eq!(Accepted::Type("text/csv").as_str(), "text/csv");
4770 + assert_eq!(Accepted::Suffix(".tar.gz").as_str(), ".tar.gz");
4771 + }
4772 +
4773 + #[test]
4774 + fn a_list_accepting_two_families_still_has_a_disclosure_to_offer() {
4775 + // The measured dropzone: `accept="image/*,video/*"`. There is no single
4776 + // family to return and there is still a preview to keep room for, which
4777 + // is why the question is asked of the list rather than of one entry.
4778 + const MEDIA: &[Accepted<'_>] = &[
4779 + Accepted::Family(Family::Image),
4780 + Accepted::Family(Family::Video),
4781 + ];
4782 + assert!(Field::upload("media", "Media", MEDIA).accepts_media());
4783 + // An installer's suffix list wants no disclosure, which is the measured
4784 + // case rather than a hypothetical one.
4785 + const BUILDS: &[Accepted<'_>] = &[Accepted::Suffix(".zip"), Accepted::Suffix(".dmg")];
4786 + assert!(!Field::upload("build", "Build", BUILDS).accepts_media());
4787 + // And a field that takes anything says so by listing nothing.
4788 + assert!(!Field::upload("any", "File", &[]).accepts_media());
4789 + }
4790 +
4791 + #[test]
4792 + fn an_upload_carries_its_list_and_takes_one_file_until_it_says_otherwise() {
4793 + const IMAGES: &[Accepted<'_>] = &[
4794 + Accepted::Type("image/jpeg"),
4795 + Accepted::Type("image/png"),
4796 + Accepted::Type("image/webp"),
4797 + ];
4798 + let avatar = Field::upload("avatar", "Avatar", IMAGES);
4799 + assert_eq!(avatar.kind, FieldKind::File);
4800 + assert_eq!(avatar.accept, IMAGES);
4801 + assert!(!avatar.multiple);
4802 + let several = Field {
4803 + multiple: true,
4804 + ..avatar
4805 + };
4806 + assert!(several.multiple);
4807 + }
4808 +
4456 4809 #[test]
4457 4810 fn the_four_readiness_states_are_one_axis_and_only_one_shows_content() {
4458 4811 // Mutually exclusive is the test for one enum against several fields: a