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