Krolyxon
Commit 83c841a

Branches

Tags

Refs →

bench5183c841a

Merge pull request #2 from krolyxon/gui

Add Gui with Egui using Nothing OS design language
Author
K
Date
2026-10-31 23:40 +0530
Committer
GitHub · 2026-10-31 23:40 +0530
Parents
fa3bbf2 b8f66f4
Commit
83c841a66ffa2963e3af17ba76dbcf3006c04db6

+10636 −1308

61 files changed

  1. A .github/workflows/ci.yml +31 −0
  2. M Cargo.lock +4268 −794
  3. M Cargo.toml +26 −3
  4. M README.md +138 −30
  5. A crates/bench51-core/Cargo.toml +10 −0
  6. R src/asm/expr.rs → crates/bench51-core/src/asm/expr.rs +0 −0
  7. R src/asm/hex.rs → crates/bench51-core/src/asm/hex.rs +0 −0
  8. R src/asm/lexer.rs → crates/bench51-core/src/asm/lexer.rs +0 −0
  9. R src/asm/listing.rs → crates/bench51-core/src/asm/listing.rs +0 −0
  10. R src/asm/mod.rs → crates/bench51-core/src/asm/mod.rs +0 −0
  11. A crates/bench51-core/src/files.rs +95 −0
  12. R src/isa.rs → crates/bench51-core/src/isa.rs +1 −1
  13. A crates/bench51-core/src/lib.rs +10 −0
  14. A crates/bench51-core/src/session.rs +417 −0
  15. R src/sfr.rs → crates/bench51-core/src/sfr.rs +0 −0
  16. R src/sim/cpu.rs → crates/bench51-core/src/sim/cpu.rs +0 −0
  17. R src/sim/disasm.rs → crates/bench51-core/src/sim/disasm.rs +0 −0
  18. R src/sim/mod.rs → crates/bench51-core/src/sim/mod.rs +5 −5
  19. R src/sim/periph.rs → crates/bench51-core/src/sim/periph.rs +0 −0
  20. A crates/bench51-core/src/syntax.rs +192 −0
  21. A crates/bench51-gui/Cargo.toml +24 −0
  22. A crates/bench51-gui/assets/bench51-gui.desktop +12 −0
  23. A crates/bench51-gui/assets/bench51-gui.svg +31 −0
  24. A crates/bench51-gui/assets/fonts/Doto[ROND,wght].ttf
  25. A crates/bench51-gui/assets/fonts/OFL-Doto.txt +93 −0
  26. A crates/bench51-gui/assets/fonts/OFL-SpaceGrotesk.txt +93 −0
  27. A crates/bench51-gui/assets/fonts/OFL-SpaceMono.txt +93 −0
  28. A crates/bench51-gui/assets/fonts/SpaceGrotesk-Medium.ttf
  29. A crates/bench51-gui/assets/fonts/SpaceGrotesk-Regular.ttf
  30. A crates/bench51-gui/assets/fonts/SpaceMono-Bold.ttf
  31. A crates/bench51-gui/assets/fonts/SpaceMono-Regular.ttf
  32. A crates/bench51-gui/src/app.rs +661 −0
  33. A crates/bench51-gui/src/chrome.rs +421 −0
  34. A crates/bench51-gui/src/editor.rs +657 −0
  35. A crates/bench51-gui/src/icons.rs +200 −0
  36. A crates/bench51-gui/src/layout.rs +362 −0
  37. A crates/bench51-gui/src/main.rs +57 −0
  38. A crates/bench51-gui/src/modals.rs +208 −0
  39. A crates/bench51-gui/src/panes/build.rs +188 −0
  40. A crates/bench51-gui/src/panes/disasm.rs +193 −0
  41. A crates/bench51-gui/src/panes/interrupts.rs +83 −0
  42. A crates/bench51-gui/src/panes/memory.rs +305 −0
  43. A crates/bench51-gui/src/panes/mod.rs +68 −0
  44. A crates/bench51-gui/src/panes/ports.rs +77 −0
  45. A crates/bench51-gui/src/panes/project.rs +137 −0
  46. A crates/bench51-gui/src/panes/registers.rs +105 −0
  47. A crates/bench51-gui/src/panes/serial.rs +86 −0
  48. A crates/bench51-gui/src/panes/symbols.rs +90 −0
  49. A crates/bench51-gui/src/panes/timers.rs +102 −0
  50. A crates/bench51-gui/src/state.rs +293 −0
  51. A crates/bench51-gui/src/theme.rs +293 −0
  52. A crates/bench51-gui/src/widgets.rs +378 −0
  53. A docs/screenshots/gui_breakpoint_light.png
  54. A docs/screenshots/gui_debug.png
  55. A docs/screenshots/gui_edit.png
  56. A docs/screenshots/gui_errors.png
  57. A docs/screenshots/gui_peripherals.png
  58. M src/main.rs +1 −4
  59. M src/ui/app.rs +81 −309
  60. M src/ui/draw.rs +34 −50
  61. M src/ui/syntax.rs +17 −112
A .github/workflows/ci.yml View
@@ -0,0 +1,31 @@
1+name: CI
2+
3+on:
4+ push:
5+ pull_request:
6+
7+env:
8+ CARGO_TERM_COLOR: always
9+
10+jobs:
11+ build:
12+ name: ${{ matrix.os }}
13+ strategy:
14+ fail-fast: false
15+ matrix:
16+ os: [ubuntu-latest, windows-latest, macos-latest]
17+ runs-on: ${{ matrix.os }}
18+ steps:
19+ - uses: actions/checkout@v4
20+ - uses: dtolnay/rust-toolchain@stable
21+ with:
22+ components: clippy
23+ - name: Install Linux GUI dependencies
24+ if: runner.os == 'Linux'
25+ run: |
26+ sudo apt-get update
27+ sudo apt-get install -y libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev
28+ - uses: Swatinem/rust-cache@v2
29+ - run: cargo build --workspace --release
30+ - run: cargo test --workspace
31+ - run: cargo clippy --workspace --all-targets -- -D warnings
M Cargo.lock View

[Diff too large] View file

