Skip to main content

max / goingson

Declare About, Search, the board and Cloud Sync goingson's first four converted files, and the first evidence about the form from a host that is not the web server: nine declarations, two productions, and nothing the vocabulary was missing. Both productions came from what a Tauri app does differently. A settings section is a `-> Vec<Node>` panel of facts and questions with no form anywhere on the screen, so a field had to be able to stand in one. And the board's move control carries which column it moves to, which is Action::with rather than a form field. Two restructurings the form asked for, and both are fixes on their own. The board read the whole task table once per column, three times per request; the read is in the handler now and every lane draws from one list. Search's results shape was fallible because it did the search itself; the handler searches and the shape describes. The rest is the usual: tuples become structs (the board's three lanes, About's seven identity rows), Options become a predicate and a reader, and a supplier hands back the smallest type that works. 920 lib tests pass, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 16:30 UTC
Signed with PGP, not checked
Commit: b9fed0fb03730afadf0bc7f73d19a4868967b7b1
Parent: be39bc8
5 files changed, +533 insertions, -348 deletions
@@ -58,6 +58,7 @@
58 58 quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.101" }
59 59 quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.101" }
60 60 quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.101" }
61 + quasi-declare = { git = "https://makenot.work/git/max/quasi.git", version = "0.1" }
61 62
62 63 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 64 # watcher is desktop app behaviour rather than a described screen, and the
@@ -33,9 +33,10 @@
33 33 #![allow(clippy::needless_pass_by_value)]
34 34
35 35 use goingson_core::{Priority, Task, TaskId, TaskStatus};
36 - use makeover_layout::{Heading, Tone};
37 - use quasi_router::screen::{Act, Meter, Row, Tag};
38 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
36 + use makeover_layout::Tone;
37 + use quasi_declare::declare;
38 + use quasi_router::screen::{Meter, Tag};
39 + use quasi_router::{Node, Response, RouteError, Router, Screen};
39 40
40 41 use crate::state::{AppState, DESKTOP_USER_ID};
41 42
@@ -47,10 +48,34 @@
47 48 /// `tasks-kanban.js`'s `COLUMNS`, and the same order. `Deleted` is not a column
48 49 /// because a deleted task is not on the board; the JS drops it by only grouping
49 50 /// the three it knows.
50 - const COLUMNS: [(TaskStatus, &str, &str); 3] = [
51 - (TaskStatus::Pending, "pending", "Pending"),
52 - (TaskStatus::Started, "started", "Started"),
53 - (TaskStatus::Completed, "done", "Completed"),
51 + ///
52 + /// Named members rather than a tuple, for `policy`'s reason: a description
53 + /// names what it draws, and `.1` is not a name.
54 + pub(super) struct Lane {
55 + /// The status a card in it has.
56 + pub status: TaskStatus,
57 + /// The region it draws in.
58 + pub id: &'static str,
59 + /// What the heading calls it, and what a move sends.
60 + pub label: &'static str,
61 + }
62 +
63 + const COLUMNS: &[Lane] = &[
64 + Lane {
65 + status: TaskStatus::Pending,
66 + id: "pending",
67 + label: "Pending",
68 + },
69 + Lane {
70 + status: TaskStatus::Started,
71 + id: "started",
72 + label: "Started",
73 + },
74 + Lane {
75 + status: TaskStatus::Completed,
76 + id: "done",
77 + label: "Completed",
78 + },
54 79 ];
55 80
56 81 /// The tone a priority wears. `tasks.rs`'s `priority_tone`, which is the same
@@ -92,113 +117,172 @@
92 117 }
93 118 }
94 119
95 - /// One task as a card.
96 - ///
97 - /// Every part of this existed before the board did, which is the measurement
98 - /// the kanban refusal never took.
99 - fn card(task: &Task) -> Row {
100 - let mut row = Row::new(&task.title).token(
101 - Tag::badge(match task.priority {
102 - Priority::High => "High",
103 - Priority::Medium => "Medium",
104 - Priority::Low => "Low",
105 - })
106 - .tone(priority_tone(&task.priority)),
107 - );
108 -
109 - if let Some(project) = &task.project_name {
110 - row = row.meta(project);
120 + /// What a priority's badge reads.
121 + fn priority_label(task: &Task) -> &'static str {
122 + match task.priority {
123 + Priority::High => "High",
124 + Priority::Medium => "Medium",
125 + Priority::Low => "Low",
111 126 }
112 -
113 - // Whether the card is available to work on. `tasks-kanban.js` draws the
114 - // same marker by calling the task row's own renderer; here it is
115 - // `Availability`, and for the same reason: a second copy is how the board
116 - // card and the task row drifted apart to begin with.
117 - if let Some(marker) = super::Availability::of(task).marker() {
118 - row = row.token(marker);
119 - }
120 -
121 - // The due date, and whether it has passed. Overdue is a judgment the app
122 - // makes and the renderer cannot, so it travels as a tone rather than as a
123 - // class the way `kanban-card-due.overdue` does.
124 - if let Some(due) = task.due {
125 - let overdue = due < chrono::Utc::now() && task.status != TaskStatus::Completed;
126 - row = row.token(
127 - Tag::badge(
128 - due.with_timezone(&chrono::Local)
129 - .format("%b %-d")
130 - .to_string(),
131 - )
132 - .tone(if overdue { Tone::Danger } else { Tone::Neutral }),
133 - );
134 - }
135 -
136 - // Subtask progress. `Meter` says done-of-total; the JS says a percentage
137 - // width, which is the same fact already divided. Absent rather than empty
138 - // when a task has no subtasks, so a card does not carry a bar at zero.
139 - if task.subtask_count() > 0 {
140 - row = row.meter(Meter {
141 - done: u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX),
142 - total: u32::try_from(task.subtask_count()).unwrap_or(u32::MAX),
143 - tone: Tone::Success,
144 - label: None,
145 - });
146 - }
147 -
148 - // The moves this card offers, which is what a drop does. Its own column is
149 - // left out: dropping a card where it already is is the one case
150 - // `onDrop` bails on, and offering it would be an act that does nothing.
151 - for (status, _, label) in COLUMNS {
152 - if status != task.status {
153 - row = row.act(Act::new(
154 - format!("Move to {label}"),
155 - Action::post(format!("/board/{}/status", task.id)).with("to", label),
156 - ));
157 - }
158 - }
159 -
160 - row.activate(Action::get(format!("/tasks/{}", task.id)))
161 127 }
162 128
163 - /// One column: its name, how many are in it, and the cards.
164 - fn column(state: &AppState, status: TaskStatus, id: &str, label: &str) -> Result<Slot, RouteError> {
165 - let tasks = state
166 - .tasks
167 - .list_all(DESKTOP_USER_ID)
168 - .map_err(|error| RouteError::internal(error.to_string()))?;
129 + /// Whether the card carries a due date.
130 + fn has_due(task: &Task) -> bool {
131 + task.due.is_some()
132 + }
169 133
170 - let rows: Vec<Row> = tasks
171 - .iter()
172 - .filter(|t| t.status == status)
173 - .map(card)
174 - .collect();
175 -
176 - let mut slot = Slot::new(id, RegionKind::Pane).with(Node::Heading {
177 - level: Heading::Section,
178 - text: label.to_owned(),
179 - });
180 -
181 - // The count. A figure rather than the JS's bare span, because it is a
182 - // number with a caption and that is what a figure is.
183 - slot = slot.with(Node::Text {
184 - text: format!("{}", rows.len()),
185 - tone: Tone::Neutral,
186 - });
187 -
188 - Ok(if rows.is_empty() {
189 - slot.with(Node::empty("No tasks"))
190 - } else {
191 - slot.with(Node::list(rows))
134 + /// That date, short. R9: read whether or not it is placed.
135 + fn due_label(task: &Task) -> String {
136 + task.due.map_or_else(String::new, |due| {
137 + due.with_timezone(&chrono::Local)
138 + .format("%b %-d")
139 + .to_string()
192 140 })
193 141 }
194 142
195 - /// The board itself, as one region of peer columns.
196 - fn board_region(state: &AppState) -> Result<Slot, RouteError> {
197 - let mut board = Slot::new("board", RegionKind::Columns);
198 - for (status, id, label) in COLUMNS {
199 - board = board.with(Node::Region(column(state, status, id, label)?));
143 + /// Whether it has passed.
144 + ///
145 + /// A judgment the app makes and the renderer cannot, so it travels as a tone
146 + /// rather than as a class the way `kanban-card-due.overdue` does.
147 + fn due_tone(task: &Task) -> Tone {
148 + let overdue = task
149 + .due
150 + .is_some_and(|due| due < chrono::Utc::now() && task.status != TaskStatus::Completed);
151 + if overdue { Tone::Danger } else { Tone::Neutral }
152 + }
153 +
154 + /// Whether the card names a project.
155 + fn has_project(task: &Task) -> bool {
156 + !project(task).is_empty()
157 + }
158 +
159 + /// That project, or nothing.
160 + fn project(task: &Task) -> &str {
161 + task.project_name.as_deref().unwrap_or_default()
162 + }
163 +
164 + /// How many subtasks are done, and how many there are.
165 + ///
166 + /// Suppliers because a cast is arithmetic and the form admits none. `Meter`
167 + /// says done-of-total; the JS says a percentage width, which is the same fact
168 + /// already divided.
169 + fn subtasks_done(task: &Task) -> u32 {
170 + u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX)
171 + }
172 +
173 + /// See [`subtasks_done`].
174 + fn subtasks_all(task: &Task) -> u32 {
175 + u32::try_from(task.subtask_count()).unwrap_or(u32::MAX)
176 + }
177 +
178 + /// Whether there is any progress to draw.
179 + ///
180 + /// Absent rather than empty when a task has no subtasks, so a card does not
181 + /// carry a bar at zero.
182 + fn has_subtasks(task: &Task) -> bool {
183 + task.subtask_count() > 0
184 + }
185 +
186 + /// Whether this lane is one the card can be moved to.
187 + ///
188 + /// Its own is left out: dropping a card where it already is is the one case
189 + /// `onDrop` bails on, and offering it would be an act that does nothing.
190 + fn elsewhere(task: &Task, lane: &Lane) -> bool {
191 + lane.status != task.status
192 + }
193 +
194 + declare! {
195 + /// One card: a task, and everything a glance at the board should say.
196 + ///
197 + /// Whether the card is available to work on is `Availability`'s, not this
198 + /// screen's: `tasks-kanban.js` drew the same marker by calling the task
199 + /// row's own renderer, and a second copy is how the board card and the task
200 + /// row drifted apart to begin with.
201 + shape card(task: &Task) -> Row;
202 +
203 + row &task.title {
204 + token Tag::badge(priority_label(task)).tone(priority_tone(&task.priority));
205 + meta project(task) when has_project(task);
206 +
207 + for marker in super::Availability::of(task).marker().into_iter() {
208 + token marker;
209 + }
210 +
211 + token Tag::badge(due_label(task)).tone(due_tone(task)) when has_due(task);
212 + meter Meter::new(subtasks_done(task), subtasks_all(task)).tone(Tone::Success)
213 + when has_subtasks(task);
214 +
215 + // The moves this card offers, which is what a drop does.
216 + for lane in COLUMNS {
217 + act "Move to {lane.label}" to post "/board/{task.id}/status"
218 + with "to" lane.label
219 + when elsewhere(task, lane);
220 + }
221 +
222 + activate to get "/tasks/{task.id}";
200 223 }
201 - Ok(board)
224 + }
225 +
226 + /// The cards in one lane.
227 + ///
228 + /// A supplier because `filter` takes a closure, and it hands back borrowed
229 + /// tasks, which is not a vocabulary type and so is not counted.
230 + fn in_lane<'a>(tasks: &'a [Task], lane: &Lane) -> Vec<&'a Task> {
231 + tasks
232 + .iter()
233 + .filter(|task| task.status == lane.status)
234 + .collect()
235 + }
236 +
237 + /// How many are in it. A number with a caption is what a figure is; the JS drew
238 + /// a bare span.
239 + fn tally(tasks: &[Task], lane: &Lane) -> String {
240 + in_lane(tasks, lane).len().to_string()
241 + }
242 +
243 + /// Whether the lane is empty.
244 + fn is_empty(tasks: &[Task], lane: &Lane) -> bool {
245 + in_lane(tasks, lane).is_empty()
246 + }
247 +
248 + declare! {
249 + /// One column: its name, how many are in it, and the cards.
250 + ///
251 + /// The read happens once in the handler and every lane is drawn from the
252 + /// same list, which is also a fix: this asked the store once per column
253 + /// before, so the board read the whole task table three times per request.
254 + shape column(tasks: &[Task], lane: &Lane) -> Slot;
255 +
256 + region lane.id as Pane {
257 + section lane.label;
258 + text tally(tasks, lane);
259 +
260 + empty "No tasks" when is_empty(tasks, lane);
261 + list {
262 + for task in in_lane(tasks, lane) {
263 + include card(task);
264 + }
265 + } when not is_empty(tasks, lane);
266 + }
267 + }
268 +
269 + declare! {
270 + /// The board itself, as one region of peer columns.
271 + shape board_region(tasks: &[Task]) -> Slot;
272 +
273 + region "board" as Columns {
274 + for lane in COLUMNS {
275 + include column(tasks, lane);
276 + }
277 + }
278 + }
279 +
280 + /// Every task the board draws from, read once.
281 + fn everything(state: &AppState) -> Result<Vec<Task>, RouteError> {
282 + state
283 + .tasks
284 + .list_all(DESKTOP_USER_ID)
285 + .map_err(|error| RouteError::internal(error.to_string()))
202 286 }
203 287
204 288 /// The whole board.
@@ -208,7 +292,7 @@
208 292 // Tasks, so the Tasks tab stays lit while it is showing.
209 293 Ok(Screen::list_detail("Board", false)
210 294 .at_place(super::shell::TASKS)
211 - .with(board_region(state)?)
295 + .with(board_region(&everything(state)?))
212 296 .into())
213 297 }
214 298
@@ -235,7 +319,7 @@
235 319 if task.status == to {
236 320 return Ok(Response::fragment(
237 321 "board",
238 - Node::Region(board_region(state)?),
322 + Node::Region(board_region(&everything(state)?)),
239 323 ));
240 324 }
241 325
@@ -245,7 +329,7 @@
245 329 let message = super::move_to(state, &task, &to)?;
246 330
247 331 Ok(
248 - Response::fragment("board", Node::Region(board_region(state)?))
332 + Response::fragment("board", Node::Region(board_region(&everything(state)?)))
249 333 .toast(Tone::Success, message),
250 334 )
251 335 }
@@ -45,7 +45,8 @@
45 45 // plain `fn(&S, Request)` pointer, so the signature is the router's.
46 46 #![allow(clippy::needless_pass_by_value)]
47 47
48 - use quasi_router::screen::{Field, Row, Tag};
48 + use quasi_declare::declare;
49 + use quasi_router::screen::Tag;
49 50 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
50 51
51 52 use crate::commands::{SearchInput, SearchResultResponse, SearchResultsResponse};
@@ -86,24 +87,46 @@
86 87 }
87 88 }
88 89
89 - /// One result as a row.
90 - ///
91 - /// The snippet is the FTS extract around the match, which is the sentence that
92 - /// says why this row is here, so it is the row's secondary text. The project is
93 - /// a plain fact and goes in `meta`.
94 - fn row_for(result: &SearchResultResponse) -> Row {
95 - let mut row = Row::new(&result.title);
90 + /// Whether a result carries the FTS extract around its match.
91 + fn has_snippet(result: &SearchResultResponse) -> bool {
92 + !snippet(result).is_empty()
93 + }
96 94
97 - if let Some(snippet) = result.snippet.as_deref().filter(|s| !s.is_empty()) {
98 - row = row.secondary(snippet);
99 - }
100 - if let Some(project) = result.project_name.as_deref().filter(|p| !p.is_empty()) {
101 - row = row.meta(project);
102 - }
95 + /// That extract, or nothing. R9: read whether or not it is placed.
96 + fn snippet(result: &SearchResultResponse) -> &str {
97 + result.snippet.as_deref().unwrap_or_default()
98 + }
103 99
104 - row = row.token(Tag::badge(kind_label(&result.result_type)));
105 - row.activate = destination(result);
106 - row
100 + /// Whether the result names a project.
101 + fn has_project(result: &SearchResultResponse) -> bool {
102 + !project(result).is_empty()
103 + }
104 +
105 + /// That project, or nothing.
106 + fn project(result: &SearchResultResponse) -> &str {
107 + result.project_name.as_deref().unwrap_or_default()
108 + }
109 +
110 + declare! {
111 + /// One result as a row.
112 + ///
113 + /// The snippet is the FTS extract around the match, which is the sentence
114 + /// that says why this row is here, so it is the row's secondary text. The
115 + /// project is a plain fact and goes in `meta`.
116 + ///
117 + /// A kind with no route opens nothing rather than opening a path built by
118 + /// interpolating a string nobody recognised. An `Option` is an iterator of
119 + /// at most one, and `.into_iter()` is the method step that says so.
120 + shape row_for(result: &SearchResultResponse) -> Row;
121 +
122 + row &result.title {
123 + secondary snippet(result) when has_snippet(result);
124 + meta project(result) when has_project(result);
125 + token Tag::badge(kind_label(&result.result_type));
126 + for opens in destination(result).into_iter() {
127 + activate to doing opens;
128 + }
129 + }
107 130 }
108 131
109 132 /// A param that is present and not blank. Blank is absent, which is what an
@@ -129,70 +152,89 @@
129 152 .map_err(|error| RouteError::internal(error.to_string()))
130 153 }
131 154
132 - /// The grammar, said once on an empty screen.
133 - ///
134 - /// Not a permanent panel: it is what there is to show when there is no query
135 - /// and no results, and it is the only place the vocabulary is written down for
136 - /// somebody who has not read the parser.
137 - fn grammar() -> Vec<Node> {
138 - vec![
139 - Node::text(
140 - "Type to search across tasks, emails, events, projects and contacts. \
141 - Words narrow by text; a colon filter narrows by fact.",
142 - ),
143 - Node::text("is:overdue is:today is:tomorrow is:thisweek is:snoozed"),
144 - Node::text("is:pending is:started is:completed is:waiting"),
145 - Node::text("priority:high priority:medium priority:low"),
146 - Node::text("type:task type:email type:event type:project type:contact"),
147 - Node::text("in:ProjectName tag:name -tag:name"),
148 - Node::text("after:2026-08-01 before:2026-09-01"),
149 - ]
155 + declare! {
156 + /// The grammar, said once on an empty screen.
157 + ///
158 + /// Not a permanent panel: it is what there is to show when there is no
159 + /// query and no results, and it is the only place the vocabulary is written
160 + /// down for somebody who has not read the parser.
161 + shape grammar() -> Vec<Node>;
162 +
163 + text "Type to search across tasks, emails, events, projects and contacts. \
164 + Words narrow by text; a colon filter narrows by fact.";
165 + text "is:overdue is:today is:tomorrow is:thisweek is:snoozed";
166 + text "is:pending is:started is:completed is:waiting";
167 + text "priority:high priority:medium priority:low";
168 + text "type:task type:email type:event type:project type:contact";
169 + text "in:ProjectName tag:name -tag:name";
170 + text "after:2026-08-01 before:2026-09-01";
150 171 }
151 172
152 - /// The filters and the results, which is what the query box replaces.
153 - fn results(state: &AppState, query: Option<&str>) -> Result<Slot, RouteError> {
154 - let mut slot = Slot::new(RESULTS, RegionKind::Pane);
155 -
156 - let Some(query) = query else {
157 - for node in grammar() {
158 - slot = slot.with(node);
159 - }
160 - return Ok(slot);
161 - };
162 -
163 - let found = look(state, query)?;
164 -
165 - // What the parser understood, said back. A badge rather than a chip: these
166 - // are the query's own words read aloud, and pressing one would have to mean
167 - // removing it from a string the reader is holding the caret in.
168 - for filter in &found.active_filters {
169 - slot = slot.with(Node::Token(Tag::badge(filter)));
170 - }
171 -
172 - if found.results.is_empty() {
173 - return Ok(slot.with(Node::empty("Nothing matches that.")));
174 - }
175 -
176 - slot = slot.with(Node::text(match found.total {
173 + /// How many results, in words.
174 + fn tally(found: &SearchResultsResponse) -> String {
175 + match found.total {
177 176 1 => "1 result".to_owned(),
178 177 total => format!("{total} results"),
179 - }));
180 -
181 - Ok(slot.with(Node::list(found.results.iter().map(row_for))))
178 + }
182 179 }
183 180
184 - /// The query box.
185 - fn box_for(query: Option<&str>) -> Field {
186 - let mut field = Field::new(makeover_layout::FieldKind::Text, "q", "Search")
187 - // The answer replaces the results region, and the field asks for it
188 - // once the value has stood still. No submit: a search is a read, and
189 - // waiting for one would be the box pretending it writes something.
190 - .consults(Action::get("/search/results").replacing(RESULTS));
191 - field.placeholder = Some("invoice is:overdue in:Ledger".to_owned());
192 - if let Some(query) = query {
193 - field = field.value(query);
181 + /// Whether anything was asked at all.
182 + ///
183 + /// The read happens in the handler and the answer arrives here, so this shape
184 + /// is infallible: a screen that could fail would have to say what it draws when
185 + /// it does, and the route already answers that.
186 + fn asked(found: Option<&SearchResultsResponse>) -> bool {
187 + found.is_some()
188 + }
189 +
190 + declare! {
191 + /// The filters and the results, which is what the query box replaces.
192 + shape results(found: Option<&SearchResultsResponse>) -> Slot;
193 +
194 + region RESULTS as Pane {
195 + for node in grammar() {
196 + include node unless asked(found);
197 + }
198 +
199 + for hit in found.into_iter() {
200 + // What the parser understood, said back. A badge rather than a
201 + // chip: these are the query's own words read aloud, and pressing
202 + // one would have to mean removing it from a string the reader is
203 + // holding the caret in.
204 + for filter in hit.active_filters.iter() {
205 + badge filter;
206 + }
207 +
208 + empty "Nothing matches that." when hit.results.is_empty();
209 +
210 + text tally(hit) unless hit.results.is_empty();
211 + list {
212 + for result in hit.results.iter() {
213 + include row_for(result);
214 + }
215 + } unless hit.results.is_empty();
216 + }
217 + }
218 + }
219 +
220 + /// What is already in the box, or nothing.
221 + fn typed(query: Option<&str>) -> &str {
222 + query.unwrap_or_default()
223 + }
224 +
225 + declare! {
226 + /// The query box.
227 + ///
228 + /// The answer replaces the results region, and the field asks for it once
229 + /// the value has stood still. No submit: a search is a read, and waiting
230 + /// for one would be the box pretending it writes something.
231 + shape box_for(query: Option<&str>) -> Field;
232 +
233 + field Text "q" "Search" {
234 + consults Action::get("/search/results").replacing(RESULTS);
235 + placeholder "invoice is:overdue in:Ledger";
236 + value typed(query);
194 237 }
195 - field
196 238 }
197 239
198 240 /// The whole screen.
@@ -203,16 +245,20 @@
203 245 .with(Node::page("Search"))
204 246 .with(Node::Field(Box::new(box_for(query))));
205 247
248 + let found = query.map(|query| look(state, query)).transpose()?;
249 +
206 250 Ok(Screen::list_detail("Search", false)
207 251 .at_place(super::shell::SEARCH)
208 252 .with(band)
209 - .with(results(state, query)?)
253 + .with(results(found.as_ref()))
210 254 .into())
211 255 }
212 256
213 257 /// The results alone, which is what the query box asks for as it is typed.
214 258 fn results_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
215 - let slot = results(state, text(&request.carried, "q"))?;
259 + let query = text(&request.carried, "q");
260 + let found = query.map(|query| look(state, query)).transpose()?;
261 + let slot = results(found.as_ref());
216 262 Ok(Response::fragment(RESULTS, Node::Region(slot)))
217 263 }
218 264
@@ -38,8 +38,9 @@
38 38 //!
39 39 //! **Re-run the first-run welcome.** Nothing describes it.
40 40
41 - use quasi_router::screen::{Choice, Field, Row};
42 - use quasi_router::{Action, Node, RouteError};
41 + use quasi_declare::declare;
42 + use quasi_router::screen::Choice;
43 + use quasi_router::{Action, RouteError};
43 44
44 45 use crate::commands::Preferences;
45 46 use crate::state::AppState;
@@ -47,66 +48,97 @@
47 48 /// The key the update-check toggle sends under.
48 49 pub(super) const UPDATE_CHECK: &str = "update_check_on_launch";
49 50
50 - /// The facts, then the preference.
51 - pub(super) fn pane(state: &AppState) -> Vec<Node> {
52 - let prefs = read(state);
53 -
54 - vec![
55 - Node::section("About GoingsOn"),
56 - Node::text("Tasks, email, calendar, contacts."),
57 - Node::list(facts(state).into_iter()),
58 - // An On/Off choice rather than a toggle kind, which is how every other
59 - // boolean on this screen is said (`plan_nudges`, `review_nudges`). One
60 - // shape for one question, and the renderer decides whether that draws
61 - // as a switch.
62 - Node::field(
63 - Field {
64 - options: vec![
65 - Choice::new("enabled", "Enabled (default)"),
66 - Choice::new("disabled", "Disabled"),
67 - ],
68 - value: Some(
69 - if prefs.update_check_on_launch {
70 - "enabled"
71 - } else {
72 - "disabled"
73 - }
74 - .to_owned(),
75 - ),
76 - ..Field::new(
77 - makeover_layout::FieldKind::Select,
78 - UPDATE_CHECK,
79 - "Check for updates on launch",
80 - )
81 - }
82 - .hint(
83 - "When a new signed release is available, a banner appears in the app. \
84 - Install is always user-initiated.",
85 - )
86 - .writes(Action::post("/settings/about/update-check")),
87 - ),
88 - ]
51 + /// The seven identity rows, in the order the shipped screen listed them.
52 + ///
53 + /// Named members rather than a tuple, for `policy`'s reason: a description
54 + /// names what it draws, and `.1` is not a name.
55 + struct Fact {
56 + /// What the row is called.
57 + label: &'static str,
58 + /// What it says.
59 + value: &'static str,
89 60 }
90 61
91 - /// The identity rows, in the order the shipped screen listed them.
62 + /// The five that are the same on every machine.
63 + const FIXED: &[Fact] = &[
64 + Fact {
65 + label: "Publisher",
66 + value: "Make Creative, LLC",
67 + },
68 + Fact {
69 + label: "License",
70 + value: "PolyForm Noncommercial 1.0.0",
71 + },
72 + Fact {
73 + label: "Contact",
74 + value: "info@makenot.work",
75 + },
76 + Fact {
77 + label: "Source",
78 + value: "makenot.work",
79 + },
80 + Fact {
81 + label: "Privacy",
82 + value: "makenot.work/policy",
83 + },
84 + ];
85 +
86 + /// Which option the update-check picker opens on.
92 87 ///
93 - /// A list of rows rather than a `dl`, because a description has no word for a
94 - /// definition list and does not need one: each row is a label and a value,
95 - /// which is what [`Row::meta`] already is. The renderer decides whether that
96 - /// draws as two columns.
97 - fn facts(state: &AppState) -> Vec<Row> {
98 - [
99 - ("Version", state.about.version.clone()),
100 - ("Platform", state.about.platform.clone()),
101 - ("Publisher", "Make Creative, LLC".to_owned()),
102 - ("License", "PolyForm Noncommercial 1.0.0".to_owned()),
103 - ("Contact", "info@makenot.work".to_owned()),
104 - ("Source", "makenot.work".to_owned()),
105 - ("Privacy", "makenot.work/policy".to_owned()),
106 - ]
107 - .into_iter()
108 - .map(|(label, value)| Row::new(label).meta(value))
109 - .collect()
88 + /// A supplier because the choice is a branch on a `bool` and the form admits no
89 + /// expression in an argument. It hands back a `&'static str`, which keeps it
90 + /// out of the population.
91 + fn update_choice(prefs: &Preferences) -> &'static str {
92 + if prefs.update_check_on_launch {
93 + "enabled"
94 + } else {
95 + "disabled"
96 + }
97 + }
98 +
99 + declare! {
100 + /// The facts, then the preference.
101 + ///
102 + /// A list of rows rather than a `dl`, because a description has no word for
103 + /// a definition list and does not need one: each row is a label and a
104 + /// value, which is what `meta` already is. The renderer decides whether
105 + /// that draws as two columns.
106 + ///
107 + /// An On/Off choice rather than a toggle kind, which is how every other
108 + /// boolean on this screen is said (`plan_nudges`, `review_nudges`). One
109 + /// shape for one question, and the renderer decides whether that draws as a
110 + /// switch.
111 + pub(super) shape pane(state: &AppState) -> Vec<Node>;
112 +
113 + let prefs = read(state);
114 +
115 + section "About GoingsOn";
116 + text "Tasks, email, calendar, contacts.";
117 +
118 + list {
119 + // The two the host resolved at startup, then the five that are the same
120 + // wherever it runs.
121 + row "Version" {
122 + meta state.about.version.clone();
123 + }
124 + row "Platform" {
125 + meta state.about.platform.clone();
126 + }
127 + for fact in FIXED {
128 + row fact.label {
129 + meta fact.value;
130 + }
131 + }
132 + }
133 +
134 + field Select UPDATE_CHECK "Check for updates on launch" {
135 + option Choice::new("enabled", "Enabled (default)");
136 + option Choice::new("disabled", "Disabled");
137 + value update_choice(&prefs);
138 + hint "When a new signed release is available, a banner appears in the app. \
139 + Install is always user-initiated.";
140 + writes Action::post("/settings/about/update-check");
141 + }
110 142 }
111 143
112 144 /// The preferences as they stand, or the defaults.
@@ -43,9 +43,9 @@
43 43 //!
44 44 //! Connecting is a handshake, a poll and an exchange, so it stays host-bound.
45 45
46 - use quasi_router::layout::Tone;
47 - use quasi_router::screen::{Act, Choice, Field, Row};
48 - use quasi_router::{Action, Node, RouteError};
46 + use quasi_declare::declare;
47 + use quasi_router::screen::Choice;
48 + use quasi_router::{Action, RouteError};
49 49
50 50 use crate::state::AppState;
51 51 use crate::syncstore::sync_state;
@@ -119,112 +119,134 @@
119 119 }
120 120 }
121 121
122 - /// The section.
123 - pub(super) fn pane(app: &AppState) -> Vec<Node> {
124 - let state = read(app);
125 -
126 - let mut nodes = vec![Node::section("Cloud Sync")];
127 -
128 - if !state.configured {
129 - // Nothing to say and nothing to set. Said plainly rather than drawn as
130 - // a section full of dashes and disabled switches.
131 - nodes.push(Node::empty(
132 - "Sync is not set up on this device. Setting it up asks a server for an \
133 - account, which this screen cannot do yet.",
134 - ));
135 - return nodes;
122 + /// Which option the auto-sync picker opens on.
123 + fn auto_choice(state: &State) -> &'static str {
124 + if state.auto_sync_enabled {
125 + "enabled"
126 + } else {
127 + "disabled"
136 128 }
137 -
138 - nodes.push(Node::list(facts(&state)));
139 - nodes.push(Node::field(
140 - Field {
141 - options: vec![
142 - Choice::new("enabled", "Enabled (default)"),
143 - Choice::new("disabled", "Disabled"),
144 - ],
145 - value: Some(
146 - if state.auto_sync_enabled {
147 - "enabled"
148 - } else {
149 - "disabled"
150 - }
151 - .to_owned(),
152 - ),
153 - ..Field::new(
154 - makeover_layout::FieldKind::Select,
155 - AUTO_SYNC,
156 - "Sync automatically",
157 - )
158 - }
159 - .hint("When off, nothing is sent or fetched until a sync is started by hand.")
160 - .writes(Action::post("/settings/sync/auto")),
161 - ));
162 - nodes.push(Node::field(
163 - Field {
164 - min: Some("1".to_owned()),
165 - max: Some("1440".to_owned()),
166 - value: Some(state.interval_minutes.to_string()),
167 - ..Field::new(
168 - makeover_layout::FieldKind::Number,
169 - INTERVAL,
170 - "Minutes between syncs",
171 - )
172 - }
173 - .writes(Action::post("/settings/sync/interval")),
174 - ));
175 -
176 - if state.authenticated {
177 - nodes.push(Node::Act(
178 - Act::new("Disconnect", Action::post("/settings/sync/disconnect"))
179 - .tone(Tone::Danger)
180 - .confirm(
181 - "Disconnect this device from cloud sync? Your data stays here; \
182 - nothing more will be sent or fetched until you sign in again.",
183 - ),
184 - ));
185 - }
186 -
187 - nodes
188 129 }
189 130
190 - /// The readout, as labelled rows.
191 - fn facts(state: &State) -> Vec<Row> {
192 - let mut rows = vec![Row::new("Status").meta(if state.authenticated {
193 - "Signed in".to_owned()
194 - } else {
195 - "Set up, not signed in".to_owned()
196 - })];
131 + /// Whether the server is named. R9: read whether or not the row is placed.
132 + fn has_server(state: &State) -> bool {
133 + !server(state).is_empty()
134 + }
197 135
198 - if let Some(url) = &state.server_url {
199 - rows.push(Row::new("Server").meta(url.clone()));
200 - }
201 - rows.push(Row::new("Encryption").meta(if state.encryption_ready {
202 - "Ready".to_owned()
203 - } else {
204 - "Not set up".to_owned()
205 - }));
206 - if let Some(device) = &state.device_id {
207 - rows.push(Row::new("This device").meta(device.clone()));
208 - }
209 - rows.push(
210 - Row::new("Last sync").meta(
211 - state
212 - .last_sync_at
213 - .clone()
214 - .unwrap_or_else(|| "Never".to_owned()),
215 - ),
216 - );
217 - // Said as a count rather than hidden at zero: "nothing waiting" is the
218 - // answer somebody opening this section is looking for.
219 - rows.push(
220 - Row::new("Waiting to send").meta(match state.pending_changes {
221 - 0 => "Nothing".to_owned(),
222 - 1 => "1 change".to_owned(),
223 - n => format!("{n} changes"),
224 - }),
225 - );
136 + /// That server, or nothing.
137 + fn server(state: &State) -> &str {
138 + state.server_url.as_deref().unwrap_or_default()
139 + }
226 140
227 - rows
141 + /// Whether this device has an id yet.
142 + fn has_device(state: &State) -> bool {
143 + !device(state).is_empty()
144 + }
145 +
146 + /// That id, or nothing.
147 + fn device(state: &State) -> &str {
148 + state.device_id.as_deref().unwrap_or_default()
149 + }
150 +
151 + /// Signed in, or set up and not signed in.
152 + fn status_label(state: &State) -> &'static str {
153 + if state.authenticated {
154 + "Signed in"
155 + } else {
156 + "Set up, not signed in"
157 + }
158 + }
159 +
160 + /// Whether the keys are in place.
161 + fn encryption_label(state: &State) -> &'static str {
162 + if state.encryption_ready {
163 + "Ready"
164 + } else {
165 + "Not set up"
166 + }
167 + }
168 +
169 + /// When it last ran, or never.
170 + fn last_sync(state: &State) -> String {
171 + state
172 + .last_sync_at
173 + .clone()
174 + .unwrap_or_else(|| "Never".to_owned())
175 + }
176 +
177 + /// How much is waiting.
178 + ///
179 + /// Said as a count rather than hidden at zero: "nothing waiting" is the answer
180 + /// somebody opening this section is looking for.
181 + fn waiting(state: &State) -> String {
182 + match state.pending_changes {
183 + 0 => "Nothing".to_owned(),
184 + 1 => "1 change".to_owned(),
185 + n => format!("{n} changes"),
186 + }
187 + }
188 +
189 + declare! {
190 + /// The section.
191 + ///
192 + /// Nothing to say and nothing to set when sync is not configured. Said
193 + /// plainly rather than drawn as a section full of dashes and disabled
194 + /// switches.
195 + ///
196 + /// The auto-sync question is an On/Off choice rather than a toggle kind,
197 + /// which is how every other boolean on this screen is said. One shape for
198 + /// one question, and the renderer decides whether that draws as a switch.
199 + pub(super) shape pane(app: &AppState) -> Vec<Node>;
200 +
201 + let state = read(app);
202 +
203 + section "Cloud Sync";
204 +
205 + empty "Sync is not set up on this device. Setting it up asks a server for an \
206 + account, which this screen cannot do yet."
207 + unless state.configured;
208 +
209 + list {
210 + row "Status" {
211 + meta status_label(&state);
212 + }
213 + row "Server" when has_server(&state) {
214 + meta server(&state);
215 + }
216 + row "Encryption" {
217 + meta encryption_label(&state);
218 + }
219 + row "This device" when has_device(&state) {
220 + meta device(&state);
221 + }
222 + row "Last sync" {
223 + meta last_sync(&state);
224 + }
225 + row "Waiting to send" {
226 + meta waiting(&state);
227 + }
228 + } when state.configured;
229 +
230 + field Select AUTO_SYNC "Sync automatically" when state.configured {
231 + option Choice::new("enabled", "Enabled (default)");
232 + option Choice::new("disabled", "Disabled");
233 + value auto_choice(&state);
234 + hint "When off, nothing is sent or fetched until a sync is started by hand.";
235 + writes Action::post("/settings/sync/auto");
236 + }
237 +
238 + field Number INTERVAL "Minutes between syncs" when state.configured {
239 + within "1" "1440";
240 + value state.interval_minutes.to_string();
241 + writes Action::post("/settings/sync/interval");
242 + }
243 +
244 + act "Disconnect" to post "/settings/sync/disconnect"
245 + when state.configured and state.authenticated {
246 + tone Danger;
247 + confirm "Disconnect this device from cloud sync? Your data stays here; \
248 + nothing more will be sent or fetched until you sign in again.";
249 + }
228 250 }
229 251
230 252 /// Turn automatic syncing on or off.