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}