Skip to main content

rsynko_manager/
transitions.rs

1use crate::*;
2use alux_ext::ext;
3use rsynko_media::portable_user_file_name;
4use std::path::{Path, PathBuf};
5
6/// Derives collection selection and removal from ordered stable identities.
7#[ext(name = CollectionNavigationExt)]
8pub impl<This> This
9where
10    This: NavigationStateAlg + QueueCatalogAlg + QueueRemoveAlg,
11    This::Id: Copy + Eq,
12{
13    /// Selects the adjacent entry with collection-end wraparound.
14    fn select_relative(&mut self, forward: bool) {
15        let ids = self.queue_ids().collect::<Vec<_>>();
16        if ids.is_empty() {
17            self.set_selected_queue_id(None);
18            return;
19        }
20        let current =
21            self.selected_queue_id().and_then(|selected| ids.iter().position(|id| *id == selected)).unwrap_or(0);
22        let next = if forward {
23            (current + 1) % ids.len()
24        } else if current == 0 {
25            ids.len() - 1
26        } else {
27            current - 1
28        };
29        self.set_selected_queue_id(Some(ids[next]));
30        if matches!(self.page(), ManagerPage::Details(_)) {
31            self.set_page(ManagerPage::Details(ids[next]));
32        }
33    }
34
35    /// Removes selection and chooses its nearest remaining neighbor.
36    fn remove_selected(&mut self) {
37        let ids = self.queue_ids().collect::<Vec<_>>();
38        let Some(selected) = self.selected_queue_id() else {
39            return;
40        };
41        let index = ids.iter().position(|id| *id == selected).unwrap_or(0);
42        self.remove_queue_entry(selected);
43        let remaining = self.queue_ids().collect::<Vec<_>>();
44        let next = remaining.get(index.min(remaining.len().saturating_sub(1))).copied();
45        self.set_selected_queue_id(next);
46        if matches!(self.page(), ManagerPage::Details(id) | ManagerPage::Formats(id) | ManagerPage::Output(id) | ManagerPage::Log(id) | ManagerPage::Command(id) if id == selected)
47        {
48            self.set_page(ManagerPage::Collection);
49        }
50    }
51}
52
53/// Derives expanded-row cursor movement and normalization.
54#[ext(name = DetailNavigationExt)]
55pub impl<This> This
56where
57    This: NavigationStateAlg + QueueCatalogAlg + DetailSelectionAlg,
58    This::Entry: QueueEntryAlg,
59    This::Id: Copy + Eq,
60{
61    /// Selects an adjacent expanded-row position, including the row itself.
62    fn select_detail(&mut self, forward: bool) {
63        let ManagerPage::Details(id) = self.page() else {
64            return;
65        };
66        let Some(entry) = self.queue_entry(id) else {
67            return;
68        };
69        let controls = entry.detail_controls();
70        if controls.is_empty() {
71            return;
72        }
73        let next = match self
74            .selected_detail_control()
75            .and_then(|selected| controls.iter().position(|control| *control == selected))
76        {
77            None if forward => Some(controls[0]),
78            None => controls.last().copied(),
79            Some(current) if forward && current + 1 == controls.len() => None,
80            Some(current) if forward => Some(controls[current + 1]),
81            Some(0) => None,
82            Some(current) => Some(controls[current - 1]),
83        };
84        self.set_selected_detail_control(next);
85    }
86
87    /// Keeps row focus and replaces a detail cursor that is no longer meaningful.
88    fn normalize_detail_control(&mut self, id: This::Id) {
89        if self.page() != ManagerPage::Details(id) {
90            return;
91        }
92        let Some(entry) = self.queue_entry(id) else {
93            return;
94        };
95        let controls = entry.detail_controls();
96        if self.selected_detail_control().is_some_and(|selected| !controls.contains(&selected)) {
97            self.set_selected_detail_control(controls.first().copied());
98        }
99    }
100}
101
102/// Derives output-name editing from an editable entry and a text draft.
103#[ext(name = OutputEditingExt)]
104pub impl<This> This
105where
106    This: NavigationStateAlg
107        + QueueCatalogAlg
108        + OutputDraftAlg
109        + InputDraftAlg
110        + QueueOutputAlg
111        + QueueSourceAlg
112        + ManagerStatusAlg,
113    This::Entry: QueueEntryAlg,
114    This::Id: Copy,
115{
116    /// Opens input editing only while the selected request still permits its input to change.
117    ///
118    /// A request whose input was already read — extracted, described, named after — no longer
119    /// permits it: changing the input then would make everything derived from it a lie.
120    fn open_input(&mut self) {
121        let Some(id) = self.selected_queue_id() else {
122            return;
123        };
124        let Some(entry) = self.queue_entry(id) else {
125            return;
126        };
127        if entry.is_editable() && entry.output_naming() == OutputNaming::Stated {
128            self.set_input_draft(entry.source().to_owned());
129            self.set_page(ManagerPage::Input(id));
130            self.set_manager_message(None);
131        }
132    }
133
134    /// Applies a non-empty input draft.
135    fn submit_input(&mut self) {
136        let ManagerPage::Input(id) = self.page() else {
137            return;
138        };
139        if self.input_draft().trim().is_empty() {
140            self.set_manager_message(Some("enter an input".to_owned()));
141            return;
142        }
143        self.set_queue_source(id, self.input_draft().trim().to_owned());
144        self.set_page(ManagerPage::Details(id));
145        self.set_manager_message(None);
146    }
147
148    /// Opens output-file-name editing only while the selected request is editable.
149    fn open_output(&mut self) {
150        let Some(id) = self.selected_queue_id() else {
151            return;
152        };
153        let Some(entry) = self.queue_entry(id) else {
154            return;
155        };
156        if entry.is_editable() {
157            self.set_output_draft(entry.output().map_or_else(String::new, |path| path.display().to_string()));
158            self.set_page(ManagerPage::Output(id));
159            self.set_manager_message(None);
160        }
161    }
162
163    /// Applies a non-empty output draft, as the kind of name the request states it is.
164    fn submit_output(&mut self) {
165        let ManagerPage::Output(id) = self.page() else {
166            return;
167        };
168        let Some(entry) = self.queue_entry(id) else {
169            return;
170        };
171        let naming = entry.output_naming();
172        let extension = entry.output().and_then(Path::extension).and_then(std::ffi::OsStr::to_str);
173        if self.output_draft().trim().is_empty() {
174            self.set_manager_message(Some("enter a name".to_owned()));
175            return;
176        }
177        // A stated path is the answer already; making it portable would take its separators away.
178        let output = match naming {
179            OutputNaming::Portable => portable_user_file_name(self.output_draft(), extension),
180            OutputNaming::Stated => PathBuf::from(self.output_draft().trim()),
181        };
182        self.set_queue_output(id, output);
183        self.set_page(ManagerPage::Details(id));
184        self.set_manager_message(None);
185    }
186}
187
188/// Derives the editable format-selection transition.
189#[ext(name = ChoiceEditingExt)]
190pub impl<This> This
191where
192    This: NavigationStateAlg + QueueCatalogAlg + FormatCatalogStateAlg + QueueDryRunAlg + ManagerStatusAlg,
193    This::Entry: QueueEntryAlg,
194    This::Id: Copy,
195{
196    /// Opens the report of what the selected request would do.
197    fn open_report(&mut self) {
198        if let Some(id) = self.selected_queue_id()
199            && self.queue_entry(id).is_some()
200        {
201            self.set_page(ManagerPage::Report(id));
202            self.set_manager_message(None);
203        }
204    }
205
206    /// Opens the record of what was observed about the selected request.
207    fn open_log(&mut self) {
208        if let Some(id) = self.selected_queue_id()
209            && self.queue_entry(id).is_some()
210        {
211            self.set_page(ManagerPage::Log(id));
212            self.set_manager_message(None);
213        }
214    }
215
216    /// Opens the command the selected request would run, stated whole.
217    fn open_command(&mut self) {
218        if let Some(id) = self.selected_queue_id()
219            && self.queue_entry(id).is_some()
220        {
221            self.set_page(ManagerPage::Command(id));
222            self.set_manager_message(None);
223        }
224    }
225
226    /// Turns the rehearsal mode of the selected request on or off.
227    ///
228    /// A request that states no rehearsal mode has none to turn, so the intention denotes nothing.
229    fn toggle_dry_run(&mut self) {
230        if let Some(id) = self.selected_queue_id()
231            && let Some(dry_run) = self.queue_entry(id).and_then(QueueEntryAlg::dry_run)
232        {
233            self.set_queue_dry_run(id, !dry_run);
234            self.set_manager_message(None);
235        }
236    }
237
238    /// Opens format selection only while the selected request is editable.
239    fn open_formats(&mut self) {
240        let Some(id) = self.selected_queue_id() else {
241            return;
242        };
243        if self.queue_entry(id).is_some_and(QueueEntryAlg::is_editable) {
244            self.request_format_catalog(id);
245            self.set_page(ManagerPage::Formats(id));
246            self.set_manager_message(None);
247        } else {
248            self.set_manager_message(Some("started downloads are immutable; duplicate to change options".to_owned()));
249        }
250    }
251}
252
253/// Derives add-source submission from source construction and queue insertion.
254#[ext(name = DraftSubmissionExt)]
255pub impl<This> This
256where
257    This: NavigationStateAlg
258        + QueueCatalogAlg
259        + QueueAppendAlg
260        + DraftStateAlg
261        + ManagerStatusAlg
262        + SourceRequestAlg
263        + SubmissionAlg
264        + MediaOptionsAlg
265        + OutputChoiceAlg,
266    This::Id: Copy,
267{
268    /// Adds requests, focuses the first new identity, and clears stale status.
269    fn add_sources(&mut self, requests: Vec<This::Source>) {
270        let added = self.append_sources(requests);
271        if let Some(added) = added.first().copied() {
272            self.set_selected_queue_id(Some(added));
273        }
274        self.set_manager_message(None);
275    }
276
277    /// Submits non-empty trimmed draft lines as progressive requests.
278    fn submit_draft(&mut self) {
279        let requests = self
280            .draft()
281            .lines()
282            .map(str::trim)
283            .filter(|source| !source.is_empty())
284            .map(|line| self.submitted(line))
285            .collect::<Vec<_>>();
286        if requests.is_empty() {
287            self.set_manager_message(Some("enter at least one source".to_owned()));
288        } else {
289            self.add_sources(requests);
290            self.set_draft(String::new());
291            self.set_page(ManagerPage::Collection);
292        }
293    }
294}
295
296/// Derives lifecycle actions for the selected stable identity.
297#[ext(name = QueueLifecycleExt)]
298pub impl<This> This
299where
300    This: NavigationStateAlg
301        + QueueCatalogAlg
302        + QueuePauseAlg
303        + QueueDuplicateAlg
304        + TransferStateAlg
305        + DetailSelectionAlg
306        + ManagerStatusAlg,
307    This::Entry: QueueEntryAlg,
308    This::Id: Copy + Eq,
309{
310    /// Applies the selected entry's state-dependent Space transition.
311    ///
312    /// Space names a collection entry, so it denotes nothing while another page is current.
313    fn apply_selected_space(&mut self) {
314        if !matches!(self.page(), ManagerPage::Collection | ManagerPage::Details(_)) {
315            return;
316        }
317        let Some(id) = self.selected_queue_id() else {
318            return;
319        };
320        match self.queue_entry(id).and_then(QueueEntryAlg::space_action) {
321            Some(SpaceAction::Start | SpaceAction::Rehearse) => self.set_waiting(id),
322            Some(SpaceAction::Pause | SpaceAction::Resume) => self.toggle_queue_pause(id),
323            None => {}
324        }
325        self.normalize_detail_control(id);
326    }
327
328    /// Duplicates selection as a fresh editable identity and opens its details.
329    fn duplicate_selected(&mut self) {
330        if let Some(id) = self.selected_queue_id()
331            && let Some(duplicate) = self.duplicate_queue_entry(id)
332        {
333            self.set_selected_queue_id(Some(duplicate));
334            self.set_page(ManagerPage::Details(duplicate));
335            self.set_selected_detail_control(None);
336            self.set_manager_message(Some("source duplicated as an editable download".to_owned()));
337        }
338    }
339
340    /// Restarts the selected failed request with its fixed options.
341    fn restart_selected(&mut self) {
342        if let Some(id) = self.selected_queue_id() {
343            self.restart_waiting(id);
344            self.normalize_detail_control(id);
345            self.set_manager_message(Some("failed download queued again".to_owned()));
346        }
347    }
348}
349
350/// Interprets manager intentions by composing independently derived meanings.
351#[ext(name = ManagerIntentExt)]
352pub impl<This> This
353where
354    This: NavigationStateAlg
355        + QueueCatalogAlg
356        + QueueAppendAlg
357        + QueueRemoveAlg
358        + QueuePauseAlg
359        + QueueDuplicateAlg
360        + QueueFormatEditAlg
361        + FormatCatalogStateAlg
362        + QueueDryRunAlg
363        + RehearsalStateAlg
364        + TransferStateAlg
365        + DetailSelectionAlg
366        + DraftStateAlg
367        + InputDraftAlg
368        + OutputDraftAlg
369        + QueueOutputAlg
370        + QueueSourceAlg
371        + SourceMetadataAlg
372        + TextEditorStateAlg
373        + ManagerStatusAlg
374        + SafeExitAlg
375        + SourceRequestAlg
376        + SubmissionAlg
377        + MediaOptionsAlg
378        + OutputChoiceAlg,
379    This::Entry: QueueEntryAlg + RequestOptionsAlg,
380    This::Id: Copy + Eq,
381{
382    /// Applies one manager intention or external observation.
383    /// Applies one manager intention through the generated fold.
384    fn apply_manager_event(&mut self, event: ManagerIntentOp<This::Id, This::Source, This::Format, This::Change>) {
385        event.interpret(&mut IntentDispatch(self));
386    }
387}
388
389/// Applies one manager intention to the state it addresses.
390///
391/// The dispatch is the generated fold, so this states only what each intention *means*.
392struct IntentDispatch<'a, This>(&'a mut This);
393
394impl<This, Id, Source, Format, Change> ManagerIntentInterpreter for IntentDispatch<'_, This>
395where
396    This: ManagerSorts<Id = Id, Source = Source, Format = Format, Change = Change>
397        + NavigationStateAlg
398        + QueueCatalogAlg
399        + QueueAppendAlg
400        + QueueRemoveAlg
401        + QueuePauseAlg
402        + QueueDuplicateAlg
403        + QueueFormatEditAlg
404        + FormatCatalogStateAlg
405        + QueueDryRunAlg
406        + RehearsalStateAlg
407        + TransferStateAlg
408        + DetailSelectionAlg
409        + DraftStateAlg
410        + InputDraftAlg
411        + OutputDraftAlg
412        + QueueOutputAlg
413        + QueueSourceAlg
414        + SourceMetadataAlg
415        + TextEditorStateAlg
416        + ManagerStatusAlg
417        + SafeExitAlg
418        + SourceRequestAlg
419        + SubmissionAlg
420        + MediaOptionsAlg
421        + OutputChoiceAlg,
422    This::Entry: QueueEntryAlg,
423    Id: Copy + Eq,
424{
425    type Id = Id;
426    type Source = Source;
427    type Format = Format;
428    type Change = Change;
429
430    fn add_sources(&mut self, requests: Vec<Self::Source>) {
431        self.0.add_sources(requests);
432    }
433
434    fn set_draft(&mut self, draft: String) {
435        self.0.set_draft(draft);
436    }
437
438    fn insert_text(&mut self, text: String) {
439        self.0.insert_text(&text);
440    }
441
442    fn set_output_draft(&mut self, draft: String) {
443        self.0.set_output_draft(draft);
444    }
445
446    fn set_input_draft(&mut self, draft: String) {
447        self.0.set_input_draft(draft);
448    }
449
450    fn open_input(&mut self) {
451        self.0.open_input();
452    }
453
454    fn submit_input(&mut self) {
455        self.0.submit_input();
456    }
457
458    fn source_metadata(&mut self, id: Self::Id, media_id: String, title: Option<String>) {
459        self.0.apply_source_metadata(id, media_id, title);
460    }
461
462    fn format_catalog(&mut self, id: Self::Id, event: FormatDiscoveryOp<Self::Format>) {
463        self.0.apply_format_catalog_event(id, event);
464    }
465
466    fn rehearsal(&mut self, id: Self::Id, event: RehearsalObservationOp<Self::Change>) {
467        self.0.apply_rehearsal_event(id, event);
468        self.0.normalize_detail_control(id);
469    }
470
471    fn open_report(&mut self) {
472        self.0.open_report();
473    }
474
475    fn toggle_dry_run(&mut self) {
476        self.0.toggle_dry_run();
477    }
478
479    fn transfer(&mut self, id: Self::Id, event: TransferObservationOp) {
480        if matches!(event, TransferObservationOp::Started {}) {
481            self.0.set_manager_message(None);
482        }
483        self.0.apply_transfer_event(id, event);
484        self.0.normalize_detail_control(id);
485    }
486
487    fn open_add_sources(&mut self) {
488        self.0.set_draft(String::new());
489        self.0.set_page(ManagerPage::AddSources);
490    }
491
492    fn open_selected(&mut self) {
493        if let Some(id) = self.0.selected_queue_id() {
494            self.0.set_page(ManagerPage::Details(id));
495            self.0.set_selected_detail_control(None);
496        }
497    }
498
499    fn activate_detail(&mut self) {
500        match self.0.selected_detail_control() {
501            Some(DetailControl::Input) => self.0.open_input(),
502            Some(DetailControl::Output) => self.0.open_output(),
503            Some(DetailControl::Format) => self.0.open_formats(),
504            Some(DetailControl::Restart) => self.0.restart_selected(),
505            Some(DetailControl::Duplicate) => self.0.duplicate_selected(),
506            Some(DetailControl::Log) => self.0.open_log(),
507            Some(DetailControl::Command) => self.0.open_command(),
508            Some(DetailControl::Report) => self.0.open_report(),
509            Some(DetailControl::DryRun) => self.0.toggle_dry_run(),
510            Some(DetailControl::Delete) => self.0.remove_selected(),
511            None => self.0.set_page(ManagerPage::Collection),
512        }
513    }
514
515    fn back(&mut self) {
516        match self.0.page() {
517            ManagerPage::Formats(id)
518            | ManagerPage::Output(id)
519            | ManagerPage::Input(id)
520            | ManagerPage::Log(id)
521            | ManagerPage::Report(id)
522            | ManagerPage::Command(id) => {
523                self.0.set_page(ManagerPage::Details(id));
524            }
525            ManagerPage::Collection | ManagerPage::AddSources | ManagerPage::Details(_) => {
526                self.0.set_page(ManagerPage::Collection);
527            }
528        }
529    }
530
531    fn safe_exit_requested(&mut self) {
532        self.0.request_safe_exit();
533        self.0.set_manager_message(Some("cancelling active downloads".to_owned()));
534    }
535
536    fn select_previous_format(&mut self) {
537        if let ManagerPage::Formats(id) = self.0.page() {
538            self.0.cycle_queue_format(id, false);
539        }
540    }
541
542    fn select_next_format(&mut self) {
543        if let ManagerPage::Formats(id) = self.0.page() {
544            self.0.cycle_queue_format(id, true);
545        }
546    }
547
548    fn submit_draft(&mut self) {
549        self.0.submit_draft();
550    }
551
552    fn delete_before_cursor(&mut self) {
553        self.0.delete_before_cursor();
554    }
555
556    fn delete_at_cursor(&mut self) {
557        self.0.delete_at_cursor();
558    }
559
560    fn move_cursor_left(&mut self) {
561        self.0.move_cursor_left();
562    }
563
564    fn move_cursor_right(&mut self) {
565        self.0.move_cursor_right();
566    }
567
568    fn move_cursor_home(&mut self) {
569        self.0.move_cursor_home();
570    }
571
572    fn move_cursor_end(&mut self) {
573        self.0.move_cursor_end();
574    }
575
576    fn select_previous(&mut self) {
577        self.0.select_relative(false);
578    }
579
580    fn select_next(&mut self) {
581        self.0.select_relative(true);
582    }
583
584    fn select_previous_detail(&mut self) {
585        self.0.select_detail(false);
586    }
587
588    fn select_next_detail(&mut self) {
589        self.0.select_detail(true);
590    }
591
592    fn open_formats(&mut self) {
593        self.0.open_formats();
594    }
595
596    fn open_output(&mut self) {
597        self.0.open_output();
598    }
599
600    fn submit_output(&mut self) {
601        self.0.submit_output();
602    }
603
604    fn apply_selected_space(&mut self) {
605        self.0.apply_selected_space();
606    }
607
608    fn duplicate_selected(&mut self) {
609        self.0.duplicate_selected();
610    }
611
612    fn remove_selected(&mut self) {
613        self.0.remove_selected();
614    }
615
616    fn restart_selected(&mut self) {
617        self.0.restart_selected();
618    }
619}