bench5183c841a
Merge pull request #2 from krolyxon/gui
Add Gui with Egui using Nothing OS design language
+10636 −1308
61 files changed
- A .github/workflows/ci.yml +31 −0
- M Cargo.lock +4268 −794
- M Cargo.toml +26 −3
- M README.md +138 −30
- A crates/bench51-core/Cargo.toml +10 −0
- R src/asm/expr.rs → crates/bench51-core/src/asm/expr.rs +0 −0
- R src/asm/hex.rs → crates/bench51-core/src/asm/hex.rs +0 −0
- R src/asm/lexer.rs → crates/bench51-core/src/asm/lexer.rs +0 −0
- R src/asm/listing.rs → crates/bench51-core/src/asm/listing.rs +0 −0
- R src/asm/mod.rs → crates/bench51-core/src/asm/mod.rs +0 −0
- A crates/bench51-core/src/files.rs +95 −0
- R src/isa.rs → crates/bench51-core/src/isa.rs +1 −1
- A crates/bench51-core/src/lib.rs +10 −0
- A crates/bench51-core/src/session.rs +417 −0
- R src/sfr.rs → crates/bench51-core/src/sfr.rs +0 −0
- R src/sim/cpu.rs → crates/bench51-core/src/sim/cpu.rs +0 −0
- R src/sim/disasm.rs → crates/bench51-core/src/sim/disasm.rs +0 −0
- R src/sim/mod.rs → crates/bench51-core/src/sim/mod.rs +5 −5
- R src/sim/periph.rs → crates/bench51-core/src/sim/periph.rs +0 −0
- A crates/bench51-core/src/syntax.rs +192 −0
- A crates/bench51-gui/Cargo.toml +24 −0
- A crates/bench51-gui/assets/bench51-gui.desktop +12 −0
- A crates/bench51-gui/assets/bench51-gui.svg +31 −0
- A crates/bench51-gui/assets/fonts/Doto[ROND,wght].ttf
- A crates/bench51-gui/assets/fonts/OFL-Doto.txt +93 −0
- A crates/bench51-gui/assets/fonts/OFL-SpaceGrotesk.txt +93 −0
- A crates/bench51-gui/assets/fonts/OFL-SpaceMono.txt +93 −0
- A crates/bench51-gui/assets/fonts/SpaceGrotesk-Medium.ttf
- A crates/bench51-gui/assets/fonts/SpaceGrotesk-Regular.ttf
- A crates/bench51-gui/assets/fonts/SpaceMono-Bold.ttf
- A crates/bench51-gui/assets/fonts/SpaceMono-Regular.ttf
- A crates/bench51-gui/src/app.rs +661 −0
- A crates/bench51-gui/src/chrome.rs +421 −0
- A crates/bench51-gui/src/editor.rs +657 −0
- A crates/bench51-gui/src/icons.rs +200 −0
- A crates/bench51-gui/src/layout.rs +362 −0
- A crates/bench51-gui/src/main.rs +57 −0
- A crates/bench51-gui/src/modals.rs +208 −0
- A crates/bench51-gui/src/panes/build.rs +188 −0
- A crates/bench51-gui/src/panes/disasm.rs +193 −0
- A crates/bench51-gui/src/panes/interrupts.rs +83 −0
- A crates/bench51-gui/src/panes/memory.rs +305 −0
- A crates/bench51-gui/src/panes/mod.rs +68 −0
- A crates/bench51-gui/src/panes/ports.rs +77 −0
- A crates/bench51-gui/src/panes/project.rs +137 −0
- A crates/bench51-gui/src/panes/registers.rs +105 −0
- A crates/bench51-gui/src/panes/serial.rs +86 −0
- A crates/bench51-gui/src/panes/symbols.rs +90 −0
- A crates/bench51-gui/src/panes/timers.rs +102 −0
- A crates/bench51-gui/src/state.rs +293 −0
- A crates/bench51-gui/src/theme.rs +293 −0
- A crates/bench51-gui/src/widgets.rs +378 −0
- A docs/screenshots/gui_breakpoint_light.png
- A docs/screenshots/gui_debug.png
- A docs/screenshots/gui_edit.png
- A docs/screenshots/gui_errors.png
- A docs/screenshots/gui_peripherals.png
- M src/main.rs +1 −4
- M src/ui/app.rs +81 −309
- M src/ui/draw.rs +34 −50
- M src/ui/syntax.rs +17 −112
| @@ -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 | |
[Diff too large] View file
| @@ -1,10 +1,33 @@ | |||
| 1 | − | [package] | |
| 2 | − | name = "bench51" | |
| 1 | + | [workspace] | |
| 2 | + | members = ["crates/*"] | |
| 3 | + | ||
| 4 | + | [workspace.package] | |
| 3 | 5 | version = "0.1.0" | |
| 4 | 6 | 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 | |
| 5 | 19 | description = "A TUI Intel 8051 assembler, simulator and debugger" | |
| 6 | 20 | ||
| 7 | 21 | [dependencies] | |
| 8 | − | clap = { version = "4.5.54", features = ["derive"] } | |
| 22 | + | bench51-core.workspace = true | |
| 23 | + | clap.workspace = true | |
| 9 | 24 | ratatui = "0.30" | |
| 10 | 25 | 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 | |
| @@ -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 | − |  | |
| 1 | + | # bench51 — an 8051 assembler, simulator & debugger | |
| 2 | + | ||
| 3 | + | [](https://github.com/krolyxon/bench51/actions/workflows/ci.yml) | |
| 4 | + | [](Cargo.toml) | |
| 5 | + | [](https://github.com/krolyxon/bench51) | |
| 6 | + | [](crates) | |
| 7 | + | [](Cargo.toml) | |
| 8 | + | [](LICENSE) | |
| 9 | + | [](#usage) | |
| 10 | + | [](https://en.wikipedia.org/wiki/Intel_8051) | |
| 11 | + | [](https://github.com/emilk/egui) | |
| 12 | + | [](https://ratatui.rs) | |
| 13 | + | [](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 | + |  | |
| 12 | 29 | ||
| 13 | 30 | <table> | |
| 14 | 31 | <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> | |
| 17 | 34 | </tr> | |
| 18 | 35 | <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> | |
| 21 | 38 | </tr> | |
| 22 | 39 | <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> | |
| 25 | 42 | </tr> | |
| 26 | 43 | <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> | |
| 29 | 46 | </tr> | |
| 30 | 47 | </table> | |
| 31 | 48 | ||
| @@ -35,15 +52,20 @@ I had microcontroller practicals to get through, and the course expects you to | |||
| 35 | 52 | use Keil µVision. Keil µVision doesn't run on Linux. Rather than fight with | |
| 36 | 53 | Wine or a Windows VM every lab session, I wrote my own: bench51 does the parts | |
| 37 | 54 | 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. | |
| 40 | 57 | ||
| 41 | 58 | ----- | |
| 42 | 59 | ||
| 43 | 60 | ## Features | |
| 44 | 61 | ||
| 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. | |
| 47 | 69 | - **Two-pass assembler** for the full 8051 instruction set (all 255 opcodes): | |
| 48 | 70 | - labels, expressions (`+ - * / MOD AND OR XOR NOT SHL SHR HIGH LOW`, `$`) | |
| 49 | 71 | - number formats `42`, `2Ah`, `0x2A`, `101010b`, `52o`, `'*'` | |
| @@ -63,9 +85,88 @@ terminal. | |||
| 63 | 85 | breakpoints, changed-value highlighting, memory poke, adjustable speed. | |
| 64 | 86 | - **Open `.hex` files**: they're disassembled into editable source that | |
| 65 | 87 | 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). | |
| 66 | 90 | ||
| 67 | 91 | ## Usage | |
| 68 | 92 | ||
| 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 | + |  | |
| 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 | + | ||
| 69 | 170 | ```bash | |
| 70 | 171 | cargo install --path . # installs the `bench51` binary | |
| 71 | 172 | ||
| @@ -81,7 +182,7 @@ cargo run --release -- --assemble prog.asm -o out.hex --lst out.lst | |||
| 81 | 182 | `--clock <MHz>` sets the oscillator frequency used for the elapsed-time display | |
| 82 | 183 | (default 11.0592). | |
| 83 | 184 | ||
| 84 | − | ## Keys | |
| 185 | + | ### Terminal keys | |
| 85 | 186 | ||
| 86 | 187 | | Key | Action | | |
| 87 | 188 | | --- | --- | | |
| @@ -100,7 +201,8 @@ cargo run --release -- --assemble prog.asm -o out.hex --lst out.lst | |||
| 100 | 201 | ||
| 101 | 202 | Edit mode: `Alt-1/2/3` switch between Listing, HEX and Errors; `Alt-↑/↓`, | |
| 102 | 203 | `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, …). | |
| 104 | 206 | ||
| 105 | 207 | Debug mode: `Tab` cycles focus between Source, Memory, Ports and Serial. | |
| 106 | 208 | Letter shortcuts work too, for terminals that swallow function keys: | |
| @@ -133,10 +235,16 @@ Letter shortcuts work too, for terminals that swallow function keys: | |||
| 133 | 235 | ## Development | |
| 134 | 236 | ||
| 135 | 237 | ```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 | |
| 138 | 240 | ``` | |
| 139 | 241 | ||
| 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) | |
| @@ -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 | |
[No content changes]
[No content changes]
[No content changes]
[No content changes]
[No content changes]
| @@ -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 | + | } | |
| @@ -28,7 +28,7 @@ pub enum Operand { | |||
| 28 | 28 | ||
| 29 | 29 | impl Operand { | |
| 30 | 30 | /// Bytes this operand occupies after the opcode byte. | |
| 31 | − | pub fn len(self) -> u8 { | |
| 31 | + | pub(crate) fn len(self) -> u8 { | |
| 32 | 32 | match self { | |
| 33 | 33 | Operand::Imm8 | Operand::Direct | Operand::Bit | Operand::NotBit => 1, | |
| 34 | 34 | Operand::Addr11 | Operand::Rel => 1, | |
| @@ -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; | |
| @@ -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 | + | } | |
[No content changes]
[No content changes]
[No content changes]
| @@ -385,7 +385,7 @@ mod example_tests { | |||
| 385 | 385 | ||
| 386 | 386 | #[test] | |
| 387 | 387 | 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")); | |
| 389 | 389 | steps(&mut s, 60_000); | |
| 390 | 390 | assert_eq!( | |
| 391 | 391 | String::from_utf8_lossy(&s.uart.tx_log), | |
| @@ -398,7 +398,7 @@ mod example_tests { | |||
| 398 | 398 | ||
| 399 | 399 | #[test] | |
| 400 | 400 | 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")); | |
| 402 | 402 | steps(&mut s, 5_000); | |
| 403 | 403 | // ~10000 cycles at one interrupt per 1000 cycles. | |
| 404 | 404 | assert!((3..=10).contains(&s.reg(7)), "R7 = {}", s.reg(7)); | |
| @@ -406,7 +406,7 @@ mod example_tests { | |||
| 406 | 406 | ||
| 407 | 407 | #[test] | |
| 408 | 408 | 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")); | |
| 410 | 410 | steps(&mut s, 20); | |
| 411 | 411 | assert!(s.idle); | |
| 412 | 412 | for _ in 0..3 { | |
| @@ -420,10 +420,10 @@ mod example_tests { | |||
| 420 | 420 | ||
| 421 | 421 | #[test] | |
| 422 | 422 | 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")); | |
| 424 | 424 | steps(&mut s, 20); | |
| 425 | 425 | 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")); | |
| 427 | 427 | steps(&mut s, 3 + 4 * 5 + 3); // setup, digits 0-3, then up to MOV P2,A | |
| 428 | 428 | assert_eq!(s.sfr(crate::sfr::P2), 0x66); // digit 4 | |
| 429 | 429 | } | |
[No content changes]
| @@ -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 | + | } | |
| @@ -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 | |
| @@ -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 | |
| @@ -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> | |
[Binary file]
| @@ -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. | |
| @@ -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. | |
| @@ -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. | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
[Binary file]
[Binary file]
[Binary file]
[Binary file]
[Binary file]
| @@ -1,12 +1,9 @@ | |||
| 1 | − | mod asm; | |
| 2 | − | mod isa; | |
| 3 | − | mod sfr; | |
| 4 | − | mod sim; | |
| 5 | 1 | mod ui; | |
| 6 | 2 | ||
| 7 | 3 | use std::path::PathBuf; | |
| 8 | 4 | use std::process::ExitCode; | |
| 9 | 5 | ||
| 6 | + | use bench51_core::asm; | |
| 10 | 7 | use clap::Parser; | |
| 11 | 8 | ||
| 12 | 9 | /// Intel 8051 assembler, simulator and TUI debugger. | |
| @@ -1,13 +1,11 @@ | |||
| 1 | − | use std::collections::{HashMap, HashSet}; | |
| 2 | 1 | use std::path::{Path, PathBuf}; | |
| 3 | 2 | use std::process::Command; | |
| 4 | 3 | ||
| 4 | + | use bench51_core::files::{self, Loaded}; | |
| 5 | + | use bench51_core::session::{MemSpace, SPEEDS, Session}; | |
| 5 | 6 | use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; | |
| 6 | 7 | use ratatui_textarea::{CursorMove, TextArea}; | |
| 7 | 8 | ||
| 8 | − | use crate::asm::{self, AsmOutput}; | |
| 9 | − | use crate::sim::{Sim, Stop, disasm}; | |
| 10 | − | ||
| 11 | 9 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 12 | 10 | pub enum Mode { | |
| 13 | 11 | Edit, | |
| @@ -31,13 +29,7 @@ pub enum Focus { | |||
| 31 | 29 | Serial, | |
| 32 | 30 | } | |
| 33 | 31 | ||
| 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; | |
| 41 | 33 | ||
| 42 | 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 43 | 35 | pub enum PromptKind { | |
| @@ -52,27 +44,6 @@ pub struct Prompt { | |||
| 52 | 44 | pub input: String, | |
| 53 | 45 | } | |
| 54 | 46 | ||
| 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 | − | ||
| 76 | 47 | pub struct App { | |
| 77 | 48 | pub path: Option<PathBuf>, | |
| 78 | 49 | pub editor: TextArea<'static>, | |
| @@ -82,27 +53,14 @@ pub struct App { | |||
| 82 | 53 | pub focus: Focus, | |
| 83 | 54 | pub mem_tab: MemTab, | |
| 84 | 55 | 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, | |
| 98 | 58 | /// Selected line in the debug source view. | |
| 99 | 59 | pub src_cursor: usize, | |
| 100 | 60 | pub src_scroll: usize, | |
| 101 | 61 | pub out_scroll: usize, | |
| 102 | 62 | pub port_sel: (usize, u8), | |
| 103 | 63 | pub serial_input: String, | |
| 104 | − | pub prev_iram: [u8; 256], | |
| 105 | − | pub prev_sfr: [u8; 128], | |
| 106 | 64 | pub show_help: bool, | |
| 107 | 65 | pub prompt: Option<Prompt>, | |
| 108 | 66 | pub quit: bool, | |
| @@ -123,28 +81,19 @@ impl App { | |||
| 123 | 81 | focus: Focus::Source, | |
| 124 | 82 | mem_tab: MemTab::Iram, | |
| 125 | 83 | 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), | |
| 135 | 85 | src_cursor: 0, | |
| 136 | 86 | src_scroll: 0, | |
| 137 | 87 | out_scroll: 0, | |
| 138 | 88 | port_sel: (1, 0), | |
| 139 | 89 | serial_input: String::new(), | |
| 140 | − | prev_iram: [0; 256], | |
| 141 | − | prev_sfr: [0; 128], | |
| 142 | 90 | show_help: false, | |
| 143 | 91 | prompt: None, | |
| 144 | 92 | quit: false, | |
| 145 | 93 | quit_armed: false, | |
| 146 | 94 | external_edit: false, | |
| 147 | 95 | }; | |
| 96 | + | app.session.status = "F1 help · F7 assemble · F5 run · F11 step".into(); | |
| 148 | 97 | match path { | |
| 149 | 98 | Some(p) => app.open(&p), | |
| 150 | 99 | None => app.set_text(TEMPLATE), | |
| @@ -161,9 +110,7 @@ impl App { | |||
| 161 | 110 | }); | |
| 162 | 111 | crate::ui::draw::style_editor(&mut self.editor); | |
| 163 | 112 | self.saved_text = self.text(); | |
| 164 | − | self.breakpoints.clear(); | |
| 165 | − | self.build = None; | |
| 166 | − | self.last_asm = None; | |
| 113 | + | self.session.clear(); | |
| 167 | 114 | self.focus = Focus::Source; | |
| 168 | 115 | self.serial_input.clear(); | |
| 169 | 116 | } | |
| @@ -178,9 +125,8 @@ impl App { | |||
| 178 | 125 | ||
| 179 | 126 | /// Source differs from what the simulator is running. | |
| 180 | 127 | 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)) | |
| 184 | 130 | } | |
| 185 | 131 | ||
| 186 | 132 | pub fn title(&self) -> String { | |
| @@ -194,37 +140,24 @@ impl App { | |||
| 194 | 140 | // ---- Files ---- | |
| 195 | 141 | ||
| 196 | 142 | 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 | + | ); | |
| 202 | 154 | } | |
| 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()); | |
| 222 | 160 | } | |
| 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()); | |
| 228 | 161 | } | |
| 229 | 162 | } | |
| 230 | 163 | ||
| @@ -241,10 +174,10 @@ impl App { | |||
| 241 | 174 | match std::fs::write(&path, text) { | |
| 242 | 175 | Ok(()) => { | |
| 243 | 176 | self.saved_text = self.text(); | |
| 244 | − | self.status = format!("saved {}", path.display()); | |
| 177 | + | self.session.status = format!("saved {}", path.display()); | |
| 245 | 178 | self.path = Some(path); | |
| 246 | 179 | } | |
| 247 | − | Err(e) => self.status = format!("cannot save {}: {e}", path.display()), | |
| 180 | + | Err(e) => self.session.status = format!("cannot save {}: {e}", path.display()), | |
| 248 | 181 | } | |
| 249 | 182 | } | |
| 250 | 183 | ||
| @@ -266,7 +199,7 @@ impl App { | |||
| 266 | 199 | None => { | |
| 267 | 200 | let p = std::env::temp_dir().join(format!("bench51-{}.asm", std::process::id())); | |
| 268 | 201 | 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()); | |
| 270 | 203 | return; | |
| 271 | 204 | } | |
| 272 | 205 | (p, true) | |
| @@ -280,11 +213,11 @@ impl App { | |||
| 280 | 213 | let _ = std::fs::remove_file(&path); | |
| 281 | 214 | } | |
| 282 | 215 | match (result, text) { | |
| 283 | − | (Err(e), _) => self.status = format!("cannot start editor: {e}"), | |
| 216 | + | (Err(e), _) => self.session.status = format!("cannot start editor: {e}"), | |
| 284 | 217 | (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") | |
| 286 | 219 | } | |
| 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()), | |
| 288 | 221 | (Ok(_), Ok(text)) => self.reload(&text, !temp), | |
| 289 | 222 | } | |
| 290 | 223 | } | |
| @@ -299,7 +232,7 @@ impl App { | |||
| 299 | 232 | lines | |
| 300 | 233 | }; | |
| 301 | 234 | if lines == self.editor.lines() { | |
| 302 | − | self.status = "no changes from external editor".into(); | |
| 235 | + | self.session.status = "no changes from external editor".into(); | |
| 303 | 236 | return; | |
| 304 | 237 | } | |
| 305 | 238 | let cursor = self.editor.cursor(); | |
| @@ -312,61 +245,33 @@ impl App { | |||
| 312 | 245 | self.saved_text = self.text(); | |
| 313 | 246 | } | |
| 314 | 247 | let n = self.editor.lines().len(); | |
| 315 | − | self.breakpoints.retain(|&l| l < n); | |
| 248 | + | self.session.breakpoints.retain(|&l| l < n); | |
| 316 | 249 | 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); | |
| 318 | 251 | } | |
| 319 | 252 | } | |
| 320 | 253 | ||
| 321 | 254 | // ---- Build & run ---- | |
| 322 | 255 | ||
| 323 | 256 | /// 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. | |
| 325 | 259 | pub fn assemble(&mut self) -> bool { | |
| 326 | 260 | 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()); | |
| 329 | 262 | 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(); | |
| 341 | 263 | self.follow_pc(); | |
| 342 | − | } else { | |
| 264 | + | } else if let Some((_, out)) = &self.session.last_asm { | |
| 343 | 265 | 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 | − | ); | |
| 350 | 266 | self.editor | |
| 351 | 267 | .move_cursor(CursorMove::Jump(first.line as u16, first.col as u16)); | |
| 352 | 268 | self.mode = Mode::Edit; | |
| 353 | 269 | self.out_tab = OutTab::Errors; | |
| 354 | 270 | } | |
| 355 | 271 | self.out_scroll = 0; | |
| 356 | − | self.last_asm = Some((source, out)); | |
| 357 | 272 | ok | |
| 358 | 273 | } | |
| 359 | 274 | ||
| 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 | − | ||
| 370 | 275 | /// Makes sure an up-to-date build is loaded; returns false on errors. | |
| 371 | 276 | fn ensure_built(&mut self) -> bool { | |
| 372 | 277 | if self.stale() && !self.assemble() { | |
| @@ -379,132 +284,65 @@ impl App { | |||
| 379 | 284 | true | |
| 380 | 285 | } | |
| 381 | 286 | ||
| 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. | |
| 402 | 288 | pub fn follow_pc(&mut self) { | |
| 403 | − | if let Some(l) = self.pc_line() { | |
| 289 | + | if let Some(l) = self.session.pc_line() { | |
| 404 | 290 | self.src_cursor = l; | |
| 405 | 291 | } | |
| 406 | 292 | } | |
| 407 | 293 | ||
| 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)) { | |
| 422 | 296 | if !self.ensure_built() { | |
| 423 | 297 | return; | |
| 424 | 298 | } | |
| 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(); | |
| 437 | 301 | } | |
| 438 | 302 | ||
| 439 | 303 | 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(); | |
| 443 | 306 | self.follow_pc(); | |
| 444 | 307 | } else if self.ensure_built() { | |
| 445 | − | self.running = true; | |
| 446 | − | self.resume = true; | |
| 447 | − | self.status = "running…".into(); | |
| 308 | + | self.session.start(); | |
| 448 | 309 | } | |
| 449 | 310 | } | |
| 450 | 311 | ||
| 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 { | |
| 456 | 315 | self.src_cursor | |
| 457 | 316 | } else { | |
| 458 | 317 | 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() { | |
| 462 | 323 | 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); | |
| 471 | 328 | }); | |
| 472 | 329 | } | |
| 473 | 330 | ||
| 474 | 331 | fn reset(&mut self) { | |
| 475 | − | self.sim.reset(); | |
| 476 | − | self.running = false; | |
| 477 | − | self.snapshot(); | |
| 332 | + | self.session.reset(); | |
| 478 | 333 | self.follow_pc(); | |
| 479 | − | self.status = "reset".into(); | |
| 480 | 334 | } | |
| 481 | 335 | ||
| 482 | 336 | 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); | |
| 491 | 339 | } | |
| 492 | 340 | ||
| 493 | 341 | /// Called once per frame. | |
| 494 | 342 | 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(); | |
| 508 | 346 | } | |
| 509 | 347 | } | |
| 510 | 348 | ||
| @@ -541,7 +379,7 @@ impl App { | |||
| 541 | 379 | (KeyCode::Char('q'), true) => { | |
| 542 | 380 | if self.dirty() && !self.quit_armed { | |
| 543 | 381 | 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(); | |
| 545 | 383 | } else { | |
| 546 | 384 | self.quit = true; | |
| 547 | 385 | } | |
| @@ -571,12 +409,9 @@ impl App { | |||
| 571 | 409 | } | |
| 572 | 410 | (KeyCode::F(8), _) => self.run_to_cursor(), | |
| 573 | 411 | (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), | |
| 580 | 415 | _ => match self.mode { | |
| 581 | 416 | Mode::Edit => self.on_edit_key(key), | |
| 582 | 417 | Mode::Debug => self.on_debug_key(key), | |
| @@ -595,7 +430,7 @@ impl App { | |||
| 595 | 430 | KeyCode::Down if alt => self.out_scroll += 1, | |
| 596 | 431 | KeyCode::PageUp if alt => self.out_scroll = self.out_scroll.saturating_sub(20), | |
| 597 | 432 | 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(), | |
| 599 | 434 | _ => { | |
| 600 | 435 | self.editor.input(key); | |
| 601 | 436 | } | |
| @@ -608,7 +443,7 @@ impl App { | |||
| 608 | 443 | KeyCode::Enter => { | |
| 609 | 444 | let mut bytes = std::mem::take(&mut self.serial_input).into_bytes(); | |
| 610 | 445 | bytes.push(b'\r'); | |
| 611 | − | self.sim.uart_send(&bytes); | |
| 446 | + | self.session.sim.uart_send(&bytes); | |
| 612 | 447 | return; | |
| 613 | 448 | } | |
| 614 | 449 | KeyCode::Backspace => { | |
| @@ -628,7 +463,7 @@ impl App { | |||
| 628 | 463 | KeyCode::Tab => self.cycle_focus(true), | |
| 629 | 464 | KeyCode::BackTab => self.cycle_focus(false), | |
| 630 | 465 | KeyCode::Esc => { | |
| 631 | − | if self.running { | |
| 466 | + | if self.session.running { | |
| 632 | 467 | self.toggle_run(); | |
| 633 | 468 | } else if self.focus == Focus::Serial { | |
| 634 | 469 | self.focus = Focus::Source; | |
| @@ -637,9 +472,9 @@ impl App { | |||
| 637 | 472 | } | |
| 638 | 473 | } | |
| 639 | 474 | 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) | |
| 641 | 476 | } | |
| 642 | − | KeyCode::Char('-') => self.speed = self.speed.saturating_sub(1), | |
| 477 | + | KeyCode::Char('-') => self.session.speed = self.session.speed.saturating_sub(1), | |
| 643 | 478 | KeyCode::Char('?') => self.show_help = true, | |
| 644 | 479 | // Letter aliases for terminals that swallow function keys. | |
| 645 | 480 | KeyCode::Char('r') => self.toggle_run(), | |
| @@ -684,6 +519,7 @@ impl App { | |||
| 684 | 519 | ||
| 685 | 520 | fn move_src(&mut self, code: KeyCode, page: usize) { | |
| 686 | 521 | let max = self | |
| 522 | + | .session | |
| 687 | 523 | .build | |
| 688 | 524 | .as_ref() | |
| 689 | 525 | .map_or(0, |b| b.source.len().saturating_sub(1)); | |
| @@ -694,7 +530,7 @@ impl App { | |||
| 694 | 530 | KeyCode::PageDown => self.src_cursor + page, | |
| 695 | 531 | KeyCode::Home => 0, | |
| 696 | 532 | 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), | |
| 698 | 534 | _ => self.src_cursor, | |
| 699 | 535 | } | |
| 700 | 536 | .min(max); | |
| @@ -745,7 +581,7 @@ impl App { | |||
| 745 | 581 | KeyCode::Left => *bit = (*bit + 1).min(7), | |
| 746 | 582 | KeyCode::Right => *bit = bit.saturating_sub(1), | |
| 747 | 583 | KeyCode::Char(' ') | KeyCode::Enter => { | |
| 748 | − | self.sim.pins[*port] ^= 1 << *bit; | |
| 584 | + | self.session.sim.pins[*port] ^= 1 << *bit; | |
| 749 | 585 | } | |
| 750 | 586 | _ => {} | |
| 751 | 587 | } | |
| @@ -786,38 +622,14 @@ impl App { | |||
| 786 | 622 | parts.first().and_then(|s| parse(s)), | |
| 787 | 623 | parts.get(1).and_then(|s| parse(s)), | |
| 788 | 624 | ) 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(); | |
| 790 | 626 | return; | |
| 791 | 627 | }; | |
| 792 | 628 | if val > 0xFF { | |
| 793 | − | self.status = "value must be a byte".into(); | |
| 629 | + | self.session.status = "value must be a byte".into(); | |
| 794 | 630 | return; | |
| 795 | 631 | } | |
| 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); | |
| 821 | 633 | } | |
| 822 | 634 | } | |
| 823 | 635 | ||
| @@ -848,46 +660,6 @@ fn editor_command(path: &Path, line: usize) -> Command { | |||
| 848 | 660 | cmd | |
| 849 | 661 | } | |
| 850 | 662 | ||
| 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 | − | ||
| 891 | 663 | const TEMPLATE: &str = "\ | |
| 892 | 664 | ; New 8051 program. F7 assembles, F5 runs, F11 steps, F1 shows all keys. | |
| 893 | 665 | ORG 0000h | |
| @@ -5,9 +5,10 @@ use ratatui::text::{Line, Span}; | |||
| 5 | 5 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Tabs, Wrap}; | |
| 6 | 6 | use ratatui_textarea::TextArea; | |
| 7 | 7 | ||
| 8 | − | use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind, SPEEDS}; | |
| 8 | + | use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind}; | |
| 9 | 9 | 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}; | |
| 11 | 12 | ||
| 12 | 13 | const ACCENT: Color = Color::Cyan; | |
| 13 | 14 | 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) { | |||
| 73 | 74 | Style::new().bg(Color::Magenta).fg(Color::White).bold(), | |
| 74 | 75 | ), | |
| 75 | 76 | }; | |
| 76 | − | let (state, state_style) = if app.running { | |
| 77 | + | let (state, state_style) = if app.session.running { | |
| 77 | 78 | ( | |
| 78 | 79 | " RUNNING ", | |
| 79 | 80 | Style::new().bg(Color::Green).fg(Color::Black).bold(), | |
| 80 | 81 | ) | |
| 81 | − | } else if app.build.is_none() { | |
| 82 | + | } else if app.session.build.is_none() { | |
| 82 | 83 | ( | |
| 83 | 84 | " NOT BUILT ", | |
| 84 | 85 | Style::new().bg(Color::DarkGray).fg(Color::White), | |
| @@ -96,14 +97,14 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { | |||
| 96 | 97 | Span::styled(state, state_style), | |
| 97 | 98 | Span::raw(format!(" {}", app.title())), | |
| 98 | 99 | ]; | |
| 99 | − | if app.build.is_some() && app.stale() { | |
| 100 | + | if app.session.build.is_some() && app.stale() { | |
| 100 | 101 | spans.push(Span::styled( | |
| 101 | 102 | " (source changed — F7 to rebuild)", | |
| 102 | 103 | Style::new().fg(Color::Yellow), | |
| 103 | 104 | )); | |
| 104 | 105 | } | |
| 105 | 106 | spans.push(Span::styled( | |
| 106 | − | format!(" speed {}/frame (+/-)", SPEEDS[app.speed]), | |
| 107 | + | format!(" speed {}/frame (+/-)", SPEEDS[app.session.speed]), | |
| 107 | 108 | DIM, | |
| 108 | 109 | )); | |
| 109 | 110 | f.render_widget(Line::from(spans), area); | |
| @@ -137,7 +138,7 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) { | |||
| 137 | 138 | } | |
| 138 | 139 | }; | |
| 139 | 140 | let width = area.width as usize; | |
| 140 | − | let status = format!(" {}", app.status); | |
| 141 | + | let status = format!(" {}", app.session.status); | |
| 141 | 142 | let pad = width.saturating_sub(status.chars().count() + keys.len() + 1); | |
| 142 | 143 | let line = Line::from(vec![ | |
| 143 | 144 | Span::styled(status, Style::new().fg(Color::White)), | |
| @@ -163,16 +164,16 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 163 | 164 | // Breakpoint / error markers aligned with the editor's scroll position. | |
| 164 | 165 | let top = app.editor.scroll_offset().0 as usize; | |
| 165 | 166 | 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(); | |
| 171 | 172 | let marks: Vec<Line> = (top..top + gutter.height as usize) | |
| 172 | 173 | .map(|l| { | |
| 173 | 174 | if errors.contains(&l) { | |
| 174 | 175 | Line::styled("✗", Style::new().fg(Color::Red).bold()) | |
| 175 | − | } else if app.breakpoints.contains(&l) { | |
| 176 | + | } else if app.session.breakpoints.contains(&l) { | |
| 176 | 177 | Line::styled("●", Style::new().fg(Color::Red)) | |
| 177 | 178 | } else { | |
| 178 | 179 | Line::raw("") | |
| @@ -186,6 +187,7 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 186 | 187 | ||
| 187 | 188 | fn draw_output(f: &mut Frame, app: &mut App, area: Rect) { | |
| 188 | 189 | let n_err = app | |
| 190 | + | .session | |
| 189 | 191 | .last_asm | |
| 190 | 192 | .as_ref() | |
| 191 | 193 | .map_or(0, |(_, o)| o.diagnostics.len()); | |
| @@ -214,7 +216,7 @@ fn draw_output(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 214 | 216 | .highlight_style(Style::new().fg(ACCENT).bold().underlined()); | |
| 215 | 217 | f.render_widget(tabs, tabs_area); | |
| 216 | 218 | ||
| 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) { | |
| 218 | 220 | (_, None) => vec![Line::styled("Press F7 to assemble.", DIM)], | |
| 219 | 221 | (OutTab::Listing, Some((src, out))) => out | |
| 220 | 222 | .listing(src) | |
| @@ -312,7 +314,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 312 | 314 | let block = panel("Source", focused); | |
| 313 | 315 | let inner = block.inner(area); | |
| 314 | 316 | f.render_widget(block, area); | |
| 315 | − | let Some(build) = &app.build else { | |
| 317 | + | let Some(build) = &app.session.build else { | |
| 316 | 318 | f.render_widget( | |
| 317 | 319 | Paragraph::new("Nothing assembled yet — press F7.").style(DIM), | |
| 318 | 320 | inner, | |
| @@ -326,7 +328,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 326 | 328 | } else if app.src_cursor >= app.src_scroll + h { | |
| 327 | 329 | app.src_scroll = app.src_cursor + 1 - h; | |
| 328 | 330 | } | |
| 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(); | |
| 330 | 332 | let lines: Vec<Line> = build | |
| 331 | 333 | .source | |
| 332 | 334 | .iter() | |
| @@ -343,7 +345,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 343 | 345 | } else { | |
| 344 | 346 | Style::new() | |
| 345 | 347 | }; | |
| 346 | − | let bp = if app.breakpoints.contains(&i) { | |
| 348 | + | let bp = if app.session.breakpoints.contains(&i) { | |
| 347 | 349 | Span::styled("●", bg.fg(Color::Red)) | |
| 348 | 350 | } else { | |
| 349 | 351 | Span::styled(" ", bg) | |
| @@ -394,27 +396,20 @@ fn flags(byte: u8, names: [&'static str; 8]) -> Vec<Span<'static>> { | |||
| 394 | 396 | } | |
| 395 | 397 | ||
| 396 | 398 | fn draw_registers(f: &mut Frame, app: &App, area: Rect) { | |
| 397 | − | let s = &app.sim; | |
| 399 | + | let s = &app.session.sim; | |
| 398 | 400 | let block = panel("Registers", false); | |
| 399 | 401 | let inner = block.inner(area); | |
| 400 | 402 | 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]; | |
| 402 | 404 | let reg = |n: u8| { | |
| 403 | 405 | let a = s.reg_addr(n) as usize; | |
| 404 | 406 | vec![ | |
| 405 | 407 | 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]), | |
| 407 | 409 | Span::raw(" "), | |
| 408 | 410 | ] | |
| 409 | 411 | }; | |
| 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(); | |
| 418 | 413 | let lines = vec![ | |
| 419 | 414 | Line::from(vec![ | |
| 420 | 415 | Span::styled("PC ", LABEL), | |
| @@ -461,18 +456,18 @@ fn draw_registers(f: &mut Frame, app: &App, area: Rect) { | |||
| 461 | 456 | Line::from(vec![ | |
| 462 | 457 | Span::styled("Time ", LABEL), | |
| 463 | 458 | Span::raw(time), | |
| 464 | − | Span::styled(format!(" @{}MHz", app.clock_mhz), DIM), | |
| 459 | + | Span::styled(format!(" @{}MHz", app.session.clock_mhz), DIM), | |
| 465 | 460 | ]), | |
| 466 | 461 | ]; | |
| 467 | 462 | f.render_widget(Paragraph::new(lines), inner); | |
| 468 | 463 | } | |
| 469 | 464 | ||
| 470 | 465 | fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) { | |
| 471 | − | let s = &app.sim; | |
| 466 | + | let s = &app.session.sim; | |
| 472 | 467 | let block = panel("Peripherals", false); | |
| 473 | 468 | let inner = block.inner(area); | |
| 474 | 469 | 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]; | |
| 476 | 471 | let sfr_val = |name: &'static str, addr: u8| { | |
| 477 | 472 | vec![ | |
| 478 | 473 | Span::styled(name, LABEL), | |
| @@ -609,17 +604,6 @@ fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) { | |||
| 609 | 604 | f.render_widget(Paragraph::new(lines), inner); | |
| 610 | 605 | } | |
| 611 | 606 | ||
| 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 | − | ||
| 623 | 607 | fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) { | |
| 624 | 608 | let focused = app.focus == Focus::Memory; | |
| 625 | 609 | let block = panel("Memory", focused); | |
| @@ -646,7 +630,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 646 | 630 | return; | |
| 647 | 631 | } | |
| 648 | 632 | ||
| 649 | − | let s = &app.sim; | |
| 633 | + | let s = &app.session.sim; | |
| 650 | 634 | let per_row: usize = if content.width >= 74 { 16 } else { 8 }; | |
| 651 | 635 | let total = match app.mem_tab { | |
| 652 | 636 | MemTab::Iram => 256, | |
| @@ -655,9 +639,9 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 655 | 639 | let rows = total / per_row; | |
| 656 | 640 | let h = content.height as usize; | |
| 657 | 641 | app.mem_scroll = app.mem_scroll.min(rows.saturating_sub(h)); | |
| 658 | − | let bps = app.bp_addrs(); | |
| 642 | + | let bps = app.session.bp_addrs(); | |
| 659 | 643 | 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); | |
| 661 | 645 | let bank = s.reg_addr(0) as usize; | |
| 662 | 646 | ||
| 663 | 647 | 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) { | |||
| 679 | 663 | let v = s.iram[a]; | |
| 680 | 664 | let st = if a == s.sp() as usize { | |
| 681 | 665 | 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] { | |
| 683 | 667 | CHANGED | |
| 684 | 668 | } else if (bank..bank + 8).contains(&a) { | |
| 685 | 669 | Style::new().fg(Color::LightBlue) | |
| @@ -724,7 +708,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) { | |||
| 724 | 708 | } | |
| 725 | 709 | ||
| 726 | 710 | fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) { | |
| 727 | − | let s = &app.sim; | |
| 711 | + | let s = &app.session.sim; | |
| 728 | 712 | let cols = if area.width >= 64 { 3 } else { 2 }; | |
| 729 | 713 | let per_col = sfr::SFRS.len().div_ceil(cols); | |
| 730 | 714 | let lines: Vec<Line> = (0..per_col) | |
| @@ -733,7 +717,7 @@ fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) { | |||
| 733 | 717 | for c in 0..cols { | |
| 734 | 718 | if let Some(&(name, addr)) = sfr::SFRS.get(c * per_col + r) { | |
| 735 | 719 | 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]; | |
| 737 | 721 | spans.push(Span::styled(format!("{name:<5}"), LABEL)); | |
| 738 | 722 | spans.push(Span::styled(format!("{addr:02X} "), DIM)); | |
| 739 | 723 | spans.push(hex2(v, changed)); | |
| @@ -754,7 +738,7 @@ fn draw_ports(f: &mut Frame, app: &App, area: Rect) { | |||
| 754 | 738 | let block = panel("Ports", focused); | |
| 755 | 739 | let inner = block.inner(area); | |
| 756 | 740 | f.render_widget(block, area); | |
| 757 | − | let s = &app.sim; | |
| 741 | + | let s = &app.session.sim; | |
| 758 | 742 | let mut lines: Vec<Line> = (0..4) | |
| 759 | 743 | .map(|p| { | |
| 760 | 744 | let latch = s.sfr(sfr::P0 + 0x10 * p as u8); | |
| @@ -797,7 +781,7 @@ fn draw_serial(f: &mut Frame, app: &App, area: Rect) { | |||
| 797 | 781 | Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); | |
| 798 | 782 | ||
| 799 | 783 | let mut text = String::new(); | |
| 800 | − | for &b in &app.sim.uart.tx_log { | |
| 784 | + | for &b in &app.session.sim.uart.tx_log { | |
| 801 | 785 | match b { | |
| 802 | 786 | b'\n' => text.push('\n'), | |
| 803 | 787 | b'\r' => {} | |
| @@ -1,122 +1,27 @@ | |||
| 1 | − | //! Minimal 8051 assembly syntax highlighting. | |
| 1 | + | //! 8051 assembly syntax highlighting for the TUI. | |
| 2 | 2 | ||
| 3 | + | use bench51_core::syntax::{Tok, tokens}; | |
| 3 | 4 | use ratatui::style::{Color, Modifier, Style}; | |
| 4 | 5 | use ratatui::text::Span; | |
| 5 | 6 | ||
| 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), | |
| 65 | 18 | } | |
| 66 | 19 | } | |
| 67 | 20 | ||
| 68 | 21 | /// Splits a source line into styled spans, applying `base` underneath. | |
| 69 | 22 | 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() | |
| 122 | 27 | } | |