Skip to main content

studio_worker/ui/
actions.rs

1//! Carries the operator's clicks to the daemon off the UI thread, and keeps
2//! the one-line result the status line shows.
3
4use std::path::PathBuf;
5use std::sync::atomic::Ordering;
6use std::sync::Arc;
7
8use chrono::{DateTime, Utc};
9use eframe::egui;
10use parking_lot::Mutex;
11
12use crate::daemon_link::{perform, Action, Replica};
13
14/// The result of the last action.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Feedback {
17    pub text: String,
18    pub ok: bool,
19    pub at: DateTime<Utc>,
20}
21
22/// Runs [`Action`]s on background threads.  Cheap to clone.
23#[derive(Clone)]
24pub struct ActionRunner {
25    config_path: PathBuf,
26    replica: Replica,
27    pub feedback: Arc<Mutex<Option<Feedback>>>,
28    ctx: Arc<Mutex<Option<egui::Context>>>,
29}
30
31impl ActionRunner {
32    pub fn new(config_path: PathBuf, replica: Replica) -> Self {
33        Self {
34            config_path,
35            replica,
36            feedback: Arc::default(),
37            ctx: Arc::default(),
38        }
39    }
40
41    /// Repaint `ctx` when an action finishes.
42    pub fn attach(&self, ctx: egui::Context) {
43        *self.ctx.lock() = Some(ctx);
44    }
45
46    /// Run `action` on a background thread.
47    pub fn run(&self, action: Action) {
48        let runner = self.clone();
49        std::thread::spawn(move || runner.run_blocking(&action));
50    }
51
52    /// Run `action` on a helper thread and wait for it.  The UI thread sits
53    /// inside the tokio runtime, where the blocking HTTP client must not
54    /// run.
55    pub fn run_and_wait(&self, action: Action) -> bool {
56        let runner = self.clone();
57        std::thread::spawn(move || runner.run_blocking(&action))
58            .join()
59            .unwrap_or(false)
60    }
61
62    /// Run `action` here and record its feedback.
63    pub fn run_blocking(&self, action: &Action) -> bool {
64        let outcome = perform(&self.config_path, action);
65        if let (Ok(_), Action::SetPaused(paused)) = (&outcome, action) {
66            // Show the new state at once; the next poll confirms it.
67            self.replica.paused.store(*paused, Ordering::SeqCst);
68        }
69        let ok = outcome.is_ok();
70        *self.feedback.lock() = Some(Feedback {
71            text: outcome.unwrap_or_else(|e| e),
72            ok,
73            at: Utc::now(),
74        });
75        if let Some(ctx) = self.ctx.lock().as_ref() {
76            ctx.request_repaint();
77        }
78        ok
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::test_support::DaemonHarness;
86
87    #[test]
88    fn an_action_records_its_feedback_and_mirrors_pause() {
89        let daemon = DaemonHarness::start();
90        let replica = Replica::default();
91        let runner = ActionRunner::new(daemon.config_path.clone(), replica.clone());
92        assert!(runner.run_blocking(&Action::SetPaused(true)));
93        assert!(replica.paused.load(Ordering::SeqCst));
94        let feedback = runner.feedback.lock().clone().unwrap();
95        assert!(feedback.ok);
96        assert_eq!(feedback.text, "paused");
97    }
98
99    #[test]
100    fn run_and_wait_works_from_inside_a_tokio_runtime() {
101        let daemon = DaemonHarness::start();
102        let runner = ActionRunner::new(daemon.config_path.clone(), Replica::default());
103        let rt = tokio::runtime::Runtime::new().unwrap();
104        let ok = rt.block_on(async { runner.run_and_wait(Action::SetPaused(true)) });
105        assert!(ok);
106        assert!(daemon.control.paused.load(Ordering::SeqCst));
107    }
108
109    #[test]
110    fn a_refusal_is_recorded_as_failed_feedback() {
111        let dir = tempfile::tempdir().unwrap();
112        let runner = ActionRunner::new(dir.path().join("config.toml"), Replica::default());
113        assert!(!runner.run_blocking(&Action::Shutdown));
114        let feedback = runner.feedback.lock().clone().unwrap();
115        assert!(!feedback.ok);
116        assert!(feedback.text.contains("not reachable"), "{}", feedback.text);
117    }
118}