M Cargo.toml View
@@ -1,10 +1,33 @@
1−[package]
2−name = "bench51"
1+[workspace]
2+members = ["crates/*"]
3+
4+[workspace.package]
35 version = "0.1.0"
46 edition = "2024"
7+
8+[workspace.lints.rust]
9+unsafe_code = "forbid"
10+
11+[workspace.dependencies]
12+bench51-core = { path = "crates/bench51-core" }
13+clap = { version = "4.5.54", features = ["derive"] }
14+
15+[package]
16+name = "bench51"
17+version.workspace = true
18+edition.workspace = true
519 description = "A TUI Intel 8051 assembler, simulator and debugger"
620
721 [dependencies]
8−clap = { version = "4.5.54", features = ["derive"] }
22+bench51-core.workspace = true
23+clap.workspace = true
924 ratatui = "0.30"
1025 ratatui-textarea = "0.9"
26+
27+[lints]
28+workspace = true
29+
30+# egui and wgpu are unusably slow without optimisation; keep our own crates
31+# quick to rebuild.
32+[profile.dev.package."*"]
33+opt-level = 2
M README.md View
@@ -1,31 +1,48 @@
1−# bench51 — a TUI 8051 assembler, simulator & debugger
2−
3−A small, Keil µVision-style IDE for the Intel 8051 that runs in your terminal.
4−Write assembly in the built-in editor, assemble it, inspect the listing and
5−Intel HEX output, then run, pause and single-step the program while watching
6−registers, SFRs, memory, ports, timers, interrupts and the serial port.
7−
8−Built in Rust with [ratatui](https://ratatui.rs). It is a learning tool, not a
9−cycle-exact replacement for a vendor simulator.
10−
11−![Debugging serial_hello.asm: source, registers, peripherals, IRAM and the UART console](docs/screenshots/debug_serial.png)
1+# bench51 — an 8051 assembler, simulator & debugger
2+
3+[![CI](https://img.shields.io/github/actions/workflow/status/krolyxon/bench51/ci.yml?branch=master&style=flat-square&label=CI&labelColor=000000)](https://github.com/krolyxon/bench51/actions/workflows/ci.yml)
4+[![Version](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fkrolyxon%2Fbench51%2Fmaster%2FCargo.toml&query=%24.workspace.package.version&label=version&style=flat-square&labelColor=000000&color=333333)](Cargo.toml)
5+[![Top language](https://img.shields.io/github/languages/top/krolyxon/bench51?style=flat-square&logo=rust&logoColor=white&labelColor=000000&color=333333)](https://github.com/krolyxon/bench51)
6+[![Lines of code](https://img.shields.io/badge/lines%20of%20code-9.8k-333333?style=flat-square&labelColor=000000)](crates)
7+[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-333333?style=flat-square&labelColor=000000)](Cargo.toml)
8+[![License](https://img.shields.io/github/license/krolyxon/bench51?style=flat-square&labelColor=000000&color=333333)](LICENSE)
9+[![Platforms](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS-333333?style=flat-square&labelColor=000000)](#usage)
10+[![Target](https://img.shields.io/badge/target-Intel%208051%20%28MCS--51%29-D71921?style=flat-square&labelColor=000000)](https://en.wikipedia.org/wiki/Intel_8051)
11+[![egui](https://img.shields.io/badge/GUI-egui-333333?style=flat-square&labelColor=000000)](https://github.com/emilk/egui)
12+[![ratatui](https://img.shields.io/badge/TUI-ratatui-333333?style=flat-square&labelColor=000000)](https://ratatui.rs)
13+[![Last commit](https://img.shields.io/github/last-commit/krolyxon/bench51?style=flat-square&labelColor=000000&color=333333)](https://github.com/krolyxon/bench51/commits)
14+
15+A small, Keil µVision-style IDE for the Intel 8051. Write assembly in the
16+editor, build it, inspect the listing and Intel HEX output, then run, pause
17+and single-step the program while watching registers, SFRs, memory, ports,
18+timers, interrupts and the serial port, all in a window laid out like
19+µVision.
20+
21+Like Emacs, it also runs in a terminal: the same assembler and simulator
22+come with a TUI (`bench51`) for SSH sessions and terminal setups.
23+
24+Built in Rust with [egui](https://github.com/emilk/egui) and
25+[ratatui](https://ratatui.rs). It is a learning tool, not a cycle-exact
26+replacement for a vendor simulator.
27+
28+![Debug session: registers, source with the PC, disassembly, memory and UART #1](docs/screenshots/gui_debug.png)
1229
1330 <table>
1431 <tr>
15− <td width="50%"><img src="docs/screenshots/listing.png" alt="Editor with the assembled Keil-style listing"></td>
16− <td width="50%"><img src="docs/screenshots/debug_breakpoint.png" alt="Stopped at a breakpoint inside the timer 0 ISR, SFR view"></td>
32+ <td width="50%"><img src="docs/screenshots/gui_edit.png" alt="Editor and Keil-style build output"></td>
33+ <td width="50%"><img src="docs/screenshots/gui_breakpoint_light.png" alt="Stopped at a breakpoint in a timer ISR, light theme"></td>
1734 </tr>
1835 <tr>
19− <td align="center"><sub>Editor + assembled listing</sub></td>
20− <td align="center"><sub>Breakpoint inside a timer ISR, SFR view</sub></td>
36+ <td align="center"><sub>Editor + build output</sub></td>
37+ <td align="center"><sub>Breakpoint in a timer ISR, light theme</sub></td>
2138 </tr>
2239 <tr>
23− <td width="50%"><img src="docs/screenshots/errors.png" alt="Assembler errors marked in the gutter and listed in the Errors tab"></td>
24− <td width="50%"><img src="docs/screenshots/hex.png" alt="Intel HEX output tab"></td>
40+ <td width="50%"><img src="docs/screenshots/gui_peripherals.png" alt="Ports, timers and interrupts windows docked side by side"></td>
41+ <td width="50%"><img src="docs/screenshots/gui_errors.png" alt="Assembler errors in the gutter and Build Output"></td>
2542 </tr>
2643 <tr>
27− <td align="center"><sub>Assembler errors in the gutter and Errors tab</sub></td>
28− <td align="center"><sub>Intel HEX output</sub></td>
44+ <td align="center"><sub>Peripheral windows, rearranged</sub></td>
45+ <td align="center"><sub>Assembler errors</sub></td>
2946 </tr>
3047 </table>
3148
@@ -35,15 +52,20 @@ I had microcontroller practicals to get through, and the course expects you to
3552 use Keil µVision. Keil µVision doesn't run on Linux. Rather than fight with
3653 Wine or a Windows VM every lab session, I wrote my own: bench51 does the parts
3754 of µVision those practicals actually need (write the code, assemble it, look
38−at the listing and HEX, step through it and watch the registers) right in the
39−terminal.
55+at the listing and HEX, step through it and watch the registers) natively on
56+Linux, in a window or right in the terminal.
4057
4158 -----
4259
4360 ## Features
4461
45−- **Editor** with undo/redo, breakpoint and error markers in the gutter —
46− or press Ctrl-G to edit in your own `$EDITOR` (vim, nvim, nano, …).
62+- **µVision layout**: menus and toolbar, a Project window, the editor, Build
63+ Output, and in a debug session the Registers, Disassembly, Memory, Symbols,
64+ UART #1, Ports, Timers and Interrupts windows. Every window docks and tabs
65+ wherever you drag it; the edit and debug layouts are remembered separately,
66+ as in µVision.
67+- **Editor** with syntax highlighting, undo/redo, find, and breakpoint, error
68+ and PC markers in the gutter.
4769 - **Two-pass assembler** for the full 8051 instruction set (all 255 opcodes):
4870 - labels, expressions (`+ - * / MOD AND OR XOR NOT SHL SHR HIGH LOW`, `$`)
4971 - number formats `42`, `2Ah`, `0x2A`, `101010b`, `52o`, `'*'`
@@ -63,9 +85,88 @@ terminal.
6385 breakpoints, changed-value highlighting, memory poke, adjustable speed.
6486 - **Open `.hex` files**: they're disassembled into editable source that
6587 re-assembles to the identical image.
88+- **Dark and light themes** in a monochrome, dot-matrix design (Space
89+ Grotesk, Space Mono and Doto, bundled).
6690
6791 ## Usage
6892
93+```bash
94+cargo install --path crates/bench51-gui # installs `bench51-gui`
95+
96+cargo run --release -p bench51-gui # welcome screen
97+cargo run --release -p bench51-gui -- examples/*.asm # open several files
98+cargo run --release -p bench51-gui -- firmware.hex # disassemble & debug a HEX file
99+cargo run --release -p bench51-gui -- --theme light
100+```
101+
102+`--theme system|dark|light` overrides the theme chosen in View → Theme, and
103+`--clock <MHz>` the Xtal set in Project → Options for Target (default
104+11.0592), which is used for the elapsed-time display.
105+
106+On Linux it runs on Wayland and X11 and uses the XDG desktop portal for file
107+dialogs. To add it to your application launcher:
108+
109+```bash
110+install -Dm644 crates/bench51-gui/assets/bench51-gui.desktop ~/.local/share/applications/bench51-gui.desktop
111+install -Dm644 crates/bench51-gui/assets/bench51-gui.svg ~/.local/share/icons/hicolor/scalable/apps/bench51-gui.svg
112+```
113+
114+It also builds for Windows and macOS. On Debian or Ubuntu, first install the
115+packages egui needs: `libxcb-render0-dev libxcb-shape0-dev
116+libxcb-xfixes0-dev libxkbcommon-dev libssl-dev`.
117+
118+## Keys
119+
120+The keys are µVision's, with the TUI's as aliases:
121+
122+| Key | Action |
123+| --- | --- |
124+| F7 | build target (also writes `.lst` and `.hex` next to a saved file) |
125+| Ctrl-F5 / F4 | start / stop the debug session |
126+| F5 | run / stop |
127+| F11 / F10 | step into / step over |
128+| Ctrl-F11 / F12 | step out |
129+| Ctrl-F10 / F8 | run to the cursor line |
130+| F9 / Ctrl-Shift-F9 | toggle breakpoint / remove all breakpoints |
131+| F6 | reset the CPU |
132+| Ctrl-N / Ctrl-O / Ctrl-S / Ctrl-W | new / open / save / close |
133+| Ctrl-F | find |
134+| F1 | all shortcuts |
135+
136+Click the gutter to toggle a breakpoint, double-click a byte in Memory to
137+change it, click a pin in Ports to hold it low from outside, and click the
138+bit chips in Registers, Timers and Interrupts to flip SFR bits. Files can be
139+dropped onto the window.
140+
141+## Terminal version
142+
143+`bench51` is the same IDE in a terminal, the way `emacs -nw` is Emacs
144+without a window. It has the editor, listing, HEX and error views, and a
145+debug screen with registers, peripherals, memory, ports and the UART. The
146+same binary also assembles headlessly, which is handy in scripts and
147+Makefiles.
148+
149+![The TUI debugging serial_hello.asm: source, registers, peripherals, IRAM and the UART console](docs/screenshots/debug_serial.png)
150+
151+<table>
152+ <tr>
153+ <td width="50%"><img src="docs/screenshots/listing.png" alt="Editor with the assembled Keil-style listing"></td>
154+ <td width="50%"><img src="docs/screenshots/debug_breakpoint.png" alt="Stopped at a breakpoint inside the timer 0 ISR, SFR view"></td>
155+ </tr>
156+ <tr>
157+ <td align="center"><sub>Editor + assembled listing</sub></td>
158+ <td align="center"><sub>Breakpoint inside a timer ISR, SFR view</sub></td>
159+ </tr>
160+ <tr>
161+ <td width="50%"><img src="docs/screenshots/errors.png" alt="Assembler errors marked in the gutter and listed in the Errors tab"></td>
162+ <td width="50%"><img src="docs/screenshots/hex.png" alt="Intel HEX output tab"></td>
163+ </tr>
164+ <tr>
165+ <td align="center"><sub>Assembler errors in the gutter and Errors tab</sub></td>
166+ <td align="center"><sub>Intel HEX output</sub></td>
167+ </tr>
168+</table>
169+
69170 ```bash
70171 cargo install --path . # installs the `bench51` binary
71172
@@ -81,7 +182,7 @@ cargo run --release -- --assemble prog.asm -o out.hex --lst out.lst
81182 `--clock <MHz>` sets the oscillator frequency used for the elapsed-time display
82183 (default 11.0592).
83184
84−## Keys
185+### Terminal keys
85186
86187 | Key | Action |
87188 | --- | --- |
@@ -100,7 +201,8 @@ cargo run --release -- --assemble prog.asm -o out.hex --lst out.lst
100201
101202 Edit mode: `Alt-1/2/3` switch between Listing, HEX and Errors; `Alt-↑/↓`,
102203 `Alt-PgUp/PgDn` scroll them. The editor uses Emacs-style keys
103−(`Ctrl-U` undo, `Ctrl-R` redo, `Ctrl-Y` paste).
204+(`Ctrl-U` undo, `Ctrl-R` redo, `Ctrl-Y` paste), or press Ctrl-G to edit in
205+your own `$EDITOR` (vim, nvim, nano, …).
104206
105207 Debug mode: `Tab` cycles focus between Source, Memory, Ports and Serial.
106208 Letter shortcuts work too, for terminals that swallow function keys:
@@ -133,10 +235,16 @@ Letter shortcuts work too, for terminals that swallow function keys:
133235 ## Development
134236
135237 ```bash
136−cargo test # assembler, CPU, peripheral and example-program tests
137−cargo clippy
238+cargo test --workspace # assembler, CPU, peripheral, session and example-program tests
239+cargo clippy --workspace --all-targets
138240 ```
139241
140−Code layout: `src/isa.rs` (opcode table shared by everything), `src/asm/`
141−(assembler, listing, Intel HEX), `src/sim/` (CPU, peripherals, disassembler),
142−`src/ui/` (ratatui front end).
242+Code layout:
243+
244+- `crates/bench51-gui/` is the `bench51-gui` desktop app (egui).
245+- `src/` is the `bench51` terminal app (ratatui), including headless
246+ assembly.
247+- `crates/bench51-core/`, shared by both: `isa.rs` (opcode table), `asm/`
248+ (assembler, listing, Intel HEX), `sim/` (CPU, peripherals, disassembler),
249+ `session.rs` (build, run, step and breakpoints), `syntax.rs`
250+ (highlighting tokens), `files.rs` (HEX loading)
A crates/bench51-core/Cargo.toml View
@@ -0,0 +1,10 @@
1+[package]
2+name = "bench51-core"
3+version.workspace = true
4+edition.workspace = true
5+description = "Intel 8051 assembler, simulator and debugger session shared by the bench51 front ends"
6+
7+[dependencies]
8+
9+[lints]
10+workspace = true
R src/asm/expr.rs → crates/bench51-core/src/asm/expr.rs View

[No content changes]

R src/asm/hex.rs → crates/bench51-core/src/asm/hex.rs View

[No content changes]

R src/asm/lexer.rs → crates/bench51-core/src/asm/lexer.rs View

[No content changes]

R src/asm/listing.rs → crates/bench51-core/src/asm/listing.rs View

[No content changes]

R src/asm/mod.rs → crates/bench51-core/src/asm/mod.rs View

[No content changes]

A crates/bench51-core/src/files.rs View
@@ -0,0 +1,95 @@
1+//! Loading programs: assembly sources as they are, Intel HEX images as
2+//! disassembled source.
3+
4+use std::path::Path;
5+
6+use crate::asm::hex;
7+use crate::sfr::{hex8, hex16};
8+use crate::sim::disasm;
9+
10+pub enum Loaded {
11+ Source(String),
12+ /// A HEX image turned into source that reassembles to the same bytes.
13+ Hex {
14+ source: String,
15+ bytes: usize,
16+ },
17+}
18+
19+pub fn is_hex_path(path: &Path) -> bool {
20+ path.extension()
21+ .is_some_and(|e| e.eq_ignore_ascii_case("hex") || e.eq_ignore_ascii_case("ihx"))
22+}
23+
24+/// Reads `path`. Errors are ready to show to the user.
25+pub fn load(path: &Path) -> Result<Loaded, String> {
26+ let text = std::fs::read_to_string(path)
27+ .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
28+ if !is_hex_path(path) {
29+ return Ok(Loaded::Source(text));
30+ }
31+ let bytes = hex::read(&text).map_err(|e| format!("invalid HEX file: {e}"))?;
32+ Ok(Loaded::Hex {
33+ source: disassemble_image(&bytes, path),
34+ bytes: bytes.len(),
35+ })
36+}
37+
38+/// Builds an assembler source from a HEX image by disassembling each
39+/// contiguous run of bytes. Reassembling it reproduces the image exactly.
40+pub fn disassemble_image(bytes: &[(u16, u8)], path: &Path) -> String {
41+ let mut code = Box::new([0u8; 0x10000]);
42+ let mut used = vec![false; 0x10000];
43+ for &(a, b) in bytes {
44+ code[a as usize] = b;
45+ used[a as usize] = true;
46+ }
47+ let mut src = format!("; disassembled from {}\n", path.display());
48+ let mut addr = 0usize;
49+ while addr < 0x10000 {
50+ if !used[addr] {
51+ addr += 1;
52+ continue;
53+ }
54+ src.push_str(&format!("\n ORG {}\n", hex16(addr as u16)));
55+ while addr < 0x10000 && used[addr] {
56+ let d = disasm::disassemble(&code, addr as u16);
57+ let size = d.size as usize;
58+ if (addr..addr + size).all(|a| a < 0x10000 && used[a]) {
59+ src.push_str(&format!(" {:<24}; {:04X}\n", d.text, addr));
60+ addr += size;
61+ } else {
62+ src.push_str(&format!(
63+ " DB {:<21}; {:04X}\n",
64+ hex8(code[addr]),
65+ addr
66+ ));
67+ addr += 1;
68+ }
69+ }
70+ }
71+ src.push_str(" END\n");
72+ src
73+}
74+
75+#[cfg(test)]
76+mod tests {
77+ use std::path::Path;
78+
79+ use crate::asm::{assemble, hex};
80+
81+ #[test]
82+ fn hex_disassembly_reassembles_to_the_same_image() {
83+ let out = assemble(include_str!("../../../examples/serial_hello.asm"));
84+ assert!(out.ok());
85+ let image = hex::read(&out.intel_hex()).unwrap();
86+ let src = super::disassemble_image(&image, Path::new("x.hex"));
87+ let again = assemble(&src);
88+ assert!(again.ok(), "{:?}", again.diagnostics);
89+ let mut a: Vec<_> = out.bytes().collect();
90+ let mut b: Vec<_> = again.bytes().collect();
91+ a.sort();
92+ b.sort();
93+ assert_eq!(a, b);
94+ }
95+}
R src/isa.rs → crates/bench51-core/src/isa.rs View
@@ -28,7 +28,7 @@ pub enum Operand {
2828
2929 impl Operand {
3030 /// Bytes this operand occupies after the opcode byte.
31− pub fn len(self) -> u8 {
31+ pub(crate) fn len(self) -> u8 {
3232 match self {
3333 Operand::Imm8 | Operand::Direct | Operand::Bit | Operand::NotBit => 1,
3434 Operand::Addr11 | Operand::Rel => 1,
A crates/bench51-core/src/lib.rs View
@@ -0,0 +1,10 @@
1+//! Intel 8051 assembler, simulator and debugger session shared by the
2+//! bench51 front ends (the `bench51` TUI and the `bench51-gui` desktop app).
3+
4+pub mod asm;
5+pub mod files;
6+pub mod isa;
7+pub mod session;
8+pub mod sfr;
9+pub mod sim;
10+pub mod syntax;
A crates/bench51-core/src/session.rs View
@@ -0,0 +1,417 @@
1+//! A debugging session: the simulator, the program loaded into it,
2+//! breakpoints and run state. Front ends own the source text and decide
3+//! when to build; everything else about running a program lives here.
4+
5+use std::collections::{HashMap, HashSet};
6+use std::path::Path;
7+
8+use crate::asm::{self, AsmOutput, Diagnostic};
9+use crate::sim::{Sim, Stop};
10+
11+/// Result of the last successful assembly, which the simulator runs.
12+pub struct Build {
13+ pub source: Vec<String>,
14+ pub out: AsmOutput,
15+ pub addr_to_line: HashMap<u16, usize>,
16+}
17+
18+impl Build {
19+ /// First address emitted by `line`, if any.
20+ pub fn line_addr(&self, line: usize) -> Option<u16> {
21+ self.out
22+ .lines
23+ .get(line)
24+ .filter(|l| !l.bytes.is_empty())
25+ .and_then(|l| l.addr)
26+ }
27+}
28+
29+/// Instructions executed per tick (one tick ≈ 33 ms) for each speed setting.
30+pub const SPEEDS: [u32; 8] = [1, 5, 25, 100, 500, 2_000, 10_000, 40_000];
31+
32+/// Instruction budget for step over / step out / run to cursor.
33+const STEP_BUDGET: u32 = 5_000_000;
34+
35+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36+pub enum MemSpace {
37+ Iram,
38+ Sfr,
39+ Xram,
40+ Code,
41+}
42+
43+impl MemSpace {
44+ pub const ALL: [MemSpace; 4] = [
45+ MemSpace::Iram,
46+ MemSpace::Sfr,
47+ MemSpace::Xram,
48+ MemSpace::Code,
49+ ];
50+
51+ pub fn name(self) -> &'static str {
52+ match self {
53+ MemSpace::Iram => "IRAM",
54+ MemSpace::Sfr => "SFR",
55+ MemSpace::Xram => "XRAM",
56+ MemSpace::Code => "CODE",
57+ }
58+ }
59+}
60+
61+pub struct Session {
62+ pub sim: Sim,
63+ /// Last assembler result, even if it had errors (for diagnostics).
64+ pub last_asm: Option<(String, AsmOutput)>,
65+ /// Last error-free build, loaded into the simulator.
66+ pub build: Option<Build>,
67+ /// Breakpoints by source line.
68+ pub breakpoints: HashSet<usize>,
69+ pub running: bool,
70+ /// The next run chunk starts a fresh run and may leave a breakpoint.
71+ resume: bool,
72+ /// Index into `SPEEDS`.
73+ pub speed: usize,
74+ pub clock_mhz: f64,
75+ /// Memory before the last step or run chunk, to highlight changes.
76+ pub prev_iram: [u8; 256],
77+ pub prev_sfr: [u8; 128],
78+ /// Outcome of the last command, for the status line.
79+ pub status: String,
80+}
81+
82+impl Session {
83+ pub fn new(clock_mhz: f64) -> Self {
84+ Self {
85+ sim: Sim::new(),
86+ last_asm: None,
87+ build: None,
88+ breakpoints: HashSet::new(),
89+ running: false,
90+ resume: false,
91+ speed: 5,
92+ clock_mhz,
93+ prev_iram: [0; 256],
94+ prev_sfr: [0; 128],
95+ status: String::new(),
96+ }
97+ }
98+
99+ /// Forgets the program and breakpoints, for a newly loaded source.
100+ pub fn clear(&mut self) {
101+ self.breakpoints.clear();
102+ self.build = None;
103+ self.last_asm = None;
104+ self.running = false;
105+ }
106+
107+ /// Whether `lines` differ from the source the simulator is running.
108+ pub fn is_stale<'a>(&self, lines: impl IntoIterator<Item = &'a str>) -> bool {
109+ self.build
110+ .as_ref()
111+ .is_none_or(|b| !b.source.iter().map(String::as_str).eq(lines))
112+ }
113+
114+ /// Diagnostics of the last assembly, if it was of exactly `source`.
115+ pub fn diagnostics_for(&self, source: &str) -> &[Diagnostic] {
116+ match &self.last_asm {
117+ Some((src, out)) if src == source => &out.diagnostics,
118+ _ => &[],
119+ }
120+ }
121+
122+ // ---- Build ----
123+
124+ /// Assembles `source`. On success the program is loaded into a freshly
125+ /// reset simulator and, if `path` (the saved source file) is given,
126+ /// `.lst` and `.hex` files are written next to it.
127+ pub fn assemble(&mut self, source: &str, path: Option<&Path>) -> bool {
128+ let out = asm::assemble(source);
129+ let ok = out.ok();
130+ if ok {
131+ self.sim.load_code(out.bytes());
132+ self.sim.reset();
133+ self.running = false;
134+ self.status = format!("assembled: {} bytes, 0 errors", out.code_size());
135+ if let Some(path) = path {
136+ self.write_outputs(&out, source, path);
137+ }
138+ self.build = Some(Build {
139+ source: source.split('\n').map(String::from).collect(),
140+ addr_to_line: out.addr_to_line(),
141+ out: out.clone(),
142+ });
143+ self.snapshot();
144+ } else {
145+ let first = &out.diagnostics[0];
146+ self.status = format!(
147+ "{} error(s); line {}: {}",
148+ out.diagnostics.len(),
149+ first.line + 1,
150+ first.msg
151+ );
152+ }
153+ self.last_asm = Some((source.to_string(), out));
154+ ok
155+ }
156+
157+ /// Makes sure a build of `source` is loaded; returns false on errors.
158+ pub fn ensure_built(&mut self, source: &str, path: Option<&Path>) -> bool {
159+ !self.is_stale(source.split('\n')) || self.assemble(source, path)
160+ }
161+
162+ fn write_outputs(&mut self, out: &AsmOutput, source: &str, path: &Path) {
163+ let res = std::fs::write(path.with_extension("lst"), out.listing(source))
164+ .and_then(|_| std::fs::write(path.with_extension("hex"), out.intel_hex()));
165+ if let Err(e) = res {
166+ self.status = format!("assembled, but writing .lst/.hex failed: {e}");
167+ }
168+ }
169+
170+ // ---- Breakpoints ----
171+
172+ pub fn toggle_breakpoint(&mut self, line: usize) {
173+ if !self.breakpoints.remove(&line) {
174+ self.breakpoints.insert(line);
175+ }
176+ }
177+
178+ pub fn bp_addrs(&self) -> HashSet<u16> {
179+ let Some(b) = &self.build else {
180+ return HashSet::new();
181+ };
182+ self.breakpoints
183+ .iter()
184+ .filter_map(|&l| b.line_addr(l))
185+ .collect()
186+ }
187+
188+ // ---- Execution ----
189+
190+ pub fn snapshot(&mut self) {
191+ self.prev_iram = self.sim.iram;
192+ self.prev_sfr = self.sim.sfr;
193+ }
194+
195+ /// Source line of the instruction at the PC.
196+ pub fn pc_line(&self) -> Option<usize> {
197+ self.build.as_ref()?.addr_to_line.get(&self.sim.pc).copied()
198+ }
199+
200+ /// Stops running and describes why in `status`.
201+ pub fn report(&mut self, stop: Stop) {
202+ self.running = false;
203+ self.status = match stop {
204+ Stop::Breakpoint(pc) => format!("breakpoint at {pc:04X}h"),
205+ Stop::ReservedOpcode { pc, opcode } => {
206+ format!("reserved opcode {opcode:02X}h at {pc:04X}h — stopped")
207+ }
208+ Stop::PowerDown => "CPU in power-down mode (PCON.PD) — reset to continue".into(),
209+ Stop::Target | Stop::Budget => format!("stopped at {:04X}h", self.sim.pc),
210+ };
211+ }
212+
213+ fn step_with(&mut self, f: impl FnOnce(&mut Sim, &HashSet<u16>) -> Stop) {
214+ self.running = false;
215+ self.snapshot();
216+ let bps = self.bp_addrs();
217+ match f(&mut self.sim, &bps) {
218+ Stop::Target => self.status = format!("PC = {:04X}h", self.sim.pc),
219+ s => self.report(s),
220+ }
221+ }
222+
223+ pub fn step_into(&mut self) {
224+ self.step_with(|s, _| match s.step() {
225+ Ok(()) => Stop::Target,
226+ Err(e) => e,
227+ });
228+ }
229+
230+ pub fn step_over(&mut self) {
231+ self.step_with(|s, b| s.step_over(STEP_BUDGET, b));
232+ }
233+
234+ pub fn step_out(&mut self) {
235+ self.step_with(|s, b| s.step_out(STEP_BUDGET, b));
236+ }
237+
238+ /// Runs until the first instruction of `line`, or a breakpoint.
239+ /// Returns false if the line holds no code.
240+ pub fn run_to_line(&mut self, line: usize) -> bool {
241+ let Some(target) = self.build.as_ref().and_then(|b| b.line_addr(line)) else {
242+ self.status = "no code on that line".into();
243+ return false;
244+ };
245+ self.step_with(|s, bps| {
246+ let mut bps = bps.clone();
247+ bps.insert(target);
248+ match s.run(STEP_BUDGET, &bps, true) {
249+ Stop::Breakpoint(pc) if pc == target => Stop::Target,
250+ other => other,
251+ }
252+ });
253+ true
254+ }
255+
256+ /// Starts free running; `tick` then advances the program. Needs a build.
257+ pub fn start(&mut self) {
258+ if self.build.is_none() {
259+ return;
260+ }
261+ self.running = true;
262+ self.resume = true;
263+ self.status = "running…".into();
264+ }
265+
266+ pub fn pause(&mut self) {
267+ self.running = false;
268+ self.status = format!("paused at {:04X}h", self.sim.pc);
269+ }
270+
271+ /// Runs one chunk of `SPEEDS[speed]` instructions if running. Front ends
272+ /// call this every ~33 ms.
273+ pub fn tick(&mut self) {
274+ if !self.running {
275+ return;
276+ }
277+ self.snapshot();
278+ let bps = self.bp_addrs();
279+ let resume = std::mem::take(&mut self.resume);
280+ match self.sim.run(SPEEDS[self.speed], &bps, resume) {
281+ Stop::Budget => {}
282+ s => self.report(s),
283+ }
284+ }
285+
286+ pub fn reset(&mut self) {
287+ self.sim.reset();
288+ self.running = false;
289+ self.snapshot();
290+ self.status = "reset".into();
291+ }
292+
293+ /// Writes `value` at `addr` in `space`. Returns false if out of range.
294+ pub fn poke(&mut self, space: MemSpace, addr: u32, value: u8) -> bool {
295+ let ok = match space {
296+ MemSpace::Iram if addr <= 0xFF => {
297+ self.sim.iram[addr as usize] = value;
298+ true
299+ }
300+ MemSpace::Sfr if (0x80..=0xFF).contains(&addr) => {
301+ self.sim.set_sfr(addr as u8, value);
302+ true
303+ }
304+ MemSpace::Xram if addr <= 0xFFFF => {
305+ self.sim.xram[addr as usize] = value;
306+ true
307+ }
308+ MemSpace::Code if addr <= 0xFFFF => {
309+ self.sim.code[addr as usize] = value;
310+ true
311+ }
312+ _ => false,
313+ };
314+ self.status = if ok {
315+ format!("wrote {value:02X}h at {addr:X}h")
316+ } else {
317+ "address out of range".into()
318+ };
319+ ok
320+ }
321+
322+ /// Simulated time since reset at `clock_mhz` (12 clocks per machine cycle).
323+ pub fn elapsed(&self) -> String {
324+ let micros = self.sim.cycles as f64 * 12.0 / self.clock_mhz;
325+ if micros >= 1_000_000.0 {
326+ format!("{:.3} s", micros / 1e6)
327+ } else if micros >= 1000.0 {
328+ format!("{:.3} ms", micros / 1e3)
329+ } else {
330+ format!("{micros:.2} µs")
331+ }
332+ }
333+}
334+
335+/// Name of the interrupt source served at vector `v`.
336+pub fn vector_name(v: u16) -> &'static str {
337+ match v {
338+ 0x03 => "INT0",
339+ 0x0B => "T0",
340+ 0x13 => "INT1",
341+ 0x1B => "T1",
342+ 0x23 => "UART",
343+ _ => "?",
344+ }
345+}
346+
347+#[cfg(test)]
348+mod tests {
349+ use super::{MemSpace, Session};
350+
351+ const PROG: &str = "\
352+ ORG 0
353+ LJMP main
354+ ORG 30h
355+main: MOV R0,#0
356+loop: ACALL bump
357+ SJMP loop
358+bump: INC R0
359+ NOP
360+ RET
361+ END";
362+
363+ fn session() -> Session {
364+ let mut s = Session::new(12.0);
365+ assert!(s.assemble(PROG, None), "{}", s.status);
366+ s
367+ }
368+
369+ #[test]
370+ fn breakpoints_steps_and_run_to_line() {
371+ let mut s = session();
372+ s.toggle_breakpoint(6); // bump: INC R0
373+ s.start();
374+ s.tick();
375+ assert!(!s.running);
376+ assert_eq!(s.pc_line(), Some(6));
377+ assert_eq!(s.status, format!("breakpoint at {:04X}h", s.sim.pc));
378+
379+ s.step_into(); // INC R0
380+ assert_eq!(s.sim.reg(0), 1);
381+ assert_eq!(s.pc_line(), Some(7));
382+ s.step_out(); // back after ACALL
383+ assert_eq!(s.pc_line(), Some(5));
384+
385+ s.toggle_breakpoint(6);
386+ s.step_into(); // SJMP loop
387+ assert_eq!(s.pc_line(), Some(4));
388+ s.step_over(); // whole call
389+ assert_eq!((s.pc_line(), s.sim.reg(0)), (Some(5), 2));
390+
391+ assert!(s.run_to_line(7));
392+ assert_eq!((s.pc_line(), s.sim.reg(0)), (Some(7), 3));
393+ assert!(!s.run_to_line(0)); // ORG: no code
394+ }
395+
396+ #[test]
397+ fn staleness_errors_and_poke() {
398+ let mut s = session();
399+ assert!(!s.is_stale(PROG.split('\n')));
400+ let edited = PROG.replace("NOP", "NOP\n NOP");
401+ assert!(s.is_stale(edited.split('\n')));
402+ assert!(s.ensure_built(&edited, None));
403+ assert!(!s.is_stale(edited.split('\n')));
404+
405+ let bad = " MOV A,\n FOO\n";
406+ assert!(!s.assemble(bad, None));
407+ assert_eq!(s.diagnostics_for(bad).len(), 2);
408+ assert!(s.diagnostics_for(PROG).is_empty());
409+ assert!(s.build.is_some(), "a failed build keeps the last good one");
410+
411+ assert!(s.poke(MemSpace::Iram, 0x30, 0xAB));
412+ assert_eq!(s.sim.iram[0x30], 0xAB);
413+ assert!(s.poke(MemSpace::Sfr, 0x90, 0x0F));
414+ assert!(!s.poke(MemSpace::Sfr, 0x20, 1));
415+ assert_eq!(s.status, "address out of range");
416+ }
417+}
R src/sfr.rs → crates/bench51-core/src/sfr.rs View

[No content changes]

R src/sim/cpu.rs → crates/bench51-core/src/sim/cpu.rs View

[No content changes]

R src/sim/disasm.rs → crates/bench51-core/src/sim/disasm.rs View

[No content changes]

R src/sim/mod.rs → crates/bench51-core/src/sim/mod.rs View
@@ -385,7 +385,7 @@ mod example_tests {
385385
386386 #[test]
387387 fn serial_hello_greets_and_echoes() {
388− let mut s = load(include_str!("../../examples/serial_hello.asm"));
388+ let mut s = load(include_str!("../../../../examples/serial_hello.asm"));
389389 steps(&mut s, 60_000);
390390 assert_eq!(
391391 String::from_utf8_lossy(&s.uart.tx_log),
@@ -398,7 +398,7 @@ mod example_tests {
398398
399399 #[test]
400400 fn timer_irq_ticks() {
401− let mut s = load(include_str!("../../examples/timer_irq.asm"));
401+ let mut s = load(include_str!("../../../../examples/timer_irq.asm"));
402402 steps(&mut s, 5_000);
403403 // ~10000 cycles at one interrupt per 1000 cycles.
404404 assert!((3..=10).contains(&s.reg(7)), "R7 = {}", s.reg(7));
@@ -406,7 +406,7 @@ mod example_tests {
406406
407407 #[test]
408408 fn button_counts_edges_while_idle() {
409− let mut s = load(include_str!("../../examples/button_int0.asm"));
409+ let mut s = load(include_str!("../../../../examples/button_int0.asm"));
410410 steps(&mut s, 20);
411411 assert!(s.idle);
412412 for _ in 0..3 {
@@ -420,10 +420,10 @@ mod example_tests {
420420
421421 #[test]
422422 fn bcd_and_lookup() {
423− let mut s = load(include_str!("../../examples/bcd_add.asm"));
423+ let mut s = load(include_str!("../../../../examples/bcd_add.asm"));
424424 steps(&mut s, 20);
425425 assert_eq!((s.iram[0x31], s.iram[0x30]), (0x69, 0x12));
426− let mut s = load(include_str!("../../examples/lookup_movc.asm"));
426+ let mut s = load(include_str!("../../../../examples/lookup_movc.asm"));
427427 steps(&mut s, 3 + 4 * 5 + 3); // setup, digits 0-3, then up to MOV P2,A
428428 assert_eq!(s.sfr(crate::sfr::P2), 0x66); // digit 4
429429 }
R src/sim/periph.rs → crates/bench51-core/src/sim/periph.rs View

[No content changes]

A crates/bench51-core/src/syntax.rs View
@@ -0,0 +1,192 @@
1+//! Minimal 8051 assembly tokenizer for syntax highlighting.
2+
3+use std::ops::Range;
4+
5+use crate::{isa, sfr};
6+
7+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+pub enum Tok {
9+ /// Whitespace, punctuation and identifiers without a special meaning.
10+ Plain,
11+ Comment,
12+ Str,
13+ /// A label definition (`name:` at the start of a line).
14+ Label,
15+ Mnemonic,
16+ /// Directives and expression operators (`ORG`, `DB`, `HIGH`, `MOD`, …).
17+ Directive,
18+ Register,
19+ /// Predefined SFR and bit names.
20+ Sfr,
21+ Number,
22+}
23+
24+fn is_directive(w: &str) -> bool {
25+ matches!(
26+ w,
27+ "ORG"
28+ | "DB"
29+ | "DW"
30+ | "DS"
31+ | "END"
32+ | "EQU"
33+ | "SET"
34+ | "BIT"
35+ | "DATA"
36+ | "IDATA"
37+ | "XDATA"
38+ | "CODE"
39+ | "CSEG"
40+ | "XSEG"
41+ | "DSEG"
42+ | "AT"
43+ | "USING"
44+ | "NAME"
45+ | "BYTE"
46+ | "WORD"
47+ )
48+}
49+
50+fn word_kind(w: &str, first_word: bool) -> Tok {
51+ let u = w.to_ascii_uppercase();
52+ let reg = matches!(u.as_str(), "A" | "AB" | "C" | "DPTR" | "PC")
53+ || (u.len() == 2
54+ && u.starts_with('R')
55+ && u.as_bytes()[1].is_ascii_digit()
56+ && u.as_bytes()[1] < b'8');
57+ if reg {
58+ Tok::Register
59+ } else if is_directive(&u) {
60+ Tok::Directive
61+ } else if first_word && isa::is_mnemonic(&u) {
62+ Tok::Mnemonic
63+ } else if sfr::SFRS.iter().chain(sfr::BITS).any(|(n, _)| *n == u) {
64+ Tok::Sfr
65+ } else if matches!(
66+ u.as_str(),
67+ "HIGH" | "LOW" | "MOD" | "AND" | "OR" | "XOR" | "NOT" | "SHL" | "SHR"
68+ ) {
69+ Tok::Directive
70+ } else {
71+ Tok::Plain
72+ }
73+}
74+
75+/// Splits a source line into classified byte ranges that together cover
76+/// the whole line.
77+pub fn tokens(line: &str) -> Vec<(Range<usize>, Tok)> {
78+ let b = line.as_bytes();
79+ let n = b.len();
80+ let mut out = Vec::new();
81+ let mut i = 0;
82+ let mut seen_word = false;
83+ while i < n {
84+ let c = b[i];
85+ let start = i;
86+ let tok = if c == b';' {
87+ i = n;
88+ Tok::Comment
89+ } else if c == b'\'' || c == b'"' {
90+ i += 1;
91+ while i < n && b[i] != c {
92+ i += 1;
93+ }
94+ i = (i + 1).min(n);
95+ Tok::Str
96+ } else if c.is_ascii_alphabetic() || c == b'_' || c == b'?' {
97+ while i < n && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'?') {
98+ i += 1;
99+ }
100+ if !seen_word && b.get(i) == Some(&b':') {
101+ Tok::Label
102+ } else {
103+ let t = word_kind(&line[start..i], !seen_word);
104+ seen_word = true;
105+ t
106+ }
107+ } else if c.is_ascii_digit() || c == b'#' || c == b'$' {
108+ i += 1;
109+ while i < n && b[i].is_ascii_alphanumeric() {
110+ i += 1;
111+ }
112+ Tok::Number
113+ } else {
114+ // Non-ASCII bytes land here too, so ranges stay on char boundaries.
115+ while i < n && !(b[i].is_ascii_alphanumeric() || b"_?;'\"#$".contains(&b[i])) {
116+ i += 1;
117+ }
118+ Tok::Plain
119+ };
120+ out.push((start..i, tok));
121+ }
122+ out
123+}
124+
125+#[cfg(test)]
126+mod tests {
127+ use super::{Tok, tokens};
128+
129+ fn kinds(line: &str) -> Vec<(&str, Tok)> {
130+ tokens(line)
131+ .into_iter()
132+ .filter(|(_, t)| *t != Tok::Plain)
133+ .map(|(r, t)| (&line[r], t))
134+ .collect()
135+ }
136+
137+ #[test]
138+ fn classifies_an_instruction_line() {
139+ assert_eq!(
140+ kinds("main: MOV TMOD,#20h ; timer 1"),
141+ vec![
142+ ("main", Tok::Label),
143+ ("MOV", Tok::Mnemonic),
144+ ("TMOD", Tok::Sfr),
145+ ("#20h", Tok::Number),
146+ ("; timer 1", Tok::Comment),
147+ ]
148+ );
149+ assert_eq!(
150+ kinds(" mov a,r7"),
151+ vec![
152+ ("mov", Tok::Mnemonic),
153+ ("a", Tok::Register),
154+ ("r7", Tok::Register)
155+ ]
156+ );
157+ }
158+
159+ #[test]
160+ fn directives_strings_and_operators() {
161+ assert_eq!(
162+ kinds("msg: DB \"Hi; there\", HIGH(x) ; c"),
163+ vec![
164+ ("msg", Tok::Label),
165+ ("DB", Tok::Directive),
166+ ("\"Hi; there\"", Tok::Str),
167+ ("HIGH", Tok::Directive),
168+ ("; c", Tok::Comment),
169+ ]
170+ );
171+ }
172+
173+ #[test]
174+ fn ranges_cover_the_line_even_with_unicode() {
175+ for line in [
176+ "",
177+ " ",
178+ "x DB 'é', 1 ; µs → ok",
179+ "\"unterminated",
180+ "€€ MOV",
181+ ] {
182+ let t = tokens(line);
183+ let mut pos = 0;
184+ for (r, _) in &t {
185+ assert_eq!(r.start, pos, "{line:?}");
186+ assert!(line.is_char_boundary(r.end));
187+ pos = r.end;
188+ }
189+ assert_eq!(pos, line.len());
190+ }
191+ }
192+}
A crates/bench51-gui/Cargo.toml View
@@ -0,0 +1,24 @@
1+[package]
2+name = "bench51-gui"
3+version.workspace = true
4+edition.workspace = true
5+description = "A Keil µVision-style desktop IDE for the Intel 8051, built on bench51"
6+
7+[dependencies]
8+bench51-core.workspace = true
9+clap.workspace = true
10+eframe = { version = "0.37", default-features = false, features = [
11+ "accesskit",
12+ "clipboard",
13+ "default_fonts",
14+ "persistence",
15+ "wayland",
16+ "wgpu",
17+ "x11",
18+] }
19+egui_tiles = { version = "0.18", features = ["serde"] }
20+rfd = "0.17"
21+serde = { version = "1", features = ["derive"] }
22+
23+[lints]
24+workspace = true
A crates/bench51-gui/assets/bench51-gui.desktop View
@@ -0,0 +1,12 @@
1+[Desktop Entry]
2+Type=Application
3+Name=bench51
4+GenericName=8051 IDE
5+Comment=Intel 8051 assembler, simulator and debugger
6+Exec=bench51-gui %F
7+Icon=bench51-gui
8+Terminal=false
9+Categories=Development;IDE;Electronics;
10+Keywords=8051;8052;MCS-51;assembler;simulator;debugger;keil;microcontroller;
11+MimeType=text/x-asm;
12+StartupWMClass=bench51-gui
A crates/bench51-gui/assets/bench51-gui.svg View
@@ -0,0 +1,31 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2+ <rect width="64" height="64" rx="12" fill="#000"/>
3+ <circle cx="13" cy="21" r="1.5" fill="#fff"/>
4+ <circle cx="17" cy="21" r="1.5" fill="#fff"/>
5+ <circle cx="21" cy="21" r="1.5" fill="#fff"/>
6+ <circle cx="25" cy="21" r="1.5" fill="#fff"/>
7+ <circle cx="29" cy="21" r="1.5" fill="#fff"/>
8+ <circle cx="13" cy="25" r="1.5" fill="#fff"/>
9+ <circle cx="13" cy="29" r="1.5" fill="#fff"/>
10+ <circle cx="17" cy="29" r="1.5" fill="#fff"/>
11+ <circle cx="21" cy="29" r="1.5" fill="#fff"/>
12+ <circle cx="25" cy="29" r="1.5" fill="#fff"/>
13+ <circle cx="29" cy="33" r="1.5" fill="#fff"/>
14+ <circle cx="29" cy="37" r="1.5" fill="#fff"/>
15+ <circle cx="13" cy="41" r="1.5" fill="#fff"/>
16+ <circle cx="29" cy="41" r="1.5" fill="#fff"/>
17+ <circle cx="17" cy="45" r="1.5" fill="#fff"/>
18+ <circle cx="21" cy="45" r="1.5" fill="#fff"/>
19+ <circle cx="25" cy="45" r="1.5" fill="#fff"/>
20+ <circle cx="45" cy="21" r="1.5" fill="#fff"/>
21+ <circle cx="41" cy="25" r="1.5" fill="#fff"/>
22+ <circle cx="45" cy="25" r="1.5" fill="#fff"/>
23+ <circle cx="45" cy="29" r="1.5" fill="#fff"/>
24+ <circle cx="45" cy="33" r="1.5" fill="#fff"/>
25+ <circle cx="45" cy="37" r="1.5" fill="#fff"/>
26+ <circle cx="45" cy="41" r="1.5" fill="#fff"/>
27+ <circle cx="41" cy="45" r="1.5" fill="#fff"/>
28+ <circle cx="45" cy="45" r="1.5" fill="#fff"/>
29+ <circle cx="49" cy="45" r="1.5" fill="#fff"/>
30+ <circle cx="52" cy="12" r="3" fill="#D71921"/>
31+</svg>
A crates/bench51-gui/assets/fonts/Doto[ROND,wght].ttf View

[Binary file]

A crates/bench51-gui/assets/fonts/OFL-Doto.txt View
@@ -0,0 +1,93 @@
1+Copyright 2024 The Doto Project Authors (https://github.com/oliverlalan/Doto)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+https://openfontlicense.org
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
A crates/bench51-gui/assets/fonts/OFL-SpaceGrotesk.txt View
@@ -0,0 +1,93 @@
1+Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
A crates/bench51-gui/assets/fonts/OFL-SpaceMono.txt View
@@ -0,0 +1,93 @@
1+Copyright 2016 The Space Mono Project Authors (https://github.com/googlefonts/spacemono)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
A crates/bench51-gui/assets/fonts/SpaceGrotesk-Medium.ttf View

[Binary file]

A crates/bench51-gui/assets/fonts/SpaceGrotesk-Regular.ttf View

[Binary file]

A crates/bench51-gui/assets/fonts/SpaceMono-Bold.ttf View

[Binary file]

A crates/bench51-gui/assets/fonts/SpaceMono-Regular.ttf View

[Binary file]

A crates/bench51-gui/src/app.rs View
@@ -0,0 +1,661 @@
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+
4+use std::path::{Path, PathBuf};
5+
6+use bench51_core::files::{self, Loaded};
7+use eframe::egui::{
8+ self, CentralPanel, Context, Key, Modifiers, Panel, Shape, ThemePreference, Ui, ViewportCommand,
9+};
10+use egui_tiles::Tree;
11+
12+use crate::layout::{self, Pane, PaneBehavior};
13+use crate::state::{Action, BuildTab, LogKind, LogLine, Modal, Mode, Prefs, State, ThemePref};
14+use crate::theme::{self, palette};
15+use crate::{chrome, modals};
16+
17+/// One simulation chunk, as in the TUI's frame loop.
18+const TICK: f64 = 1.0 / 30.0;
19+
20+const SOURCE_EXTENSIONS: [&str; 4] = ["asm", "a51", "s", "src"];
21+const HEX_EXTENSIONS: [&str; 2] = ["hex", "ihx"];
22+
23+const TEMPLATE: &str = "\
24+; New 8051 program. F7 builds, Ctrl+F5 starts debugging, F11 steps.
25+ ORG 0000h
26+ LJMP main
27+
28+ ORG 0030h
29+main: MOV A,#01h ; walking LED pattern on P1
30+loop: MOV P1,A
31+ RL A
32+ SJMP loop
33+
34+ END
35+";
36+
37+pub 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+
48+fn 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+
56+fn 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+
73+impl 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+
585+impl 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+}
A crates/bench51-gui/src/chrome.rs View
@@ -0,0 +1,421 @@
1+//! Window chrome: menu bar, toolbar and status bar. They sit directly on
2+//! the page, without boxes.
3+
4+use bench51_core::session::SPEEDS;
5+use eframe::egui::{self, Align, Button, FontId, Layout, RichText, Stroke, Ui};
6+
7+use crate::icons::Icon;
8+use crate::layout::Pane;
9+use crate::state::{Action, Modal, Mode, State, ThemePref};
10+use crate::theme::{ACCENT, LABEL, LABEL_TRACKING, palette};
11+use crate::widgets;
12+
13+fn item(ui: &mut Ui, text: &str, shortcut: &str) -> bool {
14+ let p = palette(ui.ctx());
15+ let label = RichText::new(text.to_uppercase())
16+ .font(FontId::monospace(12.0))
17+ .extra_letter_spacing(0.5)
18+ .color(p.text_primary);
19+ let mut b = Button::new(label).min_size(egui::vec2(240.0, 24.0));
20+ if !shortcut.is_empty() {
21+ b = b.shortcut_text(
22+ RichText::new(shortcut)
23+ .font(FontId::monospace(11.0))
24+ .color(p.text_disabled),
25+ );
26+ }
27+ ui.add(b).clicked()
28+}
29+
30+fn check(ui: &mut Ui, text: &str, on: bool) -> bool {
31+ let mark = if on { "● " } else { " " };
32+ item(ui, &format!("{mark}{text}"), "")
33+}
34+
35+fn title(ui: &Ui, text: &str) -> RichText {
36+ let p = palette(ui.ctx());
37+ RichText::new(text.to_uppercase())
38+ .font(FontId::monospace(LABEL))
39+ .extra_letter_spacing(LABEL_TRACKING)
40+ .color(p.text_secondary)
41+}
42+
43+pub fn menu_bar(ui: &mut Ui, st: &mut State) {
44+ let mut acts = Vec::new();
45+ let has_doc = st.doc().is_some();
46+ egui::MenuBar::new().ui(ui, |ui| {
47+ ui.menu_button(title(ui, "File"), |ui| {
48+ if item(ui, "New", "Ctrl+N") {
49+ acts.push(Action::New);
50+ }
51+ if item(ui, "Open…", "Ctrl+O") {
52+ acts.push(Action::Open);
53+ }
54+ ui.menu_button(title(ui, "Open recent"), |ui| {
55+ if st.prefs.recent.is_empty() {
56+ ui.label(palette(ui.ctx()).label("none"));
57+ }
58+ for path in &st.prefs.recent {
59+ let name = path.display().to_string();
60+ if ui
61+ .button(RichText::new(name).font(FontId::monospace(12.0)))
62+ .clicked()
63+ {
64+ acts.push(Action::OpenPath(path.clone()));
65+ }
66+ }
67+ });
68+ ui.separator();
69+ if item(ui, "Save", "Ctrl+S") {
70+ acts.push(Action::Save);
71+ }
72+ if item(ui, "Save as…", "Ctrl+Shift+S") {
73+ acts.push(Action::SaveAs);
74+ }
75+ if has_doc && item(ui, "Close file", "Ctrl+W") {
76+ acts.push(Action::Close(st.active));
77+ }
78+ ui.separator();
79+ if item(ui, "Quit", "Ctrl+Q") {
80+ acts.push(Action::Quit);
81+ }
82+ });
83+ ui.menu_button(title(ui, "Edit"), |ui| {
84+ if item(ui, "Find…", "Ctrl+F") {
85+ acts.push(Action::Find);
86+ }
87+ ui.separator();
88+ if item(ui, "Toggle breakpoint", "F9") {
89+ acts.push(Action::ToggleBreakpoint);
90+ }
91+ if item(ui, "Kill all breakpoints", "Ctrl+Shift+F9") {
92+ acts.push(Action::ClearBreakpoints);
93+ }
94+ });
95+ ui.menu_button(title(ui, "View"), |ui| {
96+ for pane in Pane::ALL {
97+ if item(ui, pane.title(), "") {
98+ acts.push(Action::ShowPane(pane));
99+ }
100+ }
101+ ui.separator();
102+ ui.menu_button(title(ui, "Theme"), |ui| {
103+ for (t, name) in [
104+ (ThemePref::System, "System"),
105+ (ThemePref::Dark, "Dark"),
106+ (ThemePref::Light, "Light"),
107+ ] {
108+ if check(ui, name, st.prefs.theme == t) {
109+ acts.push(Action::SetTheme(t));
110+ }
111+ }
112+ });
113+ if item(ui, "Zoom in", "Ctrl++") {
114+ acts.push(Action::Zoom(0.1));
115+ }
116+ if item(ui, "Zoom out", "Ctrl+-") {
117+ acts.push(Action::Zoom(-0.1));
118+ }
119+ if item(ui, "Actual size", "Ctrl+0") {
120+ acts.push(Action::ZoomReset);
121+ }
122+ ui.separator();
123+ if item(ui, "Reset layout", "") {
124+ acts.push(Action::ResetLayout);
125+ }
126+ });
127+ ui.menu_button(title(ui, "Project"), |ui| {
128+ if item(ui, "Build target", "F7") {
129+ acts.push(Action::Build);
130+ }
131+ ui.separator();
132+ if item(ui, "Options for target…", "") {
133+ acts.push(Action::Modal(Modal::Options(
134+ st.prefs.clock_mhz.to_string(),
135+ )));
136+ }
137+ });
138+ ui.menu_button(title(ui, "Debug"), |ui| {
139+ let session = if st.mode == Mode::Debug {
140+ "Stop debug session"
141+ } else {
142+ "Start debug session"
143+ };
144+ if item(ui, session, "Ctrl+F5") {
145+ acts.push(Action::ToggleDebug);
146+ }
147+ ui.separator();
148+ let run = if st.running() { "Stop" } else { "Run" };
149+ if item(ui, run, "F5") {
150+ acts.push(Action::RunPause);
151+ }
152+ if item(ui, "Step into", "F11") {
153+ acts.push(Action::StepInto);
154+ }
155+ if item(ui, "Step over", "F10") {
156+ acts.push(Action::StepOver);
157+ }
158+ if item(ui, "Step out", "Ctrl+F11") {
159+ acts.push(Action::StepOut);
160+ }
161+ if item(ui, "Run to cursor line", "Ctrl+F10") {
162+ acts.push(Action::RunToCursor);
163+ }
164+ if item(ui, "Reset CPU", "F6") {
165+ acts.push(Action::Reset);
166+ }
167+ ui.separator();
168+ ui.menu_button(title(ui, "Speed"), |ui| {
169+ for (i, n) in SPEEDS.iter().enumerate() {
170+ if check(ui, &format!("{n} / tick"), st.prefs.speed == i) {
171+ acts.push(Action::SetSpeed(i));
172+ }
173+ }
174+ });
175+ });
176+ ui.menu_button(title(ui, "Peripherals"), |ui| {
177+ for (pane, name) in [
178+ (Pane::Ports, "I/O ports"),
179+ (Pane::Timers, "Timers"),
180+ (Pane::Interrupts, "Interrupts"),
181+ (Pane::Serial, "Serial (UART #1)"),
182+ ] {
183+ if item(ui, name, "") {
184+ acts.push(Action::ShowPane(pane));
185+ }
186+ }
187+ });
188+ ui.menu_button(title(ui, "Help"), |ui| {
189+ if item(ui, "Keyboard shortcuts", "F1") {
190+ acts.push(Action::Modal(Modal::Shortcuts));
191+ }
192+ if item(ui, "About bench51", "") {
193+ acts.push(Action::Modal(Modal::About));
194+ }
195+ });
196+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
197+ let p = palette(ui.ctx());
198+ ui.label(title(ui, "bench51").color(p.text_disabled));
199+ });
200+ });
201+ st.actions.extend(acts);
202+}
203+
204+pub fn toolbar(ui: &mut Ui, st: &mut State) {
205+ let p = palette(ui.ctx());
206+ let has_doc = st.doc().is_some();
207+ let running = st.running();
208+ let debug = st.mode == Mode::Debug;
209+ let mut acts = Vec::new();
210+ ui.horizontal(|ui| {
211+ ui.spacing_mut().item_spacing.x = 2.0;
212+ let mut b = |ui: &mut Ui, icon, tip, enabled, on, act| {
213+ if widgets::icon_button(ui, icon, tip, enabled, on).clicked() {
214+ acts.push(act);
215+ }
216+ };
217+ b(ui, Icon::New, "New file (Ctrl+N)", true, false, Action::New);
218+ b(ui, Icon::Open, "Open (Ctrl+O)", true, false, Action::Open);
219+ b(
220+ ui,
221+ Icon::Save,
222+ "Save (Ctrl+S)",
223+ has_doc,
224+ false,
225+ Action::Save,
226+ );
227+ ui.add_space(18.0);
228+ b(
229+ ui,
230+ Icon::Build,
231+ "Build target (F7)",
232+ has_doc,
233+ false,
234+ Action::Build,
235+ );
236+ ui.add_space(18.0);
237+ b(
238+ ui,
239+ Icon::Debug,
240+ if debug {
241+ "Stop debug session (Ctrl+F5)"
242+ } else {
243+ "Start debug session (Ctrl+F5)"
244+ },
245+ has_doc,
246+ debug,
247+ Action::ToggleDebug,
248+ );
249+ ui.add_space(18.0);
250+ b(
251+ ui,
252+ Icon::Reset,
253+ "Reset CPU (F6)",
254+ has_doc,
255+ false,
256+ Action::Reset,
257+ );
258+ if running {
259+ b(
260+ ui,
261+ Icon::Pause,
262+ "Stop (F5)",
263+ has_doc,
264+ false,
265+ Action::RunPause,
266+ );
267+ } else {
268+ b(ui, Icon::Run, "Run (F5)", has_doc, false, Action::RunPause);
269+ }
270+ b(
271+ ui,
272+ Icon::StepInto,
273+ "Step into (F11)",
274+ has_doc,
275+ false,
276+ Action::StepInto,
277+ );
278+ b(
279+ ui,
280+ Icon::StepOver,
281+ "Step over (F10)",
282+ has_doc,
283+ false,
284+ Action::StepOver,
285+ );
286+ b(
287+ ui,
288+ Icon::StepOut,
289+ "Step out (Ctrl+F11)",
290+ has_doc,
291+ false,
292+ Action::StepOut,
293+ );
294+ b(
295+ ui,
296+ Icon::RunToCursor,
297+ "Run to cursor line (Ctrl+F10)",
298+ has_doc,
299+ false,
300+ Action::RunToCursor,
301+ );
302+ ui.add_space(18.0);
303+ b(
304+ ui,
305+ Icon::Breakpoint,
306+ "Toggle breakpoint (F9)",
307+ has_doc,
308+ false,
309+ Action::ToggleBreakpoint,
310+ );
311+
312+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
313+ ui.spacing_mut().item_spacing.x = 10.0;
314+ ui.add_space(6.0);
315+ let speed = st.prefs.speed;
316+ ui.label(
317+ RichText::new(format!("{:>6} / TICK", SPEEDS[speed]))
318+ .font(FontId::monospace(LABEL))
319+ .color(p.text_secondary),
320+ );
321+ if let Some(i) = widgets::segment_bar(
322+ ui,
323+ 136.0,
324+ 10.0,
325+ SPEEDS.len(),
326+ speed + 1,
327+ p.text_display,
328+ true,
329+ ) {
330+ acts.push(Action::SetSpeed(i));
331+ }
332+ ui.label(p.label("Speed"));
333+ });
334+ });
335+ st.actions.extend(acts);
336+}
337+
338+/// True for status messages that report a failure.
339+fn is_error(msg: &str) -> bool {
340+ let m = msg.to_ascii_lowercase();
341+ [
342+ "error(s)",
343+ "cannot ",
344+ "invalid ",
345+ "failed",
346+ "reserved opcode",
347+ "out of range",
348+ ]
349+ .iter()
350+ .any(|w| m.contains(w))
351+}
352+
353+pub fn status_bar(ui: &mut Ui, st: &State) {
354+ let p = palette(ui.ctx());
355+ let text = |s: String| {
356+ RichText::new(s.to_uppercase())
357+ .font(FontId::monospace(LABEL))
358+ .extra_letter_spacing(0.6)
359+ };
360+ ui.horizontal(|ui| {
361+ ui.spacing_mut().item_spacing.x = 14.0;
362+ let doc = st.doc();
363+ let (state, live) = match doc {
364+ None => ("no file", false),
365+ Some(d) if d.session.running => ("running", true),
366+ Some(d) if d.session.build.is_none() => ("not built", false),
367+ Some(d) if d.stale() => ("source changed", false),
368+ Some(_) if st.mode == Mode::Debug => ("stopped", false),
369+ Some(_) => ("ready", false),
370+ };
371+ let mode = if st.mode == Mode::Debug {
372+ "simulation"
373+ } else {
374+ "edit"
375+ };
376+ ui.label(text(mode.into()).color(p.text_disabled));
377+ // Running: a dot that blinks in hard 2 Hz steps.
378+ let (r, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
379+ let on = !live || (ui.input(|i| i.time) * 4.0) as i64 % 2 == 0;
380+ if live && on {
381+ ui.painter().circle_filled(r.center(), 3.5, p.text_display);
382+ } else {
383+ ui.painter()
384+ .circle_stroke(r.center(), 3.0, Stroke::new(1.0, p.text_disabled));
385+ }
386+ ui.label(text(format!("[{state}]")).color(if live {
387+ p.text_display
388+ } else {
389+ p.text_secondary
390+ }));
391+ if let Some(d) = doc {
392+ let msg = &d.session.status;
393+ if !msg.is_empty() {
394+ let color = if is_error(msg) {
395+ ACCENT
396+ } else {
397+ p.text_primary
398+ };
399+ let shown = if is_error(msg) {
400+ format!("[error: {msg}]")
401+ } else {
402+ msg.clone()
403+ };
404+ ui.label(text(shown).color(color));
405+ }
406+ }
407+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
408+ ui.label(text(format!("{} MHz", st.prefs.clock_mhz)).color(p.text_disabled));
409+ if let Some(d) = doc {
410+ if d.session.build.is_some() {
411+ ui.label(text(format!("cyc {}", d.session.sim.cycles)).color(p.text_secondary));
412+ ui.label(text(format!("t {}", d.session.elapsed())).color(p.text_secondary));
413+ }
414+ ui.label(
415+ text(format!("l {} c {}", d.cursor.0 + 1, d.cursor.1 + 1))
416+ .color(p.text_secondary),
417+ );
418+ }
419+ });
420+ });
421+}
A crates/bench51-gui/src/editor.rs View
@@ -0,0 +1,657 @@
1+//! The code editor: document tabs, a find bar, syntax-highlighted text and
2+//! a gutter with breakpoints, line numbers, addresses, errors and the PC.
3+
4+use std::collections::{HashMap, HashSet};
5+
6+use bench51_core::syntax::tokens;
7+use eframe::egui::{
8+ self, Align, Align2, Color32, CursorIcon, FontId, Id, Key, Layout, Modifiers, Rect, RichText,
9+ Sense, Shape, Stroke, TextBuffer, TextFormat, Ui, Vec2,
10+ cache::{ComputerMut, FrameCache},
11+ pos2,
12+ text::{CCursor, CCursorRange, LayoutJob},
13+ text_edit::TextEditState,
14+ vec2,
15+};
16+
17+use crate::state::{Action, Document, Mode, State};
18+use crate::theme::{self, ACCENT, BODY, DARK, LIGHT, WARNING, palette};
19+use crate::widgets::{self, PillKind};
20+
21+const MARGIN: Vec2 = vec2(10.0, 8.0);
22+
23+pub fn show(ui: &mut Ui, st: &mut State) {
24+ doc_tabs(ui, st);
25+ if st.docs.is_empty() {
26+ welcome(ui, st);
27+ return;
28+ }
29+ if st.find.open {
30+ find_bar(ui, st);
31+ }
32+ code(ui, st);
33+}
34+
35+// ------------------------------------------------------------- tabs, find
36+
37+fn doc_tabs(ui: &mut Ui, st: &mut State) {
38+ let p = palette(ui.ctx());
39+ let height = 30.0;
40+ let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
41+ ui.painter().hline(
42+ strip.x_range(),
43+ strip.bottom() - 0.5,
44+ Stroke::new(1.0, p.border),
45+ );
46+ let mut child = ui.new_child(
47+ egui::UiBuilder::new()
48+ .max_rect(strip.shrink2(vec2(6.0, 0.0)))
49+ .layout(Layout::left_to_right(Align::Center)),
50+ );
51+ let ui = &mut child;
52+ ui.spacing_mut().item_spacing.x = 0.0;
53+ for (i, doc) in st.docs.iter().enumerate() {
54+ let active = i == st.active;
55+ let name = doc.name();
56+ let galley =
57+ ui.painter()
58+ .layout_no_wrap(name, FontId::monospace(12.0), Color32::PLACEHOLDER);
59+ let pad = 12.0;
60+ let dirty_w = if doc.dirty() { 10.0 } else { 0.0 };
61+ let w = galley.size().x + 2.0 * pad + dirty_w + 14.0;
62+ let (rect, resp) = ui.allocate_exact_size(vec2(w, height), Sense::click());
63+ let resp = resp.on_hover_cursor(CursorIcon::PointingHand);
64+ let fg = if active {
65+ p.text_display
66+ } else if resp.hovered() {
67+ p.text_secondary
68+ } else {
69+ p.text_disabled
70+ };
71+ let text_pos = Align2::LEFT_CENTER
72+ .align_size_within_rect(galley.size(), rect.shrink2(vec2(pad, 0.0)))
73+ .min;
74+ let text_w = galley.size().x;
75+ ui.painter().galley(text_pos, galley, fg);
76+ if active {
77+ ui.painter().hline(
78+ text_pos.x..=text_pos.x + text_w,
79+ rect.bottom() - 1.5,
80+ Stroke::new(2.0, p.text_display),
81+ );
82+ }
83+ if doc.dirty() {
84+ ui.painter().circle_filled(
85+ pos2(text_pos.x + text_w + 7.0, rect.center().y),
86+ 2.5,
87+ p.text_secondary,
88+ );
89+ }
90+ let close =
91+ Rect::from_center_size(pos2(rect.right() - pad, rect.center().y), Vec2::splat(12.0));
92+ let close_resp = ui
93+ .interact(close, Id::unique(("doc_close", doc.id)), Sense::click())
94+ .on_hover_cursor(CursorIcon::PointingHand);
95+ if resp.hovered() || close_resp.hovered() || active {
96+ let c = if close_resp.hovered() {
97+ p.text_display
98+ } else {
99+ p.text_disabled
100+ };
101+ let r = close.shrink(3.0);
102+ let s = Stroke::new(1.2, c);
103+ ui.painter()
104+ .line_segment([r.left_top(), r.right_bottom()], s);
105+ ui.painter()
106+ .line_segment([r.right_top(), r.left_bottom()], s);
107+ }
108+ if close_resp.clicked() || resp.clicked_by(egui::PointerButton::Middle) {
109+ st.actions.push(Action::Close(i));
110+ } else if resp.clicked() {
111+ st.actions.push(Action::Activate(i));
112+ }
113+ }
114+ let plus = ui
115+ .add(
116+ egui::Button::new(
117+ RichText::new("+")
118+ .font(FontId::monospace(14.0))
119+ .color(p.text_disabled),
120+ )
121+ .frame(false),
122+ )
123+ .on_hover_text("New file (Ctrl+N)");
124+ if plus.clicked() {
125+ st.actions.push(Action::New);
126+ }
127+ if st.mode == Mode::Debug {
128+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
129+ ui.add_space(8.0);
130+ ui.label(p.label("Read-only while debugging").color(p.text_disabled));
131+ });
132+ }
133+}
134+
135+fn find_bar(ui: &mut Ui, st: &mut State) {
136+ let p = palette(ui.ctx());
137+ let mut step = None;
138+ let mut close = false;
139+ ui.horizontal(|ui| {
140+ ui.add_space(MARGIN.x);
141+ ui.label(p.label("Find"));
142+ let resp = widgets::underline_input(ui, &mut st.find.query, "text", 220.0);
143+ if std::mem::take(&mut st.find.focus) {
144+ resp.request_focus();
145+ }
146+ if resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
147+ step = Some(!ui.input(|i| i.modifiers.shift));
148+ resp.request_focus();
149+ }
150+ if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
151+ close = true;
152+ }
153+ let count = st
154+ .doc()
155+ .map_or(0, |d| find_all(&d.text, &st.find.query).len());
156+ ui.label(p.label(format!("{count} found")).color(p.text_disabled));
157+ if widgets::pill(ui, "Prev", PillKind::Ghost).clicked() {
158+ step = Some(false);
159+ }
160+ if widgets::pill(ui, "Next", PillKind::Ghost).clicked() {
161+ step = Some(true);
162+ }
163+ if widgets::pill(ui, "Close", PillKind::Ghost).clicked() {
164+ close = true;
165+ }
166+ });
167+ if let (Some(forward), Some(doc)) = (step, st.docs.get_mut(st.active)) {
168+ find_step(doc, &st.find.query, forward);
169+ }
170+ if close {
171+ st.find.open = false;
172+ }
173+}
174+
175+/// Byte ranges of case-insensitive matches of `query` in `text`.
176+fn find_all(text: &str, query: &str) -> Vec<(usize, usize)> {
177+ if query.is_empty() {
178+ return Vec::new();
179+ }
180+ let hay = text.to_ascii_lowercase();
181+ let needle = query.to_ascii_lowercase();
182+ hay.match_indices(&needle)
183+ .take(2000)
184+ .map(|(i, m)| (i, i + m.len()))
185+ .collect()
186+}
187+
188+fn find_step(doc: &mut Document, query: &str, forward: bool) {
189+ let matches = find_all(&doc.text, query);
190+ if matches.is_empty() {
191+ doc.find_hit = None;
192+ return;
193+ }
194+ let here = char_to_byte(&doc.text, doc.cursor_char);
195+ let hit = doc.find_hit.filter(|h| matches.contains(h));
196+ let pick = if forward {
197+ let from = hit.map_or(here, |h| h.0 + 1);
198+ matches.iter().find(|m| m.0 >= from).or(matches.first())
199+ } else {
200+ let before = hit.map_or(here, |h| h.0);
201+ matches
202+ .iter()
203+ .rev()
204+ .find(|m| m.0 < before)
205+ .or(matches.last())
206+ };
207+ if let Some(&(a, b)) = pick {
208+ let ca = doc.text[..a].chars().count();
209+ let cb = ca + doc.text[a..b].chars().count();
210+ doc.select = Some((ca, cb));
211+ doc.find_hit = Some((a, b));
212+ }
213+}
214+
215+// -------------------------------------------------------------- welcome
216+
217+fn welcome(ui: &mut Ui, st: &mut State) {
218+ let p = palette(ui.ctx());
219+ let rect = ui.available_rect_before_wrap();
220+ let grid = if ui.visuals().dark_mode {
221+ p.border
222+ } else {
223+ p.border_visible.gamma_multiply(0.5)
224+ };
225+ widgets::dot_grid(ui.painter(), rect, grid, 16.0);
226+ let inner = rect.shrink2(vec2(56.0, 48.0));
227+ ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
228+ ui.add_space(24.0);
229+ ui.label(p.hero("BENCH51", theme::HERO_XL));
230+ ui.add_space(4.0);
231+ ui.label(p.label("8051 assembler · simulator · debugger"));
232+ ui.add_space(40.0);
233+ ui.horizontal(|ui| {
234+ if widgets::pill(ui, "New file", PillKind::Primary).clicked() {
235+ st.actions.push(Action::New);
236+ }
237+ ui.add_space(8.0);
238+ if widgets::pill(ui, "Open…", PillKind::Secondary).clicked() {
239+ st.actions.push(Action::Open);
240+ }
241+ });
242+ if !st.prefs.recent.is_empty() {
243+ ui.add_space(48.0);
244+ ui.label(p.label("Recent"));
245+ ui.add_space(8.0);
246+ for path in st.prefs.recent.clone() {
247+ let name = path.file_name().map_or_else(
248+ || path.display().to_string(),
249+ |n| n.to_string_lossy().into(),
250+ );
251+ let dir = path
252+ .parent()
253+ .map(|d| d.display().to_string())
254+ .unwrap_or_default();
255+ ui.horizontal(|ui| {
256+ let r = ui
257+ .add(
258+ egui::Label::new(
259+ RichText::new(name)
260+ .font(FontId::monospace(BODY))
261+ .color(p.text_primary),
262+ )
263+ .sense(Sense::click()),
264+ )
265+ .on_hover_cursor(CursorIcon::PointingHand);
266+ ui.label(
267+ RichText::new(dir)
268+ .font(FontId::monospace(12.0))
269+ .color(p.text_disabled),
270+ );
271+ if r.clicked() {
272+ st.actions.push(Action::OpenPath(path.clone()));
273+ }
274+ });
275+ }
276+ }
277+ });
278+}
279+
280+// ----------------------------------------------------------------- code
281+
282+#[derive(Default)]
283+struct Highlighter;
284+
285+impl ComputerMut<(bool, &str), LayoutJob> for Highlighter {
286+ fn compute(&mut self, (dark, text): (bool, &str)) -> LayoutJob {
287+ highlight(text, dark)
288+ }
289+}
290+
291+type HighlightCache = FrameCache<LayoutJob, Highlighter>;
292+
293+/// Lays out `text` with the greyscale syntax palette. The job's text is
294+/// exactly `text`, as `TextEdit` requires.
295+pub fn highlight(text: &str, dark: bool) -> LayoutJob {
296+ let p = if dark { &DARK } else { &LIGHT };
297+ let font = FontId::monospace(BODY);
298+ let bold = FontId::new(BODY, theme::mono_bold());
299+ let mut job = LayoutJob::default();
300+ job.wrap.max_width = f32::INFINITY;
301+ for (i, line) in text.split('\n').enumerate() {
302+ if i > 0 {
303+ job.append("\n", 0.0, TextFormat::simple(font.clone(), p.text_primary));
304+ }
305+ for (r, tok) in tokens(line) {
306+ let (color, strong) = p.syntax(tok);
307+ let font_id = if strong { bold.clone() } else { font.clone() };
308+ job.append(&line[r], 0.0, TextFormat::simple(font_id, color));
309+ }
310+ }
311+ job
312+}
313+
314+pub fn char_to_byte(text: &str, ci: usize) -> usize {
315+ text.char_indices().nth(ci).map_or(text.len(), |(b, _)| b)
316+}
317+
318+/// (line, column) of char index `ci`.
319+fn line_col(text: &str, ci: usize) -> (usize, usize) {
320+ let b = char_to_byte(text, ci);
321+ let before = &text[..b];
322+ let line = before.matches('\n').count();
323+ let start = before.rfind('\n').map_or(0, |i| i + 1);
324+ (line, before[start..].chars().count())
325+}
326+
327+/// Char index of (line, column), clamped to the text.
328+fn char_index(text: &str, line: usize, col: usize) -> usize {
329+ let mut ci = 0;
330+ for (i, l) in text.split('\n').enumerate() {
331+ let n = l.chars().count();
332+ if i == line {
333+ return ci + col.min(n);
334+ }
335+ ci += n + 1;
336+ }
337+ text.chars().count()
338+}
339+
340+/// Replaces the selection with spaces up to the next 8-column tab stop,
341+/// matching the assembler listing.
342+fn insert_tab(ctx: &egui::Context, id: Id, text: &mut String) {
343+ let Some(mut state) = TextEditState::load(ctx, id) else {
344+ return;
345+ };
346+ let Some(range) = state.cursor.char_range() else {
347+ return;
348+ };
349+ let (a, b) = {
350+ let (x, y) = (range.primary.index.0, range.secondary.index.0);
351+ (x.min(y), x.max(y))
352+ };
353+ let (ba, bb) = (char_to_byte(text, a), char_to_byte(text, b));
354+ let start = text[..ba].rfind('\n').map_or(0, |i| i + 1);
355+ let col = text[start..ba].chars().count();
356+ let n = 8 - col % 8;
357+ text.replace_range(ba..bb, &" ".repeat(n));
358+ state
359+ .cursor
360+ .set_char_range(Some(CCursorRange::one(CCursor::new(a + n))));
361+ state.store(ctx, id);
362+}
363+
364+/// What the gutter and backgrounds need, gathered before the text is
365+/// borrowed by the `TextEdit`.
366+struct Marks {
367+ errors: HashMap<usize, String>,
368+ breakpoints: HashSet<usize>,
369+ pc_line: Option<usize>,
370+ /// Code address of each line, shown while debugging.
371+ addrs: Option<HashMap<usize, u16>>,
372+}
373+
374+fn code(ui: &mut Ui, st: &mut State) {
375+ let debug = st.mode == Mode::Debug;
376+ let query = if st.find.open {
377+ st.find.query.clone()
378+ } else {
379+ String::new()
380+ };
381+ let Some(doc) = st.docs.get_mut(st.active) else {
382+ return;
383+ };
384+ let p = palette(ui.ctx());
385+ let dark = ui.visuals().dark_mode;
386+ let font = FontId::monospace(BODY);
387+ let row_h = ui.fonts_mut(|f| f.row_height(&font));
388+ let char_w = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
389+ let n_lines = doc.text.split('\n').count();
390+ let digits = n_lines.to_string().len().max(3);
391+
392+ let s = &doc.session;
393+ let built = s.build.is_some() && !doc.stale();
394+ let marks = Marks {
395+ errors: s
396+ .diagnostics_for(&doc.text)
397+ .iter()
398+ .rev()
399+ .map(|d| (d.line, d.msg.clone()))
400+ .collect(),
401+ breakpoints: s.breakpoints.clone(),
402+ pc_line: (debug && built && (!s.running || s.speed <= 2))
403+ .then(|| s.pc_line())
404+ .flatten(),
405+ addrs: (debug && built).then(|| {
406+ let b = s.build.as_ref().unwrap();
407+ (0..b.out.lines.len())
408+ .filter_map(|l| Some((l, b.line_addr(l)?)))
409+ .collect()
410+ }),
411+ };
412+ let follow = std::mem::take(&mut doc.follow_pc)
413+ .then_some(marks.pc_line)
414+ .flatten();
415+
416+ let bp_w = 20.0;
417+ let num_w = digits as f32 * char_w;
418+ let addr_w = if marks.addrs.is_some() {
419+ 4.0 * char_w + 16.0
420+ } else {
421+ 0.0
422+ };
423+ let gutter_w = bp_w + num_w + addr_w + 16.0;
424+ let edit_id = Id::unique(("editor", doc.id));
425+ let ctx = ui.ctx().clone();
426+
427+ if !debug
428+ && ctx.memory(|m| m.has_focus(edit_id))
429+ && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab))
430+ {
431+ insert_tab(&ctx, edit_id, &mut doc.text);
432+ }
433+
434+ // Programmatic cursor moves: jump to a line (taking keyboard focus), or
435+ // select a find match (leaving focus in the find bar, so Enter there
436+ // can't overwrite the match).
437+ let mut scroll_to_char = None;
438+ let jump = doc
439+ .goto
440+ .take()
441+ .map(|(line, col)| {
442+ let c = char_index(&doc.text, line, col);
443+ ((c, c), true)
444+ })
445+ .or(doc.select.take().map(|r| (r, false)));
446+ if let Some(((a, b), focus)) = jump {
447+ let mut state = TextEditState::load(&ctx, edit_id).unwrap_or_default();
448+ state
449+ .cursor
450+ .set_char_range(Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))));
451+ state.store(&ctx, edit_id);
452+ if focus {
453+ ctx.memory_mut(|m| m.request_focus(edit_id));
454+ }
455+ // The TextEdit only reports its cursor while focused.
456+ doc.cursor_char = a;
457+ doc.cursor = line_col(&doc.text, a);
458+ scroll_to_char = Some(a);
459+ }
460+ let hit = doc.find_hit;
461+
462+ let matches = find_all(&doc.text, &query);
463+ let mut clicked_line = None;
464+ let mut cursor = None;
465+
466+ egui::ScrollArea::both()
467+ .id_salt(("code", doc.id))
468+ .auto_shrink([false; 2])
469+ .show(ui, |ui| {
470+ let bg = ui.painter().add(Shape::Noop);
471+ ui.horizontal_top(|ui| {
472+ ui.spacing_mut().item_spacing = Vec2::ZERO;
473+ let min_h = n_lines as f32 * row_h + 2.0 * MARGIN.y;
474+ let (gutter, gutter_resp) = ui.allocate_exact_size(
475+ vec2(gutter_w, min_h.max(ui.available_height())),
476+ Sense::click(),
477+ );
478+
479+ let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, _wrap: f32| {
480+ let job = ui.memory_mut(|m| {
481+ m.caches
482+ .cache::<HighlightCache>()
483+ .get((dark, buf.as_str()))
484+ .clone()
485+ });
486+ ui.fonts_mut(|f| f.layout_job(job))
487+ };
488+ let mut ro: &str;
489+ let buf: &mut dyn TextBuffer = if debug {
490+ ro = doc.text.as_str();
491+ &mut ro
492+ } else {
493+ &mut doc.text
494+ };
495+ let out = egui::TextEdit::multiline(buf)
496+ .id(edit_id)
497+ .font(font.clone())
498+ .frame(egui::Frame::NONE)
499+ .margin(egui::Margin::symmetric(MARGIN.x as i8, MARGIN.y as i8))
500+ .desired_width(f32::INFINITY)
501+ .desired_rows(1)
502+ .lock_focus(true)
503+ .layouter(&mut layouter)
504+ .show(ui);
505+ cursor = out.cursor_range.map(|r| r.primary.index.0);
506+
507+ let galley = &out.galley;
508+ let origin = out.galley_pos;
509+ let content_right = ui
510+ .max_rect()
511+ .right()
512+ .max(origin.x + galley.size().x + MARGIN.x);
513+ let row_rect = |line: usize| -> Rect {
514+ let y = galley
515+ .rows
516+ .get(line)
517+ .map_or(origin.y + line as f32 * row_h, |r| origin.y + r.pos.y);
518+ Rect::from_min_max(pos2(gutter.left(), y), pos2(content_right, y + row_h))
519+ };
520+
521+ // Backgrounds: find matches and the PC row.
522+ let mut shapes = Vec::new();
523+ for &(a, b) in matches.iter().take(500) {
524+ let ca = doc_text_chars(galley.text(), a);
525+ let cb = doc_text_chars(galley.text(), b);
526+ let ra = galley.pos_from_cursor(CCursor::new(ca));
527+ let rb = galley.pos_from_cursor(CCursor::new(cb));
528+ if (ra.top() - rb.top()).abs() < 1.0 {
529+ let r = Rect::from_min_max(ra.min, pos2(rb.left(), ra.bottom()))
530+ .translate(origin.to_vec2());
531+ let current = hit == Some((a, b));
532+ shapes.push(Shape::rect_filled(
533+ r.expand(1.0),
534+ 2.0,
535+ WARNING.gamma_multiply(if current { 0.85 } else { 0.3 }),
536+ ));
537+ }
538+ }
539+ if let Some(pc) = marks.pc_line {
540+ let r = row_rect(pc);
541+ shapes.push(Shape::rect_filled(r, 0.0, p.surface_raised));
542+ shapes.push(Shape::rect_filled(
543+ Rect::from_min_size(r.min, vec2(2.0, r.height())),
544+ 0.0,
545+ ACCENT,
546+ ));
547+ }
548+ ui.painter().set(bg, Shape::Vec(shapes));
549+
550+ // Gutter.
551+ let clip = ui.clip_rect();
552+ let painter = ui.painter();
553+ let first = ((clip.top() - origin.y) / row_h).floor().max(0.0) as usize;
554+ let last = (((clip.bottom() - origin.y) / row_h).ceil() as usize + 1).min(n_lines);
555+ let cursor_line = doc.cursor.0;
556+ for line in first..last {
557+ let r = row_rect(line);
558+ let cy = r.center().y;
559+ let err = marks.errors.contains_key(&line);
560+ let bp = marks.breakpoints.contains(&line);
561+ let bx = gutter.left() + 4.0 + bp_w / 2.0;
562+ if bp {
563+ painter.circle_filled(pos2(bx, cy), 4.5, ACCENT);
564+ } else if err {
565+ let s = Stroke::new(1.5, ACCENT);
566+ let d = 3.5;
567+ painter.line_segment([pos2(bx - d, cy - d), pos2(bx + d, cy + d)], s);
568+ painter.line_segment([pos2(bx + d, cy - d), pos2(bx - d, cy + d)], s);
569+ }
570+ let num_color = if err {
571+ ACCENT
572+ } else if Some(line) == marks.pc_line {
573+ p.text_display
574+ } else if line == cursor_line && !debug {
575+ p.text_secondary
576+ } else {
577+ p.text_disabled
578+ };
579+ painter.text(
580+ pos2(gutter.left() + 4.0 + bp_w + num_w, cy),
581+ Align2::RIGHT_CENTER,
582+ (line + 1).to_string(),
583+ font.clone(),
584+ num_color,
585+ );
586+ if let Some(addr) = marks.addrs.as_ref().and_then(|a| a.get(&line)) {
587+ painter.text(
588+ pos2(gutter.left() + 4.0 + bp_w + num_w + 12.0, cy),
589+ Align2::LEFT_CENTER,
590+ format!("{addr:04X}"),
591+ font.clone(),
592+ p.text_disabled,
593+ );
594+ }
595+ }
596+ let gutter_resp = gutter_resp.on_hover_cursor(CursorIcon::PointingHand);
597+ if gutter_resp.clicked()
598+ && let Some(pos) = gutter_resp.interact_pointer_pos()
599+ {
600+ let line = ((pos.y - origin.y) / row_h).floor();
601+ if line >= 0.0 && (line as usize) < n_lines {
602+ clicked_line = Some(line as usize);
603+ }
604+ }
605+ let hovered_line = gutter_resp
606+ .hover_pos()
607+ .map(|pos| ((pos.y - origin.y) / row_h).floor() as usize);
608+ if let Some(msg) = hovered_line.and_then(|l| marks.errors.get(&l)) {
609+ gutter_resp.on_hover_text(msg);
610+ }
611+
612+ if let Some(line) = follow {
613+ ui.scroll_to_rect(row_rect(line), Some(Align::Center));
614+ }
615+ if let Some(ci) = scroll_to_char {
616+ let r = galley
617+ .pos_from_cursor(CCursor::new(ci))
618+ .translate(origin.to_vec2());
619+ ui.scroll_to_rect(r.expand2(vec2(40.0, 0.0)), Some(Align::Center));
620+ }
621+ });
622+ });
623+
624+ if let Some(ci) = cursor {
625+ doc.cursor_char = ci;
626+ doc.cursor = line_col(&doc.text, ci);
627+ }
628+ if let Some(line) = clicked_line {
629+ doc.session.toggle_breakpoint(line);
630+ }
631+}
632+
633+/// Char index of byte offset `b` in `text`.
634+fn doc_text_chars(text: &str, b: usize) -> usize {
635+ text[..b.min(text.len())].chars().count()
636+}
637+
638+#[cfg(test)]
639+mod tests {
640+ use super::{char_index, highlight, line_col};
641+
642+ #[test]
643+ fn layout_job_reproduces_the_text() {
644+ let text = "main: MOV A,#1 ; µs\n\n\tDB \"x\"\n";
645+ assert_eq!(highlight(text, true).text, text);
646+ }
647+
648+ #[test]
649+ fn cursor_conversions_round_trip() {
650+ let text = "ab\nµcd\n\nx";
651+ for ci in 0..=text.chars().count() {
652+ let (l, c) = line_col(text, ci);
653+ assert_eq!(char_index(text, l, c), ci);
654+ }
655+ assert_eq!(line_col(text, 4), (1, 1));
656+ }
657+}
A crates/bench51-gui/src/icons.rs View
@@ -0,0 +1,200 @@
1+//! Monoline toolbar icons drawn with the painter: 1.5 px strokes on a
2+//! 16-unit grid, at most six strokes each, no fills.
3+
4+use std::f32::consts::TAU;
5+
6+use eframe::egui::{Color32, IconData, Painter, Pos2, Rect, Shape, Stroke, vec2};
7+
8+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9+pub enum Icon {
10+ New,
11+ Open,
12+ Save,
13+ Build,
14+ Debug,
15+ Reset,
16+ Run,
17+ Pause,
18+ StepInto,
19+ StepOver,
20+ StepOut,
21+ RunToCursor,
22+ Breakpoint,
23+}
24+
25+pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
26+ let s = rect.width().min(rect.height()) / 16.0;
27+ let o = rect.center() - vec2(8.0, 8.0) * s;
28+ let p = |x: f32, y: f32| o + vec2(x, y) * s;
29+ let stroke = Stroke::new(1.5, color);
30+ let line = |pts: &[(f32, f32)]| {
31+ painter.add(Shape::line(
32+ pts.iter().map(|&(x, y)| p(x, y)).collect(),
33+ stroke,
34+ ));
35+ };
36+ let closed = |pts: &[(f32, f32)]| {
37+ painter.add(Shape::closed_line(
38+ pts.iter().map(|&(x, y)| p(x, y)).collect(),
39+ stroke,
40+ ));
41+ };
42+ // Arc around (cx, cy) from angle a0 to a1 (radians, clockwise on screen).
43+ let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| -> Vec<Pos2> {
44+ let n = 24;
45+ (0..=n)
46+ .map(|i| {
47+ let a = a0 + (a1 - a0) * i as f32 / n as f32;
48+ p(cx + r * a.cos(), cy + r * a.sin())
49+ })
50+ .collect()
51+ };
52+ let dot = |x: f32, y: f32| painter.circle_stroke(p(x, y), 1.5 * s, stroke);
53+
54+ match icon {
55+ Icon::New => {
56+ closed(&[
57+ (3.0, 1.5),
58+ (9.5, 1.5),
59+ (13.0, 5.0),
60+ (13.0, 14.5),
61+ (3.0, 14.5),
62+ ]);
63+ line(&[(9.5, 1.5), (9.5, 5.0), (13.0, 5.0)]);
64+ }
65+ Icon::Open => {
66+ closed(&[
67+ (1.5, 3.0),
68+ (6.0, 3.0),
69+ (7.5, 4.5),
70+ (14.5, 4.5),
71+ (14.5, 13.0),
72+ (1.5, 13.0),
73+ ]);
74+ line(&[(1.5, 7.0), (14.5, 7.0)]);
75+ }
76+ Icon::Save => {
77+ line(&[(8.0, 1.5), (8.0, 10.0)]);
78+ line(&[(4.5, 6.5), (8.0, 10.0), (11.5, 6.5)]);
79+ line(&[(2.0, 10.0), (2.0, 14.0), (14.0, 14.0), (14.0, 10.0)]);
80+ }
81+ Icon::Build => {
82+ // A DIP chip: body and four pins.
83+ closed(&[(4.0, 3.5), (12.0, 3.5), (12.0, 12.5), (4.0, 12.5)]);
84+ for y in [6.0, 10.0] {
85+ line(&[(1.5, y), (4.0, y)]);
86+ line(&[(12.0, y), (14.5, y)]);
87+ }
88+ }
89+ Icon::Debug => {
90+ // Power switch: starts and stops the debug session.
91+ painter.add(Shape::line(
92+ arc(8.0, 9.0, 5.5, -TAU * 0.17, TAU * 0.67),
93+ stroke,
94+ ));
95+ line(&[(8.0, 1.5), (8.0, 8.0)]);
96+ }
97+ Icon::Reset => {
98+ painter.add(Shape::line(
99+ arc(8.0, 8.5, 5.5, -TAU * 0.2, TAU * 0.6),
100+ stroke,
101+ ));
102+ let tip = (
103+ 8.0 + 5.5 * (TAU * 0.6f32).cos(),
104+ 8.5 + 5.5 * (TAU * 0.6f32).sin(),
105+ );
106+ line(&[(tip.0 - 3.5, tip.1 - 0.5), tip, (tip.0 + 0.5, tip.1 - 3.5)]);
107+ }
108+ Icon::Run => closed(&[(4.5, 2.5), (13.0, 8.0), (4.5, 13.5)]),
109+ Icon::Pause => {
110+ line(&[(5.5, 3.0), (5.5, 13.0)]);
111+ line(&[(10.5, 3.0), (10.5, 13.0)]);
112+ }
113+ Icon::StepInto => {
114+ line(&[(8.0, 1.5), (8.0, 9.5)]);
115+ line(&[(4.5, 6.0), (8.0, 9.5), (11.5, 6.0)]);
116+ dot(8.0, 13.5);
117+ }
118+ Icon::StepOver => {
119+ painter.add(Shape::line(arc(8.0, 9.0, 5.5, TAU * 0.5, TAU), stroke));
120+ line(&[(10.5, 8.0), (13.5, 9.0), (14.5, 6.0)]);
121+ dot(8.0, 13.5);
122+ }
123+ Icon::StepOut => {
124+ line(&[(8.0, 10.5), (8.0, 2.0)]);
125+ line(&[(4.5, 5.5), (8.0, 2.0), (11.5, 5.5)]);
126+ dot(8.0, 13.5);
127+ }
128+ Icon::RunToCursor => {
129+ line(&[(1.5, 8.0), (11.0, 8.0)]);
130+ line(&[(7.5, 4.5), (11.0, 8.0), (7.5, 11.5)]);
131+ line(&[(14.5, 3.0), (14.5, 13.0)]);
132+ }
133+ Icon::Breakpoint => {
134+ painter.circle_stroke(p(8.0, 8.0), 5.0 * s, stroke);
135+ }
136+ }
137+}
138+
139+/// Window icon: a dot-matrix "51" in white on black with one red dot.
140+pub fn app_icon() -> IconData {
141+ const N: usize = 64;
142+ // 5×7 glyphs.
143+ const FIVE: [&str; 7] = [
144+ "#####", "#....", "####.", "....#", "....#", "#...#", ".###.",
145+ ];
146+ const ONE: [&str; 7] = [
147+ "..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###.",
148+ ];
149+ let mut rgba = vec![0u8; N * N * 4];
150+ // Rounded black tile.
151+ let r = 12.0;
152+ for y in 0..N {
153+ for x in 0..N {
154+ let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
155+ let dx = (r - fx).max(fx - (N as f32 - r)).max(0.0);
156+ let dy = (r - fy).max(fy - (N as f32 - r)).max(0.0);
157+ let a = (r + 0.5 - (dx * dx + dy * dy).sqrt()).clamp(0.0, 1.0);
158+ rgba[(y * N + x) * 4 + 3] = (a * 255.0) as u8;
159+ }
160+ }
161+ let mut put_disc = |cx: f32, cy: f32, r: f32, c: [u8; 3]| {
162+ for y in 0..N {
163+ for x in 0..N {
164+ let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
165+ let a = (r + 0.5 - d).clamp(0.0, 1.0);
166+ if a > 0.0 {
167+ let i = (y * N + x) * 4;
168+ for k in 0..3 {
169+ rgba[i + k] = (rgba[i + k] as f32 * (1.0 - a) + c[k] as f32 * a) as u8;
170+ }
171+ rgba[i + 3] = 255;
172+ }
173+ }
174+ }
175+ };
176+ let pitch = 4.0;
177+ let (x0, y0) = (11.0, 19.0);
178+ for (gi, glyph) in [FIVE, ONE].iter().enumerate() {
179+ for (row, bits) in glyph.iter().enumerate() {
180+ for (col, b) in bits.bytes().enumerate() {
181+ if b == b'#' {
182+ let cx = x0 + (gi * 6 + col) as f32 * pitch + pitch / 2.0;
183+ let cy = y0 + row as f32 * pitch + pitch / 2.0;
184+ put_disc(cx, cy, 1.5, [255, 255, 255]);
185+ }
186+ }
187+ }
188+ }
189+ put_disc(52.0, 12.0, 3.0, [0xD7, 0x19, 0x21]);
190+ IconData {
191+ rgba,
192+ width: N as u32,
193+ height: N as u32,
194+ }
195+}
196+
197+/// Square box for an icon of size `size` centred in `rect`.
198+pub fn icon_rect(rect: Rect, size: f32) -> Rect {
199+ Rect::from_center_size(rect.center(), vec2(size, size))
200+}
A crates/bench51-gui/src/layout.rs View
@@ -0,0 +1,362 @@
1+//! Dockable panes: Keil's edit and debug window layouts on `egui_tiles`,
2+//! with Nothing-style tab bars and flat surface cards.
3+
4+use eframe::egui::{
5+ self, Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Shape, Stroke, Ui, Vec2,
6+ WidgetText, vec2,
7+};
8+use egui_tiles::{
9+ Behavior, Container, LinearDir, ResizeState, SimplificationOptions, TabState, Tabs, Tile,
10+ TileId, Tiles, Tree, UiResponse,
11+};
12+use serde::{Deserialize, Serialize};
13+
14+use crate::panes;
15+use crate::state::State;
16+use crate::theme::{self, palette};
17+
18+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19+pub enum Pane {
20+ Project,
21+ Editor,
22+ Build,
23+ Registers,
24+ Memory,
25+ Disassembly,
26+ Symbols,
27+ Serial,
28+ Ports,
29+ Timers,
30+ Interrupts,
31+}
32+
33+impl Pane {
34+ pub const ALL: [Pane; 11] = [
35+ Pane::Project,
36+ Pane::Editor,
37+ Pane::Build,
38+ Pane::Registers,
39+ Pane::Disassembly,
40+ Pane::Memory,
41+ Pane::Symbols,
42+ Pane::Serial,
43+ Pane::Ports,
44+ Pane::Timers,
45+ Pane::Interrupts,
46+ ];
47+
48+ pub fn title(self) -> &'static str {
49+ match self {
50+ Pane::Project => "Project",
51+ Pane::Editor => "Editor",
52+ Pane::Build => "Build Output",
53+ Pane::Registers => "Registers",
54+ Pane::Memory => "Memory",
55+ Pane::Disassembly => "Disassembly",
56+ Pane::Symbols => "Symbols",
57+ Pane::Serial => "UART #1",
58+ Pane::Ports => "Ports",
59+ Pane::Timers => "Timers",
60+ Pane::Interrupts => "Interrupts",
61+ }
62+ }
63+
64+ /// Panes this one is grouped with by default, to dock it next to them
65+ /// when it is reopened.
66+ fn neighbours(self) -> &'static [Pane] {
67+ match self {
68+ Pane::Project | Pane::Registers => &[Pane::Registers, Pane::Project],
69+ Pane::Editor | Pane::Disassembly => &[Pane::Disassembly, Pane::Editor],
70+ Pane::Build | Pane::Memory | Pane::Symbols => {
71+ &[Pane::Memory, Pane::Build, Pane::Symbols]
72+ }
73+ Pane::Serial | Pane::Ports | Pane::Timers | Pane::Interrupts => &[
74+ Pane::Serial,
75+ Pane::Ports,
76+ Pane::Timers,
77+ Pane::Interrupts,
78+ Pane::Memory,
79+ ],
80+ }
81+ }
82+}
83+
84+const GAP: f32 = 6.0;
85+const TAB_BAR: f32 = 30.0;
86+
87+fn tabs(tiles: &mut Tiles<Pane>, panes: &[Pane]) -> TileId {
88+ let ids = panes.iter().map(|&p| tiles.insert_pane(p)).collect();
89+ tiles.insert_tab_tile(ids)
90+}
91+
92+fn linear(tiles: &mut Tiles<Pane>, dir: LinearDir, children: &[(TileId, f32)]) -> TileId {
93+ let ids: Vec<TileId> = children.iter().map(|c| c.0).collect();
94+ let id = match dir {
95+ LinearDir::Horizontal => tiles.insert_horizontal_tile(ids),
96+ LinearDir::Vertical => tiles.insert_vertical_tile(ids),
97+ };
98+ if let Some(Tile::Container(Container::Linear(l))) = tiles.get_mut(id) {
99+ for &(child, share) in children {
100+ l.shares.set_share(child, share);
101+ }
102+ }
103+ id
104+}
105+
106+/// Keil's build-mode layout: project on the left, editor over build output.
107+pub fn edit_tree() -> Tree<Pane> {
108+ let mut t = Tiles::default();
109+ let project = tabs(&mut t, &[Pane::Project]);
110+ let editor = tabs(&mut t, &[Pane::Editor]);
111+ let build = tabs(&mut t, &[Pane::Build]);
112+ let right = linear(&mut t, LinearDir::Vertical, &[(editor, 0.7), (build, 0.3)]);
113+ let root = linear(
114+ &mut t,
115+ LinearDir::Horizontal,
116+ &[(project, 0.18), (right, 0.82)],
117+ );
118+ Tree::new(Id::unique("edit_layout"), root, t)
119+}
120+
121+/// Keil's debug-mode layout: registers on the left, source and disassembly
122+/// in the middle, memory and peripherals along the bottom.
123+pub fn debug_tree() -> Tree<Pane> {
124+ let mut t = Tiles::default();
125+ let regs = tabs(&mut t, &[Pane::Registers, Pane::Project]);
126+ let editor = tabs(&mut t, &[Pane::Editor]);
127+ let disasm = tabs(&mut t, &[Pane::Disassembly]);
128+ let mem = tabs(&mut t, &[Pane::Memory, Pane::Symbols, Pane::Build]);
129+ let periph = tabs(
130+ &mut t,
131+ &[Pane::Serial, Pane::Ports, Pane::Timers, Pane::Interrupts],
132+ );
133+ let top = linear(
134+ &mut t,
135+ LinearDir::Horizontal,
136+ &[(editor, 0.62), (disasm, 0.38)],
137+ );
138+ let bottom = linear(
139+ &mut t,
140+ LinearDir::Horizontal,
141+ &[(mem, 0.56), (periph, 0.44)],
142+ );
143+ let right = linear(&mut t, LinearDir::Vertical, &[(top, 0.6), (bottom, 0.4)]);
144+ let root = linear(
145+ &mut t,
146+ LinearDir::Horizontal,
147+ &[(regs, 0.19), (right, 0.81)],
148+ );
149+ Tree::new(Id::unique("debug_layout"), root, t)
150+}
151+
152+/// Brings `pane` to the front, docking it next to its usual neighbours if
153+/// it was closed.
154+pub fn show_pane(tree: &mut Tree<Pane>, pane: Pane) {
155+ if let Some(id) = tree.tiles.find_pane(&pane) {
156+ tree.make_active(|tid, _| tid == id);
157+ return;
158+ }
159+ let new = tree.tiles.insert_pane(pane);
160+ let target = pane
161+ .neighbours()
162+ .iter()
163+ .filter_map(|n| tree.tiles.find_pane(n))
164+ .filter_map(|id| tree.tiles.parent_of(id))
165+ .find(|&parent| {
166+ matches!(
167+ tree.tiles.get(parent),
168+ Some(Tile::Container(Container::Tabs(_)))
169+ )
170+ });
171+ if let Some(parent) = target
172+ && let Some(Tile::Container(Container::Tabs(t))) = tree.tiles.get_mut(parent)
173+ {
174+ t.add_child(new);
175+ t.set_active(new);
176+ return;
177+ }
178+ let tabs_id = tree.tiles.insert_container(Tabs::new(vec![new]));
179+ match tree.root {
180+ Some(root) => {
181+ if let Some(Tile::Container(c)) = tree.tiles.get_mut(root) {
182+ c.add_child(tabs_id);
183+ } else {
184+ tree.root = Some(tree.tiles.insert_horizontal_tile(vec![root, tabs_id]));
185+ }
186+ }
187+ None => tree.root = Some(tabs_id),
188+ }
189+}
190+
191+/// Flat surface cards behind every visible tab group: the page shows
192+/// through the gaps between them.
193+pub fn card_shapes(tree: &Tree<Pane>, fill: Color32) -> Vec<Shape> {
194+ tree.tiles
195+ .iter()
196+ .filter(|(_, t)| matches!(t, Tile::Container(Container::Tabs(_))))
197+ .filter_map(|(&id, _)| tree.tiles.rect(id))
198+ .map(|r| Shape::rect_filled(r, CornerRadius::same(8), fill))
199+ .collect()
200+}
201+
202+pub struct PaneBehavior<'a> {
203+ pub st: &'a mut State,
204+ /// Tile of the editor pane, whose group hides its tab bar.
205+ pub editor_tile: Option<TileId>,
206+}
207+
208+impl Behavior<Pane> for PaneBehavior<'_> {
209+ fn pane_ui(&mut self, ui: &mut Ui, _tile_id: TileId, pane: &mut Pane) -> UiResponse {
210+ panes::show(*pane, ui, self.st);
211+ UiResponse::None
212+ }
213+
214+ fn tab_title_for_pane(&mut self, pane: &Pane) -> WidgetText {
215+ pane.title().into()
216+ }
217+
218+ fn is_tab_closable(&self, tiles: &Tiles<Pane>, tile_id: TileId) -> bool {
219+ matches!(tiles.get(tile_id), Some(Tile::Pane(p)) if *p != Pane::Editor)
220+ }
221+
222+ fn tab_ui(
223+ &mut self,
224+ tiles: &mut Tiles<Pane>,
225+ ui: &mut Ui,
226+ id: Id,
227+ tile_id: TileId,
228+ state: &TabState,
229+ ) -> Response {
230+ let p = palette(ui.ctx());
231+ let title = match tiles.get(tile_id) {
232+ Some(Tile::Pane(pane)) => pane.title(),
233+ _ => "Group",
234+ };
235+ let galley = WidgetText::from(p.label(title)).into_galley(
236+ ui,
237+ Some(egui::TextWrapMode::Extend),
238+ f32::INFINITY,
239+ egui::TextStyle::Small,
240+ );
241+ let pad = 12.0;
242+ let close_w = if state.closable { 16.0 } else { 0.0 };
243+ let width = galley.size().x + 2.0 * pad + close_w;
244+ let (_, rect) = ui.allocate_space(vec2(width, ui.available_height()));
245+ let resp = ui
246+ .interact(rect, id, Sense::click_and_drag())
247+ .on_hover_cursor(egui::CursorIcon::PointingHand);
248+
249+ if ui.is_rect_visible(rect) && !state.is_being_dragged {
250+ let fg = if state.active {
251+ p.text_display
252+ } else if resp.hovered() {
253+ p.text_secondary
254+ } else {
255+ p.text_disabled
256+ };
257+ let text_rect = Rect::from_min_max(
258+ rect.min + vec2(pad, 0.0),
259+ rect.max - vec2(pad + close_w, 0.0),
260+ );
261+ let pos = Align2::LEFT_CENTER
262+ .align_size_within_rect(galley.size(), text_rect)
263+ .min;
264+ let text_w = galley.size().x;
265+ ui.painter().galley(pos, galley, fg);
266+ if state.active {
267+ ui.painter().hline(
268+ pos.x..=pos.x + text_w,
269+ rect.bottom() - 1.0,
270+ Stroke::new(2.0, p.text_display),
271+ );
272+ }
273+ if state.closable {
274+ let c = Rect::from_center_size(
275+ egui::pos2(rect.right() - pad - 4.0, rect.center().y),
276+ Vec2::splat(12.0),
277+ );
278+ let close = ui
279+ .interact(c, id.with("close"), Sense::click())
280+ .on_hover_cursor(egui::CursorIcon::PointingHand);
281+ if resp.hovered() || close.hovered() || state.active {
282+ let col = if close.hovered() {
283+ p.text_display
284+ } else {
285+ p.text_disabled
286+ };
287+ let r = c.shrink(3.0);
288+ let s = Stroke::new(1.2, col);
289+ ui.painter()
290+ .line_segment([r.left_top(), r.right_bottom()], s);
291+ ui.painter()
292+ .line_segment([r.right_top(), r.left_bottom()], s);
293+ }
294+ if (close.clicked() || resp.clicked_by(egui::PointerButton::Middle))
295+ && self.on_tab_close(tiles, tile_id)
296+ {
297+ tiles.remove(tile_id);
298+ }
299+ }
300+ }
301+ resp
302+ }
303+
304+ fn is_tab_bar_visible(&self, _tile_id: TileId, tabs: &Tabs) -> bool {
305+ // The editor brings its own document tabs; a second bar above them
306+ // would only repeat "EDITOR".
307+ !(tabs.children.len() == 1 && self.editor_tile == Some(tabs.children[0]))
308+ }
309+
310+ fn tab_bar_height(&self, _style: &egui::Style) -> f32 {
311+ TAB_BAR
312+ }
313+
314+ fn gap_width(&self, _style: &egui::Style) -> f32 {
315+ GAP
316+ }
317+
318+ fn min_size(&self) -> f32 {
319+ 64.0
320+ }
321+
322+ fn tab_bar_color(&self, _visuals: &egui::Visuals) -> Color32 {
323+ Color32::TRANSPARENT
324+ }
325+
326+ fn tab_bar_hline_stroke(&self, visuals: &egui::Visuals) -> Stroke {
327+ let p = if visuals.dark_mode {
328+ &theme::DARK
329+ } else {
330+ &theme::LIGHT
331+ };
332+ Stroke::new(1.0, p.border)
333+ }
334+
335+ fn resize_stroke(&self, style: &egui::Style, state: ResizeState) -> Stroke {
336+ let p = if style.visuals.dark_mode {
337+ &theme::DARK
338+ } else {
339+ &theme::LIGHT
340+ };
341+ match state {
342+ ResizeState::Idle => Stroke::NONE,
343+ ResizeState::Hovering => Stroke::new(1.0, p.text_disabled),
344+ ResizeState::Dragging => Stroke::new(1.0, p.text_display),
345+ }
346+ }
347+
348+ fn simplification_options(&self) -> SimplificationOptions {
349+ SimplificationOptions {
350+ all_panes_must_have_tabs: true,
351+ ..Default::default()
352+ }
353+ }
354+
355+ fn drag_preview_color(&self, visuals: &egui::Visuals) -> Color32 {
356+ visuals.selection.bg_fill
357+ }
358+
359+ fn drag_preview_stroke(&self, visuals: &egui::Visuals) -> Stroke {
360+ visuals.selection.stroke
361+ }
362+}
A crates/bench51-gui/src/main.rs View
@@ -0,0 +1,57 @@
1+// No console window behind the app on Windows release builds.
2+#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
3+
4+mod app;
5+mod chrome;
6+mod editor;
7+mod icons;
8+mod layout;
9+mod modals;
10+mod panes;
11+mod state;
12+mod theme;
13+mod widgets;
14+
15+use std::path::PathBuf;
16+use std::sync::Arc;
17+
18+use clap::Parser;
19+use eframe::egui;
20+
21+/// Keil µVision-style desktop IDE for the Intel 8051, built on bench51.
22+#[derive(Parser, Debug)]
23+#[command(version, about)]
24+struct Args {
25+ /// Assembly sources (.asm, .a51) or Intel HEX files (.hex, .ihx) to open.
26+ files: Vec<PathBuf>,
27+
28+ /// Oscillator frequency in MHz, used to show elapsed time.
29+ #[arg(long)]
30+ clock: Option<f64>,
31+
32+ /// Colour theme, overriding the saved choice.
33+ #[arg(long, value_enum)]
34+ theme: Option<state::ThemePref>,
35+}
36+
37+fn main() -> eframe::Result {
38+ let args = Args::parse();
39+ let options = eframe::NativeOptions {
40+ viewport: egui::ViewportBuilder::default()
41+ .with_title("bench51")
42+ .with_app_id("bench51-gui")
43+ .with_inner_size([1440.0, 900.0])
44+ .with_min_inner_size([960.0, 600.0])
45+ .with_icon(Arc::new(icons::app_icon())),
46+ ..Default::default()
47+ };
48+ eframe::run_native(
49+ "bench51-gui",
50+ options,
51+ Box::new(move |cc| {
52+ Ok(Box::new(app::GuiApp::new(
53+ cc, args.files, args.clock, args.theme,
54+ )))
55+ }),
56+ )
57+}
A crates/bench51-gui/src/modals.rs View
@@ -0,0 +1,208 @@
1+//! Dialogs: unsaved-changes confirmation, target options, shortcuts, about.
2+
3+use eframe::egui::{
4+ self, Align, Color32, Context, CornerRadius, FontId, Key, Layout, RichText, Stroke, Ui,
5+};
6+
7+use crate::state::{Action, Modal, State};
8+use crate::theme::{BODY, HERO, palette};
9+use crate::widgets::{self, PillKind};
10+
11+const SHORTCUTS: &[(&str, &[(&str, &str)])] = &[
12+ (
13+ "File",
14+ &[
15+ ("Ctrl+N", "new file"),
16+ ("Ctrl+O", "open .asm / .a51, or .hex (disassembled)"),
17+ ("Ctrl+S F2", "save"),
18+ ("Ctrl+Shift+S", "save as"),
19+ ("Ctrl+W", "close file"),
20+ ("Ctrl+F", "find"),
21+ ],
22+ ),
23+ (
24+ "Build & debug",
25+ &[
26+ ("F7", "build target (writes .lst and .hex)"),
27+ ("Ctrl+F5 F4", "start / stop debug session"),
28+ ("F5", "run / stop"),
29+ ("F11", "step into"),
30+ ("F10", "step over"),
31+ ("Ctrl+F11 F12", "step out"),
32+ ("Ctrl+F10 F8", "run to cursor line"),
33+ ("F9", "toggle breakpoint"),
34+ ("Ctrl+Shift+F9", "kill all breakpoints"),
35+ ("F6", "reset CPU"),
36+ ("Esc", "stop running"),
37+ ],
38+ ),
39+ (
40+ "Windows",
41+ &[
42+ ("Ctrl+Tab", "next tab in the hovered group"),
43+ ("Ctrl+ +/-/0", "zoom"),
44+ ("double-click", "edit a memory byte"),
45+ ],
46+ ),
47+];
48+
49+fn frame(ctx: &Context) -> egui::Frame {
50+ let p = crate::theme::palette(ctx);
51+ egui::Frame::new()
52+ .fill(p.surface)
53+ .stroke(Stroke::new(1.0, p.border_visible))
54+ .corner_radius(CornerRadius::same(16))
55+ .inner_margin(egui::Margin::same(24))
56+}
57+
58+/// Title on the left, `[ X ]` close on the right.
59+fn header(ui: &mut Ui, title: &str) -> bool {
60+ let p = palette(ui.ctx());
61+ let mut close = false;
62+ ui.horizontal(|ui| {
63+ ui.label(p.label(title));
64+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
65+ close = widgets::pill(ui, "[ X ]", PillKind::Ghost).clicked();
66+ });
67+ });
68+ ui.add_space(12.0);
69+ close
70+}
71+
72+pub fn show(ctx: &Context, st: &mut State) {
73+ let Some(modal) = st.modal.clone() else {
74+ return;
75+ };
76+ let mut close = false;
77+ let mut acts = Vec::new();
78+ let resp = egui::Modal::new(egui::Id::unique("modal"))
79+ .frame(frame(ctx))
80+ .backdrop_color(Color32::from_black_alpha(204))
81+ .show(ctx, |ui| {
82+ let p = palette(ui.ctx());
83+ ui.set_max_width(480.0);
84+ match &modal {
85+ Modal::ConfirmClose(i) => {
86+ let name = st.docs.get(*i).map(|d| d.name()).unwrap_or_default();
87+ close |= header(ui, "Unsaved changes");
88+ ui.label(
89+ RichText::new(format!("Save changes to {name} before closing?"))
90+ .size(BODY)
91+ .color(p.text_primary),
92+ );
93+ ui.add_space(24.0);
94+ ui.horizontal(|ui| {
95+ if widgets::pill(ui, "Save", PillKind::Primary).clicked() {
96+ acts.push(Action::Activate(*i));
97+ acts.push(Action::Save);
98+ acts.push(Action::Close(*i));
99+ close = true;
100+ }
101+ if widgets::pill(ui, "Discard", PillKind::Secondary).clicked() {
102+ acts.push(Action::ForceClose(*i));
103+ close = true;
104+ }
105+ close |= widgets::pill(ui, "Cancel", PillKind::Ghost).clicked();
106+ });
107+ }
108+ Modal::ConfirmQuit => {
109+ let n = st.docs.iter().filter(|d| d.dirty()).count();
110+ close |= header(ui, "Unsaved changes");
111+ ui.label(
112+ RichText::new(format!(
113+ "{n} file{} unsaved changes.",
114+ if n == 1 { " has" } else { "s have" }
115+ ))
116+ .size(BODY)
117+ .color(p.text_primary),
118+ );
119+ ui.add_space(24.0);
120+ ui.horizontal(|ui| {
121+ if widgets::pill(ui, "Quit without saving", PillKind::Secondary).clicked() {
122+ acts.push(Action::ForceQuit);
123+ close = true;
124+ }
125+ close |= widgets::pill(ui, "Cancel", PillKind::Ghost).clicked();
126+ });
127+ }
128+ Modal::Options(text) => {
129+ close |= header(ui, "Options for target");
130+ let mut text = text.clone();
131+ ui.label(p.label("Xtal (MHz)"));
132+ ui.add_space(4.0);
133+ let r = widgets::underline_input(ui, &mut text, "11.0592", 160.0);
134+ if !r.has_focus() && !r.lost_focus() && text.is_empty() {
135+ r.request_focus();
136+ }
137+ ui.add_space(4.0);
138+ ui.label(
139+ p.label("Used to turn machine cycles into elapsed time")
140+ .color(p.text_disabled),
141+ );
142+ let valid = text.trim().parse::<f64>().ok().filter(|v| *v > 0.0);
143+ ui.add_space(24.0);
144+ let enter = r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter));
145+ ui.horizontal(|ui| {
146+ if (widgets::pill(ui, "OK", PillKind::Primary).clicked() || enter)
147+ && let Some(v) = valid
148+ {
149+ acts.push(Action::SetClock(v));
150+ close = true;
151+ }
152+ close |= widgets::pill(ui, "Cancel", PillKind::Ghost).clicked();
153+ });
154+ if !close {
155+ st.modal = Some(Modal::Options(text));
156+ }
157+ }
158+ Modal::Shortcuts => {
159+ close |= header(ui, "Keyboard shortcuts");
160+ egui::ScrollArea::vertical()
161+ .max_height(420.0)
162+ .show(ui, |ui| {
163+ for (section, keys) in SHORTCUTS {
164+ ui.label(p.label(section).color(p.text_disabled));
165+ ui.add_space(4.0);
166+ egui::Grid::new(section)
167+ .num_columns(2)
168+ .spacing([24.0, 4.0])
169+ .show(ui, |ui| {
170+ for (k, what) in *keys {
171+ ui.label(
172+ RichText::new(*k)
173+ .font(FontId::monospace(12.0))
174+ .color(p.text_display),
175+ );
176+ ui.label(RichText::new(*what).color(p.text_secondary));
177+ ui.end_row();
178+ }
179+ });
180+ ui.add_space(16.0);
181+ }
182+ });
183+ }
184+ Modal::About => {
185+ close |= header(ui, "About");
186+ ui.label(p.hero("BENCH51", HERO));
187+ ui.add_space(4.0);
188+ ui.label(p.label(format!("version {}", env!("CARGO_PKG_VERSION"))));
189+ ui.add_space(16.0);
190+ ui.label(
191+ RichText::new(
192+ "An Intel 8051 assembler, simulator and debugger laid out like \
193+ Keil µVision. A learning tool, not a cycle-exact replacement \
194+ for a vendor simulator.",
195+ )
196+ .color(p.text_primary),
197+ );
198+ }
199+ }
200+ });
201+ if resp.should_close() {
202+ close = true;
203+ }
204+ if close {
205+ st.modal = None;
206+ }
207+ st.actions.extend(acts);
208+}
A crates/bench51-gui/src/panes/build.rs View
@@ -0,0 +1,188 @@
1+//! Build Output: Keil-style messages, the listing and the Intel HEX file.
2+
3+use bench51_core::syntax::tokens;
4+use eframe::egui::text::LayoutJob;
5+use eframe::egui::{self, Align, CursorIcon, FontId, Layout, RichText, Sense, TextFormat, Ui};
6+
7+use crate::state::{Action, BuildTab, LogKind, Mode, State};
8+use crate::theme::{ACCENT, HERO, palette};
9+use crate::widgets;
10+
11+pub fn show(ui: &mut Ui, st: &mut State) {
12+ let p = palette(ui.ctx());
13+ let summary = st.doc().map(|d| match &d.session.last_asm {
14+ Some((src, out)) if out.ok() => {
15+ (out.code_size().to_string(), "bytes", false, src != &d.text)
16+ }
17+ Some((src, out)) => (
18+ out.diagnostics.len().to_string(),
19+ "errors",
20+ true,
21+ src != &d.text,
22+ ),
23+ None => (String::new(), "not built", false, false),
24+ });
25+ ui.horizontal(|ui| {
26+ if let Some((n, unit, err, changed)) = &summary {
27+ let color = if *err { ACCENT } else { p.text_display };
28+ // The PC is the hero while debugging; one per screen.
29+ if n.is_empty() {
30+ } else if st.mode == Mode::Edit {
31+ ui.label(p.hero(n, HERO).color(color));
32+ } else {
33+ ui.label(p.data(n).size(18.0).color(color));
34+ }
35+ ui.vertical(|ui| {
36+ ui.add_space(if st.mode == Mode::Edit { 14.0 } else { 2.0 });
37+ ui.label(p.label(*unit));
38+ if *changed {
39+ ui.label(p.label("[source changed]").color(p.text_disabled));
40+ }
41+ });
42+ }
43+ ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
44+ widgets::segmented(
45+ ui,
46+ &mut st.build_tab,
47+ &[
48+ (BuildTab::Messages, "Messages"),
49+ (BuildTab::Listing, "Listing"),
50+ (BuildTab::Hex, "HEX"),
51+ ],
52+ );
53+ });
54+ });
55+ ui.add_space(8.0);
56+ match st.build_tab {
57+ BuildTab::Messages => messages(ui, st),
58+ BuildTab::Listing => listing(ui, st),
59+ BuildTab::Hex => hex(ui, st),
60+ }
61+}
62+
63+fn messages(ui: &mut Ui, st: &mut State) {
64+ let p = palette(ui.ctx());
65+ if st.log.is_empty() {
66+ widgets::empty_state(
67+ ui,
68+ "No messages",
69+ "Build output appears here. F7 assembles.",
70+ );
71+ return;
72+ }
73+ let mut goto = None;
74+ egui::ScrollArea::vertical()
75+ .stick_to_bottom(true)
76+ .auto_shrink([false; 2])
77+ .show(ui, |ui| {
78+ ui.spacing_mut().item_spacing.y = 3.0;
79+ for line in &st.log {
80+ let color = match line.kind {
81+ LogKind::Info => p.text_secondary,
82+ LogKind::Ok => p.text_primary,
83+ LogKind::Error => ACCENT,
84+ };
85+ let text = RichText::new(&line.text)
86+ .font(FontId::monospace(12.5))
87+ .color(color);
88+ if let Some(target) = line.target {
89+ let r = ui
90+ .add(egui::Label::new(text).sense(Sense::click()))
91+ .on_hover_cursor(CursorIcon::PointingHand);
92+ if r.clicked() {
93+ goto = Some(target);
94+ }
95+ } else {
96+ ui.label(text);
97+ }
98+ }
99+ });
100+ if let Some((doc, line, col)) = goto {
101+ st.actions.push(Action::Goto { doc, line, col });
102+ }
103+}
104+
105+fn rows(ui: &mut Ui, id: &str, lines: &[&str], mut row: impl FnMut(&str) -> LayoutJob) {
106+ let row_h = ui.fonts_mut(|f| f.row_height(&FontId::monospace(12.5))) + 2.0;
107+ egui::ScrollArea::both()
108+ .id_salt(id)
109+ .auto_shrink([false; 2])
110+ .show_rows(ui, row_h, lines.len(), |ui, range| {
111+ ui.spacing_mut().item_spacing.y = 0.0;
112+ for i in range {
113+ ui.add(egui::Label::new(row(lines[i])).extend());
114+ }
115+ });
116+}
117+
118+fn listing(ui: &mut Ui, st: &State) {
119+ let p = palette(ui.ctx());
120+ let Some((src, out)) = st.doc().and_then(|d| d.session.last_asm.as_ref()) else {
121+ widgets::empty_state(ui, "No listing", "Press F7 to assemble.");
122+ return;
123+ };
124+ let text = out.listing(src);
125+ let lines: Vec<&str> = text.lines().collect();
126+ let size = 12.5;
127+ rows(ui, "listing", &lines, |l| {
128+ let font = FontId::monospace(size);
129+ if l.starts_with("***") {
130+ return LayoutJob::simple_singleline(l.to_string(), font, ACCENT);
131+ }
132+ let is_code = l.len() > 25 && l.is_char_boundary(25);
133+ if is_code && (l.as_bytes()[0].is_ascii_hexdigit() || l.starts_with(' ')) {
134+ let (head, rest) = l.split_at(25);
135+ let mut job = LayoutJob::default();
136+ let head_color = if l.starts_with(' ') {
137+ p.text_disabled
138+ } else {
139+ p.text_secondary
140+ };
141+ job.append(head, 0.0, TextFormat::simple(font, head_color));
142+ for (r, tok) in tokens(rest) {
143+ let (color, _) = p.syntax(tok);
144+ job.append(
145+ &rest[r],
146+ 0.0,
147+ TextFormat::simple(FontId::monospace(size), color),
148+ );
149+ }
150+ return job;
151+ }
152+ LayoutJob::simple_singleline(l.to_string(), font, p.text_secondary)
153+ });
154+}
155+
156+fn hex(ui: &mut Ui, st: &State) {
157+ let p = palette(ui.ctx());
158+ let Some((_, out)) = st.doc().and_then(|d| d.session.last_asm.as_ref()) else {
159+ widgets::empty_state(ui, "No HEX file", "Press F7 to assemble.");
160+ return;
161+ };
162+ if !out.ok() {
163+ widgets::empty_state(ui, "No HEX file", "Fix the errors to generate Intel HEX.");
164+ return;
165+ }
166+ let text = out.intel_hex();
167+ let lines: Vec<&str> = text.lines().collect();
168+ rows(ui, "hex", &lines, |l| {
169+ let font = FontId::monospace(12.5);
170+ let mut job = LayoutJob::default();
171+ if l.len() < 11 {
172+ job.append(l, 0.0, TextFormat::simple(font, p.text_primary));
173+ return job;
174+ }
175+ let n = l.len();
176+ for (part, color) in [
177+ (&l[..1], p.text_disabled),
178+ (&l[1..3], p.text_secondary),
179+ (&l[3..7], p.text_display),
180+ (&l[7..9], p.text_secondary),
181+ (&l[9..n - 2], p.text_primary),
182+ (&l[n - 2..], p.text_disabled),
183+ ] {
184+ job.append(part, 0.0, TextFormat::simple(font.clone(), color));
185+ }
186+ job
187+ });
188+}
A crates/bench51-gui/src/panes/disasm.rs View
@@ -0,0 +1,193 @@
1+//! Disassembly window: every instruction of the build with its address,
2+//! bytes and label, decoded from code memory as it is now.
3+
4+use bench51_core::sim::disasm;
5+use bench51_core::syntax::{Tok, tokens};
6+use eframe::egui::{self, Align2, CursorIcon, FontId, Id, Rect, Sense, Ui, pos2, vec2};
7+
8+use crate::state::{Action, State};
9+use crate::theme::{ACCENT, palette};
10+use crate::widgets;
11+
12+struct Row {
13+ line: usize,
14+ addr: u16,
15+ len: usize,
16+ label: String,
17+ /// Source text for DB/DW lines, which aren't instructions.
18+ data: Option<String>,
19+}
20+
21+fn rows(source: &[String], build: &bench51_core::session::Build) -> Vec<Row> {
22+ let mut out = Vec::new();
23+ for (line, lo) in build.out.lines.iter().enumerate() {
24+ let (Some(addr), false) = (lo.addr, lo.bytes.is_empty()) else {
25+ continue;
26+ };
27+ let text = source.get(line).map_or("", String::as_str);
28+ let toks = tokens(text);
29+ let mut label = String::new();
30+ let mut data = None;
31+ for (r, t) in &toks {
32+ match t {
33+ Tok::Label if label.is_empty() => label = text[r.clone()].to_string(),
34+ Tok::Directive => {
35+ let rest = &text[r.start..];
36+ let rest = rest.split(';').next().unwrap_or(rest).trim_end();
37+ data = Some(rest.to_string());
38+ break;
39+ }
40+ Tok::Mnemonic => break,
41+ _ => {}
42+ }
43+ }
44+ out.push(Row {
45+ line,
46+ addr,
47+ len: lo.bytes.len(),
48+ label,
49+ data,
50+ });
51+ }
52+ out
53+}
54+
55+pub fn show(ui: &mut Ui, st: &mut State) {
56+ let p = palette(ui.ctx());
57+ let Some(doc) = st.docs.get_mut(st.active) else {
58+ widgets::empty_state(ui, "No program", "Open or create a source file.");
59+ return;
60+ };
61+ let doc_id = doc.id;
62+ let s = &mut doc.session;
63+ let Some(build) = &s.build else {
64+ widgets::empty_state(ui, "Not built", "Press F7 to assemble.");
65+ return;
66+ };
67+ let rows = rows(&build.source, build);
68+ let font = FontId::monospace(13.0);
69+ let cw = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
70+ let row_h = ui.fonts_mut(|f| f.row_height(&font)) + 6.0;
71+ let col_addr = 20.0;
72+ let col_bytes = col_addr + 6.0 * cw;
73+ let col_label = col_bytes + 10.0 * cw;
74+ let col_insn = col_label + 10.0 * cw;
75+
76+ // Header.
77+ let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
78+ for (x, name) in [
79+ (col_addr, "Addr"),
80+ (col_bytes, "Bytes"),
81+ (col_label, "Label"),
82+ (col_insn, "Instruction"),
83+ ] {
84+ ui.painter().text(
85+ pos2(hdr.left() + x, hdr.center().y),
86+ Align2::LEFT_CENTER,
87+ name.to_uppercase(),
88+ FontId::monospace(crate::theme::LABEL),
89+ p.text_disabled,
90+ );
91+ }
92+ ui.add_space(4.0);
93+
94+ let pc = s.sim.pc;
95+ let show_pc = !s.running || s.speed <= 2;
96+ let pc_row = rows.iter().position(|r| r.addr == pc);
97+ let bps = s.breakpoints.clone();
98+
99+ // Follow the PC whenever it moves while stopped.
100+ let last_id = Id::unique(("disasm_last_pc", doc_id));
101+ let last = ui.data(|d| d.get_temp::<u16>(last_id));
102+ let mut area = egui::ScrollArea::vertical()
103+ .id_salt(("disasm", doc_id))
104+ .auto_shrink([false; 2]);
105+ if show_pc && last != Some(pc) {
106+ ui.data_mut(|d| d.insert_temp(last_id, pc));
107+ if let Some(i) = pc_row {
108+ let view = ui.available_height();
109+ area = area.vertical_scroll_offset((i as f32 * row_h - view / 2.0).max(0.0));
110+ }
111+ }
112+
113+ let mut toggle = None;
114+ let mut goto = None;
115+ area.show_rows(ui, row_h, rows.len(), |ui, range| {
116+ ui.spacing_mut().item_spacing.y = 0.0;
117+ for i in range {
118+ let r = &rows[i];
119+ let (rect, resp) =
120+ ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
121+ let painter = ui.painter();
122+ let cy = rect.center().y;
123+ let is_pc = show_pc && pc_row == Some(i);
124+ if is_pc {
125+ painter.rect_filled(rect, 0.0, p.surface_raised);
126+ painter.rect_filled(
127+ Rect::from_min_size(rect.min, vec2(2.0, rect.height())),
128+ 0.0,
129+ ACCENT,
130+ );
131+ }
132+ if bps.contains(&r.line) {
133+ painter.circle_filled(pos2(rect.left() + 10.0, cy), 4.0, ACCENT);
134+ }
135+ let text = |x: f32, s: String, c| {
136+ painter.text(
137+ pos2(rect.left() + x, cy),
138+ Align2::LEFT_CENTER,
139+ s,
140+ font.clone(),
141+ c,
142+ );
143+ };
144+ text(
145+ col_addr,
146+ format!("{:04X}", r.addr),
147+ if is_pc {
148+ p.text_display
149+ } else {
150+ p.text_secondary
151+ },
152+ );
153+ let bytes: Vec<String> = (0..r.len.min(3))
154+ .map(|k| format!("{:02X}", s.sim.code[r.addr.wrapping_add(k as u16) as usize]))
155+ .collect();
156+ let more = if r.len > 3 { "…" } else { "" };
157+ text(col_bytes, bytes.join(" ") + more, p.text_disabled);
158+ text(col_label, r.label.clone(), p.text_display);
159+ match &r.data {
160+ Some(d) => text(col_insn, d.clone(), p.text_secondary),
161+ None => text(
162+ col_insn,
163+ disasm::disassemble(&s.sim.code, r.addr).text,
164+ if is_pc {
165+ p.text_display
166+ } else {
167+ p.text_primary
168+ },
169+ ),
170+ }
171+ let resp = resp.on_hover_cursor(CursorIcon::Default);
172+ if resp.clicked()
173+ && resp
174+ .interact_pointer_pos()
175+ .is_some_and(|pos| pos.x < rect.left() + col_addr)
176+ {
177+ toggle = Some(r.line);
178+ } else if resp.double_clicked() {
179+ goto = Some(r.line);
180+ }
181+ }
182+ });
183+ if let Some(line) = toggle {
184+ s.toggle_breakpoint(line);
185+ }
186+ if let Some(line) = goto {
187+ st.actions.push(Action::Goto {
188+ doc: doc_id,
189+ line,
190+ col: 0,
191+ });
192+ }
193+}
A crates/bench51-gui/src/panes/interrupts.rs View
@@ -0,0 +1,83 @@
1+//! Interrupt system: enable, priority and request flag of each source.
2+
3+use bench51_core::session::{Session, vector_name};
4+use bench51_core::sfr::{IE, IP, SCON, TCON};
5+use eframe::egui::{self, Ui};
6+
7+use super::sfr_chip;
8+use crate::theme::{WARNING, palette};
9+use crate::widgets;
10+
11+/// (name, vector, IE/IP mask, request flag as (SFR, mask)).
12+const SOURCES: [(&str, u16, u8, (u8, u8)); 5] = [
13+ ("INT0", 0x03, 0x01, (TCON, 0x02)),
14+ ("T0", 0x0B, 0x02, (TCON, 0x20)),
15+ ("INT1", 0x13, 0x04, (TCON, 0x08)),
16+ ("T1", 0x1B, 0x08, (TCON, 0x80)),
17+ ("UART", 0x23, 0x10, (SCON, 0x03)),
18+];
19+
20+pub fn show(ui: &mut Ui, s: &mut Session) {
21+ let p = palette(ui.ctx());
22+ egui::ScrollArea::vertical()
23+ .auto_shrink([false; 2])
24+ .show(ui, |ui| {
25+ ui.horizontal(|ui| {
26+ sfr_chip(ui, s, "EA", IE, 0x80);
27+ ui.add_space(12.0);
28+ let state = if s.sim.power_down {
29+ ("power-down", WARNING)
30+ } else if s.sim.idle {
31+ ("idle", WARNING)
32+ } else {
33+ ("running", p.text_secondary)
34+ };
35+ ui.label(p.label("CPU"));
36+ ui.label(p.label(state.0).color(state.1));
37+ });
38+ ui.add_space(10.0);
39+ egui::Grid::new("irqs")
40+ .num_columns(5)
41+ .spacing([14.0, 6.0])
42+ .show(ui, |ui| {
43+ for h in ["Source", "Vector", "Enable", "Priority", "Request"] {
44+ ui.label(p.label(h).color(p.text_disabled));
45+ }
46+ ui.end_row();
47+ for (name, vector, mask, (flag_sfr, flag_mask)) in SOURCES {
48+ let active = s.sim.last_irq == Some(vector) && !s.sim.in_service.is_empty();
49+ ui.label(p.label(name).color(if active {
50+ p.text_display
51+ } else {
52+ p.text_primary
53+ }));
54+ ui.label(p.data(format!("{vector:04X}")).color(p.text_secondary));
55+ sfr_chip(ui, s, "EN", IE, mask);
56+ sfr_chip(ui, s, "HI", IP, mask);
57+ let req = s.sim.sfr(flag_sfr) & flag_mask != 0;
58+ if flag_sfr == SCON {
59+ widgets::bit_chip(ui, "RI|TI", req);
60+ } else {
61+ sfr_chip(ui, s, "REQ", flag_sfr, flag_mask);
62+ }
63+ ui.end_row();
64+ }
65+ });
66+ widgets::section(ui, "In service");
67+ let levels = if s.sim.in_service.is_empty() {
68+ "none".to_string()
69+ } else {
70+ s.sim
71+ .in_service
72+ .iter()
73+ .map(|l| format!("L{l}"))
74+ .collect::<Vec<_>>()
75+ .join(" > ")
76+ };
77+ widgets::stat_row(ui, "Levels", p.data(levels));
78+ let last = s.sim.last_irq.map_or("—".to_string(), |v| {
79+ format!("{v:04X}h {}", vector_name(v))
80+ });
81+ widgets::stat_row(ui, "Last taken", p.data(last));
82+ });
83+}
A crates/bench51-gui/src/panes/memory.rs View
@@ -0,0 +1,305 @@
1+//! Memory window: IRAM, SFRs, XRAM and code as a hex grid. Double-click a
2+//! byte to change it.
3+
4+use bench51_core::session::{MemSpace, Session};
5+use bench51_core::sfr;
6+use eframe::egui::{
7+ self, Align, Align2, FontId, Key, Layout, Rect, Sense, TextEdit, Ui, pos2, vec2,
8+};
9+
10+use crate::state::{MemView, State};
11+use crate::theme::{ACCENT, WARNING, palette};
12+use crate::widgets;
13+
14+const PER_ROW: usize = 16;
15+
16+pub fn show(ui: &mut Ui, st: &mut State) {
17+ let p = palette(ui.ctx());
18+ let mut space = st.mem.space;
19+ ui.horizontal(|ui| {
20+ if widgets::segmented(
21+ ui,
22+ &mut space,
23+ &[
24+ (MemSpace::Iram, "IRAM"),
25+ (MemSpace::Sfr, "SFR"),
26+ (MemSpace::Xram, "XRAM"),
27+ (MemSpace::Code, "CODE"),
28+ ],
29+ ) {
30+ st.mem.edit = None;
31+ }
32+ if space != MemSpace::Sfr {
33+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
34+ let r = widgets::underline_input(ui, &mut st.mem.goto, "addr", 64.0);
35+ ui.label(p.label("Go to"));
36+ if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
37+ let t = st.mem.goto.trim().trim_start_matches("0x");
38+ if let Ok(a) = u32::from_str_radix(t.trim_end_matches(['h', 'H']), 16) {
39+ st.mem.scroll_to = Some(a);
40+ }
41+ }
42+ });
43+ }
44+ });
45+ st.mem.space = space;
46+ ui.add_space(8.0);
47+ let Some(doc) = st.docs.get_mut(st.active) else {
48+ widgets::empty_state(ui, "No program", "Open or create a source file.");
49+ return;
50+ };
51+ if doc.session.build.is_none() {
52+ widgets::empty_state(ui, "Not built", "Press F7 to assemble.");
53+ return;
54+ }
55+ match space {
56+ MemSpace::Sfr => sfr_table(ui, &mut doc.session, &mut st.mem),
57+ _ => hex_grid(ui, &mut doc.session, &mut st.mem),
58+ }
59+}
60+
61+fn parse_byte(s: &str) -> Option<u8> {
62+ u8::from_str_radix(s.trim().trim_end_matches(['h', 'H']), 16).ok()
63+}
64+
65+/// Inline editor for one byte. Returns true when editing ended.
66+fn edit_cell(ui: &mut Ui, rect: Rect, text: &mut String) -> (bool, Option<u8>) {
67+ let p = palette(ui.ctx());
68+ ui.painter()
69+ .rect_filled(rect.expand(2.0), 2.0, p.surface_raised);
70+ let r = ui.put(
71+ rect.expand(2.0),
72+ TextEdit::singleline(text)
73+ .font(FontId::monospace(13.0))
74+ .char_limit(2)
75+ .frame(egui::Frame::NONE)
76+ .margin(egui::Margin::ZERO)
77+ .text_color(p.text_display),
78+ );
79+ if !r.has_focus() && !r.lost_focus() {
80+ r.request_focus();
81+ }
82+ if r.lost_focus() {
83+ let commit = ui.input(|i| i.key_pressed(Key::Enter));
84+ return (true, commit.then(|| parse_byte(text)).flatten());
85+ }
86+ (false, None)
87+}
88+
89+fn hex_grid(ui: &mut Ui, s: &mut Session, mem: &mut MemView) {
90+ let p = palette(ui.ctx());
91+ let space = mem.space;
92+ let font = FontId::monospace(13.0);
93+ let cw = ui.fonts_mut(|f| f.glyph_width(&font, '0'));
94+ let row_h = ui.fonts_mut(|f| f.row_height(&font)) + 4.0;
95+ let total = if space == MemSpace::Iram {
96+ 256
97+ } else {
98+ 0x10000
99+ };
100+ let addr_digits = if total > 256 { 4 } else { 2 };
101+ let x_bytes = (addr_digits + 2) as f32 * cw;
102+ let byte_x = |i: usize| x_bytes + (i * 3) as f32 * cw + if i >= 8 { cw } else { 0.0 };
103+ let x_ascii = byte_x(PER_ROW) + cw;
104+
105+ let pc = s.sim.pc as usize;
106+ let pc_len = bench51_core::isa::info(s.sim.code[pc]).map_or(1, |i| i.size as usize);
107+ let bps = s.bp_addrs();
108+ let bank = s.sim.reg_addr(0) as usize;
109+ let sp = s.sim.sp() as usize;
110+
111+ let mut area = egui::ScrollArea::vertical()
112+ .id_salt(("mem", space.name()))
113+ .auto_shrink([false; 2]);
114+ if let Some(a) = mem.scroll_to.take() {
115+ area = area.vertical_scroll_offset((a as usize % total / PER_ROW) as f32 * row_h);
116+ }
117+ let mut poke = None;
118+ area.show_rows(ui, row_h, total / PER_ROW, |ui, range| {
119+ ui.spacing_mut().item_spacing.y = 0.0;
120+ for row in range {
121+ let base = row * PER_ROW;
122+ let (rect, resp) =
123+ ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
124+ if !ui.is_rect_visible(rect) {
125+ continue;
126+ }
127+ let painter = ui.painter();
128+ let cy = rect.center().y;
129+ let addr = if addr_digits == 4 {
130+ format!("{base:04X}")
131+ } else {
132+ format!("{base:02X}")
133+ };
134+ painter.text(
135+ pos2(rect.left(), cy),
136+ Align2::LEFT_CENTER,
137+ addr,
138+ font.clone(),
139+ p.text_secondary,
140+ );
141+ let mut ascii = String::with_capacity(PER_ROW);
142+ for i in 0..PER_ROW {
143+ let a = base + i;
144+ let (v, fg, inverted) = match space {
145+ MemSpace::Iram => {
146+ let v = s.sim.iram[a];
147+ let fg = if v != s.prev_iram[a] {
148+ WARNING
149+ } else if (bank..bank + 8).contains(&a) {
150+ p.text_display
151+ } else if v == 0 {
152+ p.text_disabled
153+ } else {
154+ p.text_primary
155+ };
156+ (v, fg, a == sp)
157+ }
158+ MemSpace::Xram => {
159+ let v = s.sim.xram[a];
160+ (
161+ v,
162+ if v == 0 {
163+ p.text_disabled
164+ } else {
165+ p.text_primary
166+ },
167+ false,
168+ )
169+ }
170+ _ => {
171+ let v = s.sim.code[a];
172+ let fg = if bps.contains(&(a as u16)) {
173+ ACCENT
174+ } else if v == 0 {
175+ p.text_disabled
176+ } else {
177+ p.text_primary
178+ };
179+ (v, fg, (pc..pc + pc_len).contains(&a))
180+ }
181+ };
182+ let cell = Rect::from_min_size(
183+ pos2(rect.left() + byte_x(i) - 2.0, rect.top() + 1.0),
184+ vec2(2.0 * cw + 4.0, row_h - 2.0),
185+ );
186+ let editing = mem.edit.as_ref().is_some_and(|(ea, _)| *ea as usize == a);
187+ if editing {
188+ let (_, text) = mem.edit.as_mut().unwrap();
189+ let (done, value) = edit_cell(ui, cell.shrink2(vec2(2.0, 1.0)), text);
190+ if done {
191+ if let Some(v) = value {
192+ poke = Some((a as u32, v));
193+ }
194+ mem.edit = None;
195+ }
196+ } else {
197+ let painter = ui.painter();
198+ let fg = if inverted {
199+ painter.rect_filled(cell, 2.0, p.text_display);
200+ p.black
201+ } else {
202+ fg
203+ };
204+ painter.text(
205+ pos2(rect.left() + byte_x(i), cy),
206+ Align2::LEFT_CENTER,
207+ format!("{v:02X}"),
208+ font.clone(),
209+ fg,
210+ );
211+ }
212+ ascii.push(if v.is_ascii_graphic() || v == b' ' {
213+ v as char
214+ } else {
215+ '·'
216+ });
217+ }
218+ ui.painter().text(
219+ pos2(rect.left() + x_ascii, cy),
220+ Align2::LEFT_CENTER,
221+ ascii,
222+ font.clone(),
223+ p.text_disabled,
224+ );
225+ if resp.double_clicked()
226+ && let Some(pos) = resp.interact_pointer_pos()
227+ {
228+ let x = pos.x - rect.left();
229+ if let Some(i) =
230+ (0..PER_ROW).find(|&i| x >= byte_x(i) - 4.0 && x < byte_x(i) + 2.5 * cw)
231+ {
232+ let a = base + i;
233+ let v = match space {
234+ MemSpace::Iram => s.sim.iram[a],
235+ MemSpace::Xram => s.sim.xram[a],
236+ _ => s.sim.code[a],
237+ };
238+ mem.edit = Some((a as u32, format!("{v:02X}")));
239+ }
240+ }
241+ }
242+ });
243+ if let Some((a, v)) = poke {
244+ s.poke(space, a, v);
245+ }
246+}
247+
248+fn sfr_table(ui: &mut Ui, s: &mut Session, mem: &mut MemView) {
249+ let p = palette(ui.ctx());
250+ let cols = if ui.available_width() > 620.0 { 3 } else { 2 };
251+ let per_col = sfr::SFRS.len().div_ceil(cols);
252+ let mut poke = None;
253+ egui::ScrollArea::vertical()
254+ .auto_shrink([false; 2])
255+ .show(ui, |ui| {
256+ egui::Grid::new("sfr_table")
257+ .num_columns(cols * 4)
258+ .spacing([14.0, 5.0])
259+ .show(ui, |ui| {
260+ for r in 0..per_col {
261+ for c in 0..cols {
262+ let Some(&(name, addr)) = sfr::SFRS.get(c * per_col + r) else {
263+ continue;
264+ };
265+ let v = s.sim.read_direct(addr, true);
266+ let changed = super::sfr_changed(s, addr);
267+ ui.label(p.label(name).color(p.text_secondary));
268+ ui.label(p.data(format!("{addr:02X}")).color(p.text_disabled));
269+ let editing = mem.edit.as_ref().is_some_and(|(a, _)| *a == addr as u32);
270+ if editing {
271+ let (rect, _) =
272+ ui.allocate_exact_size(vec2(24.0, 18.0), Sense::hover());
273+ let (_, text) = mem.edit.as_mut().unwrap();
274+ let (done, value) = edit_cell(ui, rect, text);
275+ if done {
276+ if let Some(v) = value {
277+ poke = Some((addr as u32, v));
278+ }
279+ mem.edit = None;
280+ }
281+ } else {
282+ let color = if changed { WARNING } else { p.text_primary };
283+ let resp = ui.add(
284+ egui::Label::new(p.data(format!("{v:02X}")).color(color))
285+ .sense(Sense::click()),
286+ );
287+ if resp.double_clicked() {
288+ mem.edit = Some((addr as u32, format!("{v:02X}")));
289+ }
290+ }
291+ let bits = p.data(format!("{v:08b}")).size(12.0).color(if changed {
292+ WARNING
293+ } else {
294+ p.text_disabled
295+ });
296+ ui.label(bits);
297+ }
298+ ui.end_row();
299+ }
300+ });
301+ });
302+ if let Some((a, v)) = poke {
303+ s.poke(MemSpace::Sfr, a, v);
304+ }
305+}
A crates/bench51-gui/src/panes/mod.rs View
@@ -0,0 +1,68 @@
1+//! Tool windows docked around the editor.
2+
3+mod build;
4+mod disasm;
5+mod interrupts;
6+mod memory;
7+mod ports;
8+mod project;
9+mod registers;
10+mod serial;
11+mod symbols;
12+mod timers;
13+
14+use bench51_core::session::Session;
15+use eframe::egui::{self, Ui};
16+
17+use crate::editor;
18+use crate::layout::Pane;
19+use crate::state::State;
20+use crate::widgets;
21+
22+pub fn show(pane: Pane, ui: &mut Ui, st: &mut State) {
23+ if pane == Pane::Editor {
24+ editor::show(ui, st);
25+ return;
26+ }
27+ egui::Frame::NONE
28+ .inner_margin(egui::Margin::symmetric(14, 10))
29+ .show(ui, |ui| {
30+ ui.set_min_size(ui.available_size());
31+ match pane {
32+ Pane::Project => project::show(ui, st),
33+ Pane::Build => build::show(ui, st),
34+ Pane::Registers => with_session(ui, st, registers::show),
35+ Pane::Memory => memory::show(ui, st),
36+ Pane::Disassembly => disasm::show(ui, st),
37+ Pane::Symbols => symbols::show(ui, st),
38+ Pane::Serial => serial::show(ui, st),
39+ Pane::Ports => with_session(ui, st, ports::show),
40+ Pane::Timers => with_session(ui, st, timers::show),
41+ Pane::Interrupts => with_session(ui, st, interrupts::show),
42+ Pane::Editor => unreachable!(),
43+ }
44+ });
45+}
46+
47+/// Runs `f` with the active document's session, or shows why there is
48+/// nothing to inspect yet.
49+fn with_session(ui: &mut Ui, st: &mut State, f: fn(&mut Ui, &mut Session)) {
50+ match st.docs.get_mut(st.active) {
51+ Some(doc) if doc.session.build.is_some() => f(ui, &mut doc.session),
52+ Some(_) => widgets::empty_state(ui, "Not built", "Press F7 to assemble."),
53+ None => widgets::empty_state(ui, "No program", "Open or create a source file."),
54+ }
55+}
56+
57+/// Whether bit `mask` of SFR `addr` is set, and a click toggles it.
58+pub fn sfr_chip(ui: &mut Ui, s: &mut Session, name: &str, addr: u8, mask: u8) {
59+ let v = s.sim.sfr(addr);
60+ if widgets::bit_chip(ui, name, v & mask != 0).clicked() {
61+ s.sim.set_sfr(addr, v ^ mask);
62+ }
63+}
64+
65+/// An SFR value differs from the snapshot taken before the last step.
66+pub fn sfr_changed(s: &Session, addr: u8) -> bool {
67+ s.sim.sfr(addr) != s.prev_sfr[(addr & 0x7F) as usize]
68+}
A crates/bench51-gui/src/panes/ports.rs View
@@ -0,0 +1,77 @@
1+//! I/O ports: the latch each instruction writes, and the pins the program
2+//! reads. Click a pin to hold it low from outside, like a button.
3+
4+use bench51_core::session::Session;
5+use bench51_core::sfr::P0;
6+use eframe::egui::{self, Ui};
7+
8+use crate::theme::palette;
9+use crate::widgets::{self, Dot};
10+
11+pub fn show(ui: &mut Ui, s: &mut Session) {
12+ let p = palette(ui.ctx());
13+ egui::ScrollArea::vertical()
14+ .auto_shrink([false; 2])
15+ .show(ui, |ui| {
16+ egui::Grid::new("ports")
17+ .num_columns(11)
18+ .spacing([2.0, 4.0])
19+ .show(ui, |ui| {
20+ ui.label("");
21+ ui.label("");
22+ for bit in (0..8).rev() {
23+ ui.vertical_centered(|ui| {
24+ ui.label(p.label(bit.to_string()).color(p.text_disabled));
25+ });
26+ }
27+ ui.label("");
28+ ui.end_row();
29+ for port in 0..4usize {
30+ let addr = P0 + 0x10 * port as u8;
31+ let latch = s.sim.sfr(addr);
32+ let pins = s.sim.port_pins(port);
33+ ui.label(p.label(format!("P{port}")).color(p.text_primary));
34+ ui.label(p.label("latch").color(p.text_disabled));
35+ for bit in (0..8u8).rev() {
36+ let m = 1 << bit;
37+ let dot = if latch & m != 0 { Dot::High } else { Dot::Low };
38+ let r = widgets::pin_dot(ui, dot)
39+ .on_hover_text(format!("P{port}.{bit} latch — click to toggle"));
40+ if r.clicked() {
41+ s.sim.set_sfr(addr, latch ^ m);
42+ }
43+ }
44+ ui.label(p.data(format!("{latch:02X}")).color(p.text_secondary));
45+ ui.end_row();
46+
47+ ui.label("");
48+ ui.label(p.label("pins").color(p.text_disabled));
49+ for bit in (0..8u8).rev() {
50+ let m = 1 << bit;
51+ let dot = if s.sim.pins[port] & m == 0 {
52+ Dot::PulledLow
53+ } else if pins & m != 0 {
54+ Dot::High
55+ } else {
56+ Dot::Low
57+ };
58+ let r = widgets::pin_dot(ui, dot).on_hover_text(format!(
59+ "P{port}.{bit} pin — click to pull low from outside"
60+ ));
61+ if r.clicked() {
62+ s.sim.pins[port] ^= m;
63+ }
64+ }
65+ ui.label(p.data(format!("{pins:02X}")));
66+ ui.end_row();
67+ ui.add_space(6.0);
68+ ui.end_row();
69+ }
70+ });
71+ ui.add_space(4.0);
72+ ui.label(
73+ p.label("● 1 ○ 0 red: held low outside")
74+ .color(p.text_disabled),
75+ );
76+ });
77+}
A crates/bench51-gui/src/panes/project.rs View
@@ -0,0 +1,137 @@
1+//! Project window: the target being built, open files, and the sources in
2+//! its folder.
3+
4+use std::path::{Path, PathBuf};
5+
6+use eframe::egui::{self, CursorIcon, FontId, Id, RichText, Sense, Ui};
7+
8+use crate::state::{Action, State};
9+use crate::theme::{BODY, palette};
10+use crate::widgets;
11+
12+const EXTENSIONS: [&str; 6] = ["asm", "a51", "s", "src", "hex", "ihx"];
13+
14+/// Source files in `dir`, re-read at most every two seconds.
15+fn folder_files(ui: &Ui, dir: &Path) -> Vec<PathBuf> {
16+ let id = Id::unique(("project_dir", dir));
17+ let now = ui.input(|i| i.time);
18+ if let Some((t, files)) = ui.data(|d| d.get_temp::<(f64, Vec<PathBuf>)>(id))
19+ && now - t < 2.0
20+ {
21+ return files;
22+ }
23+ let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
24+ .into_iter()
25+ .flatten()
26+ .flatten()
27+ .map(|e| e.path())
28+ .filter(|p| {
29+ p.extension()
30+ .and_then(|e| e.to_str())
31+ .is_some_and(|e| EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
32+ })
33+ .collect();
34+ files.sort();
35+ ui.data_mut(|d| d.insert_temp(id, (now, files.clone())));
36+ files
37+}
38+
39+fn row(ui: &mut Ui, text: &str, color: egui::Color32, indent: f32) -> egui::Response {
40+ ui.horizontal(|ui| {
41+ ui.add_space(indent);
42+ ui.add(
43+ egui::Label::new(
44+ RichText::new(text)
45+ .font(FontId::monospace(BODY - 1.0))
46+ .color(color),
47+ )
48+ .sense(Sense::click())
49+ .truncate(),
50+ )
51+ .on_hover_cursor(CursorIcon::PointingHand)
52+ })
53+ .inner
54+}
55+
56+pub fn show(ui: &mut Ui, st: &mut State) {
57+ let p = palette(ui.ctx());
58+ egui::ScrollArea::vertical()
59+ .auto_shrink([false; 2])
60+ .show(ui, |ui| {
61+ ui.label(p.label("Target"));
62+ ui.add_space(4.0);
63+ match st.doc() {
64+ Some(doc) => {
65+ ui.label(
66+ RichText::new(doc.target())
67+ .font(FontId::new(18.0, crate::theme::grotesk_medium()))
68+ .color(p.text_display),
69+ );
70+ let built = if doc.session.build.is_none() {
71+ "not built"
72+ } else if doc.stale() {
73+ "source changed"
74+ } else {
75+ "built"
76+ };
77+ ui.label(p.label(built).color(p.text_disabled));
78+ }
79+ None => {
80+ ui.label(p.label("none").color(p.text_disabled));
81+ }
82+ }
83+
84+ widgets::section(ui, "Open files");
85+ let mut act = None;
86+ for (i, doc) in st.docs.iter().enumerate() {
87+ let color = if i == st.active {
88+ p.text_display
89+ } else {
90+ p.text_secondary
91+ };
92+ let name = if doc.dirty() {
93+ format!("{} •", doc.name())
94+ } else {
95+ doc.name()
96+ };
97+ if row(ui, &name, color, 12.0).clicked() {
98+ act = Some(Action::Activate(i));
99+ }
100+ }
101+
102+ let dir = st
103+ .doc()
104+ .and_then(|d| d.path.as_ref())
105+ .and_then(|p| p.parent().map(Path::to_path_buf))
106+ .or_else(|| std::env::current_dir().ok());
107+ if let Some(dir) = dir {
108+ let shown = dir
109+ .file_name()
110+ .map_or_else(|| dir.display().to_string(), |n| n.to_string_lossy().into());
111+ widgets::section(ui, &format!("Folder · {shown}"));
112+ let files = folder_files(ui, &dir);
113+ if files.is_empty() {
114+ ui.label(p.label("no sources").color(p.text_disabled));
115+ }
116+ for f in files {
117+ let open = st.docs.iter().any(|d| d.path.as_deref() == Some(&f));
118+ let name = f
119+ .file_name()
120+ .unwrap_or_default()
121+ .to_string_lossy()
122+ .into_owned();
123+ let color = if open {
124+ p.text_primary
125+ } else {
126+ p.text_secondary
127+ };
128+ if row(ui, &name, color, 12.0).double_clicked() {
129+ act = Some(Action::OpenPath(f.clone()));
130+ }
131+ }
132+ }
133+ if let Some(a) = act {
134+ st.actions.push(a);
135+ }
136+ });
137+}
A crates/bench51-gui/src/panes/registers.rs View
@@ -0,0 +1,105 @@
1+//! Keil's register window. The program counter is the screen's hero.
2+
3+use bench51_core::session::Session;
4+use bench51_core::sfr::{ACC, B, DPH, DPL, PSW, SP};
5+use bench51_core::sim::disasm;
6+use eframe::egui::{self, RichText, Ui};
7+
8+use super::{sfr_changed, sfr_chip};
9+use crate::theme::{HERO, WARNING, palette};
10+use crate::widgets;
11+
12+pub fn show(ui: &mut Ui, s: &mut Session) {
13+ let p = palette(ui.ctx());
14+ egui::ScrollArea::vertical()
15+ .auto_shrink([false; 2])
16+ .show(ui, |ui| {
17+ ui.label(p.label("Program counter"));
18+ ui.label(p.hero(format!("{:04X}", s.sim.pc), HERO));
19+ let here = disasm::disassemble(&s.sim.code, s.sim.pc);
20+ let state = if s.sim.power_down {
21+ "power-down"
22+ } else if s.sim.idle {
23+ "idle"
24+ } else {
25+ ""
26+ };
27+ ui.horizontal(|ui| {
28+ ui.label(p.data(here.text).color(p.text_secondary));
29+ if !state.is_empty() {
30+ ui.label(p.label(state).color(WARNING));
31+ }
32+ });
33+
34+ widgets::section(ui, "Registers");
35+ let value = |v: String, changed: bool| {
36+ p.data(v)
37+ .color(if changed { WARNING } else { p.text_primary })
38+ };
39+ let iram_changed = |a: u8| s.sim.iram[a as usize] != s.prev_iram[a as usize];
40+ egui::Grid::new("regs")
41+ .num_columns(4)
42+ .spacing([16.0, 6.0])
43+ .show(ui, |ui| {
44+ let pairs: [(&str, String, bool); 4] = [
45+ ("A", format!("{:02X}", s.sim.acc()), sfr_changed(s, ACC)),
46+ ("B", format!("{:02X}", s.sim.b()), sfr_changed(s, B)),
47+ ("SP", format!("{:02X}", s.sim.sp()), sfr_changed(s, SP)),
48+ (
49+ "DPTR",
50+ format!("{:04X}", s.sim.dptr()),
51+ sfr_changed(s, DPL) || sfr_changed(s, DPH),
52+ ),
53+ ];
54+ for row in pairs.chunks(2) {
55+ for (name, v, ch) in row {
56+ ui.label(p.label(*name));
57+ ui.label(value(v.clone(), *ch));
58+ }
59+ ui.end_row();
60+ }
61+ for r in 0..4u8 {
62+ for n in [r, r + 4] {
63+ let a = s.sim.reg_addr(n);
64+ ui.label(p.label(format!("R{n}")));
65+ ui.label(value(
66+ format!("{:02X}", s.sim.iram[a as usize]),
67+ iram_changed(a),
68+ ));
69+ }
70+ ui.end_row();
71+ }
72+ });
73+
74+ widgets::section(ui, "PSW");
75+ ui.horizontal_wrapped(|ui| {
76+ ui.spacing_mut().item_spacing.x = 4.0;
77+ ui.label(value(format!("{:02X}", s.sim.psw()), sfr_changed(s, PSW)));
78+ ui.add_space(6.0);
79+ for (name, mask) in [
80+ ("CY", 0x80),
81+ ("AC", 0x40),
82+ ("F0", 0x20),
83+ ("RS1", 0x10),
84+ ("RS0", 0x08),
85+ ("OV", 0x04),
86+ ("P", 0x01),
87+ ] {
88+ sfr_chip(ui, s, name, PSW, mask);
89+ }
90+ });
91+ ui.add_space(4.0);
92+ widgets::stat_row(ui, "Bank", p.data(s.sim.bank().to_string()));
93+
94+ widgets::section(ui, "Time");
95+ widgets::stat_row(ui, "Cycles", p.data(s.sim.cycles.to_string()));
96+ widgets::stat_row(ui, "Elapsed", p.data(s.elapsed()));
97+ widgets::stat_row(
98+ ui,
99+ "Clock",
100+ RichText::new(format!("{} MHz", s.clock_mhz))
101+ .font(crate::theme::mono(crate::theme::LABEL + 1.0))
102+ .color(p.text_secondary),
103+ );
104+ });
105+}
A crates/bench51-gui/src/panes/serial.rs View
@@ -0,0 +1,86 @@
1+//! UART #1: what the 8051 transmits, and a line to type into its receiver.
2+
3+use bench51_core::sfr::SCON;
4+use eframe::egui::{self, Align, FontId, Key, Layout, RichText, Ui};
5+
6+use crate::state::State;
7+use crate::theme::palette;
8+use crate::widgets::{self, PillKind};
9+
10+pub fn show(ui: &mut Ui, st: &mut State) {
11+ let p = palette(ui.ctx());
12+ let Some(doc) = st.docs.get_mut(st.active) else {
13+ widgets::empty_state(ui, "No program", "Open or create a source file.");
14+ return;
15+ };
16+ let s = &mut doc.session;
17+ let scon = s.sim.sfr(SCON);
18+ ui.horizontal(|ui| {
19+ ui.label(p.label(format!("Mode {}", scon >> 6)));
20+ ui.add_space(8.0);
21+ ui.spacing_mut().item_spacing.x = 4.0;
22+ for (name, mask) in [("REN", 0x10), ("TI", 0x02), ("RI", 0x01)] {
23+ super::sfr_chip(ui, s, name, SCON, mask);
24+ }
25+ ui.add_space(8.0);
26+ ui.label(
27+ p.label(format!("{} queued", s.sim.uart_rx_pending().len()))
28+ .color(p.text_disabled),
29+ );
30+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
31+ if widgets::pill(ui, "Clear", PillKind::Ghost).clicked() {
32+ s.sim.uart.tx_log.clear();
33+ }
34+ });
35+ });
36+ ui.add_space(6.0);
37+
38+ let mut text = String::new();
39+ for &b in &s.sim.uart.tx_log {
40+ match b {
41+ b'\n' => text.push('\n'),
42+ b'\r' => {}
43+ 0x20..=0x7E => text.push(b as char),
44+ _ => text.push('·'),
45+ }
46+ }
47+ let input_h = 34.0;
48+ egui::ScrollArea::vertical()
49+ .stick_to_bottom(true)
50+ .auto_shrink([false; 2])
51+ .max_height((ui.available_height() - input_h).max(40.0))
52+ .show(ui, |ui| {
53+ if text.is_empty() {
54+ ui.label(p.label("[no output yet]").color(p.text_disabled));
55+ } else {
56+ ui.label(
57+ RichText::new(text)
58+ .font(FontId::monospace(13.0))
59+ .color(p.text_primary),
60+ );
61+ }
62+ });
63+ ui.add_space(6.0);
64+ ui.horizontal(|ui| {
65+ ui.label(
66+ RichText::new(">")
67+ .font(FontId::monospace(13.0))
68+ .color(p.text_secondary),
69+ );
70+ let w = ui.available_width();
71+ let r = widgets::underline_input(
72+ ui,
73+ &mut st.serial_input,
74+ "type, then Enter sends it with CR",
75+ w,
76+ );
77+ if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) {
78+ let mut bytes = std::mem::take(&mut st.serial_input).into_bytes();
79+ bytes.push(b'\r');
80+ if let Some(doc) = st.docs.get_mut(st.active) {
81+ doc.session.sim.uart_send(&bytes);
82+ }
83+ r.request_focus();
84+ }
85+ });
86+}
A crates/bench51-gui/src/panes/symbols.rs View
@@ -0,0 +1,90 @@
1+//! Symbol table of the build, with live values for memory symbols.
2+
3+use bench51_core::asm::SymKind;
4+use eframe::egui::{self, CursorIcon, Sense, Ui};
5+
6+use crate::state::{Action, State};
7+use crate::theme::{WARNING, palette};
8+use crate::widgets;
9+
10+pub fn show(ui: &mut Ui, st: &mut State) {
11+ let p = palette(ui.ctx());
12+ let Some(doc) = st.docs.get(st.active) else {
13+ widgets::empty_state(ui, "No program", "Open or create a source file.");
14+ return;
15+ };
16+ let doc_id = doc.id;
17+ let s = &doc.session;
18+ let Some(build) = &s.build else {
19+ widgets::empty_state(ui, "Not built", "Press F7 to assemble.");
20+ return;
21+ };
22+ let mut goto = None;
23+ if build.out.symbols.is_empty() {
24+ widgets::empty_state(ui, "No symbols", "Labels and EQU names appear here.");
25+ return;
26+ }
27+ egui::ScrollArea::vertical()
28+ .auto_shrink([false; 2])
29+ .show(ui, |ui| {
30+ egui::Grid::new("symbols")
31+ .num_columns(4)
32+ .spacing([20.0, 6.0])
33+ .show(ui, |ui| {
34+ for h in ["Name", "Kind", "Value", "Now"] {
35+ ui.label(p.label(h).color(p.text_disabled));
36+ }
37+ ui.end_row();
38+ for (name, sym) in &build.out.symbols {
39+ let v = sym.value;
40+ let name_label = egui::Label::new(p.data(name).color(p.text_display))
41+ .sense(Sense::click());
42+ let resp = ui
43+ .add(name_label)
44+ .on_hover_cursor(CursorIcon::PointingHand)
45+ .on_hover_text("Go to definition");
46+ if resp.clicked() {
47+ goto = Some(sym.line);
48+ }
49+ ui.label(p.label(sym.kind.name()));
50+ let width = if (0..=0xFF).contains(&v) { 2 } else { 4 };
51+ ui.label(
52+ p.data(format!("{:0width$X}", v & 0xFFFF))
53+ .color(p.text_secondary),
54+ );
55+ let now = match sym.kind {
56+ SymKind::Data | SymKind::Idata if (0..=0xFF).contains(&v) => {
57+ let a = v as usize;
58+ Some((
59+ format!("{:02X}", s.sim.iram[a]),
60+ s.sim.iram[a] != s.prev_iram[a],
61+ ))
62+ }
63+ SymKind::Xdata if (0..=0xFFFF).contains(&v) => {
64+ Some((format!("{:02X}", s.sim.xram[v as usize]), false))
65+ }
66+ SymKind::Bit if (0..=0xFF).contains(&v) => {
67+ Some((u8::from(s.sim.read_bit(v as u8, true)).to_string(), false))
68+ }
69+ _ => None,
70+ };
71+ match now {
72+ Some((t, changed)) => ui.label(p.data(t).color(if changed {
73+ WARNING
74+ } else {
75+ p.text_primary
76+ })),
77+ None => ui.label(p.data("—").color(p.text_disabled)),
78+ };
79+ ui.end_row();
80+ }
81+ });
82+ });
83+ if let Some(line) = goto {
84+ st.actions.push(Action::Goto {
85+ doc: doc_id,
86+ line,
87+ col: 0,
88+ });
89+ }
90+}
A crates/bench51-gui/src/panes/timers.rs View
@@ -0,0 +1,102 @@
1+//! Timer/counter 0 and 1: mode, control bits and progress to overflow.
2+
3+use bench51_core::session::Session;
4+use bench51_core::sfr::{TCON, TH0, TH1, TL0, TL1, TMOD};
5+use eframe::egui::{self, Align, Layout, Ui};
6+
7+use super::{sfr_changed, sfr_chip};
8+use crate::theme::{WARNING, palette};
9+use crate::widgets;
10+
11+/// Count so far and the count at which the timer overflows.
12+pub fn progress(mode: u8, th: u8, tl: u8) -> (u32, u32) {
13+ match mode {
14+ 0 => (((th as u32) << 5) | (tl as u32 & 0x1F), 1 << 13),
15+ 1 => (((th as u32) << 8) | tl as u32, 1 << 16),
16+ 2 => {
17+ let span = 256 - th as u32;
18+ ((tl as u32).saturating_sub(th as u32).min(span), span.max(1))
19+ }
20+ _ => (tl as u32, 256),
21+ }
22+}
23+
24+fn timer(ui: &mut Ui, s: &mut Session, n: u8) {
25+ let p = palette(ui.ctx());
26+ let (th, tl) = if n == 0 { (TH0, TL0) } else { (TH1, TL1) };
27+ let shift = 4 * n;
28+ let ctl = (s.sim.sfr(TMOD) >> shift) & 0x0F;
29+ let mut mode = ctl & 3;
30+ let (tr, tf) = if n == 0 { (0x10, 0x20) } else { (0x40, 0x80) };
31+ let running = s.sim.sfr(TCON) & tr != 0;
32+
33+ ui.horizontal(|ui| {
34+ ui.label(p.label(format!("Timer {n}")).color(p.text_primary));
35+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
36+ let desc = match mode {
37+ 0 => "13-bit",
38+ 1 => "16-bit",
39+ 2 => "8-bit auto-reload",
40+ _ => "split 8-bit",
41+ };
42+ ui.label(p.label(desc).color(p.text_disabled));
43+ });
44+ });
45+ ui.add_space(4.0);
46+ ui.horizontal(|ui| {
47+ let changed = sfr_changed(s, th) || sfr_changed(s, tl);
48+ ui.label(
49+ p.data(format!("{:02X} {:02X}", s.sim.sfr(th), s.sim.sfr(tl)))
50+ .size(18.0)
51+ .color(if changed { WARNING } else { p.text_primary }),
52+ );
53+ ui.label(p.label(format!("TH{n} TL{n}")).color(p.text_disabled));
54+ });
55+ let (count, max) = progress(mode, s.sim.sfr(th), s.sim.sfr(tl));
56+ let segments = 24;
57+ let filled = (count as u64 * segments as u64 / max as u64) as usize;
58+ let color = if running {
59+ p.text_display
60+ } else {
61+ p.text_disabled
62+ };
63+ let w = ui.available_width().min(360.0);
64+ widgets::segment_bar(ui, w, 8.0, segments, filled, color, false);
65+ ui.add_space(6.0);
66+ ui.horizontal_wrapped(|ui| {
67+ ui.spacing_mut().item_spacing.x = 4.0;
68+ if widgets::segmented(ui, &mut mode, &[(0, "M0"), (1, "M1"), (2, "M2"), (3, "M3")]) {
69+ let tmod = s.sim.sfr(TMOD) & !(3 << shift) | (mode << shift);
70+ s.sim.set_sfr(TMOD, tmod);
71+ }
72+ ui.add_space(6.0);
73+ sfr_chip(ui, s, "GATE", TMOD, 0x08 << shift);
74+ sfr_chip(ui, s, "C/T", TMOD, 0x04 << shift);
75+ sfr_chip(ui, s, &format!("TR{n}"), TCON, tr);
76+ sfr_chip(ui, s, &format!("TF{n}"), TCON, tf);
77+ });
78+}
79+
80+pub fn show(ui: &mut Ui, s: &mut Session) {
81+ egui::ScrollArea::vertical()
82+ .auto_shrink([false; 2])
83+ .show(ui, |ui| {
84+ timer(ui, s, 0);
85+ ui.add_space(24.0);
86+ timer(ui, s, 1);
87+ });
88+}
89+
90+#[cfg(test)]
91+mod tests {
92+ use super::progress;
93+
94+ #[test]
95+ fn overflow_progress_per_mode() {
96+ assert_eq!(progress(1, 0x80, 0x00), (0x8000, 0x10000));
97+ assert_eq!(progress(0, 0x01, 0xFF), (0x3F, 0x2000));
98+ assert_eq!(progress(2, 0xFD, 0xFE), (1, 3));
99+ assert_eq!(progress(2, 0xFF, 0xFF), (0, 1));
100+ assert_eq!(progress(3, 0x12, 0x40), (0x40, 256));
101+ }
102+}
A crates/bench51-gui/src/state.rs View
@@ -0,0 +1,293 @@
1+//! Application state shared by every pane, and the actions panes ask for.
2+
3+use std::path::{Path, PathBuf};
4+
5+use bench51_core::session::{MemSpace, Session};
6+use serde::{Deserialize, Serialize};
7+
8+use crate::layout::Pane;
9+
10+/// Keil's two window layouts: building and debugging.
11+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12+pub enum Mode {
13+ Edit,
14+ Debug,
15+}
16+
17+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
18+pub enum ThemePref {
19+ System,
20+ Dark,
21+ Light,
22+}
23+
24+/// Settings that survive restarts.
25+#[derive(Serialize, Deserialize)]
26+#[serde(default)]
27+pub struct Prefs {
28+ pub theme: ThemePref,
29+ pub recent: Vec<PathBuf>,
30+ pub speed: usize,
31+ pub clock_mhz: f64,
32+ pub zoom: f32,
33+}
34+
35+impl Default for Prefs {
36+ fn default() -> Self {
37+ Self {
38+ theme: ThemePref::System,
39+ recent: Vec::new(),
40+ speed: 5,
41+ clock_mhz: 11.0592,
42+ zoom: 1.0,
43+ }
44+ }
45+}
46+
47+impl Prefs {
48+ pub fn add_recent(&mut self, path: &Path) {
49+ let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
50+ self.recent.retain(|p| *p != path);
51+ self.recent.insert(0, path);
52+ self.recent.truncate(10);
53+ }
54+}
55+
56+/// An open source file and the simulator running it.
57+pub struct Document {
58+ /// Stable id for egui widget state.
59+ pub id: u64,
60+ pub path: Option<PathBuf>,
61+ /// Name shown for documents without a path (e.g. a disassembled HEX file).
62+ pub label: Option<String>,
63+ pub text: String,
64+ pub saved: String,
65+ pub session: Session,
66+ /// Cursor position as (line, column), updated by the editor.
67+ pub cursor: (usize, usize),
68+ /// Cursor position as a char index into `text`.
69+ pub cursor_char: usize,
70+ /// Move the cursor here and scroll to it on the next frame.
71+ pub goto: Option<(usize, usize)>,
72+ /// Select this char range and scroll to it on the next frame, keeping
73+ /// keyboard focus where it is (the find bar).
74+ pub select: Option<(usize, usize)>,
75+ /// Byte range of the current find match.
76+ pub find_hit: Option<(usize, usize)>,
77+ /// Scroll the editor and disassembly to the PC on the next frame.
78+ pub follow_pc: bool,
79+}
80+
81+impl Document {
82+ pub fn new(id: u64, path: Option<PathBuf>, text: &str, clock_mhz: f64) -> Self {
83+ let text = text.replace("\r\n", "\n");
84+ Self {
85+ id,
86+ path,
87+ label: None,
88+ saved: text.clone(),
89+ text,
90+ session: Session::new(clock_mhz),
91+ cursor: (0, 0),
92+ cursor_char: 0,
93+ goto: None,
94+ select: None,
95+ find_hit: None,
96+ follow_pc: false,
97+ }
98+ }
99+
100+ pub fn name(&self) -> String {
101+ if let Some(name) = self.path.as_ref().and_then(|p| p.file_name()) {
102+ return name.to_string_lossy().into_owned();
103+ }
104+ self.label.clone().unwrap_or_else(|| "untitled.asm".into())
105+ }
106+
107+ /// File name without extension, Keil's "target" name.
108+ pub fn target(&self) -> String {
109+ let name = self.name();
110+ match name.rsplit_once('.') {
111+ Some((stem, _)) if !stem.is_empty() => stem.to_string(),
112+ _ => name,
113+ }
114+ }
115+
116+ pub fn dirty(&self) -> bool {
117+ self.text != self.saved
118+ }
119+
120+ /// The editor text differs from the program in the simulator.
121+ pub fn stale(&self) -> bool {
122+ self.session.is_stale(self.text.split('\n'))
123+ }
124+}
125+
126+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127+pub enum LogKind {
128+ Info,
129+ Ok,
130+ Error,
131+}
132+
133+/// A line in the Build Output messages.
134+pub struct LogLine {
135+ pub text: String,
136+ pub kind: LogKind,
137+ /// Clicking the line jumps here: (document id, line, column).
138+ pub target: Option<(u64, usize, usize)>,
139+}
140+
141+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142+pub enum BuildTab {
143+ Messages,
144+ Listing,
145+ Hex,
146+}
147+
148+/// Everything the menus, toolbar, shortcuts and panes can ask for. They
149+/// queue actions; `GuiApp::apply` runs them once the frame's UI is done.
150+#[derive(Debug, Clone, PartialEq)]
151+pub enum Action {
152+ New,
153+ Open,
154+ OpenPath(PathBuf),
155+ Save,
156+ SaveAs,
157+ /// Close a document, asking first if it has unsaved changes.
158+ Close(usize),
159+ /// Close without asking.
160+ ForceClose(usize),
161+ Activate(usize),
162+ Quit,
163+ /// Quit even with unsaved changes.
164+ ForceQuit,
165+ Build,
166+ ToggleDebug,
167+ RunPause,
168+ StepInto,
169+ StepOver,
170+ StepOut,
171+ RunToCursor,
172+ Reset,
173+ ToggleBreakpoint,
174+ ClearBreakpoints,
175+ SetSpeed(usize),
176+ SetClock(f64),
177+ Goto {
178+ doc: u64,
179+ line: usize,
180+ col: usize,
181+ },
182+ ShowPane(Pane),
183+ ResetLayout,
184+ SetTheme(ThemePref),
185+ Zoom(f32),
186+ ZoomReset,
187+ Find,
188+ Modal(Modal),
189+}
190+
191+#[derive(Debug, Clone, PartialEq)]
192+pub enum Modal {
193+ /// Unsaved changes in this document.
194+ ConfirmClose(usize),
195+ ConfirmQuit,
196+ Shortcuts,
197+ /// Options for Target: the oscillator frequency, being edited.
198+ Options(String),
199+ About,
200+}
201+
202+pub struct MemView {
203+ pub space: MemSpace,
204+ pub goto: String,
205+ /// Scroll so this address is visible on the next frame.
206+ pub scroll_to: Option<u32>,
207+ /// Byte being edited inline: (address, text).
208+ pub edit: Option<(u32, String)>,
209+}
210+
211+pub struct FindBar {
212+ pub open: bool,
213+ pub query: String,
214+ /// Give the query field keyboard focus on the next frame.
215+ pub focus: bool,
216+}
217+
218+pub struct State {
219+ pub docs: Vec<Document>,
220+ pub active: usize,
221+ next_doc_id: u64,
222+ pub mode: Mode,
223+ pub prefs: Prefs,
224+ pub log: Vec<LogLine>,
225+ pub actions: Vec<Action>,
226+ pub modal: Option<Modal>,
227+ pub mem: MemView,
228+ pub build_tab: BuildTab,
229+ pub serial_input: String,
230+ pub find: FindBar,
231+}
232+
233+impl State {
234+ pub fn new(prefs: Prefs) -> Self {
235+ Self {
236+ docs: Vec::new(),
237+ active: 0,
238+ next_doc_id: 1,
239+ mode: Mode::Edit,
240+ prefs,
241+ log: Vec::new(),
242+ actions: Vec::new(),
243+ modal: None,
244+ mem: MemView {
245+ space: MemSpace::Iram,
246+ goto: String::new(),
247+ scroll_to: None,
248+ edit: None,
249+ },
250+ build_tab: BuildTab::Messages,
251+ serial_input: String::new(),
252+ find: FindBar {
253+ open: false,
254+ query: String::new(),
255+ focus: false,
256+ },
257+ }
258+ }
259+
260+ pub fn doc(&self) -> Option<&Document> {
261+ self.docs.get(self.active)
262+ }
263+
264+ pub fn doc_mut(&mut self) -> Option<&mut Document> {
265+ self.docs.get_mut(self.active)
266+ }
267+
268+ pub fn add_doc(&mut self, path: Option<PathBuf>, text: &str) -> &mut Document {
269+ let id = self.next_doc_id;
270+ self.next_doc_id += 1;
271+ let mut doc = Document::new(id, path, text, self.prefs.clock_mhz);
272+ doc.session.speed = self.prefs.speed;
273+ self.docs.push(doc);
274+ self.active = self.docs.len() - 1;
275+ self.docs.last_mut().unwrap()
276+ }
277+
278+ pub fn log(&mut self, kind: LogKind, text: impl Into<String>) {
279+ self.log.push(LogLine {
280+ text: text.into(),
281+ kind,
282+ target: None,
283+ });
284+ if self.log.len() > 2000 {
285+ self.log.drain(..500);
286+ }
287+ }
288+
289+ /// The active document's simulator is free running.
290+ pub fn running(&self) -> bool {
291+ self.doc().is_some_and(|d| d.session.running)
292+ }
293+}
A crates/bench51-gui/src/theme.rs View
@@ -0,0 +1,293 @@
1+//! Nothing design tokens mapped onto egui: palettes for both themes, the
2+//! bundled fonts, and text helpers that keep the type budget in one place.
3+
4+use std::sync::Arc;
5+
6+use bench51_core::syntax::Tok;
7+use eframe::egui::{
8+ self, Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, RichText,
9+ Shadow, Stroke, Style, TextStyle, Theme, Visuals,
10+ epaint::text::{FontTweak, VariationCoords},
11+};
12+
13+/// The colour tokens of one theme (`references/tokens.md` §2).
14+pub struct Palette {
15+ /// Page background: OLED black / warm off-white paper.
16+ pub black: Color32,
17+ pub surface: Color32,
18+ pub surface_raised: Color32,
19+ pub border: Color32,
20+ pub border_visible: Color32,
21+ pub text_disabled: Color32,
22+ pub text_secondary: Color32,
23+ pub text_primary: Color32,
24+ pub text_display: Color32,
25+ pub interactive: Color32,
26+}
27+
28+pub const DARK: Palette = Palette {
29+ black: Color32::from_rgb(0x00, 0x00, 0x00),
30+ surface: Color32::from_rgb(0x11, 0x11, 0x11),
31+ surface_raised: Color32::from_rgb(0x1A, 0x1A, 0x1A),
32+ border: Color32::from_rgb(0x22, 0x22, 0x22),
33+ border_visible: Color32::from_rgb(0x33, 0x33, 0x33),
34+ text_disabled: Color32::from_rgb(0x66, 0x66, 0x66),
35+ text_secondary: Color32::from_rgb(0x99, 0x99, 0x99),
36+ text_primary: Color32::from_rgb(0xE8, 0xE8, 0xE8),
37+ text_display: Color32::from_rgb(0xFF, 0xFF, 0xFF),
38+ interactive: Color32::from_rgb(0x5B, 0x9B, 0xF6),
39+};
40+
41+pub const LIGHT: Palette = Palette {
42+ black: Color32::from_rgb(0xF5, 0xF5, 0xF5),
43+ surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
44+ surface_raised: Color32::from_rgb(0xF0, 0xF0, 0xF0),
45+ border: Color32::from_rgb(0xE8, 0xE8, 0xE8),
46+ border_visible: Color32::from_rgb(0xCC, 0xCC, 0xCC),
47+ text_disabled: Color32::from_rgb(0x99, 0x99, 0x99),
48+ text_secondary: Color32::from_rgb(0x66, 0x66, 0x66),
49+ text_primary: Color32::from_rgb(0x1A, 0x1A, 0x1A),
50+ text_display: Color32::from_rgb(0x00, 0x00, 0x00),
51+ interactive: Color32::from_rgb(0x00, 0x7A, 0xFF),
52+};
53+
54+/// Signal red: breakpoints, errors, the PC bar. Never decorative.
55+pub const ACCENT: Color32 = Color32::from_rgb(0xD7, 0x19, 0x21);
56+/// Values that changed since the last step.
57+pub const WARNING: Color32 = Color32::from_rgb(0xD4, 0xA8, 0x43);
58+
59+/// The three type sizes: labels, body/data, and the one hero per screen.
60+pub const LABEL: f32 = 11.0;
61+pub const BODY: f32 = 14.0;
62+pub const HERO: f32 = 44.0;
63+/// Doto at welcome-screen scale.
64+pub const HERO_XL: f32 = 72.0;
65+/// Tracking for ALL CAPS labels (0.08em at 11px).
66+pub const LABEL_TRACKING: f32 = 0.9;
67+
68+pub fn palette(ctx: &Context) -> &'static Palette {
69+ match ctx.theme() {
70+ Theme::Dark => &DARK,
71+ Theme::Light => &LIGHT,
72+ }
73+}
74+
75+pub fn mono_bold() -> FontFamily {
76+ FontFamily::Name("mono-bold".into())
77+}
78+
79+pub fn grotesk_medium() -> FontFamily {
80+ FontFamily::Name("grotesk-medium".into())
81+}
82+
83+pub fn doto() -> FontFamily {
84+ FontFamily::Name("doto".into())
85+}
86+
87+pub fn mono(size: f32) -> FontId {
88+ FontId::monospace(size)
89+}
90+
91+impl Palette {
92+ /// Instrument-panel label: Space Mono, ALL CAPS, tracked, secondary grey.
93+ pub fn label(&self, text: impl AsRef<str>) -> RichText {
94+ RichText::new(text.as_ref().to_uppercase())
95+ .font(mono(LABEL))
96+ .extra_letter_spacing(LABEL_TRACKING)
97+ .color(self.text_secondary)
98+ }
99+
100+ /// Data value in Space Mono at body size.
101+ pub fn data(&self, text: impl Into<String>) -> RichText {
102+ RichText::new(text)
103+ .font(mono(BODY))
104+ .color(self.text_primary)
105+ }
106+
107+ /// Dot-matrix hero number. One per screen.
108+ pub fn hero(&self, text: impl Into<String>, size: f32) -> RichText {
109+ RichText::new(text)
110+ .font(FontId::new(size, doto()))
111+ .color(self.text_display)
112+ }
113+
114+ /// Syntax colours: greyscale first, hierarchy by brightness and weight.
115+ pub fn syntax(&self, tok: Tok) -> (Color32, bool) {
116+ match tok {
117+ Tok::Label => (self.text_display, true),
118+ Tok::Mnemonic => (self.text_display, false),
119+ Tok::Register | Tok::Sfr | Tok::Plain => (self.text_primary, false),
120+ Tok::Number | Tok::Str | Tok::Directive => (self.text_secondary, false),
121+ Tok::Comment => (self.text_disabled, false),
122+ }
123+ }
124+}
125+
126+fn fonts() -> FontDefinitions {
127+ let mut defs = FontDefinitions::default();
128+ let mut add = |name: &str, bytes: &'static [u8], tweak: FontTweak| {
129+ defs.font_data.insert(
130+ name.to_owned(),
131+ Arc::new(FontData::from_static(bytes).tweak(tweak)),
132+ );
133+ };
134+ add(
135+ "space-grotesk",
136+ include_bytes!("../assets/fonts/SpaceGrotesk-Regular.ttf"),
137+ FontTweak::default(),
138+ );
139+ add(
140+ "space-grotesk-medium",
141+ include_bytes!("../assets/fonts/SpaceGrotesk-Medium.ttf"),
142+ FontTweak::default(),
143+ );
144+ add(
145+ "space-mono",
146+ include_bytes!("../assets/fonts/SpaceMono-Regular.ttf"),
147+ FontTweak::default(),
148+ );
149+ add(
150+ "space-mono-bold",
151+ include_bytes!("../assets/fonts/SpaceMono-Bold.ttf"),
152+ FontTweak::default(),
153+ );
154+ // Round dots, bold enough to read as a display face.
155+ add(
156+ "doto",
157+ include_bytes!("../assets/fonts/Doto[ROND,wght].ttf"),
158+ FontTweak {
159+ coords: VariationCoords::new([(b"ROND", 100.0), (b"wght", 800.0)]),
160+ ..FontTweak::default()
161+ },
162+ );
163+
164+ // egui's own fonts stay behind ours as fallbacks for symbols.
165+ let prop_fallback = defs.families[&FontFamily::Proportional].clone();
166+ let mono_fallback = defs.families[&FontFamily::Monospace].clone();
167+ let with = |first: &[&str], rest: &[String]| -> Vec<String> {
168+ first
169+ .iter()
170+ .map(|s| s.to_string())
171+ .chain(rest.iter().cloned())
172+ .collect()
173+ };
174+ defs.families.insert(
175+ FontFamily::Proportional,
176+ with(&["space-grotesk"], &prop_fallback),
177+ );
178+ defs.families
179+ .insert(FontFamily::Monospace, with(&["space-mono"], &mono_fallback));
180+ defs.families
181+ .insert(mono_bold(), with(&["space-mono-bold"], &mono_fallback));
182+ defs.families.insert(
183+ grotesk_medium(),
184+ with(&["space-grotesk-medium"], &prop_fallback),
185+ );
186+ defs.families
187+ .insert(doto(), with(&["doto", "space-mono"], &mono_fallback));
188+ defs
189+}
190+
191+fn visuals(p: &Palette, dark: bool) -> Visuals {
192+ let mut v = if dark {
193+ Visuals::dark()
194+ } else {
195+ Visuals::light()
196+ };
197+ let technical = CornerRadius::same(4);
198+ v.panel_fill = p.black;
199+ v.window_fill = p.surface;
200+ v.window_stroke = Stroke::new(1.0, p.border_visible);
201+ v.window_shadow = Shadow::NONE;
202+ v.popup_shadow = Shadow::NONE;
203+ v.window_corner_radius = CornerRadius::same(16);
204+ v.menu_corner_radius = CornerRadius::same(8);
205+ v.window_highlight_topmost = false;
206+ v.extreme_bg_color = p.surface;
207+ v.text_edit_bg_color = Some(p.surface);
208+ v.faint_bg_color = p.surface_raised;
209+ v.code_bg_color = p.surface_raised;
210+ v.hyperlink_color = p.interactive;
211+ v.warn_fg_color = WARNING;
212+ v.error_fg_color = ACCENT;
213+ v.selection.bg_fill = if dark {
214+ Color32::from_white_alpha(46)
215+ } else {
216+ Color32::from_black_alpha(36)
217+ };
218+ v.selection.stroke = Stroke::new(1.0, p.text_display);
219+ v.text_cursor.stroke = Stroke::new(2.0, p.text_display);
220+ v.striped = false;
221+ v.indent_has_left_vline = false;
222+ v.collapsing_header_frame = false;
223+ v.slider_trailing_fill = true;
224+ v.resize_corner_size = 8.0;
225+
226+ let w = &mut v.widgets;
227+ w.noninteractive.bg_fill = p.surface;
228+ w.noninteractive.weak_bg_fill = p.surface;
229+ w.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
230+ w.noninteractive.fg_stroke = Stroke::new(1.0, p.text_primary);
231+ w.noninteractive.corner_radius = technical;
232+
233+ w.inactive.bg_fill = p.surface_raised;
234+ w.inactive.weak_bg_fill = Color32::TRANSPARENT;
235+ w.inactive.bg_stroke = Stroke::new(1.0, p.border_visible);
236+ w.inactive.fg_stroke = Stroke::new(1.0, p.text_primary);
237+ w.inactive.corner_radius = technical;
238+ w.inactive.expansion = 0.0;
239+
240+ // Hover: the border and text brighten. No scale, no glow.
241+ w.hovered.bg_fill = p.surface_raised;
242+ w.hovered.weak_bg_fill = p.surface_raised;
243+ w.hovered.bg_stroke = Stroke::new(1.0, p.text_secondary);
244+ w.hovered.fg_stroke = Stroke::new(1.5, p.text_display);
245+ w.hovered.corner_radius = technical;
246+ w.hovered.expansion = 0.0;
247+
248+ w.active.bg_fill = p.surface_raised;
249+ w.active.weak_bg_fill = p.surface_raised;
250+ w.active.bg_stroke = Stroke::new(1.0, p.text_primary);
251+ w.active.fg_stroke = Stroke::new(2.0, p.text_display);
252+ w.active.corner_radius = technical;
253+ w.active.expansion = 0.0;
254+
255+ w.open = w.hovered;
256+ v
257+}
258+
259+fn style(theme: Theme) -> Style {
260+ let (p, dark) = match theme {
261+ Theme::Dark => (&DARK, true),
262+ Theme::Light => (&LIGHT, false),
263+ };
264+ let mut s = Style {
265+ visuals: visuals(p, dark),
266+ ..Style::default()
267+ };
268+ s.text_styles = [
269+ (TextStyle::Small, FontId::monospace(LABEL)),
270+ (TextStyle::Body, FontId::proportional(BODY)),
271+ (TextStyle::Button, FontId::monospace(12.0)),
272+ (TextStyle::Heading, FontId::new(18.0, grotesk_medium())),
273+ (TextStyle::Monospace, FontId::monospace(13.0)),
274+ ]
275+ .into();
276+ s.spacing.item_spacing = egui::vec2(8.0, 4.0);
277+ s.spacing.button_padding = egui::vec2(10.0, 4.0);
278+ s.spacing.interact_size.y = 22.0;
279+ s.spacing.menu_margin = egui::Margin::same(8);
280+ s.spacing.window_margin = egui::Margin::same(16);
281+ s.spacing.menu_spacing = 2.0;
282+ s.animation_time = 0.15;
283+ s.interaction.selectable_labels = false;
284+ s.interaction.tooltip_delay = 0.4;
285+ s
286+}
287+
288+/// Installs the fonts and both themes on `ctx`.
289+pub fn install(ctx: &Context) {
290+ ctx.set_fonts(fonts());
291+ ctx.set_style_of(Theme::Dark, style(Theme::Dark));
292+ ctx.set_style_of(Theme::Light, style(Theme::Light));
293+}
A crates/bench51-gui/src/widgets.rs View
@@ -0,0 +1,378 @@
1+//! Nothing-style controls: buttons, segmented controls and bars, bit chips,
2+//! pin dots, stat rows and empty states.
3+
4+use eframe::egui::{
5+ self, Align, Align2, Color32, CornerRadius, CursorIcon, FontId, Layout, Painter, Rect,
6+ Response, RichText, Sense, Stroke, StrokeKind, TextWrapMode, Ui, WidgetText, pos2, vec2,
7+};
8+
9+use crate::icons::{self, Icon};
10+use crate::theme::{self, ACCENT, LABEL, LABEL_TRACKING, palette};
11+
12+fn galley(ui: &Ui, text: RichText) -> std::sync::Arc<egui::Galley> {
13+ WidgetText::from(text).into_galley(
14+ ui,
15+ Some(TextWrapMode::Extend),
16+ f32::INFINITY,
17+ egui::TextStyle::Small,
18+ )
19+}
20+
21+/// Square toolbar button with a monoline icon. `on` shows it latched
22+/// (inverted), e.g. while the debug session is active.
23+pub fn icon_button(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {
24+ let p = palette(ui.ctx());
25+ let size = vec2(30.0, 28.0);
26+ let sense = if enabled {
27+ Sense::click()
28+ } else {
29+ Sense::hover()
30+ };
31+ let (rect, resp) = ui.allocate_exact_size(size, sense);
32+ let resp = resp.on_hover_text(tip);
33+ if ui.is_rect_visible(rect) {
34+ let painter = ui.painter();
35+ let radius = CornerRadius::same(4);
36+ let fg = if !enabled {
37+ p.text_disabled
38+ } else if on {
39+ p.black
40+ } else if resp.hovered() {
41+ p.text_display
42+ } else {
43+ p.text_primary
44+ };
45+ if on && enabled {
46+ painter.rect_filled(rect, radius, p.text_display);
47+ } else if resp.hovered() {
48+ painter.rect_stroke(
49+ rect,
50+ radius,
51+ Stroke::new(1.0, p.border_visible),
52+ StrokeKind::Inside,
53+ );
54+ }
55+ icons::paint(painter, icons::icon_rect(rect, 16.0), icon, fg);
56+ }
57+ if enabled {
58+ resp.on_hover_cursor(CursorIcon::PointingHand)
59+ } else {
60+ resp
61+ }
62+}
63+
64+#[derive(Clone, Copy, PartialEq, Eq)]
65+pub enum PillKind {
66+ /// White pill with black text: the one action to take.
67+ Primary,
68+ /// Outlined pill.
69+ Secondary,
70+ /// Text only.
71+ Ghost,
72+}
73+
74+/// Pill button: Space Mono, ALL CAPS, tracked.
75+pub fn pill(ui: &mut Ui, text: &str, kind: PillKind) -> Response {
76+ let p = palette(ui.ctx());
77+ let label = RichText::new(text.to_uppercase())
78+ .font(FontId::monospace(12.0))
79+ .extra_letter_spacing(0.7);
80+ let g = galley(ui, label);
81+ let size = vec2(g.size().x + 36.0, 32.0);
82+ let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
83+ if ui.is_rect_visible(rect) {
84+ let painter = ui.painter();
85+ let hovered = resp.hovered();
86+ let (fill, stroke, fg) = match kind {
87+ PillKind::Primary => (p.text_display, Stroke::NONE, p.black),
88+ PillKind::Secondary => (
89+ Color32::TRANSPARENT,
90+ Stroke::new(
91+ 1.0,
92+ if hovered {
93+ p.text_primary
94+ } else {
95+ p.border_visible
96+ },
97+ ),
98+ p.text_primary,
99+ ),
100+ PillKind::Ghost => (
101+ Color32::TRANSPARENT,
102+ Stroke::NONE,
103+ if hovered {
104+ p.text_display
105+ } else {
106+ p.text_secondary
107+ },
108+ ),
109+ };
110+ painter.rect(
111+ rect,
112+ CornerRadius::same(255),
113+ fill,
114+ stroke,
115+ StrokeKind::Inside,
116+ );
117+ let pos = Align2::CENTER_CENTER
118+ .align_size_within_rect(g.size(), rect)
119+ .min;
120+ painter.galley(pos, g, fg);
121+ }
122+ resp.on_hover_cursor(CursorIcon::PointingHand)
123+}
124+
125+/// Segmented control: the active segment is inverted. Returns true if the
126+/// value changed.
127+pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool {
128+ let p = palette(ui.ctx());
129+ let galleys: Vec<_> = options
130+ .iter()
131+ .map(|(_, l)| galley(ui, p.label(l)))
132+ .collect();
133+ let pad = 10.0;
134+ let widths: Vec<f32> = galleys.iter().map(|g| g.size().x + 2.0 * pad).collect();
135+ let size = vec2(widths.iter().sum(), 24.0);
136+ let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
137+ let radius = CornerRadius::same(4);
138+ let mut changed = false;
139+ let mut x = rect.left();
140+ for (i, ((v, label), g)) in options.iter().zip(galleys).enumerate() {
141+ let seg = Rect::from_min_size(pos2(x, rect.top()), vec2(widths[i], rect.height()));
142+ x += widths[i];
143+ let resp = ui
144+ .interact(seg, ui.auto_id_with(("seg", label, i)), Sense::click())
145+ .on_hover_cursor(CursorIcon::PointingHand);
146+ let active = *value == *v;
147+ if resp.clicked() && !active {
148+ *value = *v;
149+ changed = true;
150+ }
151+ let fg = if active {
152+ ui.painter().rect_filled(seg, radius, p.text_display);
153+ p.black
154+ } else if resp.hovered() {
155+ p.text_display
156+ } else {
157+ p.text_secondary
158+ };
159+ let pos = Align2::CENTER_CENTER
160+ .align_size_within_rect(g.size(), seg)
161+ .min;
162+ ui.painter().galley(pos, g, fg);
163+ }
164+ ui.painter().rect_stroke(
165+ rect,
166+ radius,
167+ Stroke::new(1.0, p.border_visible),
168+ StrokeKind::Inside,
169+ );
170+ changed
171+}
172+
173+/// Segmented bar: `filled` of `segments` square blocks with 2 px gaps.
174+/// Returns the index of a clicked segment.
175+pub fn segment_bar(
176+ ui: &mut Ui,
177+ width: f32,
178+ height: f32,
179+ segments: usize,
180+ filled: usize,
181+ color: Color32,
182+ clickable: bool,
183+) -> Option<usize> {
184+ let p = palette(ui.ctx());
185+ let sense = if clickable {
186+ Sense::click()
187+ } else {
188+ Sense::hover()
189+ };
190+ let (rect, resp) = ui.allocate_exact_size(vec2(width, height), sense);
191+ let gap = 2.0;
192+ let seg_w = (width - gap * (segments as f32 - 1.0)) / segments as f32;
193+ let hover_idx = resp
194+ .hover_pos()
195+ .filter(|_| clickable)
196+ .map(|pos| (((pos.x - rect.left()) / (seg_w + gap)) as usize).min(segments - 1));
197+ let empty = if ui.visuals().dark_mode {
198+ p.border
199+ } else {
200+ Color32::from_gray(0xE0)
201+ };
202+ for i in 0..segments {
203+ let x = rect.left() + i as f32 * (seg_w + gap);
204+ let r = Rect::from_min_size(pos2(x, rect.top()), vec2(seg_w, height));
205+ let c = if i < filled {
206+ color
207+ } else if hover_idx.is_some_and(|h| i <= h) {
208+ p.text_disabled
209+ } else {
210+ empty
211+ };
212+ ui.painter().rect_filled(r, 0.0, c);
213+ }
214+ if clickable && resp.clicked() {
215+ resp.on_hover_cursor(CursorIcon::PointingHand);
216+ return hover_idx;
217+ }
218+ if clickable {
219+ resp.on_hover_cursor(CursorIcon::PointingHand);
220+ }
221+ None
222+}
223+
224+/// Bit flag as a technical tag: lit when set. Clicking toggles it.
225+pub fn bit_chip(ui: &mut Ui, name: &str, on: bool) -> Response {
226+ let p = palette(ui.ctx());
227+ let g = galley(
228+ ui,
229+ RichText::new(name)
230+ .font(FontId::monospace(LABEL))
231+ .extra_letter_spacing(LABEL_TRACKING * 0.5),
232+ );
233+ let size = vec2(g.size().x + 12.0, 20.0);
234+ let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
235+ if ui.is_rect_visible(rect) {
236+ let hovered = resp.hovered();
237+ let (stroke, fg) = if on {
238+ (p.text_display, p.text_display)
239+ } else if hovered {
240+ (p.text_secondary, p.text_secondary)
241+ } else {
242+ (p.border_visible, p.text_disabled)
243+ };
244+ ui.painter().rect_stroke(
245+ rect,
246+ CornerRadius::same(4),
247+ Stroke::new(1.0, stroke),
248+ StrokeKind::Inside,
249+ );
250+ let pos = Align2::CENTER_CENTER
251+ .align_size_within_rect(g.size(), rect)
252+ .min;
253+ ui.painter().galley(pos, g, fg);
254+ }
255+ resp.on_hover_cursor(CursorIcon::PointingHand)
256+}
257+
258+#[derive(Clone, Copy, PartialEq, Eq)]
259+pub enum Dot {
260+ High,
261+ Low,
262+ /// Held low from outside the chip, like a pressed button.
263+ PulledLow,
264+}
265+
266+/// A port pin as an LED-like dot.
267+pub fn pin_dot(ui: &mut Ui, state: Dot) -> Response {
268+ let p = palette(ui.ctx());
269+ let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
270+ if ui.is_rect_visible(rect) {
271+ let c = rect.center();
272+ let r = 5.0;
273+ match state {
274+ Dot::High => {
275+ ui.painter().circle_filled(c, r, p.text_display);
276+ }
277+ Dot::PulledLow => {
278+ ui.painter().circle_filled(c, r, ACCENT);
279+ }
280+ Dot::Low => {
281+ ui.painter()
282+ .circle_stroke(c, r - 0.5, Stroke::new(1.5, p.text_disabled));
283+ }
284+ }
285+ if resp.hovered() {
286+ ui.painter()
287+ .circle_stroke(c, r + 3.0, Stroke::new(1.0, p.border_visible));
288+ }
289+ }
290+ resp.on_hover_cursor(CursorIcon::PointingHand)
291+}
292+
293+/// Label on the left, value on the right of the available width.
294+pub fn stat_row(ui: &mut Ui, label: &str, value: RichText) {
295+ let p = palette(ui.ctx());
296+ ui.horizontal(|ui| {
297+ ui.label(p.label(label));
298+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
299+ ui.label(value);
300+ });
301+ });
302+}
303+
304+/// Uniform dot grid, the dot-matrix motif for empty backgrounds.
305+pub fn dot_grid(painter: &Painter, rect: Rect, color: Color32, pitch: f32) {
306+ let x0 = (rect.left() / pitch).ceil() * pitch;
307+ let y0 = (rect.top() / pitch).ceil() * pitch;
308+ let mut y = y0;
309+ while y < rect.bottom() {
310+ let mut x = x0;
311+ while x < rect.right() {
312+ painter.circle_filled(pos2(x, y), 0.9, color);
313+ x += pitch;
314+ }
315+ y += pitch;
316+ }
317+}
318+
319+/// Centred two-line empty state on a faint dot grid.
320+pub fn empty_state(ui: &mut Ui, headline: &str, hint: &str) {
321+ let p = palette(ui.ctx());
322+ let rect = ui.available_rect_before_wrap();
323+ let grid = if ui.visuals().dark_mode {
324+ p.border
325+ } else {
326+ p.border_visible.gamma_multiply(0.6)
327+ };
328+ dot_grid(ui.painter(), rect, grid, 16.0);
329+ ui.scope_builder(
330+ egui::UiBuilder::new()
331+ .max_rect(rect)
332+ .layout(Layout::top_down(Align::Center)),
333+ |ui| {
334+ ui.add_space((rect.height() / 2.0 - 24.0).max(8.0));
335+ ui.label(p.label(headline).color(p.text_secondary));
336+ ui.add_space(6.0);
337+ ui.label(
338+ RichText::new(hint)
339+ .size(theme::BODY - 1.0)
340+ .color(p.text_disabled),
341+ );
342+ },
343+ );
344+}
345+
346+/// ALL CAPS section heading with breathing room above.
347+pub fn section(ui: &mut Ui, title: &str) {
348+ let p = palette(ui.ctx());
349+ ui.add_space(12.0);
350+ ui.label(p.label(title).color(p.text_disabled));
351+ ui.add_space(2.0);
352+}
353+
354+/// Underlined single-line input in Space Mono.
355+pub fn underline_input(ui: &mut Ui, text: &mut String, hint: &str, width: f32) -> Response {
356+ let p = palette(ui.ctx());
357+ let out = egui::TextEdit::singleline(text)
358+ .font(FontId::monospace(13.0))
359+ .hint_text(
360+ RichText::new(hint)
361+ .font(FontId::monospace(13.0))
362+ .color(p.text_disabled),
363+ )
364+ .frame(egui::Frame::NONE)
365+ .margin(egui::Margin::symmetric(0, 3))
366+ .desired_width(width)
367+ .show(ui);
368+ let resp = out.response.response;
369+ let color = if resp.has_focus() {
370+ p.text_primary
371+ } else {
372+ p.border_visible
373+ };
374+ let r = resp.rect;
375+ ui.painter()
376+ .hline(r.x_range(), r.bottom(), Stroke::new(1.0, color));
377+ resp
378+}
A docs/screenshots/gui_breakpoint_light.png View

[Binary file]

A docs/screenshots/gui_debug.png View

[Binary file]

A docs/screenshots/gui_edit.png View

[Binary file]

A docs/screenshots/gui_errors.png View

[Binary file]

A docs/screenshots/gui_peripherals.png View

[Binary file]

M src/main.rs View
@@ -1,12 +1,9 @@
1−mod asm;
2−mod isa;
3−mod sfr;
4−mod sim;
51 mod ui;
62
73 use std::path::PathBuf;
84 use std::process::ExitCode;
95
6+use bench51_core::asm;
107 use clap::Parser;
118
129 /// Intel 8051 assembler, simulator and TUI debugger.
M src/ui/app.rs View
@@ -1,13 +1,11 @@
1−use std::collections::{HashMap, HashSet};
21 use std::path::{Path, PathBuf};
32 use std::process::Command;
43
4+use bench51_core::files::{self, Loaded};
5+use bench51_core::session::{MemSpace, SPEEDS, Session};
56 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
67 use ratatui_textarea::{CursorMove, TextArea};
78
8−use crate::asm::{self, AsmOutput};
9−use crate::sim::{Sim, Stop, disasm};
10−
119 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1210 pub enum Mode {
1311 Edit,
@@ -31,13 +29,7 @@ pub enum Focus {
3129 Serial,
3230 }
3331
34−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35−pub enum MemTab {
36− Iram,
37− Sfr,
38− Xram,
39− Code,
40−}
32+pub type MemTab = MemSpace;
4133
4234 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4335 pub enum PromptKind {
@@ -52,27 +44,6 @@ pub struct Prompt {
5244 pub input: String,
5345 }
5446
55−/// Result of the last successful assembly, which the debugger runs.
56−pub struct Build {
57− pub source: Vec<String>,
58− pub out: AsmOutput,
59− pub addr_to_line: HashMap<u16, usize>,
60−}
61−
62−impl Build {
63− /// First address emitted by `line`, if any.
64− pub fn line_addr(&self, line: usize) -> Option<u16> {
65− self.out
66− .lines
67− .get(line)
68− .filter(|l| !l.bytes.is_empty())
69− .and_then(|l| l.addr)
70− }
71−}
72−
73−/// Instructions executed per frame (~30 fps) for each speed setting.
74−pub const SPEEDS: [u32; 8] = [1, 5, 25, 100, 500, 2_000, 10_000, 40_000];
75−
7647 pub struct App {
7748 pub path: Option<PathBuf>,
7849 pub editor: TextArea<'static>,
@@ -82,27 +53,14 @@ pub struct App {
8253 pub focus: Focus,
8354 pub mem_tab: MemTab,
8455 pub mem_scroll: usize,
85− /// Last assembler result, even if it had errors (for diagnostics).
86− pub last_asm: Option<(String, AsmOutput)>,
87− /// Last error-free build, loaded into the simulator.
88− pub build: Option<Build>,
89− pub sim: Sim,
90− /// Breakpoints by source line.
91− pub breakpoints: HashSet<usize>,
92− pub running: bool,
93− /// The next run chunk starts a fresh run and may leave a breakpoint.
94− resume: bool,
95− pub speed: usize,
96− pub clock_mhz: f64,
97− pub status: String,
56+ /// Simulator, build, breakpoints and run state.
57+ pub session: Session,
9858 /// Selected line in the debug source view.
9959 pub src_cursor: usize,
10060 pub src_scroll: usize,
10161 pub out_scroll: usize,
10262 pub port_sel: (usize, u8),
10363 pub serial_input: String,
104− pub prev_iram: [u8; 256],
105− pub prev_sfr: [u8; 128],
10664 pub show_help: bool,
10765 pub prompt: Option<Prompt>,
10866 pub quit: bool,
@@ -123,28 +81,19 @@ impl App {
12381 focus: Focus::Source,
12482 mem_tab: MemTab::Iram,
12583 mem_scroll: 0,
126− last_asm: None,
127− build: None,
128− sim: Sim::new(),
129− breakpoints: HashSet::new(),
130− running: false,
131− resume: false,
132− speed: 5,
133− clock_mhz,
134− status: "F1 help · F7 assemble · F5 run · F11 step".into(),
84+ session: Session::new(clock_mhz),
13585 src_cursor: 0,
13686 src_scroll: 0,
13787 out_scroll: 0,
13888 port_sel: (1, 0),
13989 serial_input: String::new(),
140− prev_iram: [0; 256],
141− prev_sfr: [0; 128],
14290 show_help: false,
14391 prompt: None,
14492 quit: false,
14593 quit_armed: false,
14694 external_edit: false,
14795 };
96+ app.session.status = "F1 help · F7 assemble · F5 run · F11 step".into();
14897 match path {
14998 Some(p) => app.open(&p),
15099 None => app.set_text(TEMPLATE),
@@ -161,9 +110,7 @@ impl App {
161110 });
162111 crate::ui::draw::style_editor(&mut self.editor);
163112 self.saved_text = self.text();
164− self.breakpoints.clear();
165− self.build = None;
166− self.last_asm = None;
113+ self.session.clear();
167114 self.focus = Focus::Source;
168115 self.serial_input.clear();
169116 }
@@ -178,9 +125,8 @@ impl App {
178125
179126 /// Source differs from what the simulator is running.
180127 pub fn stale(&self) -> bool {
181− self.build
182− .as_ref()
183− .is_none_or(|b| b.source != self.editor.lines())
128+ self.session
129+ .is_stale(self.editor.lines().iter().map(String::as_str))
184130 }
185131
186132 pub fn title(&self) -> String {
@@ -194,37 +140,24 @@ impl App {
194140 // ---- Files ----
195141
196142 fn open(&mut self, path: &Path) {
197− let text = match std::fs::read_to_string(path) {
198− Ok(t) => t,
199− Err(e) => {
200− self.status = format!("cannot open {}: {e}", path.display());
201− return;
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+ );
202154 }
203− };
204− if path
205− .extension()
206− .is_some_and(|e| e.eq_ignore_ascii_case("hex") || e.eq_ignore_ascii_case("ihx"))
207− {
208− match asm::hex::read(&text) {
209− Ok(bytes) => {
210− let src = disassemble_image(&bytes, path);
211− self.set_text(&src);
212− self.saved_text.clear();
213− self.path = None;
214− self.assemble();
215− self.status = format!(
216− "loaded {} ({} bytes) and disassembled it",
217− path.display(),
218− bytes.len()
219− );
220− }
221− Err(e) => self.status = format!("invalid HEX file: {e}"),
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());
222160 }
223− } else {
224− self.set_text(&text);
225− self.path = Some(path.to_path_buf());
226− self.mode = Mode::Edit;
227− self.status = format!("opened {}", path.display());
228161 }
229162 }
230163
@@ -241,10 +174,10 @@ impl App {
241174 match std::fs::write(&path, text) {
242175 Ok(()) => {
243176 self.saved_text = self.text();
244− self.status = format!("saved {}", path.display());
177+ self.session.status = format!("saved {}", path.display());
245178 self.path = Some(path);
246179 }
247− Err(e) => self.status = format!("cannot save {}: {e}", path.display()),
180+ Err(e) => self.session.status = format!("cannot save {}: {e}", path.display()),
248181 }
249182 }
250183
@@ -266,7 +199,7 @@ impl App {
266199 None => {
267200 let p = std::env::temp_dir().join(format!("bench51-{}.asm", std::process::id()));
268201 if let Err(e) = std::fs::write(&p, self.text() + "\n") {
269− self.status = format!("cannot write {}: {e}", p.display());
202+ self.session.status = format!("cannot write {}: {e}", p.display());
270203 return;
271204 }
272205 (p, true)
@@ -280,11 +213,11 @@ impl App {
280213 let _ = std::fs::remove_file(&path);
281214 }
282215 match (result, text) {
283− (Err(e), _) => self.status = format!("cannot start editor: {e}"),
216+ (Err(e), _) => self.session.status = format!("cannot start editor: {e}"),
284217 (Ok(s), _) if !s.success() => {
285− self.status = format!("editor exited with {s}; changes not loaded")
218+ self.session.status = format!("editor exited with {s}; changes not loaded")
286219 }
287− (Ok(_), Err(e)) => self.status = format!("cannot read {}: {e}", path.display()),
220+ (Ok(_), Err(e)) => self.session.status = format!("cannot read {}: {e}", path.display()),
288221 (Ok(_), Ok(text)) => self.reload(&text, !temp),
289222 }
290223 }
@@ -299,7 +232,7 @@ impl App {
299232 lines
300233 };
301234 if lines == self.editor.lines() {
302− self.status = "no changes from external editor".into();
235+ self.session.status = "no changes from external editor".into();
303236 return;
304237 }
305238 let cursor = self.editor.cursor();
@@ -312,61 +245,33 @@ impl App {
312245 self.saved_text = self.text();
313246 }
314247 let n = self.editor.lines().len();
315− self.breakpoints.retain(|&l| l < n);
248+ self.session.breakpoints.retain(|&l| l < n);
316249 if self.assemble() {
317− self.status = format!("reloaded from external editor; {}", self.status);
250+ self.session.status = format!("reloaded from external editor; {}", self.session.status);
318251 }
319252 }
320253
321254 // ---- Build & run ----
322255
323256 /// Assembles the editor contents. On success the program is loaded
324− /// into a freshly reset simulator.
257+ /// into a freshly reset simulator; on errors the cursor jumps to the
258+ /// first one.
325259 pub fn assemble(&mut self) -> bool {
326260 let source = self.text();
327− let out = asm::assemble(&source);
328− let ok = out.ok();
261+ let ok = self.session.assemble(&source, self.path.as_deref());
329262 if ok {
330− self.sim.load_code(out.bytes());
331− self.sim.reset();
332− self.running = false;
333− self.status = format!("assembled: {} bytes, 0 errors", out.code_size());
334− self.write_outputs(&out, &source);
335− self.build = Some(Build {
336− source: self.editor.lines().to_vec(),
337− addr_to_line: out.addr_to_line(),
338− out: out.clone(),
339− });
340− self.snapshot();
341263 self.follow_pc();
342− } else {
264+ } else if let Some((_, out)) = &self.session.last_asm {
343265 let first = &out.diagnostics[0];
344− self.status = format!(
345− "{} error(s); line {}: {}",
346− out.diagnostics.len(),
347− first.line + 1,
348− first.msg
349− );
350266 self.editor
351267 .move_cursor(CursorMove::Jump(first.line as u16, first.col as u16));
352268 self.mode = Mode::Edit;
353269 self.out_tab = OutTab::Errors;
354270 }
355271 self.out_scroll = 0;
356− self.last_asm = Some((source, out));
357272 ok
358273 }
359274
360− /// Writes `.lst` and `.hex` next to a saved source file.
361− fn write_outputs(&mut self, out: &AsmOutput, source: &str) {
362− let Some(path) = &self.path else { return };
363− let res = std::fs::write(path.with_extension("lst"), out.listing(source))
364− .and_then(|_| std::fs::write(path.with_extension("hex"), out.intel_hex()));
365− if let Err(e) = res {
366− self.status = format!("assembled, but writing .lst/.hex failed: {e}");
367− }
368− }
369−
370275 /// Makes sure an up-to-date build is loaded; returns false on errors.
371276 fn ensure_built(&mut self) -> bool {
372277 if self.stale() && !self.assemble() {
@@ -379,132 +284,65 @@ impl App {
379284 true
380285 }
381286
382− pub fn bp_addrs(&self) -> HashSet<u16> {
383− let Some(b) = &self.build else {
384− return HashSet::new();
385− };
386− self.breakpoints
387− .iter()
388− .filter_map(|&l| b.line_addr(l))
389− .collect()
390− }
391−
392− pub fn snapshot(&mut self) {
393− self.prev_iram = self.sim.iram;
394− self.prev_sfr = self.sim.sfr;
395− }
396−
397− pub fn pc_line(&self) -> Option<usize> {
398− self.build.as_ref()?.addr_to_line.get(&self.sim.pc).copied()
399− }
400−
401− /// Scrolls the source view so the PC line is visible.
287+ /// Moves the source cursor to the PC line.
402288 pub fn follow_pc(&mut self) {
403− if let Some(l) = self.pc_line() {
289+ if let Some(l) = self.session.pc_line() {
404290 self.src_cursor = l;
405291 }
406292 }
407293
408− fn report(&mut self, stop: Stop) {
409− self.running = false;
410− self.status = match stop {
411− Stop::Breakpoint(pc) => format!("breakpoint at {pc:04X}h"),
412− Stop::ReservedOpcode { pc, opcode } => {
413− format!("reserved opcode {opcode:02X}h at {pc:04X}h — stopped")
414− }
415− Stop::PowerDown => "CPU in power-down mode (PCON.PD) — reset to continue".into(),
416− Stop::Target | Stop::Budget => format!("stopped at {:04X}h", self.sim.pc),
417− };
418− self.follow_pc();
419− }
420−
421− fn step_cmd(&mut self, f: impl FnOnce(&mut Sim, &HashSet<u16>) -> Stop) {
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)) {
422296 if !self.ensure_built() {
423297 return;
424298 }
425− self.running = false;
426− self.snapshot();
427− let bps = self.bp_addrs();
428− let stop = f(&mut self.sim, &bps);
429− match stop {
430− Stop::Target => {
431− self.status = format!("PC = {:04X}h", self.sim.pc);
432− self.follow_pc();
433− }
434− Stop::Budget => self.report(Stop::Budget),
435− s => self.report(s),
436− }
299+ f(&mut self.session);
300+ self.follow_pc();
437301 }
438302
439303 fn toggle_run(&mut self) {
440− if self.running {
441− self.running = false;
442− self.status = format!("paused at {:04X}h", self.sim.pc);
304+ if self.session.running {
305+ self.session.pause();
443306 self.follow_pc();
444307 } else if self.ensure_built() {
445− self.running = true;
446− self.resume = true;
447− self.status = "running…".into();
308+ self.session.start();
448309 }
449310 }
450311
451− fn run_to_cursor(&mut self) {
452− if !self.ensure_built() {
453− return;
454− }
455− let line = if self.mode == Mode::Debug {
312+ /// Source line the cursor is on in the current mode.
313+ fn cursor_line(&self) -> usize {
314+ if self.mode == Mode::Debug {
456315 self.src_cursor
457316 } else {
458317 self.editor.cursor().0
459− };
460− let Some(target) = self.build.as_ref().and_then(|b| b.line_addr(line)) else {
461− self.status = "no code on that line".into();
318+ }
319+ }
320+
321+ fn run_to_cursor(&mut self) {
322+ if !self.ensure_built() {
462323 return;
463− };
464− self.step_cmd(|s, bps| {
465− let mut bps = bps.clone();
466− bps.insert(target);
467− match s.run(5_000_000, &bps, true) {
468− Stop::Breakpoint(pc) if pc == target => Stop::Target,
469− other => other,
470− }
324+ }
325+ let line = self.cursor_line();
326+ self.debug_cmd(|s| {
327+ s.run_to_line(line);
471328 });
472329 }
473330
474331 fn reset(&mut self) {
475− self.sim.reset();
476− self.running = false;
477− self.snapshot();
332+ self.session.reset();
478333 self.follow_pc();
479− self.status = "reset".into();
480334 }
481335
482336 fn toggle_breakpoint(&mut self) {
483− let line = if self.mode == Mode::Debug {
484− self.src_cursor
485− } else {
486− self.editor.cursor().0
487− };
488− if !self.breakpoints.remove(&line) {
489− self.breakpoints.insert(line);
490− }
337+ let line = self.cursor_line();
338+ self.session.toggle_breakpoint(line);
491339 }
492340
493341 /// Called once per frame.
494342 pub fn tick(&mut self) {
495− if !self.running {
496− return;
497− }
498− self.snapshot();
499− let bps = self.bp_addrs();
500− let resume = std::mem::take(&mut self.resume);
501− match self.sim.run(SPEEDS[self.speed], &bps, resume) {
502− Stop::Budget => {
503− if let Some(l) = self.pc_line() {
504− self.src_cursor = l;
505− }
506− }
507− s => self.report(s),
343+ if self.session.running {
344+ self.session.tick();
345+ self.follow_pc();
508346 }
509347 }
510348
@@ -541,7 +379,7 @@ impl App {
541379 (KeyCode::Char('q'), true) => {
542380 if self.dirty() && !self.quit_armed {
543381 self.quit_armed = true;
544− self.status = "unsaved changes — press Ctrl-Q again to quit".into();
382+ self.session.status = "unsaved changes — press Ctrl-Q again to quit".into();
545383 } else {
546384 self.quit = true;
547385 }
@@ -571,12 +409,9 @@ impl App {
571409 }
572410 (KeyCode::F(8), _) => self.run_to_cursor(),
573411 (KeyCode::F(9), _) => self.toggle_breakpoint(),
574− (KeyCode::F(10), _) => self.step_cmd(|s, b| s.step_over(5_000_000, b)),
575− (KeyCode::F(11), _) => self.step_cmd(|s, _| match s.step() {
576− Ok(()) => Stop::Target,
577− Err(e) => e,
578− }),
579− (KeyCode::F(12), _) => self.step_cmd(|s, b| s.step_out(5_000_000, b)),
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),
580415 _ => match self.mode {
581416 Mode::Edit => self.on_edit_key(key),
582417 Mode::Debug => self.on_debug_key(key),
@@ -595,7 +430,7 @@ impl App {
595430 KeyCode::Down if alt => self.out_scroll += 1,
596431 KeyCode::PageUp if alt => self.out_scroll = self.out_scroll.saturating_sub(20),
597432 KeyCode::PageDown if alt => self.out_scroll += 20,
598− KeyCode::Esc if self.running => self.toggle_run(),
433+ KeyCode::Esc if self.session.running => self.toggle_run(),
599434 _ => {
600435 self.editor.input(key);
601436 }
@@ -608,7 +443,7 @@ impl App {
608443 KeyCode::Enter => {
609444 let mut bytes = std::mem::take(&mut self.serial_input).into_bytes();
610445 bytes.push(b'\r');
611− self.sim.uart_send(&bytes);
446+ self.session.sim.uart_send(&bytes);
612447 return;
613448 }
614449 KeyCode::Backspace => {
@@ -628,7 +463,7 @@ impl App {
628463 KeyCode::Tab => self.cycle_focus(true),
629464 KeyCode::BackTab => self.cycle_focus(false),
630465 KeyCode::Esc => {
631− if self.running {
466+ if self.session.running {
632467 self.toggle_run();
633468 } else if self.focus == Focus::Serial {
634469 self.focus = Focus::Source;
@@ -637,9 +472,9 @@ impl App {
637472 }
638473 }
639474 KeyCode::Char('+') | KeyCode::Char('=') => {
640− self.speed = (self.speed + 1).min(SPEEDS.len() - 1)
475+ self.session.speed = (self.session.speed + 1).min(SPEEDS.len() - 1)
641476 }
642− KeyCode::Char('-') => self.speed = self.speed.saturating_sub(1),
477+ KeyCode::Char('-') => self.session.speed = self.session.speed.saturating_sub(1),
643478 KeyCode::Char('?') => self.show_help = true,
644479 // Letter aliases for terminals that swallow function keys.
645480 KeyCode::Char('r') => self.toggle_run(),
@@ -684,6 +519,7 @@ impl App {
684519
685520 fn move_src(&mut self, code: KeyCode, page: usize) {
686521 let max = self
522+ .session
687523 .build
688524 .as_ref()
689525 .map_or(0, |b| b.source.len().saturating_sub(1));
@@ -694,7 +530,7 @@ impl App {
694530 KeyCode::PageDown => self.src_cursor + page,
695531 KeyCode::Home => 0,
696532 KeyCode::End => max,
697− KeyCode::Char('.') => self.pc_line().unwrap_or(self.src_cursor),
533+ KeyCode::Char('.') => self.session.pc_line().unwrap_or(self.src_cursor),
698534 _ => self.src_cursor,
699535 }
700536 .min(max);
@@ -745,7 +581,7 @@ impl App {
745581 KeyCode::Left => *bit = (*bit + 1).min(7),
746582 KeyCode::Right => *bit = bit.saturating_sub(1),
747583 KeyCode::Char(' ') | KeyCode::Enter => {
748− self.sim.pins[*port] ^= 1 << *bit;
584+ self.session.sim.pins[*port] ^= 1 << *bit;
749585 }
750586 _ => {}
751587 }
@@ -786,38 +622,14 @@ impl App {
786622 parts.first().and_then(|s| parse(s)),
787623 parts.get(1).and_then(|s| parse(s)),
788624 ) else {
789− self.status = "usage: <addr> <value> (hex), e.g. 30 FF".into();
625+ self.session.status = "usage: <addr> <value> (hex), e.g. 30 FF".into();
790626 return;
791627 };
792628 if val > 0xFF {
793− self.status = "value must be a byte".into();
629+ self.session.status = "value must be a byte".into();
794630 return;
795631 }
796− let v = val as u8;
797− let ok = match self.mem_tab {
798− MemTab::Iram if addr <= 0xFF => {
799− self.sim.iram[addr as usize] = v;
800− true
801− }
802− MemTab::Sfr if (0x80..=0xFF).contains(&addr) => {
803− self.sim.set_sfr(addr as u8, v);
804− true
805− }
806− MemTab::Xram if addr <= 0xFFFF => {
807− self.sim.xram[addr as usize] = v;
808− true
809− }
810− MemTab::Code if addr <= 0xFFFF => {
811− self.sim.code[addr as usize] = v;
812− true
813− }
814− _ => false,
815− };
816− self.status = if ok {
817− format!("wrote {v:02X}h at {addr:X}h")
818− } else {
819− "address out of range".into()
820− };
632+ self.session.poke(self.mem_tab, addr, val as u8);
821633 }
822634 }
823635
@@ -848,46 +660,6 @@ fn editor_command(path: &Path, line: usize) -> Command {
848660 cmd
849661 }
850662
851−/// Builds an assembler source from a HEX image by disassembling each
852−/// contiguous run of bytes. Reassembling it reproduces the image exactly.
853−fn disassemble_image(bytes: &[(u16, u8)], path: &Path) -> String {
854− let mut code = Box::new([0u8; 0x10000]);
855− let mut used = vec![false; 0x10000];
856− for &(a, b) in bytes {
857− code[a as usize] = b;
858− used[a as usize] = true;
859− }
860− let mut src = format!("; disassembled from {}\n", path.display());
861− let mut addr = 0usize;
862− while addr < 0x10000 {
863− if !used[addr] {
864− addr += 1;
865− continue;
866− }
867− src.push_str(&format!(
868− "\n ORG {}\n",
869− crate::sfr::hex16(addr as u16)
870− ));
871− while addr < 0x10000 && used[addr] {
872− let d = disasm::disassemble(&code, addr as u16);
873− let size = d.size as usize;
874− if (addr..addr + size).all(|a| a < 0x10000 && used[a]) {
875− src.push_str(&format!(" {:<24}; {:04X}\n", d.text, addr));
876− addr += size;
877− } else {
878− src.push_str(&format!(
879− " DB {:<21}; {:04X}\n",
880− crate::sfr::hex8(code[addr]),
881− addr
882− ));
883− addr += 1;
884− }
885− }
886− }
887− src.push_str(" END\n");
888− src
889−}
890−
891663 const TEMPLATE: &str = "\
892664 ; New 8051 program. F7 assembles, F5 runs, F11 steps, F1 shows all keys.
893665 ORG 0000h
M src/ui/draw.rs View
@@ -5,9 +5,10 @@ use ratatui::text::{Line, Span};
55 use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Tabs, Wrap};
66 use ratatui_textarea::TextArea;
77
8−use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind, SPEEDS};
8+use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind};
99 use super::syntax::highlight;
10−use crate::sfr::{self, IE, IP, PCON, PSW, SCON, TCON, TH0, TH1, TL0, TL1, TMOD};
10+use bench51_core::session::{SPEEDS, vector_name};
11+use bench51_core::sfr::{self, IE, IP, PCON, PSW, SCON, TCON, TH0, TH1, TL0, TL1, TMOD};
1112
1213 const ACCENT: Color = Color::Cyan;
1314 const CHANGED: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
@@ -73,12 +74,12 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) {
7374 Style::new().bg(Color::Magenta).fg(Color::White).bold(),
7475 ),
7576 };
76− let (state, state_style) = if app.running {
77+ let (state, state_style) = if app.session.running {
7778 (
7879 " RUNNING ",
7980 Style::new().bg(Color::Green).fg(Color::Black).bold(),
8081 )
81− } else if app.build.is_none() {
82+ } else if app.session.build.is_none() {
8283 (
8384 " NOT BUILT ",
8485 Style::new().bg(Color::DarkGray).fg(Color::White),
@@ -96,14 +97,14 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) {
9697 Span::styled(state, state_style),
9798 Span::raw(format!(" {}", app.title())),
9899 ];
99− if app.build.is_some() && app.stale() {
100+ if app.session.build.is_some() && app.stale() {
100101 spans.push(Span::styled(
101102 " (source changed — F7 to rebuild)",
102103 Style::new().fg(Color::Yellow),
103104 ));
104105 }
105106 spans.push(Span::styled(
106− format!(" speed {}/frame (+/-)", SPEEDS[app.speed]),
107+ format!(" speed {}/frame (+/-)", SPEEDS[app.session.speed]),
107108 DIM,
108109 ));
109110 f.render_widget(Line::from(spans), area);
@@ -137,7 +138,7 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
137138 }
138139 };
139140 let width = area.width as usize;
140− let status = format!(" {}", app.status);
141+ let status = format!(" {}", app.session.status);
141142 let pad = width.saturating_sub(status.chars().count() + keys.len() + 1);
142143 let line = Line::from(vec![
143144 Span::styled(status, Style::new().fg(Color::White)),
@@ -163,16 +164,16 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) {
163164 // Breakpoint / error markers aligned with the editor's scroll position.
164165 let top = app.editor.scroll_offset().0 as usize;
165166 let errors: Vec<usize> = app
166− .last_asm
167− .as_ref()
168− .filter(|(src, _)| *src == app.text())
169− .map(|(_, o)| o.diagnostics.iter().map(|d| d.line).collect())
170− .unwrap_or_default();
167+ .session
168+ .diagnostics_for(&app.text())
169+ .iter()
170+ .map(|d| d.line)
171+ .collect();
171172 let marks: Vec<Line> = (top..top + gutter.height as usize)
172173 .map(|l| {
173174 if errors.contains(&l) {
174175 Line::styled("✗", Style::new().fg(Color::Red).bold())
175− } else if app.breakpoints.contains(&l) {
176+ } else if app.session.breakpoints.contains(&l) {
176177 Line::styled("●", Style::new().fg(Color::Red))
177178 } else {
178179 Line::raw("")
@@ -186,6 +187,7 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) {
186187
187188 fn draw_output(f: &mut Frame, app: &mut App, area: Rect) {
188189 let n_err = app
190+ .session
189191 .last_asm
190192 .as_ref()
191193 .map_or(0, |(_, o)| o.diagnostics.len());
@@ -214,7 +216,7 @@ fn draw_output(f: &mut Frame, app: &mut App, area: Rect) {
214216 .highlight_style(Style::new().fg(ACCENT).bold().underlined());
215217 f.render_widget(tabs, tabs_area);
216218
217− let lines: Vec<Line> = match (app.out_tab, &app.last_asm) {
219+ let lines: Vec<Line> = match (app.out_tab, &app.session.last_asm) {
218220 (_, None) => vec![Line::styled("Press F7 to assemble.", DIM)],
219221 (OutTab::Listing, Some((src, out))) => out
220222 .listing(src)
@@ -312,7 +314,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
312314 let block = panel("Source", focused);
313315 let inner = block.inner(area);
314316 f.render_widget(block, area);
315− let Some(build) = &app.build else {
317+ let Some(build) = &app.session.build else {
316318 f.render_widget(
317319 Paragraph::new("Nothing assembled yet — press F7.").style(DIM),
318320 inner,
@@ -326,7 +328,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
326328 } else if app.src_cursor >= app.src_scroll + h {
327329 app.src_scroll = app.src_cursor + 1 - h;
328330 }
329− let pc_line = build.addr_to_line.get(&app.sim.pc).copied();
331+ let pc_line = build.addr_to_line.get(&app.session.sim.pc).copied();
330332 let lines: Vec<Line> = build
331333 .source
332334 .iter()
@@ -343,7 +345,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
343345 } else {
344346 Style::new()
345347 };
346− let bp = if app.breakpoints.contains(&i) {
348+ let bp = if app.session.breakpoints.contains(&i) {
347349 Span::styled("●", bg.fg(Color::Red))
348350 } else {
349351 Span::styled(" ", bg)
@@ -394,27 +396,20 @@ fn flags(byte: u8, names: [&'static str; 8]) -> Vec<Span<'static>> {
394396 }
395397
396398 fn draw_registers(f: &mut Frame, app: &App, area: Rect) {
397− let s = &app.sim;
399+ let s = &app.session.sim;
398400 let block = panel("Registers", false);
399401 let inner = block.inner(area);
400402 f.render_widget(block, area);
401− let ch = |addr: u8| s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
403+ let ch = |addr: u8| s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
402404 let reg = |n: u8| {
403405 let a = s.reg_addr(n) as usize;
404406 vec![
405407 Span::styled(format!("R{n} "), LABEL),
406− hex2(s.iram[a], s.iram[a] != app.prev_iram[a]),
408+ hex2(s.iram[a], s.iram[a] != app.session.prev_iram[a]),
407409 Span::raw(" "),
408410 ]
409411 };
410− let micros = s.cycles as f64 * 12.0 / app.clock_mhz;
411− let time = if micros >= 1_000_000.0 {
412− format!("{:.3} s", micros / 1e6)
413− } else if micros >= 1000.0 {
414− format!("{:.3} ms", micros / 1e3)
415− } else {
416− format!("{micros:.2} µs")
417− };
412+ let time = app.session.elapsed();
418413 let lines = vec![
419414 Line::from(vec![
420415 Span::styled("PC ", LABEL),
@@ -461,18 +456,18 @@ fn draw_registers(f: &mut Frame, app: &App, area: Rect) {
461456 Line::from(vec![
462457 Span::styled("Time ", LABEL),
463458 Span::raw(time),
464− Span::styled(format!(" @{}MHz", app.clock_mhz), DIM),
459+ Span::styled(format!(" @{}MHz", app.session.clock_mhz), DIM),
465460 ]),
466461 ];
467462 f.render_widget(Paragraph::new(lines), inner);
468463 }
469464
470465 fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) {
471− let s = &app.sim;
466+ let s = &app.session.sim;
472467 let block = panel("Peripherals", false);
473468 let inner = block.inner(area);
474469 f.render_widget(block, area);
475− let ch = |addr: u8| s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
470+ let ch = |addr: u8| s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
476471 let sfr_val = |name: &'static str, addr: u8| {
477472 vec![
478473 Span::styled(name, LABEL),
@@ -609,17 +604,6 @@ fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) {
609604 f.render_widget(Paragraph::new(lines), inner);
610605 }
611606
612−fn vector_name(v: u16) -> &'static str {
613− match v {
614− 0x03 => "INT0",
615− 0x0B => "T0",
616− 0x13 => "INT1",
617− 0x1B => "T1",
618− 0x23 => "UART",
619− _ => "?",
620− }
621−}
622−
623607 fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
624608 let focused = app.focus == Focus::Memory;
625609 let block = panel("Memory", focused);
@@ -646,7 +630,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
646630 return;
647631 }
648632
649− let s = &app.sim;
633+ let s = &app.session.sim;
650634 let per_row: usize = if content.width >= 74 { 16 } else { 8 };
651635 let total = match app.mem_tab {
652636 MemTab::Iram => 256,
@@ -655,9 +639,9 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
655639 let rows = total / per_row;
656640 let h = content.height as usize;
657641 app.mem_scroll = app.mem_scroll.min(rows.saturating_sub(h));
658− let bps = app.bp_addrs();
642+ let bps = app.session.bp_addrs();
659643 let pc = s.pc as usize;
660− let pc_len = crate::isa::info(s.code[pc]).map_or(1, |i| i.size as usize);
644+ let pc_len = bench51_core::isa::info(s.code[pc]).map_or(1, |i| i.size as usize);
661645 let bank = s.reg_addr(0) as usize;
662646
663647 let lines: Vec<Line> = (app.mem_scroll..rows.min(app.mem_scroll + h))
@@ -679,7 +663,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
679663 let v = s.iram[a];
680664 let st = if a == s.sp() as usize {
681665 Style::new().fg(Color::Black).bg(Color::LightMagenta)
682− } else if v != app.prev_iram[a] {
666+ } else if v != app.session.prev_iram[a] {
683667 CHANGED
684668 } else if (bank..bank + 8).contains(&a) {
685669 Style::new().fg(Color::LightBlue)
@@ -724,7 +708,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
724708 }
725709
726710 fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) {
727− let s = &app.sim;
711+ let s = &app.session.sim;
728712 let cols = if area.width >= 64 { 3 } else { 2 };
729713 let per_col = sfr::SFRS.len().div_ceil(cols);
730714 let lines: Vec<Line> = (0..per_col)
@@ -733,7 +717,7 @@ fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) {
733717 for c in 0..cols {
734718 if let Some(&(name, addr)) = sfr::SFRS.get(c * per_col + r) {
735719 let v = s.read_direct(addr, true);
736− let changed = s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
720+ let changed = s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
737721 spans.push(Span::styled(format!("{name:<5}"), LABEL));
738722 spans.push(Span::styled(format!("{addr:02X} "), DIM));
739723 spans.push(hex2(v, changed));
@@ -754,7 +738,7 @@ fn draw_ports(f: &mut Frame, app: &App, area: Rect) {
754738 let block = panel("Ports", focused);
755739 let inner = block.inner(area);
756740 f.render_widget(block, area);
757− let s = &app.sim;
741+ let s = &app.session.sim;
758742 let mut lines: Vec<Line> = (0..4)
759743 .map(|p| {
760744 let latch = s.sfr(sfr::P0 + 0x10 * p as u8);
@@ -797,7 +781,7 @@ fn draw_serial(f: &mut Frame, app: &App, area: Rect) {
797781 Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
798782
799783 let mut text = String::new();
800− for &b in &app.sim.uart.tx_log {
784+ for &b in &app.session.sim.uart.tx_log {
801785 match b {
802786 b'\n' => text.push('\n'),
803787 b'\r' => {}
M src/ui/syntax.rs View
@@ -1,122 +1,27 @@
1−//! Minimal 8051 assembly syntax highlighting.
1+//! 8051 assembly syntax highlighting for the TUI.
22
3+use bench51_core::syntax::{Tok, tokens};
34 use ratatui::style::{Color, Modifier, Style};
45 use ratatui::text::Span;
56
6−use crate::{isa, sfr};
7−
8−const COMMENT: Style = Style::new().fg(Color::DarkGray);
9−const LABEL: Style = Style::new().fg(Color::Yellow);
10−const MNEMONIC: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
11−const DIRECTIVE: Style = Style::new().fg(Color::Magenta);
12−const REGISTER: Style = Style::new().fg(Color::LightBlue);
13−const NUMBER: Style = Style::new().fg(Color::LightGreen);
14−const STRING: Style = Style::new().fg(Color::Green);
15−const SFR: Style = Style::new().fg(Color::LightMagenta);
16−
17−fn is_directive(w: &str) -> bool {
18− matches!(
19− w,
20− "ORG"
21− | "DB"
22− | "DW"
23− | "DS"
24− | "END"
25− | "EQU"
26− | "SET"
27− | "BIT"
28− | "DATA"
29− | "IDATA"
30− | "XDATA"
31− | "CODE"
32− | "CSEG"
33− | "XSEG"
34− | "DSEG"
35− | "AT"
36− | "USING"
37− | "NAME"
38− | "BYTE"
39− | "WORD"
40− )
41−}
42−
43−fn word_style(w: &str, first_word: bool) -> Style {
44− let u = w.to_ascii_uppercase();
45− let reg = matches!(u.as_str(), "A" | "AB" | "C" | "DPTR" | "PC")
46− || (u.len() == 2
47− && u.starts_with('R')
48− && u.as_bytes()[1].is_ascii_digit()
49− && u.as_bytes()[1] < b'8');
50− if reg {
51− REGISTER
52− } else if is_directive(&u) {
53− DIRECTIVE
54− } else if first_word && isa::is_mnemonic(&u) {
55− MNEMONIC
56− } else if sfr::SFRS.iter().chain(sfr::BITS).any(|(n, _)| *n == u) {
57− SFR
58− } else if matches!(
59− u.as_str(),
60− "HIGH" | "LOW" | "MOD" | "AND" | "OR" | "XOR" | "NOT" | "SHL" | "SHR"
61− ) {
62− DIRECTIVE
63− } else {
64− Style::new()
7+fn style(t: Tok) -> Style {
8+ match t {
9+ Tok::Plain => Style::new(),
10+ Tok::Comment => Style::new().fg(Color::DarkGray),
11+ Tok::Label => Style::new().fg(Color::Yellow),
12+ Tok::Mnemonic => Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
13+ Tok::Directive => Style::new().fg(Color::Magenta),
14+ Tok::Register => Style::new().fg(Color::LightBlue),
15+ Tok::Number => Style::new().fg(Color::LightGreen),
16+ Tok::Str => Style::new().fg(Color::Green),
17+ Tok::Sfr => Style::new().fg(Color::LightMagenta),
6518 }
6619 }
6720
6821 /// Splits a source line into styled spans, applying `base` underneath.
6922 pub fn highlight(line: &str, base: Style) -> Vec<Span<'static>> {
70− let chars: Vec<char> = line.chars().collect();
71− let mut spans = Vec::new();
72− let mut i = 0;
73− let mut seen_word = false;
74− let push = |spans: &mut Vec<Span<'static>>, s: String, st: Style| {
75− spans.push(Span::styled(s, base.patch(st)))
76− };
77− while i < chars.len() {
78− let c = chars[i];
79− let start = i;
80− if c == ';' {
81− push(&mut spans, chars[i..].iter().collect(), COMMENT);
82− break;
83− } else if c == '\'' || c == '"' {
84− i += 1;
85− while i < chars.len() && chars[i] != c {
86− i += 1;
87− }
88− i = (i + 1).min(chars.len());
89− push(&mut spans, chars[start..i].iter().collect(), STRING);
90− } else if c.is_ascii_alphabetic() || c == '_' || c == '?' {
91− while i < chars.len()
92− && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '?')
93− {
94− i += 1;
95− }
96− let w: String = chars[start..i].iter().collect();
97− let is_label = !seen_word && chars.get(i) == Some(&':');
98− let style = if is_label {
99− LABEL
100− } else {
101− let st = word_style(&w, !seen_word);
102− seen_word = true;
103− st
104− };
105− push(&mut spans, w, style);
106− } else if c.is_ascii_digit() || c == '#' || c == '$' {
107− i += 1;
108− while i < chars.len() && chars[i].is_ascii_alphanumeric() {
109− i += 1;
110− }
111− push(&mut spans, chars[start..i].iter().collect(), NUMBER);
112− } else {
113− while i < chars.len()
114− && !(chars[i].is_ascii_alphanumeric() || "_?;'\"#$".contains(chars[i]))
115− {
116− i += 1;
117− }
118− push(&mut spans, chars[start..i].iter().collect(), Style::new());
119− }
120− }
121− spans
23+ tokens(line)
24+ .into_iter()
25+ .map(|(r, t)| Span::styled(line[r].to_string(), base.patch(style(t))))
26+ .collect()
12227 }