Krolyxon
Commit 1504706

Branches

Tags

Refs →

bench51cratesbench51-guisrcapp.rs

661 lines · 22.4 KB · Rust

1//! The application: window chrome around a tree of docked panes, the run
2//! loop, file handling, and every action the UI can ask for.
3
4use std::path::{Path, PathBuf};
5
6use bench51_core::files::{self, Loaded};
7use eframe::egui::{
8 self, CentralPanel, Context, Key, Modifiers, Panel, Shape, ThemePreference, Ui, ViewportCommand,
9};
10use egui_tiles::Tree;
11
12use crate::layout::{self, Pane, PaneBehavior};
13use crate::state::{Action, BuildTab, LogKind, LogLine, Modal, Mode, Prefs, State, ThemePref};
14use crate::theme::{self, palette};
15use crate::{chrome, modals};
16
17/// One simulation chunk, as in the TUI's frame loop.
18const TICK: f64 = 1.0 / 30.0;
19
20const SOURCE_EXTENSIONS: [&str; 4] = ["asm", "a51", "s", "src"];
21const HEX_EXTENSIONS: [&str; 2] = ["hex", "ihx"];
22
23const TEMPLATE: &str = "\
24; New 8051 program. F7 builds, Ctrl+F5 starts debugging, F11 steps.
25 ORG 0000h
26 LJMP main
27
28 ORG 0030h
29main: MOV A,#01h ; walking LED pattern on P1
30loop: MOV P1,A
31 RL A
32 SJMP loop
33
34 END
35";
36
37pub struct GuiApp {
38 st: State,
39 edit_tree: Tree<Pane>,
40 debug_tree: Tree<Pane>,
41 last_time: Option<f64>,
42 tick_debt: f64,
43 /// The user confirmed quitting with unsaved changes.
44 allow_close: bool,
45 title: String,
46}
47
48fn theme_preference(t: ThemePref) -> ThemePreference {
49 match t {
50 ThemePref::System => ThemePreference::System,
51 ThemePref::Dark => ThemePreference::Dark,
52 ThemePref::Light => ThemePreference::Light,
53 }
54}
55
56fn load_tree(
57 cc: &eframe::CreationContext<'_>,
58 key: &str,
59 default: fn() -> Tree<Pane>,
60) -> Tree<Pane> {
61 let mut tree = cc
62 .storage
63 .and_then(|s| eframe::get_value::<Tree<Pane>>(s, key))
64 .filter(|t| t.tiles.find_pane(&Pane::Editor).is_some())
65 .unwrap_or_else(default);
66 // Panes added in newer versions are simply missing; nothing to fix up.
67 if tree.root.is_none() {
68 tree = default();
69 }
70 tree
71}
72
73impl GuiApp {
74 pub fn new(
75 cc: &eframe::CreationContext<'_>,
76 files: Vec<PathBuf>,
77 clock: Option<f64>,
78 theme: Option<ThemePref>,
79 ) -> Self {
80 let ctx = &cc.egui_ctx;
81 theme::install(ctx);
82 let mut prefs: Prefs = cc
83 .storage
84 .and_then(|s| eframe::get_value(s, "prefs"))
85 .unwrap_or_default();
86 if let Some(c) = clock {
87 prefs.clock_mhz = c;
88 }
89 if let Some(t) = theme {
90 prefs.theme = t;
91 }
92 prefs.speed = prefs.speed.min(bench51_core::session::SPEEDS.len() - 1);
93 ctx.set_theme(theme_preference(prefs.theme));
94 ctx.set_zoom_factor(prefs.zoom.clamp(0.5, 3.0));
95 let mut app = Self {
96 st: State::new(prefs),
97 edit_tree: load_tree(cc, "edit_layout", layout::edit_tree),
98 debug_tree: load_tree(cc, "debug_layout", layout::debug_tree),
99 last_time: None,
100 tick_debt: 0.0,
101 allow_close: false,
102 title: String::new(),
103 };
104 for f in files {
105 app.open_path(&f);
106 }
107 app
108 }
109
110 fn tree(&mut self) -> &mut Tree<Pane> {
111 match self.st.mode {
112 Mode::Edit => &mut self.edit_tree,
113 Mode::Debug => &mut self.debug_tree,
114 }
115 }
116
117 // ---- Input ----
118
119 fn shortcuts(&mut self, ctx: &Context) {
120 if self.st.modal.is_some() {
121 return;
122 }
123 let cmd = Modifiers::COMMAND;
124 let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
125 let none = Modifiers::NONE;
126 let running = self.st.running();
127 let active = self.st.active;
128 // Most specific first: `consume_key` ignores extra Shift.
129 let table: [(Modifiers, Key, Action); 25] = [
130 (cmd_shift, Key::S, Action::SaveAs),
131 (cmd, Key::S, Action::Save),
132 (none, Key::F2, Action::Save),
133 (cmd, Key::N, Action::New),
134 (cmd, Key::O, Action::Open),
135 (cmd, Key::W, Action::Close(active)),
136 (cmd, Key::Q, Action::Quit),
137 (cmd, Key::F, Action::Find),
138 (none, Key::F1, Action::Modal(Modal::Shortcuts)),
139 (none, Key::F7, Action::Build),
140 (cmd, Key::F5, Action::ToggleDebug),
141 (none, Key::F4, Action::ToggleDebug),
142 (none, Key::F5, Action::RunPause),
143 (cmd, Key::F11, Action::StepOut),
144 (none, Key::F12, Action::StepOut),
145 (none, Key::F11, Action::StepInto),
146 (cmd, Key::F10, Action::RunToCursor),
147 (none, Key::F10, Action::StepOver),
148 (none, Key::F8, Action::RunToCursor),
149 (cmd_shift, Key::F9, Action::ClearBreakpoints),
150 (none, Key::F9, Action::ToggleBreakpoint),
151 (none, Key::F6, Action::Reset),
152 (cmd, Key::Plus, Action::Zoom(0.1)),
153 (cmd, Key::Minus, Action::Zoom(-0.1)),
154 (cmd, Key::Num0, Action::ZoomReset),
155 ];
156 ctx.input_mut(|i| {
157 for (mods, key, action) in table {
158 if i.consume_key(mods, key) {
159 self.st.actions.push(action);
160 }
161 }
162 if running && i.consume_key(none, Key::Escape) {
163 self.st.actions.push(Action::RunPause);
164 }
165 });
166 }
167
168 fn handle_drops(&mut self, ctx: &Context) {
169 let dropped: Vec<PathBuf> = ctx.input(|i| {
170 i.raw
171 .dropped_files
172 .iter()
173 .map(|f| f.path().to_path_buf())
174 .filter(|p| !p.as_os_str().is_empty())
175 .collect()
176 });
177 for path in dropped {
178 self.open_path(&path);
179 }
180 }
181
182 fn handle_close(&mut self, ctx: &Context) {
183 if ctx.input(|i| i.viewport().close_requested())
184 && !self.allow_close
185 && self.st.docs.iter().any(|d| d.dirty())
186 {
187 ctx.send_viewport_cmd(ViewportCommand::CancelClose);
188 self.st.modal = Some(Modal::ConfirmQuit);
189 }
190 }
191
192 /// Advances the running program in fixed 33 ms chunks of wall time.
193 fn pace(&mut self, ctx: &Context) {
194 let now = ctx.input(|i| i.time);
195 let dt = self.last_time.map_or(0.0, |t| now - t);
196 self.last_time = Some(now);
197 let speed = self.st.prefs.speed;
198 let Some(doc) = self.st.doc_mut() else {
199 return;
200 };
201 doc.session.speed = speed;
202 if !doc.session.running {
203 self.tick_debt = 0.0;
204 return;
205 }
206 self.tick_debt = (self.tick_debt + dt).min(TICK * 3.0);
207 while self.tick_debt >= TICK && doc.session.running {
208 doc.session.tick();
209 self.tick_debt -= TICK;
210 }
211 if !doc.session.running || speed <= 2 {
212 doc.follow_pc = true;
213 }
214 if doc.session.running {
215 ctx.request_repaint();
216 }
217 }
218
219 // ---- Files ----
220
221 fn open_path(&mut self, path: &Path) {
222 let canon = path.canonicalize().ok();
223 if let Some(i) = self.st.docs.iter().position(|d| {
224 d.path.as_ref().and_then(|p| p.canonicalize().ok()) == canon && canon.is_some()
225 }) {
226 self.activate(i);
227 return;
228 }
229 // An untouched new document is replaced, as editors usually do.
230 if self.st.docs.len() == 1 {
231 let d = &self.st.docs[0];
232 if d.path.is_none() && d.label.is_none() && !d.dirty() && d.text == TEMPLATE {
233 self.st.docs.clear();
234 }
235 }
236 match files::load(path) {
237 Err(e) => {
238 self.st.log(LogKind::Error, e.clone());
239 if let Some(d) = self.st.doc_mut() {
240 d.session.status = e;
241 }
242 }
243 Ok(Loaded::Source(text)) => {
244 self.pause_active();
245 let doc = self.st.add_doc(Some(path.to_path_buf()), &text);
246 doc.session.status = format!("opened {}", path.display());
247 self.st.prefs.add_recent(path);
248 self.st
249 .log(LogKind::Info, format!("opened {}", path.display()));
250 }
251 Ok(Loaded::Hex { source, bytes }) => {
252 self.pause_active();
253 let name = path.file_name().map(|n| n.to_string_lossy().into_owned());
254 let doc = self.st.add_doc(None, &source);
255 doc.label = name;
256 // Not saved anywhere yet: Save asks for an .asm name.
257 doc.saved.clear();
258 self.st.prefs.add_recent(path);
259 self.build();
260 if let Some(d) = self.st.doc_mut() {
261 d.session.status = format!(
262 "loaded {} ({bytes} bytes) and disassembled it",
263 path.display()
264 );
265 }
266 }
267 }
268 }
269
270 fn start_dir(&self) -> Option<PathBuf> {
271 self.st
272 .doc()
273 .and_then(|d| d.path.as_ref())
274 .and_then(|p| p.parent().map(Path::to_path_buf))
275 .or_else(|| {
276 self.st
277 .prefs
278 .recent
279 .first()
280 .and_then(|p| p.parent().map(Path::to_path_buf))
281 })
282 .or_else(|| std::env::current_dir().ok())
283 }
284
285 fn open_dialog(&mut self) {
286 let mut all: Vec<&str> = SOURCE_EXTENSIONS.to_vec();
287 all.extend(HEX_EXTENSIONS);
288 let mut dialog = rfd::FileDialog::new()
289 .set_title("Open")
290 .add_filter("8051 source or Intel HEX", &all)
291 .add_filter("8051 assembly", &SOURCE_EXTENSIONS)
292 .add_filter("Intel HEX", &HEX_EXTENSIONS)
293 .add_filter("All files", &["*"]);
294 if let Some(dir) = self.start_dir() {
295 dialog = dialog.set_directory(dir);
296 }
297 for path in dialog.pick_files().unwrap_or_default() {
298 self.open_path(&path);
299 }
300 }
301
302 fn save(&mut self, ask: bool) {
303 let dir = self.start_dir();
304 let Some(doc) = self.st.doc_mut() else {
305 return;
306 };
307 let path = match (&doc.path, ask) {
308 (Some(p), false) => Some(p.clone()),
309 _ => {
310 let name = doc.name();
311 let stem = name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s);
312 let mut dialog = rfd::FileDialog::new()
313 .set_title("Save as")
314 .add_filter("8051 assembly", &SOURCE_EXTENSIONS)
315 .set_file_name(format!("{stem}.asm"));
316 if let Some(dir) = dir {
317 dialog = dialog.set_directory(dir);
318 }
319 dialog.save_file()
320 }
321 };
322 let Some(path) = path else {
323 return;
324 };
325 let mut contents = doc.text.clone();
326 if !contents.ends_with('\n') {
327 contents.push('\n');
328 }
329 match std::fs::write(&path, contents) {
330 Ok(()) => {
331 doc.saved = doc.text.clone();
332 doc.session.status = format!("saved {}", path.display());
333 doc.path = Some(path.clone());
334 doc.label = None;
335 self.st.prefs.add_recent(&path);
336 }
337 Err(e) => {
338 let msg = format!("cannot save {}: {e}", path.display());
339 doc.session.status = msg.clone();
340 self.st.log(LogKind::Error, msg);
341 }
342 }
343 }
344
345 fn close(&mut self, i: usize) {
346 if i >= self.st.docs.len() {
347 return;
348 }
349 self.st.docs.remove(i);
350 if self.st.active >= i && self.st.active > 0 {
351 self.st.active -= 1;
352 }
353 if self.st.docs.is_empty() {
354 self.st.mode = Mode::Edit;
355 self.st.active = 0;
356 }
357 }
358
359 fn pause_active(&mut self) {
360 if let Some(d) = self.st.doc_mut()
361 && d.session.running
362 {
363 d.session.pause();
364 }
365 }
366
367 fn activate(&mut self, i: usize) {
368 if i < self.st.docs.len() && i != self.st.active {
369 self.pause_active();
370 self.st.active = i;
371 }
372 }
373
374 // ---- Build & debug ----
375
376 /// Assembles the active document and reports Keil-style in the Build
377 /// Output. Returns whether it built without errors.
378 fn build(&mut self) -> bool {
379 let Some(doc) = self.st.docs.get_mut(self.st.active) else {
380 return false;
381 };
382 let (name, target, id) = (doc.name(), doc.target(), doc.id);
383 let ok = doc.session.assemble(&doc.text, doc.path.as_deref());
384 let mut lines = vec![
385 (LogKind::Info, format!("Build target '{target}'"), None),
386 (LogKind::Info, format!("assembling {name}..."), None),
387 ];
388 let out = &doc.session.last_asm.as_ref().unwrap().1;
389 if ok {
390 lines.push((
391 LogKind::Info,
392 format!("Program Size: code={} bytes", out.code_size()),
393 None,
394 ));
395 if doc.path.is_some() {
396 lines.push((
397 LogKind::Info,
398 format!("creating hex file from \"{target}\"..."),
399 None,
400 ));
401 }
402 if doc.session.status.contains("failed") {
403 lines.push((LogKind::Error, doc.session.status.clone(), None));
404 }
405 lines.push((LogKind::Ok, format!("\"{target}\" - 0 Error(s)"), None));
406 doc.follow_pc = true;
407 } else {
408 for d in &out.diagnostics {
409 lines.push((
410 LogKind::Error,
411 format!("{name}({}:{}): error: {}", d.line + 1, d.col + 1, d.msg),
412 Some((id, d.line, d.col)),
413 ));
414 }
415 lines.push((
416 LogKind::Error,
417 format!("\"{target}\" - {} Error(s)", out.diagnostics.len()),
418 None,
419 ));
420 let first = &out.diagnostics[0];
421 doc.goto = Some((first.line, first.col));
422 }
423 for (kind, text, target) in lines {
424 self.st.log.push(LogLine { text, kind, target });
425 }
426 self.st.log(LogKind::Info, "");
427 if !ok {
428 self.st.mode = Mode::Edit;
429 self.st.build_tab = BuildTab::Messages;
430 layout::show_pane(&mut self.edit_tree, Pane::Build);
431 }
432 ok
433 }
434
435 fn ensure_built(&mut self) -> bool {
436 match self.st.doc() {
437 None => false,
438 Some(d) if d.stale() => self.build(),
439 Some(_) => true,
440 }
441 }
442
443 /// Runs a debugger command on the active document, building first and
444 /// entering the debug layout, as Keil does.
445 fn debug_cmd(&mut self, f: impl FnOnce(&mut bench51_core::session::Session, (usize, usize))) {
446 if !self.ensure_built() {
447 return;
448 }
449 self.st.mode = Mode::Debug;
450 if let Some(doc) = self.st.doc_mut() {
451 f(&mut doc.session, doc.cursor);
452 doc.follow_pc = true;
453 }
454 }
455
456 fn apply(&mut self, ctx: &Context, action: Action) {
457 match action {
458 Action::New => {
459 self.pause_active();
460 self.st.add_doc(None, TEMPLATE);
461 }
462 Action::Open => self.open_dialog(),
463 Action::OpenPath(p) => self.open_path(&p),
464 Action::Save => self.save(false),
465 Action::SaveAs => self.save(true),
466 Action::Close(i) => {
467 if self.st.docs.get(i).is_some_and(|d| d.dirty()) {
468 self.st.modal = Some(Modal::ConfirmClose(i));
469 } else {
470 self.close(i);
471 }
472 }
473 Action::ForceClose(i) => self.close(i),
474 Action::Activate(i) => self.activate(i),
475 Action::Quit => ctx.send_viewport_cmd(ViewportCommand::Close),
476 Action::ForceQuit => {
477 self.allow_close = true;
478 ctx.send_viewport_cmd(ViewportCommand::Close);
479 }
480 Action::Build => {
481 if self.build() && self.st.mode == Mode::Edit {
482 self.st.build_tab = BuildTab::Messages;
483 }
484 }
485 Action::ToggleDebug => match self.st.mode {
486 Mode::Edit => {
487 if self.ensure_built() {
488 self.st.mode = Mode::Debug;
489 if let Some(d) = self.st.doc_mut() {
490 d.follow_pc = true;
491 }
492 }
493 }
494 Mode::Debug => {
495 self.pause_active();
496 self.st.mode = Mode::Edit;
497 }
498 },
499 Action::RunPause => {
500 if self.st.running() {
501 self.pause_active();
502 if let Some(d) = self.st.doc_mut() {
503 d.follow_pc = true;
504 }
505 } else {
506 self.debug_cmd(|s, _| s.start());
507 }
508 }
509 Action::StepInto => self.debug_cmd(|s, _| s.step_into()),
510 Action::StepOver => self.debug_cmd(|s, _| s.step_over()),
511 Action::StepOut => self.debug_cmd(|s, _| s.step_out()),
512 Action::RunToCursor => self.debug_cmd(|s, (line, _)| {
513 s.run_to_line(line);
514 }),
515 Action::Reset => {
516 if let Some(d) = self.st.doc_mut() {
517 d.session.reset();
518 d.follow_pc = true;
519 }
520 }
521 Action::ToggleBreakpoint => {
522 if let Some(d) = self.st.doc_mut() {
523 d.session.toggle_breakpoint(d.cursor.0);
524 }
525 }
526 Action::ClearBreakpoints => {
527 if let Some(d) = self.st.doc_mut() {
528 d.session.breakpoints.clear();
529 d.session.status = "all breakpoints removed".into();
530 }
531 }
532 Action::SetSpeed(i) => self.st.prefs.speed = i,
533 Action::SetClock(mhz) => {
534 self.st.prefs.clock_mhz = mhz;
535 for d in &mut self.st.docs {
536 d.session.clock_mhz = mhz;
537 }
538 }
539 Action::Goto { doc, line, col } => {
540 if let Some(i) = self.st.docs.iter().position(|d| d.id == doc) {
541 self.activate(i);
542 self.st.docs[i].goto = Some((line, col));
543 layout::show_pane(self.tree(), Pane::Editor);
544 }
545 }
546 Action::ShowPane(p) => layout::show_pane(self.tree(), p),
547 Action::ResetLayout => {
548 *self.tree() = match self.st.mode {
549 Mode::Edit => layout::edit_tree(),
550 Mode::Debug => layout::debug_tree(),
551 }
552 }
553 Action::SetTheme(t) => {
554 self.st.prefs.theme = t;
555 ctx.set_theme(theme_preference(t));
556 }
557 Action::Zoom(d) => ctx.set_zoom_factor((ctx.zoom_factor() + d).clamp(0.5, 3.0)),
558 Action::ZoomReset => ctx.set_zoom_factor(1.0),
559 Action::Find => {
560 if self.st.doc().is_some() {
561 self.st.find.open = true;
562 self.st.find.focus = true;
563 }
564 }
565 Action::Modal(m) => self.st.modal = Some(m),
566 }
567 }
568
569 fn update_title(&mut self, ctx: &Context) {
570 let title = match self.st.doc() {
571 Some(d) => format!(
572 "{}{} — bench51",
573 d.name(),
574 if d.dirty() { " •" } else { "" }
575 ),
576 None => "bench51".to_string(),
577 };
578 if title != self.title {
579 ctx.send_viewport_cmd(ViewportCommand::Title(title.clone()));
580 self.title = title;
581 }
582 }
583}
584
585impl eframe::App for GuiApp {
586 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
587 let ctx = ui.ctx().clone();
588 let p = palette(&ctx);
589 self.shortcuts(&ctx);
590 self.handle_drops(&ctx);
591 self.handle_close(&ctx);
592 self.pace(&ctx);
593 self.st.prefs.zoom = ctx.zoom_factor();
594
595 let bar = |top: i8, bottom: i8| {
596 egui::Frame::NONE.fill(p.black).inner_margin(egui::Margin {
597 left: 10,
598 right: 10,
599 top,
600 bottom,
601 })
602 };
603 Panel::top("menu")
604 .frame(bar(4, 0))
605 .show_separator_line(false)
606 .show(ui, |ui| chrome::menu_bar(ui, &mut self.st));
607 Panel::top("toolbar")
608 .frame(bar(4, 6))
609 .show_separator_line(false)
610 .show(ui, |ui| chrome::toolbar(ui, &mut self.st));
611 Panel::bottom("status")
612 .frame(bar(6, 6))
613 .show_separator_line(false)
614 .show(ui, |ui| chrome::status_bar(ui, &self.st));
615
616 CentralPanel::default()
617 .frame(
618 egui::Frame::NONE
619 .fill(p.black)
620 .inner_margin(egui::Margin::symmetric(6, 0)),
621 )
622 .show(ui, |ui| {
623 let tree = match self.st.mode {
624 Mode::Edit => &mut self.edit_tree,
625 Mode::Debug => &mut self.debug_tree,
626 };
627 let cards = ui.painter().add(Shape::Noop);
628 let editor_tile = tree.tiles.find_pane(&Pane::Editor);
629 let mut behavior = PaneBehavior {
630 st: &mut self.st,
631 editor_tile,
632 };
633 tree.ui(&mut behavior, ui);
634 ui.painter()
635 .set(cards, Shape::Vec(layout::card_shapes(tree, p.surface)));
636 });
637
638 modals::show(&ctx, &mut self.st);
639 for action in std::mem::take(&mut self.st.actions) {
640 self.apply(&ctx, action);
641 }
642 self.update_title(&ctx);
643 }
644
645 fn save(&mut self, storage: &mut dyn eframe::Storage) {
646 eframe::set_value(storage, "prefs", &self.st.prefs);
647 eframe::set_value(storage, "edit_layout", &self.edit_tree);
648 eframe::set_value(storage, "debug_layout", &self.debug_tree);
649 }
650
651 /// Layouts, theme and zoom are saved above. egui's own memory would
652 /// restore scroll positions keyed by document number onto whatever file
653 /// gets that number next session.
654 fn persist_egui_memory(&self) -> bool {
655 false
656 }
657
658 fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {
659 visuals.panel_fill.to_normalized_gamma_f32()
660 }
661}