| 143 |
143 |
|
//! own? Bespoke keeps [`Region::Bespoke`], which already carries a completion
|
| 144 |
144 |
|
//! heatmap and is the right answer for a calendar nobody will build twice.
|
| 145 |
145 |
|
//!
|
|
146 |
+ |
//! 0.12.0 is two more from the same proving ground, and the same sorting
|
|
147 |
+ |
//! happened first: six findings came out of a measurement of goingson's whole
|
|
148 |
+ |
//! frontend, and four of them turned out to be asking what a control *calls*,
|
|
149 |
+ |
//! which this crate cannot say. The two that were really here:
|
|
150 |
+ |
//!
|
|
151 |
+ |
//! - [`Readiness`] grows from two states to four. It named `Ready` and
|
|
152 |
+ |
//! `Pending` and stopped, so a screen whose list came back empty had nothing
|
|
153 |
+ |
//! to say about it; goingson draws an empty state at 27 sites and Balanced
|
|
154 |
+ |
//! Breakfast at 9. `Empty` and `Failed` are the same axis rather than a new
|
|
155 |
+ |
//! member beside it, because a region shows one of the four and never two.
|
|
156 |
+ |
//! `#[non_exhaustive]` arrives with them, the pairing [`RowPart`] made at
|
|
157 |
+ |
//! 0.9.0 and for the same reason.
|
|
158 |
+ |
//! - [`Column::sortable`], [`Column::sorted`] and [`Sort`]. The one finding in
|
|
159 |
+ |
//! the set that completes a member rather than adding one: `Column` shipped
|
|
160 |
+ |
//! with a width and a priority and could not say that a table is ordered by a
|
|
161 |
+ |
//! column, so a described table could draw no caret and offer no reordering.
|
|
162 |
+ |
//!
|
|
163 |
+ |
//! What each of those deliberately leaves out is the address — what pressing a
|
|
164 |
+ |
//! header calls, and where an empty state's "Add your first project" button
|
|
165 |
+ |
//! goes. That is the boundary this crate is defined by, and four findings moved
|
|
166 |
+ |
//! across it rather than being answered here.
|
|
167 |
+ |
//!
|
| 146 |
168 |
|
//! # Where the description stops
|
| 147 |
169 |
|
//!
|
| 148 |
170 |
|
//! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
|
| 788 |
810 |
|
}
|
| 789 |
811 |
|
}
|
| 790 |
812 |
|
|
| 791 |
|
- |
/// Whether the content of a region has arrived.
|
|
813 |
+ |
/// What is in a region right now.
|
| 792 |
814 |
|
///
|
| 793 |
815 |
|
/// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
|
| 794 |
816 |
|
/// nothing at all is renderer policy, the same class of decision that got
|
| 796 |
818 |
|
/// each grew a skeleton with differently-named parts; both keep them, as the
|
| 797 |
819 |
|
/// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
|
| 798 |
820 |
|
/// and needs none, because an immediate-mode renderer simply repaints.
|
|
821 |
+ |
///
|
|
822 |
+ |
/// # Four states and not two, as of 0.12.0
|
|
823 |
+ |
///
|
|
824 |
+ |
/// `703f4cd2`. It named `Ready` and `Pending` and stopped, so a described screen
|
|
825 |
+ |
/// whose list came back empty had to render an empty region or invent its own
|
|
826 |
+ |
/// placeholder text, and neither says what it is. goingson draws one at 27 sites
|
|
827 |
+ |
/// across 12 files and Balanced Breakfast at 9, with a class family that had
|
|
828 |
+ |
/// already drifted into `empty-state`, `empty-state--error`, `error-state` and
|
|
829 |
+ |
/// six more.
|
|
830 |
+ |
///
|
|
831 |
+ |
/// The four are one axis because they are mutually exclusive: a region shows its
|
|
832 |
+ |
/// content, or a sign that it is coming, or a sign that there is none, or a sign
|
|
833 |
+ |
/// that it broke. Never two. That is the test for one enum against several
|
|
834 |
+ |
/// fields, and it is why this grew rather than a new member arriving beside it.
|
|
835 |
+ |
///
|
|
836 |
+ |
/// # What is not here
|
|
837 |
+ |
///
|
|
838 |
+ |
/// **The message.** "No projects yet" is content, and this names a state. It
|
|
839 |
+ |
/// lives with whatever holds the region — in quasi's case a `Slot` — alongside
|
|
840 |
+ |
/// the action that leads out of the emptiness, since an address is the one thing
|
|
841 |
+ |
/// this crate never names.
|
|
842 |
+ |
///
|
|
843 |
+ |
/// **How much room it gets.** goingson's `--compact`, `--dashboard` and
|
|
844 |
+ |
/// `--padded` are the same state at three sizes, and a size is
|
|
845 |
+ |
/// `makeover-geometry`'s question. Naming them here would be this crate stating
|
|
846 |
+ |
/// values again.
|
|
847 |
+ |
///
|
|
848 |
+ |
/// **The icon.** Presentation, and each host has its own answer or none.
|
| 799 |
849 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
850 |
+ |
#[non_exhaustive]
|
| 800 |
851 |
|
pub enum Readiness {
|
| 801 |
852 |
|
/// The content is here.
|
| 802 |
853 |
|
Ready,
|
| 803 |
854 |
|
/// The content is on its way.
|
| 804 |
855 |
|
Pending,
|
|
856 |
+ |
/// The content arrived and there is none of it.
|
|
857 |
+ |
///
|
|
858 |
+ |
/// Not a failure. An empty list is the normal state of a new install, and a
|
|
859 |
+ |
/// renderer that drew it in a danger tone would be reporting a fault where
|
|
860 |
+ |
/// there is none.
|
|
861 |
+ |
Empty,
|
|
862 |
+ |
/// The content did not arrive.
|
|
863 |
+ |
Failed,
|
|
864 |
+ |
}
|
|
865 |
+ |
|
|
866 |
+ |
impl Readiness {
|
|
867 |
+ |
/// Whether the region draws its own content, or something standing in for
|
|
868 |
+ |
/// it.
|
|
869 |
+ |
///
|
|
870 |
+ |
/// The question every renderer asks first, so it is answered once here
|
|
871 |
+ |
/// rather than by a `matches!` in each. A state added later is a stand-in
|
|
872 |
+ |
/// until proven otherwise: falling back to drawing content that may not be
|
|
873 |
+ |
/// there is the worse of the two mistakes.
|
|
874 |
+ |
#[must_use]
|
|
875 |
+ |
pub const fn shows_content(self) -> bool {
|
|
876 |
+ |
matches!(self, Self::Ready)
|
|
877 |
+ |
}
|
|
878 |
+ |
|
|
879 |
+ |
/// What the state means, for a renderer choosing a colour.
|
|
880 |
+ |
///
|
|
881 |
+ |
/// Derived rather than carried, which is the opposite of [`Meter`] and
|
|
882 |
+ |
/// [`Figure`], and the difference is worth stating: a proportion's meaning
|
|
883 |
+ |
/// depends on what is being counted and only the app knows it, while
|
|
884 |
+ |
/// "nothing here yet" and "this broke" mean the same thing in every app that
|
|
885 |
+ |
/// will ever have them.
|
|
886 |
+ |
#[must_use]
|
|
887 |
+ |
pub const fn tone(self) -> Tone {
|
|
888 |
+ |
match self {
|
|
889 |
+ |
Self::Failed => Tone::Danger,
|
|
890 |
+ |
_ => Tone::Neutral,
|
|
891 |
+ |
}
|
|
892 |
+ |
}
|
| 805 |
893 |
|
}
|
| 806 |
894 |
|
|
| 807 |
895 |
|
/// How much of a set is done.
|
| 1477 |
1565 |
|
pub width: Width,
|
| 1478 |
1566 |
|
/// What it is worth when room runs out.
|
| 1479 |
1567 |
|
pub priority: Priority,
|
|
1568 |
+ |
/// Whether the user can reorder the table by this column.
|
|
1569 |
+ |
///
|
|
1570 |
+ |
/// `ce620871`. What reordering *calls* is not here — that is an address, and
|
|
1571 |
+ |
/// this crate names none — so a host pairs this with the route the way it
|
|
1572 |
+ |
/// pairs a row's parts with the row's activation. This says the affordance
|
|
1573 |
+ |
/// exists, which is what a renderer needs to draw a header a user can press
|
|
1574 |
+ |
/// rather than a heading they cannot.
|
|
1575 |
+ |
pub sortable: bool,
|
|
1576 |
+ |
/// Which way the table is ordered by this column, if it is.
|
|
1577 |
+ |
///
|
|
1578 |
+ |
/// `None` on every column but the one in force. A renderer draws the caret
|
|
1579 |
+ |
/// from this and a webview sets `aria-sort`, which is why it is per column
|
|
1580 |
+ |
/// rather than a single fact on the table: the host idiom is a property of
|
|
1581 |
+ |
/// the header cell.
|
|
1582 |
+ |
///
|
|
1583 |
+ |
/// Independent of [`sortable`](Self::sortable) rather than implied by it,
|
|
1584 |
+ |
/// because both combinations mean something. A column sorted and not
|
|
1585 |
+ |
/// sortable is a list ordered by a key the user cannot change, which is a
|
|
1586 |
+ |
/// real thing to describe and a caret worth drawing.
|
|
1587 |
+ |
pub sorted: Option<Sort>,
|
|
1588 |
+ |
}
|
|
1589 |
+ |
|
|
1590 |
+ |
/// Which way a column is ordered.
|
|
1591 |
+ |
///
|
|
1592 |
+ |
/// Two, because there is no third. "Unsorted" is [`Column::sorted`] being
|
|
1593 |
+ |
/// `None`, and folding it in here would be the same absence said twice.
|
|
1594 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
1595 |
+ |
pub enum Sort {
|
|
1596 |
+ |
/// Smallest, earliest or first alphabetically at the top.
|
|
1597 |
+ |
Ascending,
|
|
1598 |
+ |
/// The other way.
|
|
1599 |
+ |
Descending,
|
|
1600 |
+ |
}
|
|
1601 |
+ |
|
|
1602 |
+ |
impl Sort {
|
|
1603 |
+ |
/// The other direction, for a header that flips when pressed.
|
|
1604 |
+ |
#[must_use]
|
|
1605 |
+ |
pub const fn reversed(self) -> Self {
|
|
1606 |
+ |
match self {
|
|
1607 |
+ |
Self::Ascending => Self::Descending,
|
|
1608 |
+ |
Self::Descending => Self::Ascending,
|
|
1609 |
+ |
}
|
|
1610 |
+ |
}
|
|
1611 |
+ |
|
|
1612 |
+ |
/// What a webview writes into `aria-sort`.
|
|
1613 |
+ |
///
|
|
1614 |
+ |
/// Named here rather than in the webview renderer because a terminal and an
|
|
1615 |
+ |
/// immediate-mode painter both want the same two words for a caret's label,
|
|
1616 |
+ |
/// and three renderers picking their own is the drift this crate ends.
|
|
1617 |
+ |
#[must_use]
|
|
1618 |
+ |
pub const fn as_str(self) -> &'static str {
|
|
1619 |
+ |
match self {
|
|
1620 |
+ |
Self::Ascending => "ascending",
|
|
1621 |
+ |
Self::Descending => "descending",
|
|
1622 |
+ |
}
|
|
1623 |
+ |
}
|
| 1480 |
1624 |
|
}
|
| 1481 |
1625 |
|
|
| 1482 |
1626 |
|
impl<'a> Column<'a> {
|
| 1487 |
1631 |
|
name,
|
| 1488 |
1632 |
|
width: Width::Fill,
|
| 1489 |
1633 |
|
priority: Priority::Secondary,
|
|
1634 |
+ |
sortable: false,
|
|
1635 |
+ |
sorted: None,
|
| 1490 |
1636 |
|
}
|
| 1491 |
1637 |
|
}
|
| 1492 |
1638 |
|
|
| 1504 |
1650 |
|
mod tests {
|
| 1505 |
1651 |
|
use super::*;
|
| 1506 |
1652 |
|
|
|
1653 |
+ |
#[test]
|
|
1654 |
+ |
fn the_four_readiness_states_are_one_axis_and_only_one_shows_content() {
|
|
1655 |
+ |
// Mutually exclusive is the test for one enum against several fields: a
|
|
1656 |
+ |
// region shows its content, or that it is coming, or that there is none,
|
|
1657 |
+ |
// or that it broke. Never two.
|
|
1658 |
+ |
assert!(Readiness::Ready.shows_content());
|
|
1659 |
+ |
for state in [Readiness::Pending, Readiness::Empty, Readiness::Failed] {
|
|
1660 |
+ |
assert!(!state.shows_content());
|
|
1661 |
+ |
}
|
|
1662 |
+ |
}
|
|
1663 |
+ |
|
|
1664 |
+ |
#[test]
|
|
1665 |
+ |
fn an_empty_region_is_not_a_broken_one() {
|
|
1666 |
+ |
// An empty list is the normal state of a new install. Drawing it in a
|
|
1667 |
+ |
// danger tone reports a fault where there is none, and this is the one
|
|
1668 |
+ |
// place the distinction is carried.
|
|
1669 |
+ |
assert_eq!(Readiness::Empty.tone(), Tone::Neutral);
|
|
1670 |
+ |
assert_eq!(Readiness::Failed.tone(), Tone::Danger);
|
|
1671 |
+ |
assert_eq!(Readiness::Pending.tone(), Tone::Neutral);
|
|
1672 |
+ |
}
|
|
1673 |
+ |
|
|
1674 |
+ |
#[test]
|
|
1675 |
+ |
fn a_column_can_be_sorted_without_being_sortable() {
|
|
1676 |
+ |
// Both combinations mean something, which is why the two fields are
|
|
1677 |
+ |
// independent rather than one implying the other. A list ordered by a
|
|
1678 |
+ |
// key the user cannot change is a real thing with a caret worth drawing.
|
|
1679 |
+ |
let fixed = Column {
|
|
1680 |
+ |
sorted: Some(Sort::Descending),
|
|
1681 |
+ |
..Column::new("Created")
|
|
1682 |
+ |
};
|
|
1683 |
+ |
|
|
1684 |
+ |
assert!(!fixed.sortable);
|
|
1685 |
+ |
assert_eq!(fixed.sorted.map(Sort::as_str), Some("descending"));
|
|
1686 |
+ |
|
|
1687 |
+ |
let offered = Column {
|
|
1688 |
+ |
sortable: true,
|
|
1689 |
+ |
..Column::new("Name")
|
|
1690 |
+ |
};
|
|
1691 |
+ |
assert_eq!(offered.sorted, None);
|
|
1692 |
+ |
}
|
|
1693 |
+ |
|
|
1694 |
+ |
#[test]
|
|
1695 |
+ |
fn a_direction_flips_and_says_what_it_is() {
|
|
1696 |
+ |
assert_eq!(Sort::Ascending.reversed(), Sort::Descending);
|
|
1697 |
+ |
assert_eq!(Sort::Descending.reversed().reversed(), Sort::Descending);
|
|
1698 |
+ |
assert_eq!(Sort::Ascending.as_str(), "ascending");
|
|
1699 |
+ |
}
|
|
1700 |
+ |
|
| 1507 |
1701 |
|
#[test]
|
| 1508 |
1702 |
|
fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() {
|
| 1509 |
1703 |
|
// Three of goingson's five sites tone the figure by their own means, so
|
| 2048 |
2242 |
|
fn columns_drop_by_priority_and_never_by_position() {
|
| 2049 |
2243 |
|
let cols = [
|
| 2050 |
2244 |
|
Column {
|
| 2051 |
|
- |
name: "Title",
|
| 2052 |
2245 |
|
width: Width::Fill,
|
| 2053 |
2246 |
|
priority: Priority::Essential,
|
|
2247 |
+ |
..Column::new("Title")
|
| 2054 |
2248 |
|
},
|
| 2055 |
2249 |
|
Column {
|
| 2056 |
|
- |
name: "Due",
|
| 2057 |
2250 |
|
width: Width::Fixed,
|
| 2058 |
2251 |
|
priority: Priority::Secondary,
|
|
2252 |
+ |
..Column::new("Due")
|
| 2059 |
2253 |
|
},
|
| 2060 |
2254 |
|
Column {
|
| 2061 |
|
- |
name: "Estimate",
|
| 2062 |
2255 |
|
width: Width::Fixed,
|
| 2063 |
2256 |
|
priority: Priority::Optional,
|
|
2257 |
+ |
..Column::new("Estimate")
|
| 2064 |
2258 |
|
},
|
| 2065 |
2259 |
|
];
|
| 2066 |
2260 |
|
|
| 2095 |
2289 |
|
let before = [
|
| 2096 |
2290 |
|
Column::new("Title"),
|
| 2097 |
2291 |
|
Column {
|
| 2098 |
|
- |
name: "Estimate",
|
| 2099 |
2292 |
|
width: Width::Fixed,
|
| 2100 |
2293 |
|
priority: Priority::Optional,
|
|
2294 |
+ |
..Column::new("Estimate")
|
| 2101 |
2295 |
|
},
|
| 2102 |
2296 |
|
];
|
| 2103 |
2297 |
|
let after = [
|
| 2104 |
2298 |
|
Column::new("Title"),
|
| 2105 |
2299 |
|
Column::new("Project"), // inserted
|
| 2106 |
2300 |
|
Column {
|
| 2107 |
|
- |
name: "Estimate",
|
| 2108 |
2301 |
|
width: Width::Fixed,
|
| 2109 |
2302 |
|
priority: Priority::Optional,
|
|
2303 |
+ |
..Column::new("Estimate")
|
| 2110 |
2304 |
|
},
|
| 2111 |
2305 |
|
];
|
| 2112 |
2306 |
|
|