max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
1 file changed,
+386 insertions,
-59 deletions
| @@ -91,18 +91,16 @@ | |||
| 91 | 91 | //! [`Outcome::Locate`]: quasi_router::Outcome::Locate | |
| 92 | 92 | //! [`Locating::labelled`]: quasi_router::Locating::labelled | |
| 93 | 93 | ||
| 94 | - | use quasi_router::layout::{FieldKind, Tone}; | |
| 95 | - | use quasi_router::{ | |
| 96 | - | Act, Action, Choice, Field, Locating, Node, Outcome, RegionKind, Request, Response, RouteError, | |
| 97 | - | Router, Screen, Slot, | |
| 98 | - | }; | |
| 94 | + | use quasi_declare::declare; | |
| 95 | + | use quasi_router::layout::Tone; | |
| 96 | + | use quasi_router::{Action, Choice, Locating, Outcome, Request, Response, RouteError, Router}; | |
| 99 | 97 | ||
| 100 | - | use super::{Channels, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject}; | |
| 98 | + | use super::{Channels, Export, Format, Panels, Phase, ProfileChoice, Setting, Settings, Subject}; | |
| 101 | 99 | ||
| 102 | 100 | /// The region the whole flow answers into. | |
| 103 | 101 | /// | |
| 104 | - | /// One region for four screens, because they are four answers to one address | |
| 105 | - | /// rather than four places. Nothing navigates between them and the back button | |
| 102 | + | /// One region for five screens, because they are five answers to one address | |
| 103 | + | /// rather than five places. Nothing navigates between them and the back button | |
| 106 | 104 | /// has nowhere to go, which is the truth: the user cannot walk back into | |
| 107 | 105 | /// configuring an export that is already running. | |
| 108 | 106 | const BODY: &str = "export-body"; | |
| @@ -134,7 +132,7 @@ | |||
| 134 | 132 | ||
| 135 | 133 | /// `GET /export` | |
| 136 | 134 | fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 137 | - | Ok(screen(state).into()) | |
| 135 | + | Ok(showing(state)) | |
| 138 | 136 | } | |
| 139 | 137 | ||
| 140 | 138 | /// `POST /export/begin` | |
| @@ -169,7 +167,7 @@ | |||
| 169 | 167 | Setting::from_key(name).ok_or_else(|| RouteError::not_found("no such export setting"))?; | |
| 170 | 168 | let value = request.payload.get(name).unwrap_or_default(); | |
| 171 | 169 | state.export.configure(setting, value); | |
| 172 | - | Ok(screen(state).into()) | |
| 170 | + | Ok(showing(state)) | |
| 173 | 171 | } | |
| 174 | 172 | ||
| 175 | 173 | /// `POST /export/destination` | |
| @@ -189,7 +187,7 @@ | |||
| 189 | 187 | fn destination(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 190 | 188 | Ok(Response::locate(Locating::folder( | |
| 191 | 189 | "Export Destination", | |
| 192 | - | setting_route(Setting::Destination), | |
| 190 | + | Action::post("/export/set/destination"), | |
| 193 | 191 | Setting::Destination.as_str(), | |
| 194 | 192 | ))) | |
| 195 | 193 | } | |
| @@ -197,221 +195,590 @@ | |||
| 197 | 195 | /// `POST /export/start` | |
| 198 | 196 | fn start(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 199 | 197 | state.export.start(); | |
| 200 | - | Ok(screen(state).into()) | |
| 198 | + | Ok(showing(state)) | |
| 201 | 199 | } | |
| 202 | 200 | ||
| 203 | 201 | /// `POST /export/cancel` | |
| 204 | 202 | fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 205 | 203 | state.export.cancel(); | |
| 206 | - | Ok(screen(state).into()) | |
| 204 | + | Ok(showing(state)) | |
| 207 | 205 | } | |
| 208 | 206 | ||
| 209 | 207 | /// `POST /export/dismiss` | |
| 210 | 208 | fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 211 | 209 | state.export.dismiss(); | |
| 212 | - | Ok(screen(state).into()) | |
| 210 | + | Ok(showing(state)) | |
| 213 | 211 | } | |
| 214 | 212 | ||
| 215 | - | /// Whichever of the four screens the flow is on. | |
| 216 | - | fn screen(state: &Panels<'_>) -> Screen { | |
| 217 | - | let body = match state.export.phase() { | |
| 218 | - | Phase::Idle => idle(), | |
| 219 | - | Phase::Configuring { | |
| 220 | - | subjects, | |
| 221 | - | profiles, | |
| 222 | - | settings, | |
| 223 | - | available_bytes, | |
| 224 | - | } => configuring(&subjects, &profiles, &settings, available_bytes), | |
| 225 | - | Phase::Running { | |
| 226 | - | done, | |
| 227 | - | total, | |
| 228 | - | current, | |
| 229 | - | } => running(done, total, ¤t), | |
| 230 | - | Phase::Finished { | |
| 231 | - | total, | |
| 232 | - | errors, | |
| 233 | - | destination, | |
| 234 | - | } => finished(total, &errors, destination.as_deref()), | |
| 235 | - | Phase::Cancelled { | |
| 236 | - | done, | |
| 237 | - | total, | |
| 238 | - | destination, | |
| 239 | - | } => cancelled(done, total, destination.as_deref()), | |
| 240 | - | }; | |
| 241 | - | Screen::sidebar_content("Export").with(body) | |
| 213 | + | /// The screen, read and then described. | |
| 214 | + | fn showing(state: &Panels<'_>) -> Response { | |
| 215 | + | Response::from(screen(&read(state.export))) | |
| 242 | 216 | } | |
| 243 | 217 | ||
| 244 | - | /// Nothing to export. | |
| 218 | + | /// Whichever of the five screens the flow is on. | |
| 245 | 219 | /// | |
| 246 | - | /// A stand-in rather than an empty pane, and with no way out offered: the export | |
| 247 | - | /// flow is entered by selecting samples in the file list, which is a different | |
| 248 | - | /// screen. Offering a control here would be inventing an affordance the shipped | |
| 249 | - | /// app does not have. | |
| 250 | - | fn idle() -> Slot { | |
| 251 | - | Slot::new(BODY, RegionKind::Pane).with(Node::empty( | |
| 252 | - | "Nothing is being exported. Select samples and choose Export to start.", | |
| 253 | - | )) | |
| 220 | + | /// Five members rather than five returns, because the region is one region: the | |
| 221 | + | /// flow is in exactly one phase, and the description says which by looping over | |
| 222 | + | /// the one member that is there. A match assigning the region five ways would | |
| 223 | + | /// have to remember, in each arm, everything the other four do not draw. | |
| 224 | + | struct Exporting { | |
| 225 | + | /// Whether nothing is being exported and nothing is being set up. | |
| 226 | + | idle: bool, | |
| 227 | + | /// Choosing what and where, while that is what is happening. | |
| 228 | + | configuring: Option<Configuring>, | |
| 229 | + | /// Files being written, while any are. | |
| 230 | + | running: Option<Running>, | |
| 231 | + | /// However it went, once it is over. | |
| 232 | + | finished: Option<Finished>, | |
| 233 | + | /// How far it got, where it was given up on. | |
| 234 | + | cancelled: Option<Cancelled>, | |
| 254 | 235 | } | |
| 255 | 236 | ||
| 256 | - | /// Choosing what and where. | |
| 257 | - | fn configuring( | |
| 237 | + | /// Choosing what and where, before anything is written. | |
| 238 | + | struct Configuring { | |
| 239 | + | /// What is about to be exported, and what is available to do it with. | |
| 240 | + | said: String, | |
| 241 | + | /// Everything worth saying before anything is written. | |
| 242 | + | warnings: Vec<Warning>, | |
| 243 | + | /// The profile picker, while there are profiles to pick from. | |
| 244 | + | /// | |
| 245 | + | /// Absent rather than empty when there are none: an empty dropdown is a | |
| 246 | + | /// control that cannot be used. | |
| 247 | + | device: Option<Device>, | |
| 248 | + | /// The audio settings, while no profile is locking them. | |
| 249 | + | audio: Option<Audio>, | |
| 250 | + | /// Whether the tree survives the export, as the control carries it. | |
| 251 | + | flatten: &'static str, | |
| 252 | + | /// Whether a sidecar goes beside each file, as the control carries it. | |
| 253 | + | sidecar: &'static str, | |
| 254 | + | /// How to name the files, while they are all landing in one folder. | |
| 255 | + | naming: Option<Naming>, | |
| 256 | + | /// Where they go, as the host spells the path. | |
| 257 | + | destination: String, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// Something worth warning about before anything is written. | |
| 261 | + | struct Warning { | |
| 262 | + | said: String, | |
| 263 | + | tone: Tone, | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// The device profiles on offer, and what the chosen one says about itself. | |
| 267 | + | struct Device { | |
| 268 | + | /// Every profile, and the manual answer that is not one. | |
| 269 | + | options: Vec<Choice>, | |
| 270 | + | /// The one in force, or empty for manual. | |
| 271 | + | chosen: String, | |
| 272 | + | /// What that profile says about itself, as one line. | |
| 273 | + | describes: Option<String>, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /// What to write and at what, which only exists while nothing is locking it. | |
| 277 | + | struct Audio { | |
| 278 | + | /// The format, as the control carries it. | |
| 279 | + | format: &'static str, | |
| 280 | + | /// Whether that format means decoding and re-encoding. | |
| 281 | + | /// | |
| 282 | + | /// What it costs is said on the control that chose it rather than in a | |
| 283 | + | /// banner under it: a note is the same sentence attached to the answer that | |
| 284 | + | /// earns it. | |
| 285 | + | reencoding: bool, | |
| 286 | + | /// The rate and the depth, which are only questions when re-encoding. | |
| 287 | + | resampling: Option<Resampling>, | |
| 288 | + | /// The channel layout, as the control carries it. | |
| 289 | + | channels: &'static str, | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// What to re-encode at. | |
| 293 | + | struct Resampling { | |
| 294 | + | /// The sample rate, or empty for each file's own. | |
| 295 | + | rate: String, | |
| 296 | + | /// The bit depth, or empty for each file's own. | |
| 297 | + | depth: String, | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// How to name the output files, and what the first one would be called. | |
| 301 | + | struct Naming { | |
| 302 | + | /// The pattern as it stands. | |
| 303 | + | pattern: String, | |
| 304 | + | /// What the first file would be called, where the pattern resolves. | |
| 305 | + | named: Option<String>, | |
| 306 | + | /// What is wrong with the pattern, where it does not parse. | |
| 307 | + | /// | |
| 308 | + | /// At most one of the two, and neither when there is nothing to resolve | |
| 309 | + | /// against. A parse failure is the point rather than an error to swallow: it | |
| 310 | + | /// is what catches a typo before two hundred files are written under it. | |
| 311 | + | problem: Option<String>, | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | /// Files being written. | |
| 315 | + | struct Running { | |
| 316 | + | /// Whether the worker has not counted the files yet. | |
| 317 | + | /// | |
| 318 | + | /// Zero is that rather than an empty export, and a meter of zero of zero | |
| 319 | + | /// would draw as finished. Pending is the honest reading. | |
| 320 | + | starting: bool, | |
| 321 | + | /// How far along, once there is a total to be a proportion of. | |
| 322 | + | gauge: Option<Gauge>, | |
| 323 | + | /// The file being written now, while one is named. | |
| 324 | + | current: Option<String>, | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// Files written against files to write. | |
| 328 | + | struct Gauge { | |
| 329 | + | done: u32, | |
| 330 | + | total: u32, | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// Finished, however it went. | |
| 334 | + | struct Finished { | |
| 335 | + | /// What a clean finish says. | |
| 336 | + | said: Option<String>, | |
| 337 | + | /// What a dirty one says, and every file that failed. | |
| 338 | + | failures: Option<Failures>, | |
| 339 | + | /// Where the files landed, where the flow was told. | |
| 340 | + | destination: Option<String>, | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// The files that did not make it. | |
| 344 | + | struct Failures { | |
| 345 | + | /// How many, and out of how many. | |
| 346 | + | said: String, | |
| 347 | + | /// Each one, by name and by what went wrong. | |
| 348 | + | each: Vec<Failure>, | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | /// One file that did not make it. | |
| 352 | + | struct Failure { | |
| 353 | + | name: String, | |
| 354 | + | said: String, | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | /// Given up on partway. | |
| 358 | + | struct Cancelled { | |
| 359 | + | /// How far it got before it stopped. | |
| 360 | + | said: String, | |
| 361 | + | /// Where the partial files sit, where the flow was told. | |
| 362 | + | destination: Option<String>, | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// What the flow draws, read off the app. | |
| 366 | + | fn read(export: &dyn Export) -> Exporting { | |
| 367 | + | let phase = export.phase(); | |
| 368 | + | Exporting { | |
| 369 | + | idle: matches!(phase, Phase::Idle), | |
| 370 | + | configuring: match &phase { | |
| 371 | + | Phase::Configuring { | |
| 372 | + | subjects, | |
| 373 | + | profiles, | |
| 374 | + | settings, | |
| 375 | + | available_bytes, | |
| 376 | + | } => Some(configuring_read( | |
| 377 | + | subjects, | |
| 378 | + | profiles, | |
| 379 | + | settings, | |
| 380 | + | *available_bytes, | |
| 381 | + | )), | |
| 382 | + | _ => None, | |
| 383 | + | }, | |
| 384 | + | running: match &phase { | |
| 385 | + | Phase::Running { | |
| 386 | + | done, | |
| 387 | + | total, | |
| 388 | + | current, | |
| 389 | + | } => Some(running_read(*done, *total, current)), | |
| 390 | + | _ => None, | |
| 391 | + | }, | |
| 392 | + | finished: match &phase { | |
| 393 | + | Phase::Finished { | |
| 394 | + | total, | |
| 395 | + | errors, | |
| 396 | + | destination, | |
| 397 | + | } => Some(finished_read(*total, errors, destination.as_deref())), | |
| 398 | + | _ => None, | |
| 399 | + | }, | |
| 400 | + | cancelled: match &phase { | |
| 401 | + | Phase::Cancelled { | |
| 402 | + | done, | |
| 403 | + | total, | |
| 404 | + | destination, | |
| 405 | + | } => Some(cancelled_read(*done, *total, destination.as_deref())), | |
| 406 | + | _ => None, | |
| 407 | + | }, | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | /// The configure screen, read off what is going and what it may be written as. | |
| 412 | + | fn configuring_read( | |
| 258 | 413 | subjects: &[Subject], | |
| 259 | 414 | profiles: &[ProfileChoice], | |
| 260 | 415 | settings: &Settings, | |
| 261 | 416 | available: Option<u64>, | |
| 262 | - | ) -> Slot { | |
| 263 | - | let mut body = Slot::new(BODY, RegionKind::Pane) | |
| 264 | - | .with(Node::page("Export Samples")) | |
| 265 | - | .with(Node::text(subject_count(subjects.len(), profiles.len()))); | |
| 417 | + | ) -> Configuring { | |
| 418 | + | Configuring { | |
| 419 | + | said: subject_count(subjects.len(), profiles.len()), | |
| 420 | + | warnings: warnings(subjects, profiles, settings, available), | |
| 421 | + | device: (!profiles.is_empty()) | |
| 422 | + | .then(|| device_read(profiles, settings.device_profile.as_deref())), | |
| 423 | + | audio: settings | |
| 424 | + | .device_profile | |
| 425 | + | .is_none() | |
| 426 | + | .then(|| audio_read(settings)), | |
| 427 | + | flatten: switched(settings.flatten), | |
| 428 | + | sidecar: switched(settings.sidecar), | |
| 429 | + | naming: settings | |
| 430 | + | .flatten | |
| 431 | + | .then(|| naming_read(settings.naming_pattern.as_deref(), subjects.first())), | |
| 432 | + | destination: settings.destination.clone(), | |
| 433 | + | } | |
| 434 | + | } | |
| 266 | 435 | ||
| 267 | - | for warning in warnings(subjects, profiles, settings, available) { | |
| 268 | - | body = body.with(warning); | |
| 436 | + | /// The profiles on offer, and whatever the chosen one says about itself. | |
| 437 | + | fn device_read(profiles: &[ProfileChoice], chosen: Option<&str>) -> Device { | |
| 438 | + | let mut options = vec![Choice::new(String::new(), "None (manual)")]; | |
| 439 | + | options.extend(profiles.iter().map(|profile| { | |
| 440 | + | Choice::new( | |
| 441 | + | profile.name.clone(), | |
| 442 | + | format!("{} ({})", profile.name, profile.manufacturer), | |
| 443 | + | ) | |
| 444 | + | })); | |
| 445 | + | Device { | |
| 446 | + | options, | |
| 447 | + | chosen: chosen.unwrap_or_default().to_owned(), | |
| 448 | + | describes: chosen_profile(profiles, chosen).map(describe), | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// What to write and at what, read off the settings. | |
| 453 | + | fn audio_read(settings: &Settings) -> Audio { | |
| 454 | + | let reencoding = settings.format != Format::Original; | |
| 455 | + | Audio { | |
| 456 | + | format: match settings.format { | |
| 457 | + | Format::Original => "original", | |
| 458 | + | Format::Wav => "wav", | |
| 459 | + | Format::Aiff => "aiff", | |
| 460 | + | }, | |
| 461 | + | reencoding, | |
| 462 | + | resampling: reencoding.then(|| Resampling { | |
| 463 | + | rate: settings | |
| 464 | + | .sample_rate | |
| 465 | + | .map_or_else(String::new, |rate| rate.to_string()), | |
| 466 | + | depth: settings | |
| 467 | + | .bit_depth | |
| 468 | + | .map_or_else(String::new, |depth| depth.to_string()), | |
| 469 | + | }), | |
| 470 | + | channels: match settings.channels { | |
| 471 | + | Channels::Original => "original", | |
| 472 | + | Channels::Mono => "mono", | |
| 473 | + | Channels::Stereo => "stereo", | |
| 474 | + | }, | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | /// The naming pattern, and what the first file would be called under it. | |
| 479 | + | /// | |
| 480 | + | /// Describable because it is pure: parsing a pattern and resolving it against | |
| 481 | + | /// one item's own fields reaches nothing outside the description. | |
| 482 | + | fn naming_read(pattern: Option<&str>, first: Option<&Subject>) -> Naming { | |
| 483 | + | let mut naming = Naming { | |
| 484 | + | pattern: pattern.unwrap_or_default().to_owned(), | |
| 485 | + | named: None, | |
| 486 | + | problem: None, | |
| 487 | + | }; | |
| 488 | + | let Some(source) = pattern.filter(|pattern| !pattern.is_empty()) else { | |
| 489 | + | return naming; | |
| 490 | + | }; | |
| 491 | + | match audiofiles_core::rename::RenamePattern::parse(source) { | |
| 492 | + | Ok(parsed) => { | |
| 493 | + | if let Some(first) = first { | |
| 494 | + | let stem = parsed.resolve(&audiofiles_core::rename::RenameContext { | |
| 495 | + | name: first.name.clone(), | |
| 496 | + | extension: first.ext.clone(), | |
| 497 | + | bpm: first.bpm, | |
| 498 | + | musical_key: first.musical_key.clone(), | |
| 499 | + | duration: first.duration, | |
| 500 | + | index: 0, | |
| 501 | + | }); | |
| 502 | + | naming.named = Some(if first.ext.is_empty() { | |
| 503 | + | stem | |
| 504 | + | } else { | |
| 505 | + | format!("{stem}.{}", first.ext) | |
| 506 | + | }); | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | Err(error) => naming.problem = Some(error.to_string()), | |
| 510 | + | } | |
| 511 | + | naming | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | /// How far a running export has got. | |
| 515 | + | fn running_read(done: usize, total: usize, current: &str) -> Running { | |
| 516 | + | Running { | |
| 517 | + | starting: total == 0, | |
| 518 | + | gauge: (total > 0).then(|| Gauge { | |
| 519 | + | done: clamp(done), | |
| 520 | + | total: clamp(total), | |
| 521 | + | }), | |
| 522 | + | current: (!current.is_empty()).then(|| current.to_owned()), | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | /// How a finished export went. | |
| 527 | + | fn finished_read(total: usize, errors: &[(String, String)], destination: Option<&str>) -> Finished { | |
| 528 | + | Finished { | |
| 529 | + | said: errors | |
| 530 | + | .is_empty() | |
| 531 | + | .then(|| format!("Successfully exported {total} files.")), | |
| 532 | + | failures: (!errors.is_empty()).then(|| Failures { | |
| 533 | + | said: format!("Exported {total} files with {} errors.", errors.len()), | |
| 534 | + | each: errors | |
| 535 | + | .iter() | |
| 536 | + | .map(|(name, said)| Failure { | |
| 537 | + | name: name.clone(), | |
| 538 | + | said: said.clone(), | |
| 539 | + | }) | |
| 540 | + | .collect(), | |
| 541 | + | }), | |
| 542 | + | destination: destination.map(str::to_owned), | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | /// How far a cancelled export had got. | |
| 547 | + | fn cancelled_read(done: usize, total: usize, destination: Option<&str>) -> Cancelled { | |
| 548 | + | Cancelled { | |
| 549 | + | said: format!("{done} of {total} samples were written before this stopped."), | |
| 550 | + | destination: destination.map(str::to_owned), | |
| 551 | + | } | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | declare! { | |
| 555 | + | /// The screen, which is a different screen per phase. | |
| 556 | + | /// | |
| 557 | + | /// One route answering five shapes rather than five routes: the phase is not | |
| 558 | + | /// an address, and a user cannot navigate to `Running` -- they arrive there | |
| 559 | + | /// because something happened. Five addresses would be five places you could | |
| 560 | + | /// bookmark into a lie. | |
| 561 | + | shape screen(flow: &Exporting) -> Screen; | |
| 562 | + | ||
| 563 | + | screen sidebar_content "Export" { | |
| 564 | + | region BODY as Pane { | |
| 565 | + | // A stand-in rather than an empty pane, and with no way out | |
| 566 | + | // offered: the flow is entered by selecting samples in the file | |
| 567 | + | // list, which is a different screen. Offering a control here would | |
| 568 | + | // be inventing an affordance the shipped app does not have. | |
| 569 | + | empty "Nothing is being exported. Select samples and choose Export \ | |
| 570 | + | to start." when flow.idle; | |
| 571 | + | ||
| 572 | + | for setup in flow.configuring.iter() { | |
| 573 | + | extend configuring(setup); | |
| 574 | + | } | |
| 575 | + | for run in flow.running.iter() { | |
| 576 | + | extend running(run); | |
| 577 | + | } | |
| 578 | + | for done in flow.finished.iter() { | |
| 579 | + | extend finished(done); | |
| 580 | + | } | |
| 581 | + | for stopped in flow.cancelled.iter() { | |
| 582 | + | extend cancelled(stopped); | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | declare! { | |
| 589 | + | /// Choosing what and where. | |
| 590 | + | shape configuring(setup: &Configuring) -> Vec<Node>; | |
| 591 | + | ||
| 592 | + | page "Export Samples"; | |
| 593 | + | text &setup.said; | |
| 594 | + | ||
| 595 | + | for warning in setup.warnings.iter() { | |
| 596 | + | banner warning.tone &warning.said; | |
| 269 | 597 | } | |
| 270 | 598 | ||
| 271 | - | if !profiles.is_empty() { |
Lines truncated