max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
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. |