cog_task/launcher/
mod.rs

1use crate::assets::{Icon, VERSION};
2use crate::comm::QReader;
3use crate::gui::{
4    self, style_ui, text::button1, text::tooltip, Style, TEXT_SIZE_DIALOGUE_BODY,
5    TEXT_SIZE_DIALOGUE_TITLE,
6};
7use crate::util::SystemInfo;
8use eframe::egui::{Align, CursorIcon, Layout, Vec2, Window};
9use eframe::glow::HasContext;
10use eframe::{egui, App, Storage};
11use egui::widget_text::RichText;
12use egui_extras::{Size, StripBuilder};
13use heck::ToTitleCase;
14use itertools::Itertools;
15use native_dialog::FileDialog;
16use std::env::{current_dir, current_exe};
17use std::path::PathBuf;
18use std::thread;
19use std::time::Duration;
20
21enum Status {
22    None,
23    Result(String),
24    SystemInfo,
25    Help,
26}
27
28pub struct Launcher {
29    root_dir: PathBuf,
30    task_paths: Vec<PathBuf>,
31    task_labels: Vec<String>,
32    busy: bool,
33    active_task: Option<String>,
34    status: Status,
35    sys_info: SystemInfo,
36    sync_reader: QReader<LauncherSignal>,
37}
38
39impl Default for Launcher {
40    fn default() -> Self {
41        let root_dir = current_dir()
42            .expect("Unable to get current directory.")
43            .parent()
44            .unwrap()
45            .to_path_buf();
46
47        Self::new(root_dir)
48    }
49}
50
51impl Launcher {
52    pub fn new(root_dir: PathBuf) -> Self {
53        if let Ok(content) = root_dir.read_dir() {
54            let task_paths: Vec<_> = content
55                .into_iter()
56                .filter_map(|e| {
57                    if let Ok(e) = e {
58                        if let Ok(t) = e.file_type() {
59                            if t.is_dir() && e.path().join("task.ron").exists() {
60                                return Some(e.path());
61                            }
62                        }
63                    }
64                    None
65                })
66                .sorted()
67                .collect();
68
69            let task_labels: Vec<_> = task_paths
70                .iter()
71                .map(|p| p.file_name().unwrap().to_str().unwrap().to_title_case())
72                .collect();
73
74            Self {
75                root_dir,
76                task_paths,
77                task_labels,
78                busy: false,
79                active_task: None,
80                status: Status::None,
81                sys_info: SystemInfo::new(),
82                sync_reader: QReader::new(),
83            }
84        } else {
85            Self {
86                root_dir,
87                task_paths: vec![],
88                task_labels: vec![],
89                busy: false,
90                active_task: None,
91                status: Status::None,
92                sys_info: SystemInfo::new(),
93                sync_reader: QReader::new(),
94            }
95        }
96    }
97
98    pub fn window_size(&self) -> Vec2 {
99        let count = self.task_paths.len() as u32;
100        let width = 580;
101        let height = (180 + count * 75).max(260).min(700);
102        Vec2::from([width as f32, height as f32])
103    }
104
105    fn run_task(&mut self, task: PathBuf) {
106        if task.file_name().is_none() || self.busy {
107            return;
108        }
109
110        let curr = current_dir().unwrap();
111        let root = current_exe().unwrap().parent().unwrap().to_path_buf();
112        let path = root.join("cog-server").to_str().unwrap().to_owned();
113        let mut sync_writer = self.sync_reader.writer();
114        self.busy = true;
115        self.active_task = Some(task.file_name().unwrap().to_str().unwrap().to_title_case());
116        thread::spawn(move || {
117            use std::process::Command;
118            let proc = Command::new(path).current_dir(curr).arg(task).output();
119
120            match proc {
121                Ok(o) => {
122                    let stdout = o.stdout.into_iter().map(|c| c as char).collect::<String>();
123                    let stderr = o.stderr.into_iter().map(|c| c as char).collect::<String>();
124                    if !stdout.is_empty() {
125                        println!("\n{stdout}");
126                    }
127                    if !stderr.is_empty() {
128                        eprintln!("\n{stderr}");
129                        sync_writer.push(LauncherSignal::TaskCrashed(stderr));
130                    } else {
131                        sync_writer.push(LauncherSignal::TaskClosed);
132                    }
133                }
134                Err(e) => {
135                    let status = format!(
136                        "Failed to spawn `cog-server`.\nMake sure it is adjacent to `cog-launcher`.\n\n{e:#?}"
137                    );
138                    println!("\nEE: {status}");
139                    sync_writer.push(LauncherSignal::TaskCrashed(status));
140                }
141            }
142        });
143    }
144
145    #[inline(always)]
146    pub fn title() -> &'static str {
147        "CogTask Launcher"
148    }
149
150    pub fn run(mut self) {
151        let options = eframe::NativeOptions {
152            always_on_top: false,
153            maximized: false,
154            decorated: true,
155            fullscreen: false,
156            drag_and_drop_support: false,
157            icon_data: None,
158            initial_window_pos: None,
159            initial_window_size: Some(self.window_size() * 2.0),
160            min_window_size: None,
161            max_window_size: None,
162            resizable: false,
163            transparent: false,
164            vsync: true,
165            multisampling: 0,
166            depth_buffer: 0,
167            stencil_buffer: 0,
168            hardware_acceleration: eframe::HardwareAcceleration::Preferred,
169            renderer: Default::default(),
170            follow_system_theme: false,
171            default_theme: eframe::Theme::Light,
172            run_and_return: false,
173        };
174
175        self.sys_info.renderer = format!("{:#?}", options.renderer);
176        self.sys_info.hw_acceleration = format!("{:#?}", options.hardware_acceleration);
177
178        eframe::run_native(
179            Self::title(),
180            options,
181            Box::new(|cc| {
182                gui::init(&cc.egui_ctx);
183                if let Some(gl) = &cc.gl {
184                    self.sys_info
185                        .renderer
186                        .push_str(&format!(" ({:?})", gl.version()))
187                }
188
189                if let Some(storage) = cc.storage {
190                    if let Some(root_dir) = storage.get_string("root_dir") {
191                        let sys_info = self.sys_info.clone();
192                        self = Self::new(PathBuf::from(root_dir));
193                        self.sys_info = sys_info;
194                    }
195                }
196
197                Box::new(self)
198            }),
199        );
200    }
201
202    fn process(&mut self, msg: LauncherSignal) {
203        match (self.busy, msg) {
204            (true, LauncherSignal::TaskClosed) => {
205                self.busy = false;
206            }
207            (true, LauncherSignal::TaskCrashed(status)) => {
208                self.status = Status::Result(status);
209                self.busy = false;
210            }
211            _ => {}
212        };
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Hash)]
217pub enum LauncherSignal {
218    TaskCrashed(String),
219    TaskClosed,
220}
221
222impl App for Launcher {
223    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
224        while let Some(message) = self.sync_reader.try_pop() {
225            self.process(message);
226        }
227
228        if ctx.input().key_pressed(egui::Key::Escape) {
229            self.status = Status::None;
230        }
231
232        frame.set_window_size(self.window_size());
233
234        self.show(ctx);
235
236        ctx.set_pixels_per_point(2.0);
237        ctx.request_repaint_after(Duration::from_millis(250));
238    }
239
240    fn save(&mut self, storage: &mut dyn Storage) {
241        if let Ok(root_dir) = self.root_dir.canonicalize() {
242            storage.set_string("root_dir", root_dir.to_str().unwrap().to_string());
243        }
244        storage.flush();
245        thread::sleep(Duration::from_secs_f32(0.5));
246    }
247}
248
249impl Launcher {
250    fn show(&mut self, ctx: &egui::Context) {
251        let frame = egui::Frame::window(&ctx.style())
252            .inner_margin(0.0)
253            .outer_margin(0.0);
254
255        egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
256            if self.busy {
257                ui.output().cursor_icon = CursorIcon::NotAllowed;
258            }
259
260            ui.add_enabled_ui(!self.busy, |ui| {
261                StripBuilder::new(ui)
262                    .size(Size::exact(10.0))
263                    .size(Size::exact(55.0))
264                    .size(Size::exact(52.0))
265                    .size(Size::exact(14.0))
266                    .size(Size::exact(4.0))
267                    .size(Size::exact(20.0))
268                    .size(Size::remainder())
269                    .size(Size::exact(20.0))
270                    .vertical(|mut strip| {
271                        strip.empty();
272
273                        strip.cell(|ui| {
274                            ui.centered_and_justified(|ui| {
275                                ui.heading(if self.busy {
276                                    format!("CogTask v{VERSION} (busy)")
277                                } else {
278                                    format!("CogTask v{VERSION}")
279                                });
280                            });
281                        });
282
283                        strip.cell(|ui| {
284                            StripBuilder::new(ui)
285                                .size(Size::remainder())
286                                .size(Size::exact(240.0))
287                                .size(Size::remainder())
288                                .horizontal(|mut strip| {
289                                    strip.empty();
290                                    strip.cell(|ui| self.show_controls(ui));
291                                    strip.empty();
292                                });
293                        });
294
295                        strip.empty();
296
297                        strip.strip(|builder| {
298                            builder
299                                .size(Size::remainder())
300                                .size(Size::exact(240.0))
301                                .size(Size::remainder())
302                                .horizontal(|mut strip| {
303                                    strip.empty();
304                                    strip.cell(|ui| {
305                                        ui.vertical_centered_justified(|ui| {
306                                            ui.separator();
307                                        });
308                                    });
309                                    strip.empty();
310                                });
311                        });
312
313                        strip.empty();
314
315                        strip.cell(|ui| self.show_tasks(ui));
316
317                        strip.empty();
318                    });
319            });
320        });
321
322        if !matches!(self.status, Status::None) {
323            self.show_status(ctx);
324        }
325    }
326
327    fn show_controls(&mut self, ui: &mut egui::Ui) {
328        enum Interaction {
329            None,
330            LoadTask,
331            LoadTaskRepo,
332            ShowSystemInfo,
333            ShowHelp,
334        }
335
336        let mut interaction = Interaction::None;
337
338        style_ui(ui, Style::IconControls);
339        ui.columns(4, |columns| {
340            if columns[0]
341                .button(Icon::Folder)
342                .on_hover_text(tooltip("Load task"))
343                .clicked()
344            {
345                interaction = Interaction::LoadTask;
346            }
347            if columns[1]
348                .button(Icon::FolderTree)
349                .on_hover_text(tooltip("Load task catalogue"))
350                .clicked()
351            {
352                interaction = Interaction::LoadTaskRepo;
353            }
354            if columns[2]
355                .button(Icon::SystemInfo)
356                .on_hover_text(tooltip("System information"))
357                .clicked()
358            {
359                interaction = Interaction::ShowSystemInfo;
360            }
361            if columns[3]
362                .button(Icon::Help)
363                .on_hover_text(tooltip("Help"))
364                .clicked()
365            {
366                interaction = Interaction::ShowHelp;
367            }
368        });
369
370        match interaction {
371            Interaction::None => {}
372            Interaction::LoadTask => {
373                let path = FileDialog::new()
374                    .set_location(&self.root_dir)
375                    .show_open_single_dir();
376
377                match path {
378                    Ok(Some(path)) => self.run_task(path),
379                    Ok(None) => {}
380                    Err(e) => {
381                        self.status = Status::Result(format!(
382                            "Failed to open file dialog. Are you on a VM?\n\n({e:?})"
383                        ));
384                    }
385                }
386            }
387            Interaction::LoadTaskRepo => {
388                let path = FileDialog::new()
389                    .set_location(&self.root_dir)
390                    .show_open_single_dir();
391
392                match path {
393                    Ok(Some(path)) => {
394                        let sys_info = self.sys_info.clone();
395                        *self = Self::new(path);
396                        self.sys_info = sys_info;
397                    }
398                    Ok(None) => {}
399                    Err(e) => {
400                        self.status = Status::Result(format!(
401                            "Failed to open file dialog. Are you on a VM?\n\n({e:?})"
402                        ))
403                    }
404                }
405            }
406            Interaction::ShowSystemInfo => {
407                self.status = Status::SystemInfo;
408            }
409            Interaction::ShowHelp => {
410                self.status = Status::Help;
411            }
412        }
413    }
414
415    fn show_tasks(&mut self, ui: &mut egui::Ui) {
416        enum Interaction {
417            None,
418            StartTask(usize),
419        }
420
421        let mut interaction = Interaction::None;
422
423        let task_buttons: Vec<_> = self
424            .task_labels
425            .iter()
426            .map(|label| egui::Button::new(button1(label)))
427            .collect();
428
429        if task_buttons.is_empty() {
430            ui.centered_and_justified(|ui| {
431                ui.label("(No tasks found in task directory)");
432            });
433        } else {
434            egui::ScrollArea::vertical().show(ui, |ui| {
435                ui.vertical_centered(|ui| {
436                    style_ui(ui, Style::SelectButton);
437                    ui.spacing_mut().item_spacing = Vec2::new(25.0, 20.0);
438                    for (i, button) in task_buttons.into_iter().enumerate() {
439                        if ui.add(button).clicked() {
440                            interaction = Interaction::StartTask(i);
441                        }
442                    }
443                });
444            });
445        }
446
447        match interaction {
448            Interaction::None => {}
449            Interaction::StartTask(i) => self.run_task(self.task_paths[i].clone()),
450        }
451    }
452
453    fn show_status(&mut self, ctx: &egui::Context) {
454        if matches!(self.status, Status::None) {
455            return;
456        }
457
458        let (title, content) = match &self.status {
459            Status::Result(status) => ("Status", status.to_owned()),
460            Status::SystemInfo => ("System Info", format!("{:#?}", self.sys_info)),
461            Status::Help => ("Help", "...".to_owned()),
462            _ => ("", "".to_owned()),
463        };
464
465        let (width, height) = if matches!(self.status, Status::Result(_)) {
466            (560.0, 250.0)
467        } else {
468            (560.0, 200.0)
469        };
470
471        let mut open = true;
472        Window::new(
473            RichText::from(title)
474                .size(TEXT_SIZE_DIALOGUE_TITLE)
475                .strong(),
476        )
477        .collapsible(false)
478        .open(&mut open)
479        .vscroll(true)
480        .min_width(width)
481        .default_size(Vec2::new(width, height))
482        .show(ctx, |ui| {
483            ui.with_layout(Layout::top_down(Align::Center), |ui| {
484                ui.label(RichText::from(content.clone()).size(TEXT_SIZE_DIALOGUE_BODY * 0.9));
485            })
486            .response
487            .context_menu(|ui| {
488                if ui
489                    .button(RichText::new("Copy").size(TEXT_SIZE_DIALOGUE_BODY * 0.9))
490                    .clicked()
491                {
492                    ui.close_menu();
493                    ui.output().copied_text = content.trim().to_owned();
494                }
495            });
496        });
497        if !open {
498            self.status = Status::None;
499        }
500    }
501}