| 65 |
65 |
|
//! the screen carries no second list of what it is willing to name.
|
| 66 |
66 |
|
|
| 67 |
67 |
|
use audiofiles_core::config_key::ConfigKey;
|
| 68 |
|
- |
use quasi_router::layout::FieldKind;
|
|
68 |
+ |
|
|
69 |
+ |
use quasi_declare::declare;
|
| 69 |
70 |
|
use quasi_router::layout::{Contrast, ThemeVariant};
|
| 70 |
|
- |
use quasi_router::{
|
| 71 |
|
- |
Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot,
|
| 72 |
|
- |
};
|
|
71 |
+ |
use quasi_router::{Action, Node, Request, Response, RouteError, Router, Screen};
|
| 73 |
72 |
|
// Aliased: this module's `super::ThemeChoice` is the host's own resolved theme,
|
| 74 |
73 |
|
// and the router's is how a description names one. Both are in scope here
|
| 75 |
74 |
|
// because this function is exactly the seam between them.
|
| 80 |
79 |
|
/// The region the whole screen answers into.
|
| 81 |
80 |
|
const BODY: &str = "settings-body";
|
| 82 |
81 |
|
|
|
82 |
+ |
/// One column of the file list, as the description names it.
|
|
83 |
+ |
///
|
|
84 |
+ |
/// A struct rather than a pair, for the reason MNW's two reshaped consts give:
|
|
85 |
+ |
/// a description names what it draws and `.1` is not a name.
|
|
86 |
+ |
struct Column {
|
|
87 |
+ |
/// The stored flag, which is also the key its checkbox writes to.
|
|
88 |
+ |
key: &'static str,
|
|
89 |
+ |
/// What the checkbox reads.
|
|
90 |
+ |
label: &'static str,
|
|
91 |
+ |
}
|
|
92 |
+ |
|
| 83 |
93 |
|
/// The columns the file list can show, as described names against the flags the
|
| 84 |
94 |
|
/// stored `column_config` blob carries.
|
| 85 |
95 |
|
///
|
| 87 |
97 |
|
/// second half of this port's findings: the description names five booleans
|
| 88 |
98 |
|
/// because five is what the user sees, and storage keeps them in one JSON value
|
| 89 |
99 |
|
/// because that is what `ColumnConfig` already was. The route below is what
|
| 90 |
|
- |
/// reconciles the two, and it is the right place for it — a description that
|
|
100 |
+ |
/// reconciles the two, and it is the right place for it: a description that
|
| 91 |
101 |
|
/// named the blob would be describing a storage format.
|
| 92 |
|
- |
const COLUMNS: &[(&str, &str)] = &[
|
| 93 |
|
- |
("column.bpm", "BPM"),
|
| 94 |
|
- |
("column.key", "Key"),
|
| 95 |
|
- |
("column.duration", "Duration"),
|
| 96 |
|
- |
("column.peak_db", "Peak dB"),
|
| 97 |
|
- |
("column.tags", "Tags"),
|
|
102 |
+ |
const COLUMNS: [Column; 5] = [
|
|
103 |
+ |
Column {
|
|
104 |
+ |
key: "column.bpm",
|
|
105 |
+ |
label: "BPM",
|
|
106 |
+ |
},
|
|
107 |
+ |
Column {
|
|
108 |
+ |
key: "column.key",
|
|
109 |
+ |
label: "Key",
|
|
110 |
+ |
},
|
|
111 |
+ |
Column {
|
|
112 |
+ |
key: "column.duration",
|
|
113 |
+ |
label: "Duration",
|
|
114 |
+ |
},
|
|
115 |
+ |
Column {
|
|
116 |
+ |
key: "column.peak_db",
|
|
117 |
+ |
label: "Peak dB",
|
|
118 |
+ |
},
|
|
119 |
+ |
Column {
|
|
120 |
+ |
key: "column.tags",
|
|
121 |
+ |
label: "Tags",
|
|
122 |
+ |
},
|
| 98 |
123 |
|
];
|
| 99 |
124 |
|
|
| 100 |
125 |
|
/// Register this screen's routes.
|
| 108 |
133 |
|
|
| 109 |
134 |
|
/// `GET /settings`
|
| 110 |
135 |
|
fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
|
| 111 |
|
- |
Ok(screen(state)?.into())
|
|
136 |
+ |
Ok(showing(state)?.into())
|
| 112 |
137 |
|
}
|
| 113 |
138 |
|
|
| 114 |
139 |
|
/// `POST /settings/config/{key}`
|
| 123 |
148 |
|
|
| 124 |
149 |
|
if let Some((key, stored)) = column_write(state, name, value) {
|
| 125 |
150 |
|
set(state, key, &stored)?;
|
| 126 |
|
- |
return Ok(screen(state)?.into());
|
|
151 |
+ |
return Ok(showing(state)?.into());
|
| 127 |
152 |
|
}
|
| 128 |
153 |
|
|
| 129 |
154 |
|
let key = ConfigKey::from_key(name).ok_or_else(|| RouteError::not_found("no such setting"))?;
|
| 130 |
155 |
|
set(state, key, value)?;
|
| 131 |
|
- |
Ok(screen(state)?.into())
|
|
156 |
+ |
Ok(showing(state)?.into())
|
| 132 |
157 |
|
}
|
| 133 |
158 |
|
|
| 134 |
159 |
|
/// `POST /settings/columns/reset`
|
| 135 |
160 |
|
fn reset_columns(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
|
| 136 |
161 |
|
set(state, ConfigKey::ColumnConfig, "")?;
|
| 137 |
|
- |
Ok(Response::from(screen(state)?).toast(
|
|
162 |
+ |
Ok(Response::from(showing(state)?).toast(
|
| 138 |
163 |
|
quasi_router::layout::Tone::Success,
|
| 139 |
164 |
|
"Columns restored to defaults.",
|
| 140 |
165 |
|
))
|
| 141 |
166 |
|
}
|
| 142 |
167 |
|
|
| 143 |
|
- |
/// The whole screen.
|
|
168 |
+ |
/// Everything the screen draws that has to be read, read once.
|
|
169 |
+ |
///
|
|
170 |
+ |
/// Three of this module's four shapes were `-> Result<_>`, which is a read that
|
|
171 |
+ |
/// has not been hoisted rather than a refusal. This is the hoist: one fallible
|
|
172 |
+ |
/// pass over the config store, and four total descriptions after it.
|
|
173 |
+ |
pub(super) struct Settings {
|
|
174 |
+ |
/// The theme picker's entries, in `makeover::order_theme_options`' order.
|
|
175 |
+ |
themes: Vec<DescribedTheme>,
|
|
176 |
+ |
/// The theme id stored, when one is. Absent stays absent: the picker
|
|
177 |
+ |
/// carries no value at all rather than an empty one.
|
|
178 |
+ |
chosen: Option<String>,
|
|
179 |
+ |
/// Loop playback.
|
|
180 |
+ |
preview_loop: bool,
|
|
181 |
+ |
/// Auto-play on navigate.
|
|
182 |
+ |
preview_autoplay: bool,
|
|
183 |
+ |
/// Auto-trim resample overshoot.
|
|
184 |
+ |
forge_auto_trim: bool,
|
|
185 |
+ |
/// The file list's columns, and whether each is showing.
|
|
186 |
+ |
columns: Vec<Showing>,
|
|
187 |
+ |
/// Row density, as the stored string.
|
|
188 |
+ |
row_height: String,
|
|
189 |
+ |
/// The theme showing, when it is one there is something to export from.
|
|
190 |
+ |
exportable: Option<Exportable>,
|
|
191 |
+ |
}
|
|
192 |
+ |
|
|
193 |
+ |
/// One column checkbox, with the answer the stored blob gave.
|
|
194 |
+ |
struct Showing {
|
|
195 |
+ |
/// The stored flag, which is also the key the checkbox writes to.
|
|
196 |
+ |
key: &'static str,
|
|
197 |
+ |
/// What the checkbox reads.
|
|
198 |
+ |
label: &'static str,
|
|
199 |
+ |
/// Whether it is showing today.
|
|
200 |
+ |
on: bool,
|
|
201 |
+ |
}
|
|
202 |
+ |
|
|
203 |
+ |
/// The theme showing, in the two words the Advanced sentence uses.
|
|
204 |
+ |
struct Exportable {
|
|
205 |
+ |
/// What it is called.
|
|
206 |
+ |
name: String,
|
|
207 |
+ |
/// Its id, which is also the filename the export writes.
|
|
208 |
+ |
id: String,
|
|
209 |
+ |
}
|
|
210 |
+ |
|
|
211 |
+ |
/// The whole screen's read, in one fallible pass.
|
|
212 |
+ |
fn read(state: &Panels<'_>) -> Result<Settings, RouteError> {
|
|
213 |
+ |
let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
|
|
214 |
+ |
Ok(Settings {
|
|
215 |
+ |
themes: state
|
|
216 |
+ |
.themes
|
|
217 |
+ |
.iter()
|
|
218 |
+ |
.map(|theme| {
|
|
219 |
+ |
DescribedTheme::new(
|
|
220 |
+ |
theme.id.clone(),
|
|
221 |
+ |
theme.name.clone(),
|
|
222 |
+ |
variant_of(theme.variant),
|
|
223 |
+ |
tier_of(theme.contrast),
|
|
224 |
+ |
)
|
|
225 |
+ |
})
|
|
226 |
+ |
.collect(),
|
|
227 |
+ |
chosen: get(state, ConfigKey::Theme)?,
|
|
228 |
+ |
preview_loop: is_on(state, ConfigKey::PreviewLoop)?,
|
|
229 |
+ |
preview_autoplay: is_on(state, ConfigKey::PreviewAutoplay)?,
|
|
230 |
+ |
forge_auto_trim: is_on(state, ConfigKey::ForgeAutoTrimOvershoot)?,
|
|
231 |
+ |
columns: COLUMNS
|
|
232 |
+ |
.iter()
|
|
233 |
+ |
.map(|column| Showing {
|
|
234 |
+ |
key: column.key,
|
|
235 |
+ |
label: column.label,
|
|
236 |
+ |
on: column_shown(&stored, column.key),
|
|
237 |
+ |
})
|
|
238 |
+ |
.collect(),
|
|
239 |
+ |
row_height: get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned()),
|
|
240 |
+ |
exportable: active_theme(state).map(|theme| Exportable {
|
|
241 |
+ |
name: theme.name.clone(),
|
|
242 |
+ |
id: theme.id.clone(),
|
|
243 |
+ |
}),
|
|
244 |
+ |
})
|
|
245 |
+ |
}
|
|
246 |
+ |
|
|
247 |
+ |
/// A stored boolean, in the two spellings the store has written over the years.
|
|
248 |
+ |
fn is_on(state: &Panels<'_>, key: ConfigKey) -> Result<bool, RouteError> {
|
|
249 |
+ |
Ok(get(state, key)?.is_some_and(|value| value == "1" || value == "true"))
|
|
250 |
+ |
}
|
|
251 |
+ |
|
|
252 |
+ |
/// The whole screen, read and then described.
|
| 144 |
253 |
|
///
|
| 145 |
254 |
|
/// Reachable from [`storage`](super::storage), whose acts all answer with the
|
| 146 |
|
- |
/// settings window again: the Storage section is served from its own module and
|
| 147 |
|
- |
/// is spliced in below, so the two halves of one screen live where their routes
|
| 148 |
|
- |
/// do.
|
| 149 |
|
- |
pub(super) fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
|
| 150 |
|
- |
let mut body = Slot::new(BODY, RegionKind::Pane)
|
| 151 |
|
- |
.with(Node::page("Settings"))
|
| 152 |
|
- |
// The way off. quasicoherent `33c27e81`: this screen replaces the
|
| 153 |
|
- |
// shell's, so without a described way back the only exit was a host
|
| 154 |
|
- |
// affordance -- an Escape this app read itself, which a pointer could
|
| 155 |
|
- |
// not reach and a touch gesture had nothing to bind to. `Action::back`
|
| 156 |
|
- |
// is the described fact, and every host answers it from its own
|
| 157 |
|
- |
// history.
|
| 158 |
|
- |
.with(Node::Act(Act::new("Close", Action::back())))
|
| 159 |
|
- |
.with(Node::section("Appearance"))
|
| 160 |
|
- |
.with(appearance(state))
|
| 161 |
|
- |
.with(Node::section("Preview"))
|
| 162 |
|
- |
.with(toggle(state, ConfigKey::PreviewLoop, "Loop playback")?)
|
| 163 |
|
- |
.with(toggle(
|
| 164 |
|
- |
state,
|
| 165 |
|
- |
ConfigKey::PreviewAutoplay,
|
| 166 |
|
- |
"Auto-play on navigate",
|
| 167 |
|
- |
)?)
|
| 168 |
|
- |
.with(Node::section("Forge"))
|
| 169 |
|
- |
.with(toggle(
|
| 170 |
|
- |
state,
|
| 171 |
|
- |
ConfigKey::ForgeAutoTrimOvershoot,
|
| 172 |
|
- |
"Auto-trim resample overshoot",
|
| 173 |
|
- |
)?)
|
| 174 |
|
- |
.with(Node::section("Display"));
|
|
255 |
+ |
/// settings window again: four of its sections are served from their own
|
|
256 |
+ |
/// modules and are spliced in here, so the halves of one screen live where
|
|
257 |
+ |
/// their routes do.
|
|
258 |
+ |
pub(super) fn showing(state: &Panels<'_>) -> Result<Screen, RouteError> {
|
|
259 |
+ |
Ok(screen(
|
|
260 |
+ |
&read(state)?,
|
|
261 |
+ |
super::storage::section(state),
|
|
262 |
+ |
super::trash::section(&super::trash::read(state)),
|
|
263 |
+ |
super::licence::section(&super::licence::read(state)),
|
|
264 |
+ |
super::advanced::section(state.advanced.mirror().as_ref()),
|
|
265 |
+ |
))
|
|
266 |
+ |
}
|
| 175 |
267 |
|
|
| 176 |
|
- |
let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
|
| 177 |
|
- |
for (name, label) in COLUMNS {
|
| 178 |
|
- |
body = body.with(Node::Field(Box::new(
|
| 179 |
|
- |
Field::new(FieldKind::Checkbox, *name, *label)
|
| 180 |
|
- |
.value(if column_shown(&stored, name) {
|
| 181 |
|
- |
"on"
|
| 182 |
|
- |
} else {
|
| 183 |
|
- |
""
|
| 184 |
|
- |
})
|
| 185 |
|
- |
.writes(Action::post(format!("/settings/config/{name}"))),
|
| 186 |
|
- |
)));
|
|
268 |
+ |
declare! {
|
|
269 |
+ |
/// The settings window.
|
|
270 |
+ |
///
|
|
271 |
+ |
/// The four spliced sections arrive already drawn rather than being reached
|
|
272 |
+ |
/// from here, because each is its own module's description and each owns
|
|
273 |
+ |
/// the routes its acts address.
|
|
274 |
+ |
pub(super) shape screen(
|
|
275 |
+ |
settings: &Settings,
|
|
276 |
+ |
storage: Vec<Node>,
|
|
277 |
+ |
trash: Vec<Node>,
|
|
278 |
+ |
licence: Vec<Node>,
|
|
279 |
+ |
advanced: Vec<Node>,
|
|
280 |
+ |
) -> Screen;
|
|
281 |
+ |
|
|
282 |
+ |
screen sidebar_content "Settings" {
|
|
283 |
+ |
region BODY as Pane {
|
|
284 |
+ |
page "Settings";
|
|
285 |
+ |
|
|
286 |
+ |
// The way off. quasicoherent `33c27e81`: this screen replaces the
|
|
287 |
+ |
// shell's, so without a described way back the only exit was a host
|
|
288 |
+ |
// affordance -- an Escape this app read itself, which a pointer
|
|
289 |
+ |
// could not reach and a touch gesture had nothing to bind to.
|
|
290 |
+ |
// `Action::back` is the described fact, and every host answers it
|
|
291 |
+ |
// from its own history.
|
|
292 |
+ |
act "Close" to back;
|
|
293 |
+ |
|
|
294 |
+ |
section "Appearance";
|
|
295 |
+ |
include appearance(settings);
|
|
296 |
+ |
|
|
297 |
+ |
section "Preview";
|
|
298 |
+ |
include toggle(ConfigKey::PreviewLoop.as_str(), "Loop playback", settings.preview_loop);
|
|
299 |
+ |
include toggle(
|
|
300 |
+ |
ConfigKey::PreviewAutoplay.as_str(),
|
|
301 |
+ |
"Auto-play on navigate",
|
|
302 |
+ |
settings.preview_autoplay
|
|
303 |
+ |
);
|
|
304 |
+ |
|
|
305 |
+ |
section "Forge";
|
|
306 |
+ |
include toggle(
|
|
307 |
+ |
ConfigKey::ForgeAutoTrimOvershoot.as_str(),
|
|
308 |
+ |
"Auto-trim resample overshoot",
|
|
309 |
+ |
settings.forge_auto_trim
|
|
310 |
+ |
);
|
|
311 |
+ |
|
|
312 |
+ |
section "Display";
|
|
313 |
+ |
for column in settings.columns.iter() {
|
|
314 |
+ |
include toggle(column.key, column.label, column.on);
|
|
315 |
+ |
}
|
|
316 |
+ |
act "Reset columns" to post "/settings/columns/reset" {
|
|
317 |
+ |
confirm "Restore column visibility, sort and row density to defaults?";
|
|
318 |
+ |
}
|
|
319 |
+ |
include row_height(&settings.row_height);
|
|
320 |
+ |
|
|
321 |
+ |
extend storage;
|
|
322 |
+ |
extend trash;
|
|
323 |
+ |
extend licence;
|
|
324 |
+ |
|
|
325 |
+ |
// The classifier's door. Max ruled it a window of its own; what
|
|
326 |
+ |
// stays here is one act, so the thing is findable from Settings.
|
|
327 |
+ |
// See `super::classifier`'s header.
|
|
328 |
+ |
section "Tagging";
|
|
329 |
+ |
text "Rules, auto-tagging, clustering and folder tags, with the \
|
|
330 |
+ |
classifiers you have imported.";
|
|
331 |
+ |
act "Tag classifier..." to get "/classifier";
|
|
332 |
+ |
|
|
333 |
+ |
// Advanced. The heading is unconditional and the export is not: a
|
|
334 |
+ |
// theme that is gone has nothing to export, and the section still
|
|
335 |
+ |
// has an import and a mirror under it.
|
|
336 |
+ |
section "Advanced";
|
|
337 |
+ |
for theme in settings.exportable.iter() {
|
|
338 |
+ |
text "The theme showing is {theme.name}. Exporting writes \
|
|
339 |
+ |
{theme.id}.toml wherever you choose.";
|
|
340 |
+ |
act "Export current theme" to post "/settings/theme/export";
|
|
341 |
+ |
}
|
|
342 |
+ |
extend advanced;
|
|
343 |
+ |
}
|
| 187 |
344 |
|
}
|
| 188 |
|
- |
|
| 189 |
|
- |
body = body
|
| 190 |
|
- |
.with(Node::Act(
|
| 191 |
|
- |
Act::new("Reset columns", Action::post("/settings/columns/reset"))
|
| 192 |
|
- |
.confirm("Restore column visibility, sort and row density to defaults?"),
|
| 193 |
|
- |
))
|
| 194 |
|
- |
.with(Node::Field(Box::new(row_height(state)?)));
|
| 195 |
|
- |
|
| 196 |
|
- |
body = super::storage::section(body, state);
|
| 197 |
|
- |
body = body.extend(super::trash::section(&super::trash::read(state)));
|
| 198 |
|
- |
body = body.extend(super::licence::section(&super::licence::read(state)));
|
| 199 |
|
- |
|
| 200 |
|
- |
// The classifier's door. Max ruled it a window of its own; what stays here
|
| 201 |
|
- |
// is one act, so the thing is findable from Settings.
|
| 202 |
|
- |
// See `super::classifier`'s header.
|
| 203 |
|
- |
body = body
|
| 204 |
|
- |
.with(Node::section("Tagging"))
|
| 205 |
|
- |
.with(Node::text(
|
| 206 |
|
- |
"Rules, auto-tagging, clustering and folder tags, with the classifiers you have imported.",
|
| 207 |
|
- |
))
|
| 208 |
|
- |
.with(Node::Act(Act::new(
|
| 209 |
|
- |
"Tag classifier...",
|
| 210 |
|
- |
Action::get("/classifier"),
|
| 211 |
|
- |
)));
|
| 212 |
|
- |
|
| 213 |
|
- |
// Advanced. The heading is unconditional and
|
| 214 |
|
- |
// the export is not: a theme that is gone has nothing to export, and the
|
| 215 |
|
- |
// section still has an import and a mirror under it.
|
| 216 |
|
- |
body = body.with(Node::section("Advanced"));
|
| 217 |
|
- |
if let Some(active) = active_theme(state) {
|
| 218 |
|
- |
body = body
|
| 219 |
|
- |
.with(Node::text(format!(
|
| 220 |
|
- |
"The theme showing is {}. Exporting writes {}.toml wherever you choose.",
|
| 221 |
|
- |
active.name, active.id
|
| 222 |
|
- |
)))
|
| 223 |
|
- |
.with(Node::Act(Act::new(
|
| 224 |
|
- |
"Export current theme",
|
| 225 |
|
- |
Action::post("/settings/theme/export"),
|
| 226 |
|
- |
)));
|
| 227 |
|
- |
}
|
| 228 |
|
- |
body = body.extend(super::advanced::section(state.advanced.mirror().as_ref()));
|
| 229 |
|
- |
|
| 230 |
|
- |
Ok(Screen::sidebar_content("Settings").with(body))
|
| 231 |
345 |
|
}
|
| 232 |
346 |
|
|
| 233 |
347 |
|
/// The theme showing, as the host resolved it.
|
| 249 |
363 |
|
/// answers with the file and the host puts it somewhere).
|
| 250 |
364 |
|
///
|
| 251 |
365 |
|
/// A control that asks the host where to put something and then acts is not a
|
| 252 |
|
- |
/// picker. The route
|
| 253 |
|
- |
/// hands over bytes and a suggested name; where they land is the host's. So the
|
| 254 |
|
- |
/// description never names a path, and the same act reads correctly on a
|
| 255 |
|
- |
/// terminal and in a browser.
|
|
366 |
+ |
/// picker. The route hands over bytes and a suggested name; where they land is
|
|
367 |
+ |
/// the host's. So the description never names a path, and the same act reads
|
|
368 |
+ |
/// correctly on a terminal and in a browser.
|
| 256 |
369 |
|
fn export_theme(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
|
| 257 |
370 |
|
let active = active_theme(state).ok_or_else(|| RouteError::not_found("no theme to export"))?;
|
| 258 |
371 |
|
let source = active
|
| 266 |
379 |
|
))
|
| 267 |
380 |
|
}
|
| 268 |
381 |
|
|
| 269 |
|
- |
/// The theme picker.
|
| 270 |
|
- |
///
|
| 271 |
|
- |
/// `FieldKind::Theme` and not `FieldKind::Select`, as of makeover-layout
|
| 272 |
|
- |
/// 0.38.0. Thirty-odd options that have to collapse and carry a swatch each is
|
| 273 |
|
- |
/// not the dropdown a plain choice gets.
|
| 274 |
|
- |
///
|
| 275 |
|
- |
/// # The finding in the module header is closed
|
| 276 |
|
- |
///
|
| 277 |
|
- |
/// It said a set of choices cannot be grouped, and recorded this app as the
|
| 278 |
|
- |
/// **second consumer** of goingson's finding with the stronger case: the
|
| 279 |
|
- |
/// shipped egui picker grouped by variant *and* sorted by contrast tier within
|
| 280 |
|
- |
/// each group, and `Choice` is a value and a label, so the described version
|
| 281 |
|
- |
/// flattened it and lost the tier.
|
| 282 |
|
- |
///
|
| 283 |
|
- |
/// The answer is not a group on the choice. Grouping recurs at exactly one live
|
| 284 |
|
- |
/// site across the tree, so the thing that
|
| 285 |
|
- |
/// recurs is this picker rather than option lists that group. `Field::theme`
|
| 286 |
|
- |
/// takes entries carrying their variant and their measured tier as values, and
|
| 287 |
|
- |
/// both facts come back.
|
| 288 |
|
- |
///
|
| 289 |
|
- |
/// Nothing here groups and nothing here sorts. `panel::themes` hands the list
|
| 290 |
|
- |
/// over in `makeover::order_theme_options`' order, which is the order the
|
| 291 |
|
- |
/// renderer reads the groups out of.
|
| 292 |
|
- |
fn appearance(state: &Panels<'_>) -> Node {
|
| 293 |
|
- |
let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
|
| 294 |
|
- |
let themes = state
|
| 295 |
|
- |
.themes
|
| 296 |
|
- |
.iter()
|
| 297 |
|
- |
.map(|theme| {
|
| 298 |
|
- |
DescribedTheme::new(
|
| 299 |
|
- |
theme.id.clone(),
|
| 300 |
|
- |
theme.name.clone(),
|
| 301 |
|
- |
variant_of(theme.variant),
|
| 302 |
|
- |
tier_of(theme.contrast),
|
| 303 |
|
- |
)
|
| 304 |
|
- |
})
|
| 305 |
|
- |
.collect();
|
| 306 |
|
- |
let mut field = Field::theme(ConfigKey::Theme.as_str(), "Theme", themes)
|
| 307 |
|
- |
.writes(Action::post("/settings/config/theme"));
|
| 308 |
|
- |
field.value = chosen;
|
| 309 |
|
- |
Node::Field(Box::new(field))
|
|
382 |
+ |
declare! {
|
|
383 |
+ |
/// The theme picker.
|
|
384 |
+ |
///
|
|
385 |
+ |
/// One member, `themes`, and its second site in the tree: goingson's
|
|
386 |
+ |
/// `settings::theme_field` is the first and is the model. The stored id is
|
|
387 |
+ |
/// placed by a loop over its `Option`, so a picker with nothing stored
|
|
388 |
+ |
/// carries no value rather than an empty one, which is what the assignment
|
|
389 |
+ |
/// this replaces was doing by hand.
|
|
390 |
+ |
///
|
|
391 |
+ |
/// Nothing here groups and nothing here sorts. `panel::themes` hands the
|
|
392 |
+ |
/// list over in `makeover::order_theme_options`' order, which is the order
|
|
393 |
+ |
/// the renderer reads the groups out of.
|
|
394 |
+ |
shape appearance(settings: &Settings) -> Field;
|
|
395 |
+ |
|
|
396 |
+ |
field Theme ConfigKey::Theme.as_str() "Theme" {
|
|
397 |
+ |
themes settings.themes.clone();
|
|
398 |
+ |
for chosen in settings.chosen.iter() {
|
|
399 |
+ |
value chosen;
|
|
400 |
+ |
}
|
|
401 |
+ |
writes Action::post("/settings/config/theme");
|
|
402 |
+ |
}
|
| 310 |
403 |
|
}
|
| 311 |
404 |
|
|
| 312 |
405 |
|
/// `makeover`'s variant as the description layer's own.
|
| 335 |
428 |
|
}
|
| 336 |
429 |
|
}
|
| 337 |
430 |
|
|
| 338 |
|
- |
/// A boolean setting as a checkbox that writes when it changes.
|
| 339 |
|
- |
///
|
| 340 |
|
- |
/// `Field::writes` rather than a form: this screen has no submit and never
|
| 341 |
|
- |
/// should, which is the shape `14612ed8` was recounted for. Thirteen of
|
| 342 |
|
- |
/// goingson's nineteen change-sites were standalone controls, and every control
|
| 343 |
|
- |
/// here is one.
|
| 344 |
|
- |
fn toggle(state: &Panels<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
|
| 345 |
|
- |
let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true");
|
| 346 |
|
- |
Ok(Node::Field(Box::new(
|
| 347 |
|
- |
Field::new(FieldKind::Checkbox, key.as_str(), label)
|
| 348 |
|
- |
.value(if on { "on" } else { "" })
|
| 349 |
|
- |
.writes(Action::post(format!("/settings/config/{}", key.as_str()))),
|
| 350 |
|
- |
)))
|
|
431 |
+ |
declare! {
|
|
432 |
+ |
/// A boolean setting as a checkbox that writes when it changes.
|
|
433 |
+ |
///
|
|
434 |
+ |
/// `Field::writes` rather than a form: this screen has no submit and never
|
|
435 |
+ |
/// should, which is the shape `14612ed8` was recounted for. Thirteen of
|
|
436 |
+ |
/// goingson's nineteen change-sites were standalone controls, and every
|
|
437 |
+ |
/// control here is one.
|
|
438 |
+ |
///
|
|
439 |
+ |
/// The five column checkboxes are this too. A column's described name is
|
|
440 |
+ |
/// the key it writes, so the only thing that told them apart was the
|
|
441 |
+ |
/// reconciliation the write route does, and that was never the
|
|
442 |
+ |
/// description's.
|
|
443 |
+ |
shape toggle(key: &str, label: &str, on: bool) -> Field;
|
|
444 |
+ |
|
|
445 |
+ |
let value = given on {
|
|
446 |
+ |
true -> "on",
|
|
447 |
+ |
otherwise -> "",
|
|
448 |
+ |
};
|
|
449 |
+ |
|
|
450 |
+ |
field Checkbox key label {
|
|
451 |
+ |
value value;
|
|
452 |
+ |
writes Action::post("/settings/config/{key}");
|
|
453 |
+ |
}
|
| 351 |
454 |
|
}
|
| 352 |
455 |
|
|
| 353 |
|
- |
/// Row density, as a bounded number rather than a slider.
|
| 354 |
|
- |
///
|
| 355 |
|
- |
/// The description says what the value may be and not what it looks like:
|
| 356 |
|
- |
/// `Field::min` and `max` are the bounds the shipped slider draws as a track,
|
| 357 |
|
- |
/// and a renderer with no slider draws a number that still cannot go out of
|
| 358 |
|
- |
/// range. Naming the widget would have been the description choosing a control.
|
| 359 |
|
- |
fn row_height(state: &Panels<'_>) -> Result<Field, RouteError> {
|
| 360 |
|
- |
let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned());
|
| 361 |
|
- |
Ok(Field::new(
|
| 362 |
|
- |
FieldKind::Number,
|
| 363 |
|
- |
ConfigKey::RowHeight.as_str(),
|
| 364 |
|
- |
"Row height",
|
| 365 |
|
- |
)
|
| 366 |
|
- |
.value(current)
|
| 367 |
|
- |
.hint("Between 20 and 32 pixels.")
|
| 368 |
|
- |
.writes(Action::post(format!(
|