Krolyxon
Commit 42b14d3

Branches

Tags

Refs →

bench5142b14d3

add bench51-gui, a µVision-style desktop front end

An egui app laid out like Keil µVision: menus, toolbar, project window,
editor with breakpoint gutter and build output, plus registers,
disassembly, memory, symbols, UART, ports, timers and interrupts windows
in a debug session. Panes dock with egui_tiles; edit and debug layouts
are saved separately. Styled after the Nothing design language in dark
and light themes, with Space Grotesk, Space Mono and Doto bundled (OFL).
Author
krolyxon
Date
2026-10-31 23:33 +0530
Parent
86cbab6
Commit
42b14d3f5a400605dd3018f009900577f5cd1e97

+5307 −0

32 files changed

  1. A crates/bench51-gui/Cargo.toml +21 −0
  2. A crates/bench51-gui/assets/bench51-gui.desktop +12 −0
  3. A crates/bench51-gui/assets/bench51-gui.svg +31 −0
  4. A crates/bench51-gui/assets/fonts/Doto[ROND,wght].ttf
  5. A crates/bench51-gui/assets/fonts/OFL-Doto.txt +93 −0
  6. A crates/bench51-gui/assets/fonts/OFL-SpaceGrotesk.txt +93 −0
  7. A crates/bench51-gui/assets/fonts/OFL-SpaceMono.txt +93 −0
  8. A crates/bench51-gui/assets/fonts/SpaceGrotesk-Medium.ttf
  9. A crates/bench51-gui/assets/fonts/SpaceGrotesk-Regular.ttf
  10. A crates/bench51-gui/assets/fonts/SpaceMono-Bold.ttf
  11. A crates/bench51-gui/assets/fonts/SpaceMono-Regular.ttf
  12. A crates/bench51-gui/src/app.rs +661 −0
  13. A crates/bench51-gui/src/chrome.rs +421 −0
  14. A crates/bench51-gui/src/editor.rs +657 −0
  15. A crates/bench51-gui/src/icons.rs +200 −0
  16. A crates/bench51-gui/src/layout.rs +362 −0
  17. A crates/bench51-gui/src/main.rs +57 −0
  18. A crates/bench51-gui/src/modals.rs +208 −0
  19. A crates/bench51-gui/src/panes/build.rs +188 −0
  20. A crates/bench51-gui/src/panes/disasm.rs +193 −0
  21. A crates/bench51-gui/src/panes/interrupts.rs +83 −0
  22. A crates/bench51-gui/src/panes/memory.rs +305 −0
  23. A crates/bench51-gui/src/panes/mod.rs +68 −0
  24. A crates/bench51-gui/src/panes/ports.rs +77 −0
  25. A crates/bench51-gui/src/panes/project.rs +137 −0
  26. A crates/bench51-gui/src/panes/registers.rs +105 −0
  27. A crates/bench51-gui/src/panes/serial.rs +86 −0
  28. A crates/bench51-gui/src/panes/symbols.rs +90 −0
  29. A crates/bench51-gui/src/panes/timers.rs +102 −0
  30. A crates/bench51-gui/src/state.rs +293 −0
  31. A crates/bench51-gui/src/theme.rs +293 −0
  32. A crates/bench51-gui/src/widgets.rs +378 −0
A crates/bench51-gui/Cargo.toml View
@@ -0,0 +1,21 @@
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"] }
A crates/bench51-gui/assets/bench51-gui.desktop View
@@ -0,0 +1,12 @@
1+[Desktop Entry]
2+Type=Application
3+Name=bench51
4+GenericName=8051 IDE
5+Comment=Intel 8051 assembler, simulator and debugger
6+Exec=bench51-gui %F
7+Icon=bench51-gui
8+Terminal=false
9+Categories=Development;IDE;Electronics;
10+Keywords=8051;8052;MCS-51;assembler;simulator;debugger;keil;microcontroller;
11+MimeType=text/x-asm;
12+StartupWMClass=bench51-gui
A crates/bench51-gui/assets/bench51-gui.svg View
@@ -0,0 +1,31 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2+ <rect width="64" height="64" rx="12" fill="#000"/>
3+ <circle cx="13" cy="21" r="1.5" fill="#fff"/>
4+ <circle cx="17" cy="21" r="1.5" fill="#fff"/>
5+ <circle cx="21" cy="21" r="1.5" fill="#fff"/>
6+ <circle cx="25" cy="21" r="1.5" fill="#fff"/>
7+ <circle cx="29" cy="21" r="1.5" fill="#fff"/>
8+ <circle cx="13" cy="25" r="1.5" fill="#fff"/>
9+ <circle cx="13" cy="29" r="1.5" fill="#fff"/>
10+ <circle cx="17" cy="29" r="1.5" fill="#fff"/>
11+ <circle cx="21" cy="29" r="1.5" fill="#fff"/>
12+ <circle cx="25" cy="29" r="1.5" fill="#fff"/>
13+ <circle cx="29" cy="33" r="1.5" fill="#fff"/>
14+ <circle cx="29" cy="37" r="1.5" fill="#fff"/>
15+ <circle cx="13" cy="41" r="1.5" fill="#fff"/>
16+ <circle cx="29" cy="41" r="1.5" fill="#fff"/>
17+ <circle cx="17" cy="45" r="1.5" fill="#fff"/>
18+ <circle cx="21" cy="45" r="1.5" fill="#fff"/>
19+ <circle cx="25" cy="45" r="1.5" fill="#fff"/>
20+ <circle cx="45" cy="21" r="1.5" fill="#fff"/>
21+ <circle cx="41" cy="25" r="1.5" fill="#fff"/>
22+ <circle cx="45" cy="25" r="1.5" fill="#fff"/>
23+ <circle cx="45" cy="29" r="1.5" fill="#fff"/>
24+ <circle cx="45" cy="33" r="1.5" fill="#fff"/>
25+ <circle cx="45" cy="37" r="1.5" fill="#fff"/>
26+ <circle cx="45" cy="41" r="1.5" fill="#fff"/>
27+ <circle cx="41" cy="45" r="1.5" fill="#fff"/>
28+ <circle cx="45" cy="45" r="1.5" fill="#fff"/>
29+ <circle cx="49" cy="45" r="1.5" fill="#fff"/>
30+ <circle cx="52" cy="12" r="3" fill="#D71921"/>
31+</svg>
A crates/bench51-gui/assets/fonts/Doto[ROND,wght].ttf View

[Binary file]

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

[Binary file]

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

[Binary file]

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

[Binary file]

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

[Binary file]

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