Krolyxon
Commit 8756c54

Branches

Tags

Refs →

bench51srcuiapp.rs

675 lines · 22.5 KB · Rust

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use bench51_core::files::{self, Loaded};
5use bench51_core::session::{MemSpace, SPEEDS, Session};
6use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
7use ratatui_textarea::{CursorMove, TextArea};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Mode {
11 Edit,
12 Debug,
13}
14
15/// Right-hand pane in edit mode.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutTab {
18 Listing,
19 Hex,
20 Errors,
21}
22
23/// Focusable panels in debug mode.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Focus {
26 Source,
27 Memory,
28 Ports,
29 Serial,
30}
31
32pub type MemTab = MemSpace;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PromptKind {
36 Open,
37 SaveAs,
38 /// `addr value` poke into the memory shown in the memory panel.
39 Poke,
40}
41
42pub struct Prompt {
43 pub kind: PromptKind,
44 pub input: String,
45}
46
47pub struct App {
48 pub path: Option<PathBuf>,
49 pub editor: TextArea<'static>,
50 saved_text: String,
51 pub mode: Mode,
52 pub out_tab: OutTab,
53 pub focus: Focus,
54 pub mem_tab: MemTab,
55 pub mem_scroll: usize,
56 /// Simulator, build, breakpoints and run state.
57 pub session: Session,
58 /// Selected line in the debug source view.
59 pub src_cursor: usize,
60 pub src_scroll: usize,
61 pub out_scroll: usize,
62 pub port_sel: (usize, u8),
63 pub serial_input: String,
64 pub show_help: bool,
65 pub prompt: Option<Prompt>,
66 pub quit: bool,
67 quit_armed: bool,
68 /// Set by Ctrl-G; the event loop suspends the TUI and calls
69 /// `edit_externally`.
70 pub external_edit: bool,
71}
72
73impl App {
74 pub fn new(path: Option<PathBuf>, clock_mhz: f64) -> Self {
75 let mut app = Self {
76 path: None,
77 editor: TextArea::default(),
78 saved_text: String::new(),
79 mode: Mode::Edit,
80 out_tab: OutTab::Listing,
81 focus: Focus::Source,
82 mem_tab: MemTab::Iram,
83 mem_scroll: 0,
84 session: Session::new(clock_mhz),
85 src_cursor: 0,
86 src_scroll: 0,
87 out_scroll: 0,
88 port_sel: (1, 0),
89 serial_input: String::new(),
90 show_help: false,
91 prompt: None,
92 quit: false,
93 quit_armed: false,
94 external_edit: false,
95 };
96 app.session.status = "F1 help · F7 assemble · F5 run · F11 step".into();
97 match path {
98 Some(p) => app.open(&p),
99 None => app.set_text(TEMPLATE),
100 }
101 app
102 }
103
104 fn set_text(&mut self, text: &str) {
105 let lines: Vec<String> = text.lines().map(String::from).collect();
106 self.editor = TextArea::new(if lines.is_empty() {
107 vec![String::new()]
108 } else {
109 lines
110 });
111 crate::ui::draw::style_editor(&mut self.editor);
112 self.saved_text = self.text();
113 self.session.clear();
114 self.focus = Focus::Source;
115 self.serial_input.clear();
116 }
117
118 pub fn text(&self) -> String {
119 self.editor.lines().join("\n")
120 }
121
122 pub fn dirty(&self) -> bool {
123 self.text() != self.saved_text
124 }
125
126 /// Source differs from what the simulator is running.
127 pub fn stale(&self) -> bool {
128 self.session
129 .is_stale(self.editor.lines().iter().map(String::as_str))
130 }
131
132 pub fn title(&self) -> String {
133 let name = self
134 .path
135 .as_ref()
136 .map_or("untitled.asm".to_string(), |p| p.display().to_string());
137 format!("{name}{}", if self.dirty() { " [+]" } else { "" })
138 }
139
140 // ---- Files ----
141
142 fn open(&mut self, path: &Path) {
143 match files::load(path) {
144 Err(e) => self.session.status = e,
145 Ok(Loaded::Hex { source, bytes }) => {
146 self.set_text(&source);
147 self.saved_text.clear();
148 self.path = None;
149 self.assemble();
150 self.session.status = format!(
151 "loaded {} ({bytes} bytes) and disassembled it",
152 path.display()
153 );
154 }
155 Ok(Loaded::Source(text)) => {
156 self.set_text(&text);
157 self.path = Some(path.to_path_buf());
158 self.mode = Mode::Edit;
159 self.session.status = format!("opened {}", path.display());
160 }
161 }
162 }
163
164 fn save(&mut self, path: Option<PathBuf>) {
165 let Some(path) = path.or_else(|| self.path.clone()) else {
166 self.prompt = Some(Prompt {
167 kind: PromptKind::SaveAs,
168 input: String::new(),
169 });
170 return;
171 };
172 let mut text = self.text();
173 text.push('\n');
174 match std::fs::write(&path, text) {
175 Ok(()) => {
176 self.saved_text = self.text();
177 self.session.status = format!("saved {}", path.display());
178 self.path = Some(path);
179 }
180 Err(e) => self.session.status = format!("cannot save {}: {e}", path.display()),
181 }
182 }
183
184 /// Opens the source in `$EDITOR` and loads the result back. The
185 /// caller must have suspended the TUI. A saved file is edited in place
186 /// (unsaved changes are saved first); an untitled buffer goes through
187 /// a temporary file.
188 pub fn edit_externally(&mut self) {
189 let (path, temp) = match self.path.clone() {
190 Some(p) => {
191 if self.dirty() {
192 self.save(None);
193 if self.dirty() {
194 return; // save failed; `save` set the status
195 }
196 }
197 (p, false)
198 }
199 None => {
200 let p = std::env::temp_dir().join(format!("bench51-{}.asm", std::process::id()));
201 if let Err(e) = std::fs::write(&p, self.text() + "\n") {
202 self.session.status = format!("cannot write {}: {e}", p.display());
203 return;
204 }
205 (p, true)
206 }
207 };
208
209 let line = self.editor.cursor().0 + 1;
210 let result = editor_command(&path, line).status();
211 let text = std::fs::read_to_string(&path);
212 if temp {
213 let _ = std::fs::remove_file(&path);
214 }
215 match (result, text) {
216 (Err(e), _) => self.session.status = format!("cannot start editor: {e}"),
217 (Ok(s), _) if !s.success() => {
218 self.session.status = format!("editor exited with {s}; changes not loaded")
219 }
220 (Ok(_), Err(e)) => self.session.status = format!("cannot read {}: {e}", path.display()),
221 (Ok(_), Ok(text)) => self.reload(&text, !temp),
222 }
223 }
224
225 /// Replaces the buffer with externally edited text, keeping the cursor
226 /// and breakpoints, and reassembles if anything changed.
227 fn reload(&mut self, text: &str, from_saved_file: bool) {
228 let lines: Vec<String> = text.lines().map(String::from).collect();
229 let lines = if lines.is_empty() {
230 vec![String::new()]
231 } else {
232 lines
233 };
234 if lines == self.editor.lines() {
235 self.session.status = "no changes from external editor".into();
236 return;
237 }
238 let cursor = self.editor.cursor();
239 let (row, col) = (cursor.0, cursor.1);
240 self.editor = TextArea::new(lines);
241 crate::ui::draw::style_editor(&mut self.editor);
242 self.editor
243 .move_cursor(CursorMove::Jump(row as u16, col as u16));
244 if from_saved_file {
245 self.saved_text = self.text();
246 }
247 let n = self.editor.lines().len();
248 self.session.breakpoints.retain(|&l| l < n);
249 if self.assemble() {
250 self.session.status = format!("reloaded from external editor; {}", self.session.status);
251 }
252 }
253
254 // ---- Build & run ----
255
256 /// Assembles the editor contents. On success the program is loaded
257 /// into a freshly reset simulator; on errors the cursor jumps to the
258 /// first one.
259 pub fn assemble(&mut self) -> bool {
260 let source = self.text();
261 let ok = self.session.assemble(&source, self.path.as_deref());
262 if ok {
263 self.follow_pc();
264 } else if let Some((_, out)) = &self.session.last_asm {
265 let first = &out.diagnostics[0];
266 self.editor
267 .move_cursor(CursorMove::Jump(first.line as u16, first.col as u16));
268 self.mode = Mode::Edit;
269 self.out_tab = OutTab::Errors;
270 }
271 self.out_scroll = 0;
272 ok
273 }
274
275 /// Makes sure an up-to-date build is loaded; returns false on errors.
276 fn ensure_built(&mut self) -> bool {
277 if self.stale() && !self.assemble() {
278 return false;
279 }
280 if self.mode == Mode::Edit {
281 self.mode = Mode::Debug;
282 self.focus = Focus::Source;
283 }
284 true
285 }
286
287 /// Moves the source cursor to the PC line.
288 pub fn follow_pc(&mut self) {
289 if let Some(l) = self.session.pc_line() {
290 self.src_cursor = l;
291 }
292 }
293
294 /// Runs a session command once a build is loaded, then follows the PC.
295 fn debug_cmd(&mut self, f: impl FnOnce(&mut Session)) {
296 if !self.ensure_built() {
297 return;
298 }
299 f(&mut self.session);
300 self.follow_pc();
301 }
302
303 fn toggle_run(&mut self) {
304 if self.session.running {
305 self.session.pause();
306 self.follow_pc();
307 } else if self.ensure_built() {
308 self.session.start();
309 }
310 }
311
312 /// Source line the cursor is on in the current mode.
313 fn cursor_line(&self) -> usize {
314 if self.mode == Mode::Debug {
315 self.src_cursor
316 } else {
317 self.editor.cursor().0
318 }
319 }
320
321 fn run_to_cursor(&mut self) {
322 if !self.ensure_built() {
323 return;
324 }
325 let line = self.cursor_line();
326 self.debug_cmd(|s| {
327 s.run_to_line(line);
328 });
329 }
330
331 fn reset(&mut self) {
332 self.session.reset();
333 self.follow_pc();
334 }
335
336 fn toggle_breakpoint(&mut self) {
337 let line = self.cursor_line();
338 self.session.toggle_breakpoint(line);
339 }
340
341 /// Called once per frame.
342 pub fn tick(&mut self) {
343 if self.session.running {
344 self.session.tick();
345 self.follow_pc();
346 }
347 }
348
349 // ---- Input ----
350
351 pub fn on_mouse(&mut self, m: MouseEvent) {
352 let delta: isize = match m.kind {
353 MouseEventKind::ScrollDown => 3,
354 MouseEventKind::ScrollUp => -3,
355 _ => return,
356 };
357 match self.mode {
358 Mode::Edit => self.out_scroll = self.out_scroll.saturating_add_signed(delta),
359 Mode::Debug => self.src_cursor = self.src_cursor.saturating_add_signed(delta),
360 }
361 }
362
363 pub fn on_key(&mut self, key: KeyEvent) {
364 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
365 if !(ctrl && key.code == KeyCode::Char('q')) {
366 self.quit_armed = false;
367 }
368 if self.prompt.is_some() {
369 self.on_prompt_key(key);
370 return;
371 }
372 if self.show_help {
373 self.show_help = false;
374 return;
375 }
376
377 // Global keys.
378 match (key.code, ctrl) {
379 (KeyCode::Char('q'), true) => {
380 if self.dirty() && !self.quit_armed {
381 self.quit_armed = true;
382 self.session.status = "unsaved changes — press Ctrl-Q again to quit".into();
383 } else {
384 self.quit = true;
385 }
386 }
387 (KeyCode::F(1), _) => self.show_help = true,
388 (KeyCode::F(2), _) | (KeyCode::Char('s'), true) => self.save(None),
389 (KeyCode::Char('g'), true) => self.external_edit = true,
390 (KeyCode::F(3), _) | (KeyCode::Char('o'), true) => {
391 self.prompt = Some(Prompt {
392 kind: PromptKind::Open,
393 input: String::new(),
394 })
395 }
396 (KeyCode::F(4), _) => match self.mode {
397 // ensure_built switches to debug mode on success.
398 Mode::Edit => {
399 self.ensure_built();
400 }
401 Mode::Debug => self.mode = Mode::Edit,
402 },
403 (KeyCode::F(5), _) => self.toggle_run(),
404 (KeyCode::F(6), _) => self.reset(),
405 (KeyCode::F(7), _) => {
406 if self.assemble() && self.mode == Mode::Edit {
407 self.out_tab = OutTab::Listing;
408 }
409 }
410 (KeyCode::F(8), _) => self.run_to_cursor(),
411 (KeyCode::F(9), _) => self.toggle_breakpoint(),
412 (KeyCode::F(10), _) => self.debug_cmd(Session::step_over),
413 (KeyCode::F(11), _) => self.debug_cmd(Session::step_into),
414 (KeyCode::F(12), _) => self.debug_cmd(Session::step_out),
415 _ => match self.mode {
416 Mode::Edit => self.on_edit_key(key),
417 Mode::Debug => self.on_debug_key(key),
418 },
419 }
420 }
421
422 fn on_edit_key(&mut self, key: KeyEvent) {
423 let alt = key.modifiers.contains(KeyModifiers::ALT);
424 match key.code {
425 // Alt-1/2/3 switch the output tab; Alt-Up/Down scroll it.
426 KeyCode::Char('1') if alt => self.out_tab = OutTab::Listing,
427 KeyCode::Char('2') if alt => self.out_tab = OutTab::Hex,
428 KeyCode::Char('3') if alt => self.out_tab = OutTab::Errors,
429 KeyCode::Up if alt => self.out_scroll = self.out_scroll.saturating_sub(1),
430 KeyCode::Down if alt => self.out_scroll += 1,
431 KeyCode::PageUp if alt => self.out_scroll = self.out_scroll.saturating_sub(20),
432 KeyCode::PageDown if alt => self.out_scroll += 20,
433 KeyCode::Esc if self.session.running => self.toggle_run(),
434 _ => {
435 self.editor.input(key);
436 }
437 }
438 }
439
440 fn on_debug_key(&mut self, key: KeyEvent) {
441 if self.focus == Focus::Serial {
442 match key.code {
443 KeyCode::Enter => {
444 let mut bytes = std::mem::take(&mut self.serial_input).into_bytes();
445 bytes.push(b'\r');
446 self.session.sim.uart_send(&bytes);
447 return;
448 }
449 KeyCode::Backspace => {
450 self.serial_input.pop();
451 return;
452 }
453 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
454 self.serial_input.push(c);
455 return;
456 }
457 _ => {}
458 }
459 }
460
461 let page = 16usize;
462 match key.code {
463 KeyCode::Tab => self.cycle_focus(true),
464 KeyCode::BackTab => self.cycle_focus(false),
465 KeyCode::Esc => {
466 if self.session.running {
467 self.toggle_run();
468 } else if self.focus == Focus::Serial {
469 self.focus = Focus::Source;
470 } else {
471 self.mode = Mode::Edit;
472 }
473 }
474 KeyCode::Char('+') | KeyCode::Char('=') => {
475 self.session.speed = (self.session.speed + 1).min(SPEEDS.len() - 1)
476 }
477 KeyCode::Char('-') => self.session.speed = self.session.speed.saturating_sub(1),
478 KeyCode::Char('?') => self.show_help = true,
479 // Letter aliases for terminals that swallow function keys.
480 KeyCode::Char('r') => self.toggle_run(),
481 KeyCode::Char('s') => self.on_key(KeyEvent::from(KeyCode::F(11))),
482 KeyCode::Char('n') => self.on_key(KeyEvent::from(KeyCode::F(10))),
483 KeyCode::Char('o') => self.on_key(KeyEvent::from(KeyCode::F(12))),
484 KeyCode::Char('g') => self.run_to_cursor(),
485 KeyCode::Char('b') => self.toggle_breakpoint(),
486 KeyCode::Char('x') => self.reset(),
487 KeyCode::Char('e') => self.mode = Mode::Edit,
488 KeyCode::Char('i') => self.set_mem_tab(MemTab::Iram),
489 KeyCode::Char('f') => self.set_mem_tab(MemTab::Sfr),
490 KeyCode::Char('d') => self.set_mem_tab(MemTab::Xram),
491 KeyCode::Char('c') => self.set_mem_tab(MemTab::Code),
492 KeyCode::Char('p') => {
493 self.prompt = Some(Prompt {
494 kind: PromptKind::Poke,
495 input: String::new(),
496 });
497 }
498 _ => match self.focus {
499 Focus::Source => self.move_src(key.code, page),
500 Focus::Memory => self.move_mem(key.code, page),
501 Focus::Ports => self.move_ports(key.code),
502 Focus::Serial => {}
503 },
504 }
505 }
506
507 fn set_mem_tab(&mut self, tab: MemTab) {
508 self.mem_tab = tab;
509 self.mem_scroll = 0;
510 self.focus = Focus::Memory;
511 }
512
513 fn cycle_focus(&mut self, fwd: bool) {
514 const ORDER: [Focus; 4] = [Focus::Source, Focus::Memory, Focus::Ports, Focus::Serial];
515 let i = ORDER.iter().position(|&f| f == self.focus).unwrap_or(0);
516 let n = ORDER.len();
517 self.focus = ORDER[if fwd { (i + 1) % n } else { (i + n - 1) % n }];
518 }
519
520 fn move_src(&mut self, code: KeyCode, page: usize) {
521 let max = self
522 .session
523 .build
524 .as_ref()
525 .map_or(0, |b| b.source.len().saturating_sub(1));
526 self.src_cursor = match code {
527 KeyCode::Up => self.src_cursor.saturating_sub(1),
528 KeyCode::Down => self.src_cursor + 1,
529 KeyCode::PageUp => self.src_cursor.saturating_sub(page),
530 KeyCode::PageDown => self.src_cursor + page,
531 KeyCode::Home => 0,
532 KeyCode::End => max,
533 KeyCode::Char('.') => self.session.pc_line().unwrap_or(self.src_cursor),
534 _ => self.src_cursor,
535 }
536 .min(max);
537 }
538
539 fn move_mem(&mut self, code: KeyCode, page: usize) {
540 let rows = match self.mem_tab {
541 MemTab::Iram => 16,
542 MemTab::Sfr => 8,
543 MemTab::Xram | MemTab::Code => 4096,
544 };
545 self.mem_scroll = match code {
546 KeyCode::Up => self.mem_scroll.saturating_sub(1),
547 KeyCode::Down => self.mem_scroll + 1,
548 KeyCode::PageUp => self.mem_scroll.saturating_sub(page),
549 KeyCode::PageDown => self.mem_scroll + page,
550 KeyCode::Home => 0,
551 KeyCode::End => rows,
552 KeyCode::Left => {
553 self.mem_tab = match self.mem_tab {
554 MemTab::Iram => MemTab::Code,
555 MemTab::Sfr => MemTab::Iram,
556 MemTab::Xram => MemTab::Sfr,
557 MemTab::Code => MemTab::Xram,
558 };
559 0
560 }
561 KeyCode::Right => {
562 self.mem_tab = match self.mem_tab {
563 MemTab::Iram => MemTab::Sfr,
564 MemTab::Sfr => MemTab::Xram,
565 MemTab::Xram => MemTab::Code,
566 MemTab::Code => MemTab::Iram,
567 };
568 0
569 }
570 _ => self.mem_scroll,
571 }
572 .min(rows.saturating_sub(1));
573 }
574
575 fn move_ports(&mut self, code: KeyCode) {
576 let (port, bit) = &mut self.port_sel;
577 match code {
578 KeyCode::Up => *port = port.saturating_sub(1),
579 KeyCode::Down => *port = (*port + 1).min(3),
580 // Bits are drawn MSB first, so Left moves towards bit 7.
581 KeyCode::Left => *bit = (*bit + 1).min(7),
582 KeyCode::Right => *bit = bit.saturating_sub(1),
583 KeyCode::Char(' ') | KeyCode::Enter => {
584 self.session.sim.pins[*port] ^= 1 << *bit;
585 }
586 _ => {}
587 }
588 }
589
590 fn on_prompt_key(&mut self, key: KeyEvent) {
591 let Some(p) = &mut self.prompt else { return };
592 match key.code {
593 KeyCode::Esc => self.prompt = None,
594 KeyCode::Backspace => {
595 p.input.pop();
596 }
597 KeyCode::Char(c) => p.input.push(c),
598 KeyCode::Enter => {
599 let p = self.prompt.take().unwrap();
600 let input = p.input.trim().to_string();
601 if input.is_empty() {
602 return;
603 }
604 match p.kind {
605 PromptKind::Open => self.open(Path::new(&input)),
606 PromptKind::SaveAs => self.save(Some(PathBuf::from(input))),
607 PromptKind::Poke => self.poke(&input),
608 }
609 }
610 _ => {}
611 }
612 }
613
614 /// `addr value` → write `value` at `addr` in the current memory tab.
615 fn poke(&mut self, input: &str) {
616 let parse = |s: &str| {
617 let s = s.trim_start_matches("0x").trim_end_matches(['h', 'H']);
618 u32::from_str_radix(s, 16).ok()
619 };
620 let parts: Vec<&str> = input.split_whitespace().collect();
621 let (Some(addr), Some(val)) = (
622 parts.first().and_then(|s| parse(s)),
623 parts.get(1).and_then(|s| parse(s)),
624 ) else {
625 self.session.status = "usage: <addr> <value> (hex), e.g. 30 FF".into();
626 return;
627 };
628 if val > 0xFF {
629 self.session.status = "value must be a byte".into();
630 return;
631 }
632 self.session.poke(self.mem_tab, addr, val as u8);
633 }
634}
635
636/// Command that opens `path` at `line` in the user's editor: `$EDITOR`,
637/// then `$VISUAL`, then `vi`. Run through `sh` so values such as
638/// `code --wait` work as they do for git.
639fn editor_command(path: &Path, line: usize) -> Command {
640 let editor = ["EDITOR", "VISUAL"]
641 .iter()
642 .filter_map(|v| std::env::var(v).ok())
643 .find(|v| !v.trim().is_empty())
644 .unwrap_or_else(|| "vi".into());
645 // Editors known to accept `+LINE` before the file name.
646 let program = editor.split_whitespace().next().unwrap_or_default();
647 let name = Path::new(program)
648 .file_name()
649 .and_then(|n| n.to_str())
650 .unwrap_or_default();
651 let goto = matches!(
652 name,
653 "vi" | "vim" | "nvim" | "nano" | "emacs" | "micro" | "kak" | "ne"
654 )
655 .then(|| format!("+{line}"));
656
657 let mut cmd = Command::new("sh");
658 cmd.arg("-c").arg(format!("{editor} \"$@\"")).arg("sh");
659 cmd.args(goto).arg(path);
660 cmd
661}
662
663const TEMPLATE: &str = "\
664; New 8051 program. F7 assembles, F5 runs, F11 steps, F1 shows all keys.
665 ORG 0000h
666 LJMP main
667
668 ORG 0030h
669main: MOV A,#01h ; walking LED pattern on P1
670loop: MOV P1,A
671 RL A
672 SJMP loop
673
674 END
675";