Repository: crumblingstatue/hexerator Branch: main Commit: dce0723ead20 Files: 117 Total size: 635.2 KB Directory structure: gitextract_lra3dm9g/ ├── .github/ │ └── workflows/ │ ├── linux.yml │ └── windows.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── build.rs ├── hexerator-plugin-api/ │ ├── Cargo.toml │ └── src/ │ └── lib.rs ├── lua/ │ ├── color.lua │ └── fill.lua ├── plugins/ │ └── hello-world/ │ ├── Cargo.toml │ └── src/ │ └── lib.rs ├── rust-toolchain.toml ├── rustfmt.toml ├── scripts/ │ └── gen-prim-test-file.rs ├── src/ │ ├── app/ │ │ ├── backend_command.rs │ │ ├── command.rs │ │ ├── debug.rs │ │ ├── edit_state.rs │ │ ├── interact_mode.rs │ │ └── presentation.rs │ ├── app.rs │ ├── args.rs │ ├── backend/ │ │ └── sfml.rs │ ├── backend.rs │ ├── color.rs │ ├── config.rs │ ├── damage_region.rs │ ├── data.rs │ ├── dec_conv.rs │ ├── edit_buffer.rs │ ├── find_util.rs │ ├── gui/ │ │ ├── bottom_panel.rs │ │ ├── command.rs │ │ ├── dialogs/ │ │ │ ├── auto_save_reload.rs │ │ │ ├── jump.rs │ │ │ ├── lua_color.rs │ │ │ ├── lua_fill.rs │ │ │ ├── pattern_fill.rs │ │ │ ├── truncate.rs │ │ │ └── x86_asm.rs │ │ ├── dialogs.rs │ │ ├── egui_ui_ext.rs │ │ ├── file_ops.rs │ │ ├── inspect_panel.rs │ │ ├── message_dialog.rs │ │ ├── ops.rs │ │ ├── root_ctx_menu.rs │ │ ├── selection_menu.rs │ │ ├── top_menu/ │ │ │ ├── analysis.rs │ │ │ ├── cursor.rs │ │ │ ├── edit.rs │ │ │ ├── file.rs │ │ │ ├── help.rs │ │ │ ├── meta.rs │ │ │ ├── perspective.rs │ │ │ ├── plugins.rs │ │ │ ├── scripting.rs │ │ │ └── view.rs │ │ ├── top_menu.rs │ │ ├── top_panel.rs │ │ ├── windows/ │ │ │ ├── about.rs │ │ │ ├── bookmarks.rs │ │ │ ├── debug.rs │ │ │ ├── external_command.rs │ │ │ ├── file_diff_result.rs │ │ │ ├── find_dialog.rs │ │ │ ├── find_memory_pointers.rs │ │ │ ├── layouts.rs │ │ │ ├── lua_console.rs │ │ │ ├── lua_editor.rs │ │ │ ├── lua_help.rs │ │ │ ├── lua_watch.rs │ │ │ ├── meta_diff.rs │ │ │ ├── open_process.rs │ │ │ ├── perspectives.rs │ │ │ ├── preferences.rs │ │ │ ├── regions.rs │ │ │ ├── script_manager.rs │ │ │ ├── structs.rs │ │ │ ├── vars.rs │ │ │ ├── views.rs │ │ │ └── zero_partition.rs │ │ └── windows.rs │ ├── gui.rs │ ├── hex_conv.rs │ ├── hex_ui.rs │ ├── input.rs │ ├── layout.rs │ ├── main.rs │ ├── meta/ │ │ ├── perspective.rs │ │ ├── region.rs │ │ └── value_type.rs │ ├── meta.rs │ ├── meta_state.rs │ ├── parse_radix.rs │ ├── plugin.rs │ ├── result_ext.rs │ ├── scripting.rs │ ├── session_prefs.rs │ ├── shell.rs │ ├── slice_ext.rs │ ├── source.rs │ ├── str_ext.rs │ ├── struct_meta_item.rs │ ├── timer.rs │ ├── update.rs │ ├── util.rs │ ├── value_color.rs │ ├── view/ │ │ └── draw.rs │ ├── view.rs │ └── windows.rs └── test_files/ ├── empty-file └── plaintext.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/linux.yml ================================================ name: Linux on: push: branches: [ "main" ] pull_request: branches: [ "main" ] env: CARGO_TERM_COLOR: always jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install deps run: | sudo apt-get update sudo apt-get install libpthread-stubs0-dev libgl1-mesa-dev libx11-dev libx11-xcb-dev libxcb-image0-dev libxrandr-dev libxcb-randr0-dev libudev-dev libfreetype6-dev libglew-dev libjpeg8-dev libgpgme11-dev libjpeg62 libxcursor-dev cmake libclang-dev clang - name: Build run: cargo build --verbose - name: Run tests run: cargo test --verbose ================================================ FILE: .github/workflows/windows.yml ================================================ name: Windows on: push: branches: [ "main" ] tags: - v* pull_request: branches: [ "main" ] env: CARGO_TERM_COLOR: always jobs: build: runs-on: windows-latest steps: - uses: actions/checkout@v4 - name: Run tests run: cargo test --verbose - name: Do a release build run: cargo build --release --verbose - uses: actions/upload-artifact@v4 with: name: hexerator-win64-build path: target/release/hexerator.exe ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [0.4.0] - 2025-03-29 ### Tutorial There is now a basic tutorial you can find here: ### New features - [Memory mapped file support][mmap] - [Allow defining data layouts with Rust struct syntax][struct] - `View->Ruler` can use struct definitions - [Mouse drag selection][mdrag] - You can finally select regions by dragging the mouse, rather than having to use shift+1/shift+2 - [Block selection with alt+drag][mblock] - You can select non-contiguous sections by holding alt and drawing a rectangle with the mouse [mmap]: [struct]: [mdrag]: [mblock]: ### UI changes - Add custom right panel to file open dialog - Shows information about the highlighted file - Allows selecting advanced options - Backtrace support for error popups - External command window now provides more options for working directory - Show information about rows/column positions in more places - `Home`/`End` now jumps to row begin/end. - `ctrl+Home`/`ctrl+End` are now used for view begin/end. - The selection can now be quickly cleared with a `Clear` button in the top panel - Add a "quick scroll" slider popup to the bottom panel, to quickly navigate huge files. - Add Find&Replace for `HexString` find type - Add a bunch of icons to buttons - Remove superfluous "Perspectives" menu ### Other Improvements - Make stream buffer size configurable, use a larger default size - Hexerator now retries opening a file as read-only if there was a permission error - Hex strings now accept parsing comma separated, or "packed" (unseparated) hex values - The command line help on Windows is now functional - Increase/decrease byte (`ctrl+=`/`ctrl+-`) now works on selections - Add Windows CI - Bunch of bug fixes and minor UX improvements, as usual ### CLI Add `--view` flag to select view to focus on startup ## [0.3.0] - 2024-10-16 ### UI changes **Hex Editor:** - `Del` key zeroes out the byte at cursor **Bookmarks window:** - Jump-to button in detail view - Value edit input in detail view - Context menu option to copy a bookmark's offset - Add right click menu option to reoffset all bookmarks based on a known offset (read help label) **File diff window:** - Now takes the value types of bookmarks into account, showing the whole values of bookmarks instead of just raw bytes. - Add "Highlight all" button to highlight all differences - Add "Open this" and "Diff with..." buttons to speed up diffing subsequent versions of a file **Find dialog:** - Add help hover popups for the find type dropdown - Add "string diff" and "pattern equivalence" find types. See the help popups ;) - Add basic replace functionality to Ascii find **X86 assembly dialog:** - Add ability to jump to offset of decoded instructions **Root context menu:** - Add "copy selection as utf-8 text" - Add "zero fill" (Shortcut: `Del`) **External command window:** - Now openable with `Ctrl+E` - Allow closing with `Esc` key - Add "selection only" toggle to only pass selection to external command **Open process window:** - Add UI to launch a child process in order to view its memory (hexerator doesn't have to be root) - The virtual memory map window now makes it more clear that you're no longer looking at the list of processes, but the maps for a process. **Jump dialog:** - Replace (broken) "relative" option with "absolute" **Preferences window:** - Make the ui tabbed - Small ui improvements ### Lua scripting - Replaced LuaJIT with Lua 5.4, because LuaJIT is incompatible with `panic=abort`. - Add Lua syntax highlighting in most places - Add Lua API help window (`Scripting - Lua help`) - Add a bunch more API items (see `Scripting -> Lua help`) - Allow saving named scripts, and add script manager window to overview them - Add Lua console window for quick evaluation and "watching" expressions - Scripts can now take arguments (`args` table, e.g. `args.foo`) ### Plugins New feature. Allow loading dylib plugins. Documentation to be added. For now, see the `hexerator_plugin_api` crate inside the repo. ### Command line - Add `--version` flag - Add `--debug` flag to start with debug logging enabled and debug window open - Add `--spawn-command ...` flag to spawn a child process and open it in process list (hexerator doesn't have to be root) - Add `--autosave` and `--autoreload []` to enable autosave/autoreaload through CLI - Add `--layout ` to switch to a layout at startup - Add `--new ` option to create a new (zero-filled) buffer ### Fixes - Loading process memory on windows now correctly sets relative offset - When failing to load a file via command line arg, error reason is now properly displayed ### Other - `Analysis -> Zero partition` for "zero-partitioning" files that contain large zeroed out sections (like process memory). - Add feature to autoreload only visible part (as opposed to whole file) - Replace blocking file dialog with nonblocking egui file dialog - Update egui to 0.29 - Experimental support for custom color themes (See `Preferences` -> `Style`) - Make monochrome and "grayscale" hex text colors customizable - No more dynamic dependency on SFML. It's statically linked now. - Various bug fixes and minor improvements, too many to list individually ## [0.2.0] - 2023-01-27 ### Added - Support for common value types in find dialog, in addition to u8 - About dialog with version info + links - Clickable file size label in bottom right corner - Functionality to change the length of the data (truncate/extend) - Context menus in process open menu to copy addresses/sizes/etc. to clipboard - Right click context menu option on a view to remove it from the current layout - Layout properties is accessible from right click context menu on the layout - Error reporting message dialog if the program panics - Each file can set a metafile association to always load that meta when loaded - Vsync and fps limit settings in preferences window - Bookmark names are displayed when mouse hovers over a bookmarked offset - "Open bookmark" context menu option in hex view for existing bookmarks - "Save as" action - Hex string search in find dialog (de ad be ef) - Window title now includes filename of opened file - Ability to save/load scripts in lua execute dialog - `app:bookmark_set_int(name, value)` lua method to set integer value of a bookmark - `app:region_pattern_fill(name, pattern)` lua method to fill a region - Context menu to copy bookmark names in bookmarks window - Make the offsets in the find dialog copiable/pasteable - Add x86 disassembly ### Changed - Update to egui 0.20 - Open file dialog opens same directory as current file, if available - Replace most native message boxes with egui ones - Inspect panel shows value at edit cursor if mouse pointer is over a window that covers the hex view. - Make path label in top right corner click-to-copy - Process name filter in process open dialog is now case-insensitive - "Diff with file" file prompt will now open in same directory as current file - Don't insert a tab character for text views in edit mode when tab is pressed to switch focus - Active selection actions in edit menu are now in a submenu named "Selection" - "Copy as hex" is now known as "Copy as hex text" - Bookmarks table is now resizable horizontally - Bookmarks table is now scrollable vertically - Native dialog boxes now have a title, and their text is selectable and copyable! - Bookmarks window name filter is now case insensitive - Bookmarks window description editor is now monospace - Bookmark description is now in a scroll area - Bookmarks window "add new at cursor" button selects newly added bookmark automatically - Create default metadata for empty documents, allowing creation of binary files from scratch with Hexerator - File path label has context menu for various options, left clicking opens the file in default application ### Fixed - Show error message box instead of panic when failing to allocate textures - Prevent fill dialog and Jump dialog from constantly stealing focus when they are open - Certain dialog types no longer erroneusly stack on top of themselves if opened multiple times. - Lua fill dialog with empty selection now has a close button. - Make regions window scroll properly - Pattern fill dialog is now closeable - "Select all" action now doesn't select more data than is available, even if region is bigger than data. ## [0.1.0] - 2022-09-16 Initial release. [0.1.0]: https://github.com/crumblingstatue/hexerator/releases/tag/v0.1.0 [0.2.0]: https://github.com/crumblingstatue/hexerator/releases/tag/v0.2.0 [0.3.0]: https://github.com/crumblingstatue/hexerator/releases/tag/v0.3.0 ================================================ FILE: Cargo.toml ================================================ [package] name = "hexerator" version = "0.5.0-dev" edition = "2024" license = "MIT OR Apache-2.0" [features] backend-sfml = ["dep:egui-sf2g", "dep:sf2g"] default = ["backend-sfml"] [dependencies] gamedebug_core = { git = "https://github.com/crumblingstatue/gamedebug_core.git" } clap = { version = "4.5.4", features = ["derive"] } anyhow = "1.0.81" rand = "0.10.0" rmp-serde = "1.1.2" serde = { version = "1.0.197", features = ["derive"] } directories = "6.0.0" recently_used_list = { git = "https://github.com/crumblingstatue/recently_used_list.git" } memchr = "2.7.2" glu-sys = "0.1.4" thiserror = "2" either = "1.10.0" tree_magic_mini = "3.1.6" slotmap = { version = "1.0.7", features = ["serde"] } egui-sf2g = { version = "0.7", optional = true } sf2g = { version = "0.4", optional = true, features = ["text"] } num-traits = "0.2.18" serde-big-array = "0.5.1" egui = { version = "0.34", features = ["serde"] } egui_extras = { version = "0.34", default-features = false } itertools = "0.14" sysinfo = { version = "0.38", default-features = false, features = ["system"] } proc-maps = "0.4.0" open = "5.1.2" arboard = { version = "3.6.0", default-features = false } paste = "1.0.14" iced-x86 = "1.21.0" strum = { version = "0.27", features = ["derive"] } egui_code_editor = "0.2.14" # luajit breaks with panic=abort, because it relies on unwinding for exception handling mlua = { version = "0.11", features = ["luau", "vendored"] } egui-file-dialog.git = "https://github.com/jannistpl/egui-file-dialog.git" human_bytes = "0.4.3" shlex = "1.3.0" egui-fontcfg = { git = "https://github.com/crumblingstatue/egui-fontcfg.git" } egui_colors = "0.11.0" libloading = "0.9" hexerator-plugin-api = { path = "hexerator-plugin-api" } image.version = "0.25" image.default-features = false image.features = ["png", "bmp"] structparse = { git = "https://github.com/crumblingstatue/structparse.git" } memmap2 = "0.9.5" egui-phosphor.git = "https://github.com/crumblingstatue/egui-phosphor.git" egui-phosphor.branch = "egui-034" constcat = "0.6.0" [target."cfg(windows)".dependencies.windows-sys] version = "0.59.0" features = [ "Win32_System_Diagnostics_Debug", "Win32_Foundation", "Win32_System_Threading", ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # Uncomment in case incremental compilation breaks things #[profile.dev] #incremental = false # Buggy rustc is breaking code with incremental compilation # Compile deps with optimizations in dev mode [profile.dev.package."*"] opt-level = 2 [profile.dev] panic = "abort" [profile.release] panic = "abort" lto = "thin" codegen-units = 1 [build-dependencies] vergen-gitcl = { version = "9.1.0", default-features = false, features = [ "build", "cargo", "rustc", ] } [workspace] members = ["hexerator-plugin-api", "plugins/hello-world"] exclude = ["scripts"] ================================================ FILE: LICENSE-APACHE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022 crumblingstatue and Hexerator contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: LICENSE-MIT ================================================ MIT License Copyright (c) 2022 crumblingstatue and Hexerator contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Hexerator Versatile GUI hex editor focused on binary file exploration and aiding pattern recognition. Written in Rust. Check out [the Hexerator book](https://crumblingstatue.github.io/hexerator-book/0.4.0) for a detailed list of features, and more! ## Note for contributors: Hexerator only supports latest nightly Rust. You need an up-to-date nightly to build Hexerator. Hexerator doesn't shy away from experimenting with unstable Rust features. Contributors can use any nightly feature they wish. Contributors however are free to rewrite code to use stable features if it doesn't result in: - A loss of features - Reduced performance - Significantly worse maintainability ================================================ FILE: build.rs ================================================ use { std::error::Error, vergen_gitcl::{BuildBuilder, CargoBuilder, Emitter, GitclBuilder, RustcBuilder}, }; fn main() -> Result<(), Box> { let gitcl = GitclBuilder::default().sha(false).commit_timestamp(true).build()?; let build = BuildBuilder::default().build_timestamp(true).build()?; let cargo = CargoBuilder::default() .target_triple(true) .debug(true) .opt_level(true) .build()?; let rustc = RustcBuilder::default().semver(true).build()?; Emitter::default() .add_instructions(&gitcl)? .add_instructions(&build)? .add_instructions(&cargo)? .add_instructions(&rustc)? .emit()?; Ok(()) } ================================================ FILE: hexerator-plugin-api/Cargo.toml ================================================ [package] name = "hexerator-plugin-api" version = "0.1.0" edition = "2024" [dependencies] ================================================ FILE: hexerator-plugin-api/src/lib.rs ================================================ pub trait Plugin { fn name(&self) -> &str; fn desc(&self) -> &str; fn methods(&self) -> Vec; fn on_method_called( &mut self, name: &str, params: &[Option], hx: &mut dyn HexeratorHandle, ) -> MethodResult; } pub type MethodResult = Result, String>; pub struct PluginMethod { pub method_name: &'static str, pub human_name: Option<&'static str>, pub desc: &'static str, pub params: &'static [MethodParam], } pub struct MethodParam { pub name: &'static str, pub ty: ValueTy, } pub enum ValueTy { U64, String, } pub enum Value { U64(u64), F64(f64), String(String), } impl ValueTy { pub fn label(&self) -> &'static str { match self { ValueTy::U64 => "u64", ValueTy::String => "string", } } } pub trait HexeratorHandle { fn selection_range(&self) -> Option<[usize; 2]>; fn get_data(&self, start: usize, end: usize) -> Option<&[u8]>; fn get_data_mut(&mut self, start: usize, end: usize) -> Option<&mut [u8]>; fn debug_log(&self, msg: &str); fn perspective(&self, name: &str) -> Option; fn perspective_rows(&self, ph: &PerspectiveHandle) -> Vec<&[u8]>; } pub struct PerspectiveHandle { pub key_data: u64, } impl PerspectiveHandle { pub fn rows<'hx>(&self, hx: &'hx dyn HexeratorHandle) -> Vec<&'hx [u8]> { hx.perspective_rows(self) } } ================================================ FILE: lua/color.lua ================================================ return function(b) local r = b local g = b local b = b return {r % 256, g % 256, b % 256} end ================================================ FILE: lua/fill.lua ================================================ -- Return a byte based on offset `off` and the current byte value `b` function(off, b) return off % 256 end ================================================ FILE: plugins/hello-world/Cargo.toml ================================================ [package] name = "hello-world" version = "0.1.0" edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] hexerator-plugin-api = { path = "../../hexerator-plugin-api" } ================================================ FILE: plugins/hello-world/src/lib.rs ================================================ //! Hexerator hello world example plugin use hexerator_plugin_api::{ HexeratorHandle, MethodParam, MethodResult, Plugin, PluginMethod, Value, ValueTy, }; struct HelloPlugin; impl Plugin for HelloPlugin { fn name(&self) -> &str { "Hello world plugin" } fn desc(&self) -> &str { "Hi! I'm an example plugin for Hexerator" } fn methods(&self) -> Vec { vec![ PluginMethod { method_name: "say_hello", human_name: Some("Say hello"), desc: "Write 'hello' to debug log.", params: &[], }, PluginMethod { method_name: "fill_selection", human_name: Some("Fill selection"), desc: "Fills the selection with 0x42", params: &[], }, PluginMethod { method_name: "sum_range", human_name: None, desc: "Sums up the values in the provided range", params: &[ MethodParam { name: "from", ty: ValueTy::U64, }, MethodParam { name: "to", ty: ValueTy::U64, }, ], }, ] } fn on_method_called( &mut self, name: &str, params: &[Option], hx: &mut dyn HexeratorHandle, ) -> MethodResult { match name { "say_hello" => { hx.debug_log("Hello world!"); Ok(None) } "fill_selection" => match hx.selection_range() { Some([start, end]) => match hx.get_data_mut(start, end) { Some(data) => { data.fill(0x42); Ok(None) } None => Err("Selection out of bounds".into()), }, None => Err("Selection unavailable".into()), }, "sum_range" => { let &[Some(Value::U64(from)), Some(Value::U64(to))] = params else { return Err("Invalid params".into()); }; match hx.get_data_mut(from as usize, to as usize) { Some(data) => { let sum: u64 = data.iter().map(|b| *b as u64).sum(); Ok(Some(Value::U64(sum))) } None => Err("Out of bounds".into()), } } _ => Err(format!("Unknown method: {name}")), } } } #[unsafe(no_mangle)] pub extern "Rust" fn hexerator_plugin_new() -> Box { Box::new(HelloPlugin) } ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "nightly" # Lock to a specific nightly before release #channel = "nightly-2025-03-22" ================================================ FILE: rustfmt.toml ================================================ imports_granularity = "One" group_imports = "One" chain_width = 80 ================================================ FILE: scripts/gen-prim-test-file.rs ================================================ #!/usr/bin/env -S cargo +nightly -Zscript use std::{fs::File, io::Write}; fn main() { let mut f = File::create("test_files/primitives.bin").unwrap(); macro_rules! prim { ($t:ident, $val:literal) => { let v: $t = $val; let mut buf = std::io::Cursor::new([0u8; 48]); // Write desc write!(&mut buf, "{} = {}", stringify!($t), v).unwrap(); f.write_all(buf.get_ref()).unwrap(); // Write byte repr // le buf.get_mut().fill(0); buf.get_mut()[..std::mem::size_of::<$t>()].copy_from_slice(&v.to_le_bytes()); f.write_all(buf.get_ref()).unwrap(); // be buf.get_mut().fill(0); buf.get_mut()[..std::mem::size_of::<$t>()].copy_from_slice(&v.to_be_bytes()); f.write_all(buf.get_ref()).unwrap(); }; } prim!(u8, 42); prim!(i8, 42); prim!(u16, 4242); prim!(i16, 4242); prim!(u32, 424242); prim!(i32, 424242); prim!(u64, 424242424242); prim!(i64, 424242424242); prim!(u128, 424242424242424242424242); prim!(i128, 424242424242424242424242); prim!(f32, 42.4242); prim!(f64, 4242.42424242); } ================================================ FILE: src/app/backend_command.rs ================================================ //! This module is similar in purpose to [`crate::app::command`]. //! //! See that module for more information. use { super::App, crate::config::Config, egui_sf2g::sf2g::graphics::RenderWindow, std::collections::VecDeque, }; pub enum BackendCmd { SetWindowTitle(String), ApplyVsyncCfg, ApplyFpsLimit, } /// Gui command queue. /// /// Push operations with `push`, and call [`App::flush_backend_command_queue`] when you have /// exclusive access to the [`App`]. /// /// [`App::flush_backend_command_queue`] is called automatically every frame, if you don't need to perform the operations sooner. #[derive(Default)] pub struct BackendCommandQueue { inner: VecDeque, } impl BackendCommandQueue { pub fn push(&mut self, command: BackendCmd) { self.inner.push_back(command); } } impl App { /// Flush the [`BackendCommandQueue`] and perform all operations queued up. /// /// Automatically called every frame, but can be called manually if operations need to be /// performed sooner. pub fn flush_backend_command_queue(&mut self, rw: &mut RenderWindow) { while let Some(cmd) = self.backend_cmd.inner.pop_front() { perform_command(cmd, rw, &self.cfg); } } } fn perform_command(cmd: BackendCmd, rw: &mut RenderWindow, cfg: &Config) { match cmd { BackendCmd::SetWindowTitle(title) => rw.set_title(&title), BackendCmd::ApplyVsyncCfg => { rw.set_vertical_sync_enabled(cfg.vsync); } BackendCmd::ApplyFpsLimit => { rw.set_framerate_limit(cfg.fps_limit); } } } ================================================ FILE: src/app/command.rs ================================================ //! Due to various issues with overlapping borrows, it's not always feasible to do every operation //! on the application state at the time the action is requested. //! //! Sometimes we need to wait until we have exclusive access to the application before we can //! perform an operation. //! //! One possible way to do this is to encode whatever data an operation requires, and save it until //! we have exclusive access, and then perform it. use { super::{App, backend_command::BackendCmd}, crate::{ damage_region::DamageRegion, data::Data, gui::Gui, meta::{NamedView, PerspectiveKey, RegionKey}, scripting::exec_lua, shell::msg_if_fail, view::{HexData, View, ViewKind}, }, mlua::Lua, std::{collections::VecDeque, path::Path}, }; pub enum Cmd { CreatePerspective { region_key: RegionKey, name: String, }, RemovePerspective(PerspectiveKey), SetSelection(usize, usize), SetAndFocusCursor(usize), SetLayout(crate::meta::LayoutKey), FocusView(crate::meta::ViewKey), CreateView { perspective_key: PerspectiveKey, name: String, }, /// Finish saving a truncated file SaveTruncateFinish, /// Extend (or truncate) the data buffer to a new length ExtendDocument { new_len: usize, }, /// Paste bytes at the requested index PasteBytes { at: usize, bytes: Vec, }, /// A new source was loaded, process the changes ProcessSourceChange, } /// Application command queue. /// /// Push operations with `push`, and call `App::flush_command_queue` when you have /// exclusive access to the `App`. /// /// `App::flush_command_queue` is called automatically every frame, if you don't need to perform the operations sooner. #[derive(Default)] pub struct CommandQueue { inner: VecDeque, } impl CommandQueue { pub fn push(&mut self, command: Cmd) { self.inner.push_back(command); } } impl App { /// Flush the [`CommandQueue`] and perform all operations queued up. /// /// Automatically called every frame, but can be called manually if operations need to be /// performed sooner. pub fn flush_command_queue( &mut self, gui: &mut Gui, lua: &Lua, font_size: u16, line_spacing: u16, ) { while let Some(cmd) = self.cmd.inner.pop_front() { perform_command(self, cmd, gui, lua, font_size, line_spacing); } } } /// Perform a command. Called by `App::flush_command_queue`, but can be called manually if you /// have a `Cmd` you would like you perform. pub fn perform_command( app: &mut App, cmd: Cmd, gui: &mut Gui, lua: &Lua, font_size: u16, line_spacing: u16, ) { match cmd { Cmd::CreatePerspective { region_key, name } => { let per_key = app.add_perspective_from_region(region_key, name); gui.win.perspectives.open.set(true); gui.win.perspectives.rename_idx = per_key; } Cmd::SetSelection(a, b) => { app.hex_ui.select_a = Some(a); app.hex_ui.select_b = Some(b); } Cmd::SetAndFocusCursor(off) => { app.edit_state.cursor = off; app.center_view_on_offset(off); app.hex_ui.flash_cursor(); } Cmd::SetLayout(key) => app.hex_ui.current_layout = key, Cmd::FocusView(key) => app.hex_ui.focused_view = Some(key), Cmd::RemovePerspective(key) => { app.meta_state.meta.low.perspectives.remove(key); // TODO: Should probably handle dangling keys somehow. // either by not allowing removal in that case, or being robust against dangling keys // or removing everything that uses a dangling key. } Cmd::CreateView { perspective_key, name, } => { app.meta_state.meta.views.insert(NamedView { view: View::new( ViewKind::Hex(HexData::with_font_size(font_size)), perspective_key, ), name, }); } Cmd::SaveTruncateFinish => { msg_if_fail( app.save_truncated_file_finish(), "Save error", &mut gui.msg_dialog, ); } Cmd::ExtendDocument { new_len } => { app.data.resize(new_len, 0); } Cmd::PasteBytes { at, bytes } => { let range = at..at + bytes.len(); app.data[range.clone()].copy_from_slice(&bytes); app.data.widen_dirty_region(DamageRegion::Range(range)); } Cmd::ProcessSourceChange => { // Allocate a clean data buffer for streaming sources if app.source.as_ref().is_some_and(|src| src.attr.stream) { app.data = Data::clean_from_buf(Vec::new()); } app.backend_cmd.push(BackendCmd::SetWindowTitle(format!( "{} - Hexerator", app.source_file().map_or("no source", path_filename_as_str) ))); if let Some(key) = &app.meta_state.meta.onload_script { let scr = &app.meta_state.meta.scripts[*key]; let content = scr.content.clone(); let result = exec_lua( lua, &content, app, gui, "", Some(*key), font_size, line_spacing, ); msg_if_fail( result, "Failed to execute onload lua script", &mut gui.msg_dialog, ); } } } } fn path_filename_as_str(path: &Path) -> &str { path.file_name() .map_or("", |osstr| osstr.to_str().unwrap_or_default()) } ================================================ FILE: src/app/debug.rs ================================================ #![allow(unused_imports)] use { super::App, gamedebug_core::{imm, imm_dbg}, }; impl App { /// Central place to put some immediate state debugging (using gamedebug_core) pub(crate) fn imm_debug_fun(&self) { // Put immediate debugging code here (F12 to open debug console) } } ================================================ FILE: src/app/edit_state.rs ================================================ #[derive(Default, Debug)] pub struct EditState { // The editing byte offset pub cursor: usize, cursor_history: Vec, cursor_history_current: usize, } impl EditState { /// Set cursor and save history pub fn set_cursor(&mut self, offset: usize) { self.cursor_history.truncate(self.cursor_history_current); self.cursor_history.push(self.cursor); self.cursor = offset; self.cursor_history_current += 1; } /// Set cursor, don't save history pub fn set_cursor_no_history(&mut self, offset: usize) { self.cursor = offset; } /// Step cursor forward without saving history pub fn step_cursor_forward(&mut self) { self.cursor += 1; } /// Step cursor back without saving history pub fn step_cursor_back(&mut self) { self.cursor = self.cursor.saturating_sub(1); } /// Offset cursor by amount, not saving history pub fn offset_cursor(&mut self, amount: usize) { self.cursor += amount; } pub fn cursor_history_back(&mut self) -> bool { if self.cursor_history_current > 0 { self.cursor_history.push(self.cursor); self.cursor_history_current -= 1; self.cursor = self.cursor_history[self.cursor_history_current]; true } else { false } } pub fn cursor_history_forward(&mut self) -> bool { if self.cursor_history_current + 1 < self.cursor_history.len() { self.cursor_history_current += 1; self.cursor = self.cursor_history[self.cursor_history_current]; true } else { false } } } ================================================ FILE: src/app/interact_mode.rs ================================================ /// User interaction mode /// /// There are 2 modes: View and Edit #[derive(PartialEq, Eq, Debug)] pub enum InteractMode { /// Mode optimized for viewing the contents /// /// For example arrow keys scroll the content View, /// Mode optimized for editing the contents /// /// For example arrow keys move the cursor Edit, } ================================================ FILE: src/app/presentation.rs ================================================ use { crate::{ color::{RgbaColor, rgba}, value_color::ColorMethod, }, serde::{Deserialize, Serialize}, }; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct Presentation { pub color_method: ColorMethod, pub invert_color: bool, pub sel_color: RgbaColor, pub cursor_color: RgbaColor, pub cursor_active_color: RgbaColor, } impl Default for Presentation { fn default() -> Self { Self { color_method: ColorMethod::Default, invert_color: false, sel_color: rgba(75, 75, 75, 255), cursor_color: rgba(160, 160, 160, 255), cursor_active_color: rgba(255, 255, 255, 255), } } } ================================================ FILE: src/app.rs ================================================ use { self::{ backend_command::BackendCommandQueue, command::{Cmd, CommandQueue}, edit_state::EditState, }, crate::{ args::{Args, SourceArgs}, config::Config, damage_region::DamageRegion, data::Data, gui::{ Gui, message_dialog::{Icon, MessageDialog}, windows::FileDiffResultWindow, }, hex_ui::HexUi, input::Input, layout::{Layout, default_margin, do_auto_layout}, meta::{ LayoutKey, Meta, NamedRegion, NamedView, PerspectiveKey, PerspectiveMap, RegionKey, RegionMap, ViewKey, perspective::Perspective, region::Region, }, meta_state::MetaState, plugin::PluginContainer, result_ext::AnyhowConv as _, session_prefs::{Autoreload, SessionPrefs}, shell::{msg_fail, msg_if_fail}, source::{Source, SourceAttributes, SourcePermissions, SourceProvider, SourceState}, view::{HexData, TextData, View, ViewKind, ViewportScalar}, }, anyhow::Context as _, egui_sf2g::sf2g::graphics::RenderWindow, gamedebug_core::{per, per_dbg}, hexerator_plugin_api::MethodResult, mlua::Lua, slotmap::Key as _, std::{ ffi::OsString, fs::{File, OpenOptions}, io::{Read as _, Seek as _, SeekFrom, Write as _}, path::{Path, PathBuf}, sync::mpsc::Receiver, thread, time::Instant, }, }; pub mod backend_command; pub mod command; mod debug; pub mod edit_state; pub mod interact_mode; pub mod presentation; /// The hexerator application state pub struct App { pub data: Data, pub edit_state: EditState, pub input: Input, pub src_args: SourceArgs, pub source: Option, stream_read_recv: Option>>, pub cfg: Config, last_reload: Instant, pub preferences: SessionPrefs, pub hex_ui: HexUi, pub meta_state: MetaState, pub clipboard: arboard::Clipboard, /// Command queue for queuing up operations to perform on the application state. pub cmd: CommandQueue, pub backend_cmd: BackendCommandQueue, /// A quit was requested pub quit_requested: bool, pub plugins: Vec, /// Size of the buffer used for streaming reads pub stream_buffer_size: usize, } const DEFAULT_STREAM_BUFFER_SIZE: usize = 65_536; /// Source management impl App { pub fn reload(&mut self) -> anyhow::Result<()> { match &mut self.source { Some(src) => match &mut src.provider { SourceProvider::File(file) => { self.data.reload_from_file(&self.src_args, file)?; } SourceProvider::Stdin(_) => { anyhow::bail!("Can't reload streaming sources like standard input") } #[cfg(windows)] SourceProvider::WinProc { handle, start, size, } => unsafe { crate::windows::read_proc_memory(*handle, &mut self.data, *start, *size)?; }, }, None => anyhow::bail!("No file to reload"), } Ok(()) } pub(crate) fn load_file_args( &mut self, mut src_args: SourceArgs, meta_path: Option, msg: &mut MessageDialog, font_size: u16, line_spacing: u16, column_count: Option, ) { if load_file_from_src_args( &mut src_args, &mut self.cfg, &mut self.source, &mut self.data, msg, &mut self.cmd, ) { // Set up meta if !self.preferences.keep_meta { if let Some(meta_path) = meta_path { if let Err(e) = self.consume_meta_from_file(meta_path, false) { self.set_new_clean_meta(font_size, line_spacing, column_count); msg_fail(&e, "Failed to load metafile", msg); } } else if let Some(src_path) = per_dbg!(&src_args.file) && let Some(meta_path) = per_dbg!(self.cfg.meta_assocs.get(src_path)) { // We only load if the new meta path is not the same as the old. // Keep the current metafile otherwise if self.meta_state.current_meta_path != *meta_path { per!( "Mismatch: {:?} vs. {:?}", self.meta_state.current_meta_path.display(), meta_path.display() ); let meta_path = meta_path.clone(); if let Err(e) = self.consume_meta_from_file(meta_path.clone(), false) { self.set_new_clean_meta(font_size, line_spacing, column_count); msg_fail(&e, &format!("Failed to load metafile {meta_path:?}"), msg); } } } else { // We didn't load any meta, but we're loading a new file. // Set up a new clean meta for it. self.set_new_clean_meta(font_size, line_spacing, column_count); } } self.src_args = src_args; if let Some(offset) = self.src_args.jump { self.center_view_on_offset(offset); self.edit_state.cursor = offset; self.hex_ui.flash_cursor(); } } } pub fn save(&mut self, msg: &mut MessageDialog) -> anyhow::Result<()> { let file = match &mut self.source { Some(src) => match &mut src.provider { SourceProvider::File(file) => file, SourceProvider::Stdin(_) => anyhow::bail!("Standard input doesn't support saving"), #[cfg(windows)] SourceProvider::WinProc { handle, start, .. } => { if let Some(region) = self.data.dirty_region { let mut n_write = 0; unsafe { if windows_sys::Win32::System::Diagnostics::Debug::WriteProcessMemory( *handle, (*start + region.begin) as _, self.data[region.begin..].as_mut_ptr() as _, region.len(), &mut n_write, ) == 0 { anyhow::bail!("Failed to write process memory"); } } self.data.dirty_region = None; } return Ok(()); } }, None => anyhow::bail!("No surce opened, nothing to save"), }; // If the file was truncated, we completely save over it if self.data.len() != self.data.orig_data_len { msg.open( Icon::Warn, "File truncated/extended", "Data is truncated/extended. Are you sure you want to save?", ); msg.custom_button_row_ui(Box::new(|ui, payload, cmd| { if ui .button(egui::RichText::new("Save & Truncate").color(egui::Color32::RED)) .clicked() { payload.close = true; cmd.push(Cmd::SaveTruncateFinish); } if ui.button("Cancel").clicked() { payload.close = true; } })); return Ok(()); } let offset = self.src_args.hard_seek.unwrap_or(0); file.seek(SeekFrom::Start(offset as u64))?; let data_to_write = match self.data.dirty_region { Some(region) => { #[expect( clippy::cast_possible_wrap, reason = "Files bigger than i64::MAX aren't supported" )] file.seek(SeekFrom::Current(region.begin as _))?; // TODO: We're assuming here that end of the region is the same position as the last dirty byte // Make sure to enforce this invariant. // Add 1 to the end to write the dirty region even if it's 1 byte self.data.get(region.begin..region.end + 1) } None => Some(&self.data[..]), }; let Some(data_to_write) = data_to_write else { anyhow::bail!("No data to write (possibly out of bounds indexing)"); }; file.write_all(data_to_write)?; self.data.undirty(); if let Err(e) = self.save_temp_metafile_backup() { per!("Failed to save metafile backup: {}", e); } Ok(()) } pub fn save_truncated_file_finish(&mut self) -> anyhow::Result<()> { let Some(source) = &mut self.source else { anyhow::bail!("There is no source"); }; let SourceProvider::File(file) = &mut source.provider else { anyhow::bail!("Source is not a file"); }; file.set_len(self.data.len() as u64)?; file.rewind()?; file.write_all(&self.data)?; self.data.undirty(); Ok(()) } pub(crate) fn source_file(&self) -> Option<&Path> { self.src_args.file.as_deref() } pub(crate) fn load_file( &mut self, path: PathBuf, read_only: bool, msg: &mut MessageDialog, font_size: u16, line_spacing: u16, ) { self.load_file_args( SourceArgs { file: Some(path), jump: None, hard_seek: None, take: None, read_only, stream: false, stream_buffer_size: None, unsafe_mmap: None, mmap_len: None, }, None, msg, font_size, line_spacing, None, ); } pub fn close_file(&mut self) { // We potentially had large data, free it instead of clearing the Vec self.data.close(); self.src_args.file = None; self.source = None; } pub(crate) fn backup_path(&self) -> Option { self.src_args.file.as_ref().map(|file| { let mut os_string = OsString::from(file); os_string.push(".hexerator_bak"); os_string.into() }) } pub(crate) fn restore_backup(&mut self) -> Result<(), anyhow::Error> { std::fs::copy( self.backup_path().context("Failed to get backup path")?, self.src_args.file.as_ref().context("No file open")?, )?; self.reload() } pub(crate) fn create_backup(&self) -> Result<(), anyhow::Error> { std::fs::copy( self.src_args.file.as_ref().context("No file open")?, self.backup_path().context("Failed to get backup path")?, )?; Ok(()) } /// Reload only what's visible on the screen (current layout) fn reload_visible(&mut self) -> anyhow::Result<()> { let [lo, hi] = self.visible_byte_range(); self.reload_range(lo, hi) } pub fn reload_range(&mut self, lo: usize, hi: usize) -> anyhow::Result<()> { let Some(src) = &self.source else { anyhow::bail!("No source") }; anyhow::ensure!(lo <= hi); match &src.provider { SourceProvider::File(file) => { let mut file = file; let offset = match self.src_args.hard_seek { Some(hs) => hs + lo, None => lo, }; file.seek(SeekFrom::Start(offset as u64))?; match self.data.get_mut(lo..=hi) { Some(buf) => file.read_exact(buf)?, None => anyhow::bail!("Reload range out of bounds"), } Ok(()) } SourceProvider::Stdin(_) => anyhow::bail!("Not implemented"), #[cfg(windows)] SourceProvider::WinProc { .. } => anyhow::bail!("Not implemented"), } } #[allow(clippy::unnecessary_wraps, reason = "cfg shenanigans")] pub(crate) fn load_proc_memory( &mut self, pid: sysinfo::Pid, start: usize, size: usize, is_write: bool, msg: &mut MessageDialog, font_size: u16, line_spacing: u16, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] { load_proc_memory_linux( self, pid, start, size, is_write, msg, font_size, line_spacing, ); Ok(()) } #[cfg(windows)] return crate::windows::load_proc_memory( self, pid, start, size, is_write, font_size, line_spacing, msg, ); #[cfg(target_os = "macos")] return load_proc_memory_macos(self, pid, start, size, is_write, font, msg); } } const DEFAULT_COLUMN_COUNT: usize = 48; /// Metafile impl App { /// Set a new clean meta for the current data, and switch to default layout pub fn set_new_clean_meta( &mut self, font_size: u16, line_spacing: u16, column_count: Option, ) { per!("Setting up new clean meta"); self.meta_state.current_meta_path.clear(); self.meta_state.meta = Meta::default(); let layout_key = setup_empty_meta( self.data.len(), &mut self.meta_state.meta, font_size, line_spacing, column_count.unwrap_or(DEFAULT_COLUMN_COUNT), ); self.meta_state.clean_meta = self.meta_state.meta.clone(); Self::switch_layout(&mut self.hex_ui, &self.meta_state.meta, layout_key); } /// Like `set_new_clean_meta`, but keeps the clean meta intact /// /// Used for "Clear meta" action. pub fn clear_meta(&mut self, font_size: u16, line_spacing: u16) { self.meta_state.meta = Meta::default(); let layout_key = setup_empty_meta( self.data.len(), &mut self.meta_state.meta, font_size, line_spacing, DEFAULT_COLUMN_COUNT, ); Self::switch_layout(&mut self.hex_ui, &self.meta_state.meta, layout_key); } pub fn save_temp_metafile_backup(&mut self) -> anyhow::Result<()> { // We set the last_meta_backup first, so if save fails, we don't get // a never ending stream of constant save failures. self.meta_state.last_meta_backup.set(Instant::now()); self.save_meta_to_file(temp_metafile_backup_path(), true)?; per!("Saved temp metafile backup"); Ok(()) } pub fn save_meta_to_file(&mut self, path: PathBuf, temp: bool) -> Result<(), anyhow::Error> { let data = rmp_serde::to_vec(&self.meta_state.meta)?; std::fs::write(&path, data)?; if !temp { self.meta_state.current_meta_path = path; self.meta_state.clean_meta = self.meta_state.meta.clone(); } Ok(()) } pub fn save_meta(&mut self) -> Result<(), anyhow::Error> { self.save_meta_to_file(self.meta_state.current_meta_path.clone(), false) } pub fn consume_meta_from_file( &mut self, path: PathBuf, temp: bool, ) -> Result<(), anyhow::Error> { per!("Consuming metafile: {}", path.display()); let data = std::fs::read(&path)?; let meta = rmp_serde::from_slice(&data).context("Deserialization error")?; self.hex_ui.clear_meta_refs(); self.meta_state.meta = meta; if !temp { self.meta_state.current_meta_path = path; self.meta_state.clean_meta = self.meta_state.meta.clone(); } self.meta_state.meta.post_load_init(); // Switch to first layout, if there is one if let Some(layout_key) = self.meta_state.meta.layouts.keys().next() { Self::switch_layout(&mut self.hex_ui, &self.meta_state.meta, layout_key); } Ok(()) } pub fn add_perspective_from_region( &mut self, region_key: RegionKey, name: String, ) -> PerspectiveKey { let mut per = Perspective::from_region(region_key, name); if let Some(focused_per) = Self::focused_perspective(&self.hex_ui, &self.meta_state.meta) { per.cols = focused_per.cols; } self.meta_state.meta.low.perspectives.insert(per) } } /// Navigation impl App { pub fn search_focus(&mut self, offset: usize) { self.edit_state.cursor = offset; self.center_view_on_offset(offset); self.hex_ui.flash_cursor(); } pub(crate) fn center_view_on_offset(&mut self, offset: usize) { if let Some(key) = self.hex_ui.focused_view { self.meta_state.meta.views[key].view.center_on_offset( offset, &self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, ); } } pub fn cursor_history_back(&mut self) { if self.edit_state.cursor_history_back() { self.center_view_on_offset(self.edit_state.cursor); self.hex_ui.flash_cursor(); } } pub fn cursor_history_forward(&mut self) { if self.edit_state.cursor_history_forward() { self.center_view_on_offset(self.edit_state.cursor); self.hex_ui.flash_cursor(); } } pub(crate) fn set_cursor_init(&mut self) { self.edit_state.cursor = self.src_args.jump.unwrap_or(0); self.center_view_on_offset(self.edit_state.cursor); self.hex_ui.flash_cursor(); } pub(crate) fn switch_layout(app_hex_ui: &mut HexUi, app_meta: &Meta, k: LayoutKey) { app_hex_ui.current_layout = k; // Set focused view to the first available view in the layout if let Some(view_key) = app_meta.layouts[k].view_grid.first().and_then(|row| row.first()) { app_hex_ui.focused_view = Some(*view_key); } } /// Tries to switch to a layout with the given name. Returns `false` if a layout with that name wasn't found. #[must_use] pub(crate) fn switch_layout_by_name( app_hex_ui: &mut HexUi, app_meta: &Meta, name: &str, ) -> bool { match app_meta.layouts.iter().find(|(_k, v)| v.name == name) { Some((k, _v)) => { Self::switch_layout(app_hex_ui, app_meta, k); true } None => false, } } /// Tries to focus a view with the given name. Returns `false` if a view with that name wasn't found. #[must_use] pub(crate) fn focus_first_view_of_name( app_hex_ui: &mut HexUi, app_meta: &Meta, name: &str, ) -> bool { match app_meta.views.iter().find(|(_k, v)| v.name == name) { Some((k, _v)) => { Self::focus_first_view_of_key(app_hex_ui, app_meta, k); true } None => false, } } pub(crate) fn focus_prev_view_in_layout(&mut self) { if let Some(focused_view_key) = self.hex_ui.focused_view { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; if let Some(focused_idx) = layout.iter().position(|k| k == focused_view_key) { let new_idx = if focused_idx == 0 { layout.iter().count() - 1 } else { focused_idx - 1 }; if let Some(new_key) = layout.iter().nth(new_idx) { self.hex_ui.focused_view = Some(new_key); } } } } pub(crate) fn focus_next_view_in_layout(&mut self) { if let Some(focused_view_key) = self.hex_ui.focused_view { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; if let Some(focused_idx) = layout.iter().position(|k| k == focused_view_key) { let new_idx = if focused_idx == layout.iter().count() - 1 { 0 } else { focused_idx + 1 }; if let Some(new_key) = layout.iter().nth(new_idx) { self.hex_ui.focused_view = Some(new_key); } } } } pub(crate) fn focus_first_view_of_key( app_hex_ui: &mut HexUi, app_meta: &Meta, view_key: ViewKey, ) { if let Some(layout_key) = app_meta .layouts .iter() .find_map(|(k, l)| l.contains_view(view_key).then_some(k)) { Self::switch_layout(app_hex_ui, app_meta, layout_key); app_hex_ui.focused_view = Some(view_key); } } } /// Perspective manipulation impl App { pub(crate) fn inc_cols(&mut self) { self.col_change_impl(|col| *col += 1); } pub(crate) fn dec_cols(&mut self) { self.col_change_impl(|col| *col -= 1); } pub(crate) fn halve_cols(&mut self) { self.col_change_impl(|col| *col /= 2); } pub(crate) fn double_cols(&mut self) { self.col_change_impl(|col| *col *= 2); } fn col_change_impl(&mut self, f: impl FnOnce(&mut usize)) { if let Some(key) = self.hex_ui.focused_view { let view = &mut self.meta_state.meta.views[key].view; col_change_impl_view_perspective( view, &mut self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, f, self.preferences.col_change_lock_col, self.preferences.col_change_lock_row, ); } } } /// Finding things impl App { // Byte offset of a pixel position in the viewport // // Also returns the index of the view the position is from pub fn byte_offset_at_pos(&self, x: i16, y: i16) -> Option<(usize, ViewKey)> { let layout = self.meta_state.meta.layouts.get(self.hex_ui.current_layout)?; for view_key in layout.iter() { if let Some(pos) = self.view_byte_offset_at_pos(view_key, x, y) { return Some((pos, view_key)); } } None } pub fn view_byte_offset_at_pos(&self, view_key: ViewKey, x: i16, y: i16) -> Option { let NamedView { view, .. } = self.meta_state.meta.views.get(view_key)?; view.row_col_offset_of_pos( x, y, &self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, ) .map(|[row, col]| { self.meta_state.meta.low.perspectives[view.perspective].byte_offset_of_row_col( row, col, &self.meta_state.meta.low.regions, ) }) } pub fn view_at_pos(&self, x: ViewportScalar, y: ViewportScalar) -> Option { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; for row in &layout.view_grid { for key in row { let view = &self.meta_state.meta.views[*key]; if view.view.viewport_rect.contains_pos(x, y) { return Some(*key); } } } None } pub fn view_idx_at_pos(&self, x: i16, y: i16) -> Option { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; for view_key in layout.iter() { let view = &self.meta_state.meta.views[view_key]; if view.view.viewport_rect.contains_pos(x, y) { return Some(view_key); } } None } /// Iterator over the views in the current layout fn active_views(&self) -> impl Iterator { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; layout.iter().map(|key| &self.meta_state.meta.views[key]) } /// Largest visible byte range in the current perspective fn visible_byte_range(&self) -> [usize; 2] { let mut min_lo = self.data.len(); let mut max_hi = 0; for view in self.active_views() { let offsets = view.view.offsets( &self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, ); let lo = offsets.byte; min_lo = std::cmp::min(min_lo, lo); let hi = lo + view.view.bytes_per_page(&self.meta_state.meta.low.perspectives); max_hi = std::cmp::max(max_hi, hi); } [min_lo, max_hi].map(|v| v.clamp(0, self.data.len())) } pub(crate) fn focused_view_mut(&mut self) -> Option<(ViewKey, &mut View)> { self.hex_ui.focused_view.and_then(|key| { self.meta_state.meta.views.get_mut(key).map(|view| (key, &mut view.view)) }) } pub(crate) fn row_region(&self, row: usize) -> Option { let per = Self::focused_perspective(&self.hex_ui, &self.meta_state.meta)?; let per_reg = self.meta_state.meta.low.regions.get(per.region)?.region; // Beginning of the region let beg = per_reg.begin; // Number of columns let cols = per.cols; let row_begin = beg + row * cols; // Regions are inclusive, so we subtract 1 let row_end = (row_begin + cols).saturating_sub(1); Some(Region { begin: row_begin, end: row_end, }) } pub(crate) fn col_offsets(&self, col: usize) -> Option> { let per = Self::focused_perspective(&self.hex_ui, &self.meta_state.meta)?; let per_reg = self.meta_state.meta.low.regions.get(per.region)?.region; let beg = per_reg.begin; let end = per_reg.end; let cols = per.cols; let offsets = (beg..=end).step_by(cols).map(|off| off + col).collect(); Some(offsets) } pub(crate) fn cursor_col_offsets(&self) -> Option> { self.row_col_of_cursor().and_then(|[_, col]| self.col_offsets(col)) } /// Returns the row and column of the provided byte position, according to focused perspective pub(crate) fn row_col_of_byte_pos(&self, pos: usize) -> Option<[usize; 2]> { Self::focused_perspective(&self.hex_ui, &self.meta_state.meta) .map(|per| calc_perspective_row_col(pos, per, &self.meta_state.meta.low.regions)) } /// Returns the byte position of the provided row and column, according to focused perspective pub(crate) fn byte_pos_of_row_col(&self, row: usize, col: usize) -> Option { Self::focused_perspective(&self.hex_ui, &self.meta_state.meta).map(|per| { calc_perspective_row_col_offset(row, col, per, &self.meta_state.meta.low.regions) }) } /// Returns the row and column of the current cursor, according to focused perspective pub(crate) fn row_col_of_cursor(&self) -> Option<[usize; 2]> { self.row_col_of_byte_pos(self.edit_state.cursor) } pub fn focused_perspective<'a>(hex_ui: &HexUi, meta: &'a Meta) -> Option<&'a Perspective> { hex_ui.focused_view.map(|view_key| { let per_key = meta.views[view_key].view.perspective; &meta.low.perspectives[per_key] }) } pub fn focused_region<'a>(hex_ui: &HexUi, meta: &'a Meta) -> Option<&'a NamedRegion> { Self::focused_perspective(hex_ui, meta).and_then(|per| meta.low.regions.get(per.region)) } pub(crate) fn region_key_for_view(&self, view_key: ViewKey) -> RegionKey { let per_key = self.meta_state.meta.views[view_key].view.perspective; self.meta_state.meta.low.perspectives[per_key].region } /// Figure out the byte offset of the row `offset` is on pub(crate) fn find_row_start(&self, offset: usize) -> Option { match self.row_col_of_byte_pos(offset) { Some([row, _col]) => self.byte_pos_of_row_col(row, 0), None => None, } } /// Figure out the byte offset of the row `offset` is on + end pub(crate) fn find_row_end(&self, offset: usize) -> Option { Self::focused_perspective(&self.hex_ui, &self.meta_state.meta).map(|per| { let [row, _col] = calc_perspective_row_col(offset, per, &self.meta_state.meta.low.regions); calc_perspective_row_col_offset( row, per.cols.saturating_sub(1), per, &self.meta_state.meta.low.regions, ) }) } } fn calc_perspective_row_col(pos: usize, per: &Perspective, regions: &RegionMap) -> [usize; 2] { let cols = per.cols; let region_begin = regions[per.region].region.begin; let byte_pos = pos.saturating_sub(region_begin); [byte_pos / cols, byte_pos % cols] } fn calc_perspective_row_col_offset( row: usize, col: usize, per: &Perspective, regions: &RegionMap, ) -> usize { let region_begin = regions[per.region].region.begin; row * per.cols + col + region_begin } /// Editing impl App { pub(crate) fn mod_byte_at_cursor(&mut self, f: impl FnOnce(&mut u8)) { if let Some(byte) = self.data.get_mut(self.edit_state.cursor) { f(byte); self.data.widen_dirty_region(DamageRegion::Single(self.edit_state.cursor)); } } pub(crate) fn inc_byte_at_cursor(&mut self) { self.mod_byte_at_cursor(|b| *b = b.wrapping_add(1)); } pub(crate) fn dec_byte_at_cursor(&mut self) { self.mod_byte_at_cursor(|b| *b = b.wrapping_sub(1)); } pub(crate) fn inc_byte_or_bytes(&mut self) { let mut any = false; for region in self.hex_ui.selected_regions() { self.data.mod_range(region.to_range(), |byte| { *byte = byte.wrapping_add(1); }); any = true; } if !any { self.inc_byte_at_cursor(); } } pub(crate) fn dec_byte_or_bytes(&mut self) { let mut any = false; for region in self.hex_ui.selected_regions() { self.data.mod_range(region.to_range(), |byte| { *byte = byte.wrapping_sub(1); }); any = true; } if !any { self.dec_byte_at_cursor(); } } } /// Etc. impl App { pub(crate) fn new( mut args: Args, cfg: Config, font_size: u16, line_spacing: u16, gui: &mut Gui, ) -> anyhow::Result { if args.recent && let Some(recent) = cfg.recent.most_recent() { args.src = recent.clone(); } let mut this = Self { data: Data::default(), edit_state: EditState::default(), input: Input::default(), src_args: SourceArgs::default(), source: None, stream_read_recv: None, cfg, last_reload: Instant::now(), preferences: SessionPrefs::default(), hex_ui: HexUi::default(), meta_state: MetaState::default(), clipboard: arboard::Clipboard::new()?, cmd: Default::default(), backend_cmd: Default::default(), quit_requested: false, plugins: Vec::new(), stream_buffer_size: args.src.stream_buffer_size.unwrap_or(DEFAULT_STREAM_BUFFER_SIZE), }; for path in args.load_plugin { // Safety: This will cause UB on a bad plugin. Nothing we can do. // // It's up to the user not to load bad plugins. this.plugins.push(unsafe { PluginContainer::new(path)? }); } if args.autosave { this.preferences.auto_save = true; } if let Some(interval_ms) = args.autoreload { if args.autoreload_only_visible { this.preferences.auto_reload = Autoreload::Visible; } else { this.preferences.auto_reload = Autoreload::All; } this.preferences.auto_reload_interval_ms = interval_ms; } match args.new { Some(new_len) => { if let Some(path) = args.src.file { if path.exists() { anyhow::bail!("Can't use --new for {path:?}: File already exists"); } // Set up source for this new file let f = OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) .open(&path)?; f.set_len(new_len as u64)?; this.source = Some(Source::file(f)); this.src_args.file = Some(path); } this.data = Data::clean_from_buf(vec![0; new_len]); // Set clean meta for the newly allocated buffer this.set_new_clean_meta(font_size, line_spacing, args.column_count); } None => { // Set a clean meta, for an empty document this.set_new_clean_meta(font_size, line_spacing, args.column_count); this.load_file_args( args.src, args.meta, &mut gui.msg_dialog, font_size, line_spacing, args.column_count, ); } } if let Some(name) = args.layout && !Self::switch_layout_by_name(&mut this.hex_ui, &this.meta_state.meta, &name) { let err = anyhow::anyhow!("No layout with name '{name}' found."); msg_fail(&err, "Couldn't switch layout", &mut gui.msg_dialog); } if let Some(name) = args.view && !Self::focus_first_view_of_name(&mut this.hex_ui, &this.meta_state.meta, &name) { let err = anyhow::anyhow!("No view with name '{name}' found."); msg_fail(&err, "Couldn't focus view", &mut gui.msg_dialog); } // Set cursor to the beginning of the focused region we ended up with if let Some(reg) = Self::focused_region(&this.hex_ui, &this.meta_state.meta) { this.edit_state.cursor = reg.region.begin; } // Diff against a file if requested if let Some(path) = &args.diff_against { let result = this.diff_with_file(path.clone(), &mut gui.win.file_diff_result); msg_if_fail(result, "Failed to diff", &mut gui.msg_dialog); } Ok(this) } /// Reoffset all bookmarks based on the difference between the cursor and `offset` pub(crate) fn reoffset_bookmarks_cursor_diff(&mut self, offset: usize) { #[expect( clippy::cast_possible_wrap, reason = "We assume that the offset is not greater than isize" )] let difference = self.edit_state.cursor as isize - offset as isize; for bm in &mut self.meta_state.meta.bookmarks { bm.offset = bm.offset.saturating_add_signed(difference); } } pub(crate) fn try_read_stream(&mut self) { let Some(src) = &mut self.source else { return }; if !src.attr.stream { return; }; let Some(view_key) = self.hex_ui.focused_view else { return; }; let view = &self.meta_state.meta.views[view_key].view; let view_byte_offset = view .offsets( &self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, ) .byte; let bytes_per_page = view.bytes_per_page(&self.meta_state.meta.low.perspectives); // Don't read past what we need for our current view offset if view_byte_offset + bytes_per_page < self.data.len() { return; } if src.state.stream_end { return; } match &self.stream_read_recv { Some(recv) => match recv.try_recv() { Ok(buf) => { if buf.is_empty() { src.state.stream_end = true; } else { self.data.extend_from_slice(&buf[..]); let perspective = &self.meta_state.meta.low.perspectives[view.perspective]; let region = &mut self.meta_state.meta.low.regions[perspective.region].region; region.end = self.data.len().saturating_sub(1); } } Err(e) => match e { std::sync::mpsc::TryRecvError::Empty => {} std::sync::mpsc::TryRecvError::Disconnected => self.stream_read_recv = None, }, }, None => { let (tx, rx) = std::sync::mpsc::channel(); let mut src_clone = src.provider.clone(); self.stream_read_recv = Some(rx); let buffer_size = self.stream_buffer_size; thread::spawn(move || { let mut buf = vec![0; buffer_size]; let result = try { let amount = src_clone.read(&mut buf).how()?; buf.truncate(amount); tx.send(buf).how()?; }; if let Err(e) = result { per!("Stream error: {}", e); } }); } } } /// Called every frame pub(crate) fn update( &mut self, gui: &mut Gui, rw: &mut RenderWindow, lua: &Lua, font_size: u16, line_spacing: u16, ) { if !self.hex_ui.current_layout.is_null() { let layout = &self.meta_state.meta.layouts[self.hex_ui.current_layout]; do_auto_layout( layout, &mut self.meta_state.meta.views, &self.hex_ui.hex_iface_rect, &self.meta_state.meta.low.perspectives, &self.meta_state.meta.low.regions, ); } if self.preferences.auto_save && self.data.dirty_region.is_some() && let Err(e) = self.save(&mut gui.msg_dialog) { per!("Save fail: {}", e); } if self.preferences.auto_reload.is_active() && self.source.is_some() && self.last_reload.elapsed().as_millis() >= u128::from(self.preferences.auto_reload_interval_ms) { match &self.preferences.auto_reload { Autoreload::Disabled => {} Autoreload::All => { if msg_if_fail(self.reload(), "Auto-reload fail", &mut gui.msg_dialog).is_some() { self.preferences.auto_reload = Autoreload::Disabled; } } Autoreload::Visible => { if msg_if_fail( self.reload_visible(), "Auto-reload fail", &mut gui.msg_dialog, ) .is_some() { self.preferences.auto_reload = Autoreload::Disabled; } } } self.last_reload = Instant::now(); } // Here we perform all queued up `Command`s. self.flush_command_queue(gui, lua, font_size, line_spacing); self.flush_backend_command_queue(rw); } pub(crate) fn focused_view_select_all(&mut self) { if let Some(view) = self.hex_ui.focused_view { let p_key = self.meta_state.meta.views[view].view.perspective; let p = &self.meta_state.meta.low.perspectives[p_key]; let r = &self.meta_state.meta.low.regions[p.region]; self.hex_ui.select_a = Some(r.region.begin); // Don't select more than the data length, even if region is bigger self.hex_ui.select_b = Some(r.region.end.min(self.data.len().saturating_sub(1))); } } pub(crate) fn focused_view_select_row(&mut self) { if let Some([row, _]) = self.row_col_of_cursor() && let Some(reg) = self.row_region(row) { // To make behavior consistent with "select col", we clear all extra selections beforehand self.hex_ui.extra_selections.clear(); self.hex_ui.select_a = Some(reg.begin); self.hex_ui.select_b = Some(reg.end); } } pub(crate) fn focused_view_select_col(&mut self) { let Some(offsets) = self.cursor_col_offsets() else { return; }; self.hex_ui.extra_selections.clear(); let mut offsets = offsets.into_iter(); if let Some(off) = offsets.next() { self.hex_ui.select_a = Some(off); self.hex_ui.select_b = Some(off); } for col in offsets { self.hex_ui.extra_selections.push(Region { begin: col, end: col, }); } } pub(crate) fn diff_with_file( &self, path: PathBuf, file_diff_result_window: &mut FileDiffResultWindow, ) -> anyhow::Result<()> { // FIXME: Skipping ignores changes to bookmarked values that happen later than the first // byte. let file_data = read_source_to_buf(&path, &self.src_args)?; let mut offs = Vec::new(); let mut skip = 0; for ((offset, &my_byte), &file_byte) in self.data.iter().enumerate().zip(file_data.iter()) { if skip > 0 { skip -= 1; continue; } if my_byte != file_byte { offs.push(offset); } if let Some((_, bm)) = Meta::bookmark_for_offset(&self.meta_state.meta.bookmarks, offset) { skip = bm.value_type.byte_len() - 1; } } file_diff_result_window.offsets = offs; file_diff_result_window.file_data = file_data; file_diff_result_window.path = path; file_diff_result_window.open.set(true); Ok(()) } pub(crate) fn call_plugin_method( &mut self, plugin_name: &str, method_name: &str, args: &[Option], ) -> MethodResult { let mut plugins = std::mem::take(&mut self.plugins); let result = 'block: { for plugin in &mut plugins { if plugin_name == plugin.plugin.name() { break 'block plugin.plugin.on_method_called(method_name, args, self); } } Err(format!("Plugin `{plugin_name}` not found.")) }; std::mem::swap(&mut self.plugins, &mut plugins); result } pub(crate) fn remove_dangling(&mut self) { self.meta_state.meta.remove_dangling(); if self .hex_ui .focused_view .is_some_and(|key| !self.meta_state.meta.views.contains_key(key)) { eprintln!("Unset dangling focused view"); self.hex_ui.focused_view = None; } } } /// Set up an empty meta with the defaults pub fn setup_empty_meta( data_len: usize, meta: &mut Meta, font_size: u16, line_spacing: u16, cols: usize, ) -> LayoutKey { let def_region = meta.low.regions.insert(NamedRegion { name: "default".into(), region: Region { begin: 0, end: data_len.saturating_sub(1), }, desc: String::new(), }); let default_perspective = meta.low.perspectives.insert(Perspective { region: def_region, cols, flip_row_order: false, name: "default".to_string(), }); let mut layout = Layout { name: "Default layout".into(), view_grid: vec![vec![]], margin: default_margin(), }; for view in default_views(default_perspective, font_size, line_spacing) { let k = meta.views.insert(view); layout.view_grid[0].push(k); } meta.layouts.insert(layout) } pub fn get_clipboard_string(cb: &mut arboard::Clipboard, msg: &mut MessageDialog) -> String { match cb.get_text() { Ok(text) => text, Err(e) => { msg.open( Icon::Error, "Failed to get text from clipboard", e.to_string(), ); String::new() } } } pub fn set_clipboard_string(cb: &mut arboard::Clipboard, msg: &mut MessageDialog, text: &str) { msg_if_fail(cb.set_text(text), "Failed to set clipboard text", msg); } #[cfg(target_os = "linux")] fn load_proc_memory_linux( app: &mut App, pid: sysinfo::Pid, start: usize, size: usize, is_write: bool, msg: &mut MessageDialog, font_size: u16, line_spacing: u16, ) { app.load_file_args( SourceArgs { file: Some(Path::new("/proc/").join(pid.to_string()).join("mem")), jump: None, hard_seek: Some(start), take: Some(size), read_only: !is_write, stream: false, stream_buffer_size: None, unsafe_mmap: None, mmap_len: None, }, None, msg, font_size, line_spacing, None, ); } #[cfg(target_os = "macos")] fn load_proc_memory_macos( app: &mut App, pid: sysinfo::Pid, start: usize, size: usize, is_write: bool, font: &Font, msg: &mut MessageDialog, events: &EventQueue, ) -> anyhow::Result<()> { app.load_file_args( Args { src: SourceArgs { file: Some(Path::new("/proc/").join(pid.to_string()).join("mem")), jump: None, hard_seek: Some(start), take: Some(size), read_only: !is_write, stream: false, }, recent: false, meta: None, }, font, msg, events, ) } pub fn read_source_to_buf(path: &Path, args: &SourceArgs) -> Result, anyhow::Error> { let mut f = File::open(path)?; if let &Some(to) = &args.hard_seek { #[expect( clippy::cast_possible_wrap, reason = "Files bigger than i64::MAX aren't supported" )] f.seek(SeekFrom::Current(to as i64))?; } #[expect( clippy::cast_possible_truncation, reason = "On 32 bit, max supported file size is 4 GB" )] let len = args.take.unwrap_or(f.metadata()?.len() as usize); let mut buf = vec![0; len]; f.read_exact(&mut buf)?; Ok(buf) } pub fn temp_metafile_backup_path() -> PathBuf { std::env::temp_dir().join("hexerator_meta_backup.meta") } pub fn col_change_impl_view_perspective( view: &mut View, perspectives: &mut PerspectiveMap, regions: &RegionMap, f: impl FnOnce(&mut usize), lock_x: bool, lock_y: bool, ) { let prev_offset = view.offsets(perspectives, regions); f(&mut perspectives[view.perspective].cols); perspectives[view.perspective].clamp_cols(regions); view.scroll_to_byte_offset(prev_offset.byte, perspectives, regions, lock_x, lock_y); } pub fn default_views( perspective: PerspectiveKey, font_size: u16, line_spacing: u16, ) -> Vec { vec![ NamedView { view: View::new( ViewKind::Hex(HexData::with_font_size(font_size)), perspective, ), name: "Default hex".into(), }, NamedView { view: View::new( ViewKind::Text(TextData::with_font_info(line_spacing, font_size)), perspective, ), name: "Default text".into(), }, NamedView { view: View::new(ViewKind::Block, perspective), name: "Default block".into(), }, ] } /// Returns if the file was actually loaded. fn load_file_from_src_args( src_args: &mut SourceArgs, cfg: &mut Config, source: &mut Option, data: &mut Data, msg: &mut MessageDialog, cmd: &mut CommandQueue, ) -> bool { if let Some(file_arg) = &src_args.file { if file_arg.as_os_str() == "-" { *source = Some(Source { provider: SourceProvider::Stdin(std::io::stdin()), attr: SourceAttributes { stream: true, permissions: SourcePermissions { write: false }, }, state: SourceState::default(), }); cmd.push(Cmd::ProcessSourceChange); true } else { let result = try { let mut file = open_file(file_arg, src_args.read_only)?; if let Some(path) = &mut src_args.file { match path.canonicalize() { Ok(canon) => *path = canon, Err(e) => msg.open( Icon::Warn, "Warning", format!( "Failed to canonicalize path {}: {}\n\ Recent use list might not be able to load it back.", path.display(), e ), ), } } cfg.recent.use_(src_args.clone()); if !src_args.stream { if let Some(mmap_mode) = src_args.unsafe_mmap { let mut opts = memmap2::MmapOptions::new(); if let Some(len) = src_args.mmap_len { opts.len(len); } // Safety: // // Memory mapped file access cannot be made 100% safe, not much we can do here. // // The command line option is called `--unsafe-mmap` to reflect this. *data = unsafe { match mmap_mode { crate::args::MmapMode::Cow => { Data::new_mmap_mut(opts.map_copy(&file)?) } crate::args::MmapMode::DangerousMut => { Data::new_mmap_mut(opts.map_mut(&file)?) } crate::args::MmapMode::Ro => Data::new_mmap_immut(opts.map(&file)?), } }; } else { *data = Data::clean_from_buf(read_contents(&*src_args, &mut file)?); } } *source = Some(Source { provider: SourceProvider::File(file), attr: SourceAttributes { stream: src_args.stream, permissions: SourcePermissions { write: !src_args.read_only, }, }, state: SourceState::default(), }); cmd.push(Cmd::ProcessSourceChange); }; match result { Ok(()) => true, Err(e) => { if !src_args.read_only && e.kind() == std::io::ErrorKind::PermissionDenied { eprintln!("Failed to open file: {e}. Retrying read-only."); src_args.read_only = true; return load_file_from_src_args(src_args, cfg, source, data, msg, cmd); } msg_fail(&e, "Failed to open file", msg); false } } } } else { false } } fn open_file(path: &Path, read_only: bool) -> std::io::Result { OpenOptions::new().read(true).write(!read_only).open(path) } pub(crate) fn read_contents(args: &SourceArgs, file: &mut File) -> std::io::Result> { let seek = args.hard_seek.unwrap_or(0); file.seek(SeekFrom::Start(seek as u64))?; let mut data = Vec::new(); match args.take { Some(amount) => (&*file).take(amount as u64).read_to_end(&mut data)?, None => file.read_to_end(&mut data)?, }; Ok(data) } ================================================ FILE: src/args.rs ================================================ use { crate::parse_radix::parse_guess_radix, clap::Parser, serde::{Deserialize, Serialize}, std::path::PathBuf, }; /// Hexerator: Versatile hex editor #[derive(Parser, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] pub struct Args { /// Arguments relating to the source to open #[clap(flatten)] pub src: SourceArgs, /// Open most recently used file #[arg(long)] pub recent: bool, /// Load this metafile #[arg(long, value_name = "path")] pub meta: Option, /// Show version information and exit #[arg(long)] pub version: bool, /// Start with debug logging enabled #[arg(long)] pub debug: bool, /// Spawn and open memory of a command with arguments (must be last option) #[arg(long, value_name="command", allow_hyphen_values=true, num_args=1..)] pub spawn_command: Vec, #[arg(long, value_name = "name")] /// When spawning a command, open the process list with this filter, rather than selecting a pid pub look_for_proc: Option, /// Automatically reload the source for the current buffer in millisecond intervals (default:250) #[arg(long, value_name="interval", default_missing_value="250", num_args=0..=1)] pub autoreload: Option, /// Only autoreload the data visible in the current layout #[arg(long)] pub autoreload_only_visible: bool, /// Automatically save if there is an edited region in the file #[arg(long)] pub autosave: bool, /// Open this layout on startup instead of the default #[arg(long, value_name = "name")] pub layout: Option, /// Focus the first instance of this view on startup #[arg(long, value_name = "name")] pub view: Option, #[arg(long)] /// Load a dynamic library plugin at startup pub load_plugin: Vec, /// Allocate a new (zero-filled) buffer. Also creates the provided file argument if it doesn't exist. #[arg(long, value_name = "length")] pub new: Option, /// Diff against this file #[arg(long, value_name = "path", alias = "diff-with")] pub diff_against: Option, /// Set the initial column count of the default perspective #[arg(short = 'c', long = "col")] pub column_count: Option, } /// Arguments for opening a source (file/stream/process/etc) #[derive(Parser, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)] pub struct SourceArgs { /// The file to read pub file: Option, /// Jump to offset on startup #[arg(short = 'j', long="jump", value_name="offset", value_parser = parse_guess_radix::)] pub jump: Option, /// Seek to offset, consider it beginning of the file in the editor #[arg(long, value_name="offset", value_parser = parse_guess_radix::)] pub hard_seek: Option, /// Read only this many bytes #[arg(long, value_name = "bytes", value_parser = parse_guess_radix::)] pub take: Option, /// Open file as read-only, without writing privileges #[arg(long)] pub read_only: bool, /// Specify source as a streaming source (for example, standard streams). /// Sets read-only attribute. #[arg(long)] pub stream: bool, /// The buffer size in bytes to use for reading when streaming #[arg(long)] #[serde(default)] pub stream_buffer_size: Option, /// Try to open the source using mmap rather than load into a buffer #[serde(default)] #[arg(long, value_name = "mode")] pub unsafe_mmap: Option, /// Assume the memory mapped file is of this length (might be needed for looking at block devices, etc.) #[serde(default)] #[arg(long, value_name = "len")] pub mmap_len: Option, } /// How the memory mapping should operate #[derive( Clone, Copy, clap::ValueEnum, Debug, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr, strum::EnumIter, Default, )] pub enum MmapMode { /// Read-only memory map. /// Note: Some features may not work, as Hexerator was designed for a mutable data buffer. #[default] Ro, /// Copy-on-write memory map. /// Changes are only visible locally. Cow, /// Mutable memory map. /// *WARNING*: Any edits will immediately take effect. THEY CANNOT BE UNDONE. DangerousMut, } ================================================ FILE: src/backend/sfml.rs ================================================ use { crate::{ color::{RgbColor, RgbaColor}, view::{ViewportScalar, ViewportVec}, }, egui_sf2g::sf2g::graphics::Color, }; impl From for RgbaColor { fn from(Color { r, g, b, a }: Color) -> Self { Self { r, g, b, a } } } impl From for Color { fn from(RgbaColor { r, g, b, a }: RgbaColor) -> Self { Self { r, g, b, a } } } impl From for Color { fn from(src: RgbColor) -> Self { Self { r: src.r, g: src.g, b: src.b, a: 255, } } } impl TryFrom> for ViewportVec { type Error = >::Error; fn try_from(sf_vec: sf2g::system::Vector2) -> Result { Ok(Self { x: sf_vec.x.try_into()?, y: sf_vec.y.try_into()?, }) } } ================================================ FILE: src/backend.rs ================================================ #[cfg(feature = "backend-sfml")] mod sfml; ================================================ FILE: src/color.rs ================================================ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] pub struct RgbaColor { pub r: u8, pub g: u8, pub b: u8, pub a: u8, } impl RgbaColor { pub(crate) fn with_as_egui_mut(&mut self, f: impl FnOnce(&mut egui::Color32)) { let mut ec = self.to_egui(); f(&mut ec); *self = Self::from_egui(ec); } fn from_egui(c: egui::Color32) -> Self { Self { r: c.r(), g: c.g(), b: c.b(), a: c.a(), } } fn to_egui(self) -> egui::Color32 { egui::Color32::from_rgba_premultiplied(self.r, self.g, self.b, self.a) } } pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> RgbaColor { RgbaColor { r, g, b, a } } #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] pub struct RgbColor { pub r: u8, pub g: u8, pub b: u8, } impl RgbColor { pub const WHITE: Self = rgb(255, 255, 255); pub fn invert(&self) -> Self { rgb(!self.r, !self.g, !self.b) } pub(crate) fn cap_brightness(&self, limit: u8) -> Self { Self { r: self.r.min(limit), g: self.g.min(limit), b: self.b.min(limit), } } } pub const fn rgb(r: u8, g: u8, b: u8) -> RgbColor { RgbColor { r, g, b } } ================================================ FILE: src/config.rs ================================================ use { crate::{args::SourceArgs, result_ext::AnyhowConv as _}, anyhow::Context as _, directories::ProjectDirs, egui_fontcfg::CustomFontPaths, recently_used_list::RecentlyUsedList, serde::{Deserialize, Serialize}, std::{ collections::{BTreeMap, HashMap}, path::PathBuf, }, }; #[derive(Serialize, Deserialize)] pub struct Config { pub recent: RecentlyUsedList, pub style: Style, /// filepath->meta associations #[serde(default)] pub meta_assocs: MetaAssocs, #[serde(default = "default_vsync")] pub vsync: bool, #[serde(default)] pub fps_limit: u32, #[serde(default)] pub pinned_dirs: Vec, #[serde(default)] pub custom_font_paths: CustomFontPaths, #[serde(default)] pub font_families: BTreeMap>, } #[derive(Serialize, Deserialize)] pub struct PinnedDir { pub path: PathBuf, pub label: String, } const fn default_vsync() -> bool { true } pub type MetaAssocs = HashMap; #[derive(Serialize, Deserialize, Default)] pub struct Style { pub font_sizes: FontSizes, } #[derive(Serialize, Deserialize)] pub struct FontSizes { pub heading: u8, pub body: u8, pub monospace: u8, pub button: u8, pub small: u8, } impl Default for FontSizes { fn default() -> Self { Self { small: 10, body: 14, button: 14, heading: 16, monospace: 14, } } } const DEFAULT_RECENT_CAPACITY: usize = 16; impl Default for Config { fn default() -> Self { let mut recent = RecentlyUsedList::default(); recent.set_capacity(DEFAULT_RECENT_CAPACITY); Self { recent, style: Style::default(), meta_assocs: HashMap::default(), fps_limit: 0, vsync: default_vsync(), pinned_dirs: Vec::new(), custom_font_paths: Default::default(), font_families: Default::default(), } } } pub struct LoadedConfig { pub config: Config, /// If `Some`, saving this config file will overwrite an old one that couldn't be loaded pub old_config_err: Option, } impl Config { pub fn load_or_default() -> anyhow::Result { let proj_dirs = project_dirs().context("Failed to get project dirs")?; let cfg_dir = proj_dirs.config_dir(); if !cfg_dir.exists() { std::fs::create_dir_all(cfg_dir)?; } let cfg_file = cfg_dir.join(FILENAME); if !cfg_file.exists() { Ok(LoadedConfig { config: Self::default(), old_config_err: None, }) } else { let result = try { let cfg_bytes = std::fs::read(cfg_file).how()?; rmp_serde::from_slice(&cfg_bytes).how()? }; match result { Ok(cfg) => Ok(LoadedConfig { config: cfg, old_config_err: None, }), Err(e) => Ok(LoadedConfig { config: Self::default(), old_config_err: Some(e), }), } } } pub fn save(&self) -> anyhow::Result<()> { let bytes = rmp_serde::to_vec(self)?; let proj_dirs = project_dirs().context("Failed to get project dirs")?; let cfg_dir = proj_dirs.config_dir(); std::fs::write(cfg_dir.join(FILENAME), bytes)?; Ok(()) } } pub fn project_dirs() -> Option { ProjectDirs::from("", "crumblingstatue", "hexerator") } pub trait ProjectDirsExt { fn color_theme_path(&self) -> PathBuf; } impl ProjectDirsExt for ProjectDirs { fn color_theme_path(&self) -> PathBuf { self.config_dir().join("egui_colors_theme.pal") } } const FILENAME: &str = "hexerator.cfg"; ================================================ FILE: src/damage_region.rs ================================================ pub enum DamageRegion { Single(usize), Range(std::ops::Range), RangeInclusive(std::ops::RangeInclusive), } impl DamageRegion { pub(crate) fn begin(&self) -> usize { match self { Self::Single(offset) => *offset, Self::Range(range) => range.start, Self::RangeInclusive(range) => *range.start(), } } pub(crate) fn end(&self) -> usize { match self { Self::Single(offset) => *offset, Self::Range(range) => range.end - 1, Self::RangeInclusive(range) => *range.end(), } } } impl From> for DamageRegion { fn from(range: std::ops::RangeInclusive) -> Self { Self::RangeInclusive(range) } } ================================================ FILE: src/data.rs ================================================ use { crate::{damage_region::DamageRegion, meta::region::Region}, std::ops::{Deref, DerefMut}, }; /// The data we are viewing/editing #[derive(Default, Debug)] pub struct Data { data: Option, /// The region that was changed compared to the source pub dirty_region: Option, /// Original data length. Compared with current data length to detect truncation. pub orig_data_len: usize, } enum DataProvider { Vec(Vec), MmapMut(memmap2::MmapMut), MmapImmut(memmap2::Mmap), } impl std::fmt::Debug for DataProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Vec(..) => f.write_str("Vec"), Self::MmapMut(..) => f.write_str("MmapMut"), Self::MmapImmut(..) => f.write_str("MmapImmut"), } } } impl Data { pub(crate) fn clean_from_buf(buf: Vec) -> Self { Self { orig_data_len: buf.len(), data: Some(DataProvider::Vec(buf)), dirty_region: None, } } pub(crate) fn new_mmap_mut(mmap: memmap2::MmapMut) -> Self { Self { orig_data_len: mmap.len(), data: Some(DataProvider::MmapMut(mmap)), dirty_region: None, } } pub(crate) fn new_mmap_immut(mmap: memmap2::Mmap) -> Self { Self { orig_data_len: mmap.len(), data: Some(DataProvider::MmapImmut(mmap)), dirty_region: None, } } /// Drop any expensive allocations and reset to "empty" state pub(crate) fn close(&mut self) { self.data = None; self.dirty_region = None; } pub(crate) fn widen_dirty_region(&mut self, damage: DamageRegion) { match &mut self.dirty_region { Some(dirty_region) => { if damage.begin() < dirty_region.begin { dirty_region.begin = damage.begin(); } if damage.begin() > dirty_region.end { dirty_region.end = damage.begin(); } let end = damage.end(); { if end < dirty_region.begin { gamedebug_core::per!("TODO: logic error in widen_dirty_region"); return; } if end > dirty_region.end { dirty_region.end = end; } } } None => { self.dirty_region = Some(Region { begin: damage.begin(), end: damage.end(), }); } } } /// Clears the dirty region (asserts data is same as source), and sets length same as source pub(crate) fn undirty(&mut self) { self.dirty_region = None; self.orig_data_len = self.len(); } pub(crate) fn resize(&mut self, new_len: usize, value: u8) { match &mut self.data { Some(DataProvider::Vec(v)) => v.resize(new_len, value), etc => { eprintln!("Data::resize: Unimplemented for {etc:?}"); } } } pub(crate) fn extend_from_slice(&mut self, slice: &[u8]) { match &mut self.data { Some(DataProvider::Vec(v)) => v.extend_from_slice(slice), etc => { eprintln!("Data::extend_from_slice: Unimplemented for {etc:?}"); } } } pub(crate) fn drain(&mut self, range: std::ops::Range) { match &mut self.data { Some(DataProvider::Vec(v)) => { v.drain(range); } etc => { eprintln!("Data::drain: Unimplemented for {etc:?}"); } } } pub(crate) fn zero_fill_region(&mut self, region: Region) { let range = region.begin..=region.end; if let Some(data) = self.get_mut(range.clone()) { data.fill(0); self.widen_dirty_region(DamageRegion::RangeInclusive(range)); } } pub(crate) fn reload_from_file( &mut self, src_args: &crate::args::SourceArgs, file: &mut std::fs::File, ) -> anyhow::Result<()> { match &mut self.data { Some(DataProvider::Vec(buf)) => { *buf = crate::app::read_contents(src_args, file)?; } etc => anyhow::bail!("Reload not supported for {etc:?}"), } self.dirty_region = None; Ok(()) } pub(crate) fn mod_range( &mut self, range: std::ops::RangeInclusive, mut f: impl FnMut(&mut u8), ) { for byte in self.get_mut(range.clone()).into_iter().flatten() { f(byte); } self.widen_dirty_region(range.into()); } } impl Deref for Data { type Target = [u8]; fn deref(&self) -> &Self::Target { match &self.data { Some(DataProvider::Vec(v)) => v, Some(DataProvider::MmapMut(map)) => map, Some(DataProvider::MmapImmut(map)) => map, None => &[], } } } impl DerefMut for Data { fn deref_mut(&mut self) -> &mut Self::Target { match &mut self.data { Some(DataProvider::Vec(v)) => v, Some(DataProvider::MmapMut(map)) => map, Some(DataProvider::MmapImmut(_)) => &mut [], None => &mut [], } } } ================================================ FILE: src/dec_conv.rs ================================================ fn byte_10_digits(byte: u8) -> [u8; 3] { [byte / 100, (byte % 100) / 10, byte % 10] } #[test] fn test_byte_10_digits() { assert_eq!(byte_10_digits(255), [2, 5, 5]); } pub fn byte_to_dec_digits(byte: u8) -> [u8; 3] { const TABLE: &[u8; 10] = b"0123456789"; let [a, b, c] = byte_10_digits(byte); [TABLE[a as usize], TABLE[b as usize], TABLE[c as usize]] } #[test] fn test_byte_to_dec_digits() { let pairs = [ (255, b"255"), (0, b"000"), (1, b"001"), (15, b"015"), (16, b"016"), (154, b"154"), (167, b"167"), (6, b"006"), (64, b"064"), (127, b"127"), (128, b"128"), (129, b"129"), ]; for (byte, hex) in pairs { assert_eq!(byte_to_dec_digits(byte), *hex); } } ================================================ FILE: src/edit_buffer.rs ================================================ use gamedebug_core::per; #[derive(Debug, Default, Clone)] pub struct EditBuffer { pub buf: Vec, pub cursor: u16, /// Whether this edit buffer has been edited pub dirty: bool, } impl EditBuffer { pub(crate) fn resize(&mut self, new_size: u16) { self.buf.resize(usize::from(new_size), 0); } /// Enter a byte. Returns if editing is "finished" (at end) pub(crate) fn enter_byte(&mut self, byte: u8) -> bool { self.dirty = true; self.buf[self.cursor as usize] = byte; self.cursor += 1; if usize::from(self.cursor) >= self.buf.len() { self.reset(); true } else { false } } pub fn reset(&mut self) { self.cursor = 0; self.dirty = false; } pub(crate) fn update_from_string(&mut self, s: &str) { let bytes = s.as_bytes(); self.buf[..bytes.len()].copy_from_slice(bytes); } /// Returns whether the cursor could be moved any further pub(crate) fn move_cursor_back(&mut self) -> bool { if self.cursor == 0 { false } else { self.cursor -= 1; true } } /// Move the cursor to the end #[expect( clippy::cast_possible_truncation, reason = "Buffer is never bigger than u16::MAX" )] pub(crate) fn move_cursor_end(&mut self) { self.cursor = (self.buf.len() - 1) as u16; } /// Returns whether the cursor could be moved any further #[expect( clippy::cast_possible_truncation, reason = "Buffer is never bigger than u16::MAX" )] pub(crate) fn move_cursor_forward(&mut self) -> bool { if self.cursor >= self.buf.len() as u16 - 1 { false } else { per!("Moving cursor forward, no problem"); self.cursor += 1; true } } pub(crate) fn move_cursor_begin(&mut self) { self.cursor = 0; } } ================================================ FILE: src/find_util.rs ================================================ pub fn find_hex_string( hex_string: &str, haystack: &[u8], mut f: impl FnMut(usize), ) -> anyhow::Result<()> { let needle = parse_hex_string(hex_string)?; for offset in memchr::memmem::find_iter(haystack, &needle) { f(offset); } Ok(()) } enum HexStringSepKind { Comma, Whitespace, Dense, } fn detect_hex_string_sep_kind(hex_string: &str) -> HexStringSepKind { if hex_string.contains(',') { HexStringSepKind::Comma } else if hex_string.contains(char::is_whitespace) { HexStringSepKind::Whitespace } else { HexStringSepKind::Dense } } fn chunks_2(input: &str) -> impl Iterator> { input .as_bytes() .as_chunks::<2>() .0 .iter() .map(|pair| std::str::from_utf8(pair).map_err(anyhow::Error::from)) } pub fn parse_hex_string(hex_string: &str) -> anyhow::Result> { match detect_hex_string_sep_kind(hex_string) { HexStringSepKind::Comma => { hex_string.split(',').map(|tok| parse_hex_token(tok.trim())).collect() } HexStringSepKind::Whitespace => { hex_string.split_whitespace().map(parse_hex_token).collect() } HexStringSepKind::Dense => chunks_2(hex_string).map(|tok| parse_hex_token(tok?)).collect(), } } fn parse_hex_token(tok: &str) -> anyhow::Result { Ok(u8::from_str_radix(tok, 16)?) } #[test] fn test_parse_hex_string() { assert_eq!( parse_hex_string("de ad be ef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef] ); assert_eq!( parse_hex_string("de, ad, be, ef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef] ); assert_eq!( parse_hex_string("deadbeef").unwrap(), vec![0xde, 0xad, 0xbe, 0xef] ); } ================================================ FILE: src/gui/bottom_panel.rs ================================================ use { super::{Gui, dialogs::JumpDialog, egui_ui_ext::EguiResponseExt as _}, crate::{ app::{App, interact_mode::InteractMode}, meta::find_most_specific_region_for_offset, shell::msg_if_fail, util::human_size, view::ViewportVec, }, constcat::concat, egui::{Align, Color32, DragValue, Stroke, TextFormat, TextStyle, Ui, text::LayoutJob}, egui_phosphor::regular as ic, slotmap::Key as _, }; const L_SCROLL: &str = concat!(ic::MOUSE_SCROLL, " scroll"); pub fn ui(ui: &mut Ui, app: &mut App, mouse_pos: ViewportVec, gui: &mut Gui) { ui.horizontal(|ui| { let job = key_label(ui, "F1", "View"); if ui .selectable_label(app.hex_ui.interact_mode == InteractMode::View, job) .clicked() { app.hex_ui.interact_mode = InteractMode::View; } ui.style_mut().visuals.selection.bg_fill = Color32::from_rgb(168, 150, 32); let job = key_label(ui, "F2", "Edit"); if ui .selectable_label(app.hex_ui.interact_mode == InteractMode::Edit, job) .clicked() { app.hex_ui.interact_mode = InteractMode::Edit; } ui.separator(); let data_len = app.data.len(); if data_len != 0 && let Some(view_key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[view_key].view; let per = match app.meta_state.meta.low.perspectives.get_mut(view.perspective) { Some(per) => per, None => { ui.label("Invalid perspective key"); return; } }; ui.label("offset"); ui.add(DragValue::new( &mut app.meta_state.meta.low.regions[per.region].region.begin, )); ui.label("columns"); ui.add(DragValue::new(&mut per.cols)); let offsets = view.offsets( &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, ); let re = ui.button(L_SCROLL); if re.clicked() { gui.show_quick_scroll_popup ^= true; } #[expect( clippy::cast_precision_loss, reason = "Precision is good until 52 bits (more than reasonable)" )] let mut ratio = offsets.byte as f64 / data_len as f64; if gui.show_quick_scroll_popup { let avail_w = ui.available_width(); egui::Window::new("quick_scroll_popup") .resizable(false) .title_bar(false) .fixed_pos(re.rect.right_top()) .show(ui.ctx(), |ui| { ui.spacing_mut().slider_width = avail_w * 0.8; let re = ui.add( egui::Slider::new(&mut ratio, 0.0..=1.0) .custom_formatter(|n, _| format!("{:.2}%", n * 100.)), ); if re.changed() { // This is used for a rough scroll, so lossy conversion is to be expected #[expect( clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss )] let new_off = (app.data.len() as f64 * ratio) as usize; view.scroll_to_byte_offset( new_off, &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, false, true, ); } ui.horizontal(|ui| { ui.label(human_size(offsets.byte)); if ui.button("Close").clicked() { gui.show_quick_scroll_popup = false; } }); }); } ui.label(format!( "row {} col {} byte {} ({:.2}%)", offsets.row, offsets.col, offsets.byte, ratio * 100.0 )) .on_hover_text_deferred(|| human_size(offsets.byte)); } ui.separator(); let [row, col] = app.row_col_of_cursor().unwrap_or([0, 0]); let mut text = egui::RichText::new(format!( "cursor: {} ({:x}) [r{row} c{col}]", app.edit_state.cursor, app.edit_state.cursor, )); let out_of_bounds = app.edit_state.cursor >= app.data.len(); let cursor_end = app.edit_state.cursor == app.data.len().saturating_sub(1); let cursor_begin = app.edit_state.cursor == 0; if out_of_bounds { text = text.color(Color32::RED); } else if cursor_end { text = text.color(Color32::YELLOW); } else if cursor_begin { text = text.color(Color32::GREEN); } let re = ui.label(text); re.context_menu(|ui| { if ui.button("Copy").clicked() { let result = app.clipboard.set_text(app.edit_state.cursor.to_string()); msg_if_fail(result, "Failed to set clipboard text", &mut gui.msg_dialog); } if ui.button("Copy absolute").on_hover_text("Hard seek + cursor").clicked() { let result = app.clipboard.set_text( (app.edit_state.cursor + app.src_args.hard_seek.unwrap_or(0)).to_string(), ); msg_if_fail(result, "Failed to set clipboard text", &mut gui.msg_dialog); } }); if re.clicked() { Gui::add_dialog(&mut gui.dialogs, JumpDialog::default()); } if out_of_bounds { re.on_hover_text("Cursor is out of bounds"); } else if cursor_end { re.on_hover_text("Cursor is at end of document"); } else if cursor_begin { re.on_hover_text("Cursor is at beginning"); } else { re.on_hover_text_deferred(|| human_size(app.edit_state.cursor)); } if let Some(label) = app .meta_state .meta .bookmarks .iter() .find_map(|bm| (bm.offset == app.edit_state.cursor).then_some(bm.label.as_str())) { ui.label(egui::RichText::new(label).color(Color32::from_rgb(150, 170, 40))); } if let Some(region) = find_most_specific_region_for_offset( &app.meta_state.meta.low.regions, app.edit_state.cursor, ) { let reg = &app.meta_state.meta.low.regions[region]; region_label(ui, ®.name).context_menu(|ui| { if ui.button("Select").clicked() { app.hex_ui.select_a = Some(reg.region.begin); app.hex_ui.select_b = Some(reg.region.end); } }); } if !app.hex_ui.current_layout.is_null() && let Some((offset, _view_idx)) = app.byte_offset_at_pos(mouse_pos.x, mouse_pos.y) { let [row, col] = app.row_col_of_byte_pos(offset).unwrap_or([0, 0]); ui.label(format!("mouse: {offset} ({offset:x}) [r{row} c{col}]")); if let Some(region) = find_most_specific_region_for_offset(&app.meta_state.meta.low.regions, offset) { region_label(ui, &app.meta_state.meta.low.regions[region].name); } } ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { let mut txt = egui::RichText::new(format!("File size: {}", app.data.len())); let truncated = app.data.len() != app.data.orig_data_len; if truncated { txt = txt.color(Color32::RED); } let label = egui::Label::new(txt).sense(egui::Sense::click()); let mut label_re = ui.add(label).on_hover_ui(|ui| { ui.label("Click to copy"); ui.label(format!("Human size: {}", human_size(app.data.len()))); }); if truncated { label_re = label_re.on_hover_text_deferred(|| { format!("Length changed, orig.: {}", app.data.orig_data_len) }); } if label_re.clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &app.data.len().to_string(), ); } }); }); } fn region_label(ui: &mut Ui, name: &str) -> egui::Response { let label = egui::Label::new(egui::RichText::new(format!("[{name}]")).color(Color32::LIGHT_BLUE)) .sense(egui::Sense::click()); ui.add(label) } /// A key "box" and then some text. Like `[F1] View` fn key_label(ui: &Ui, key_text: &str, label_text: &str) -> LayoutJob { let mut job = LayoutJob::default(); let style = ui.style(); let body_font = TextStyle::Body.resolve(style); job.append( key_text, 0.0, TextFormat { font_id: body_font.clone(), color: style.visuals.widgets.active.fg_stroke.color, background: style.visuals.code_bg_color, italics: false, underline: Stroke::NONE, strikethrough: Stroke::NONE, valign: Align::Center, ..Default::default() }, ); job.append( label_text, 10.0, TextFormat::simple(body_font, style.visuals.widgets.active.fg_stroke.color), ); job } ================================================ FILE: src/gui/command.rs ================================================ //! This module is similar in purpose to [`crate::app::command`]. //! //! See that module for more information. use { super::Gui, crate::shell::msg_fail, std::{collections::VecDeque, process::Command}, sysinfo::ProcessesToUpdate, }; pub enum GCmd { OpenPerspectiveWindow, /// Spawn a command with optional arguments. Must not be an empty vector. SpawnCommand { args: Vec, /// If `Some`, don't focus a pid, just filter for this process in the list. /// /// The idea is that if your command spawns a child process, it might not spawn immediately, /// so the user can wait for it to appear on the process list, with the applied filter. look_for_proc: Option, }, } /// Gui command queue. /// /// Push operations with `push`, and call [`Gui::flush_command_queue`] when you have /// exclusive access to the [`Gui`]. /// /// [`Gui::flush_command_queue`] is called automatically every frame, if you don't need to perform the operations sooner. #[derive(Default)] pub struct GCommandQueue { inner: VecDeque, } impl GCommandQueue { pub fn push(&mut self, command: GCmd) { self.inner.push_back(command); } } impl Gui { /// Flush the [`GCommandQueue`] and perform all operations queued up. /// /// Automatically called every frame, but can be called manually if operations need to be /// performed sooner. pub fn flush_command_queue(&mut self) { while let Some(cmd) = self.cmd.inner.pop_front() { perform_command(self, cmd); } } } fn perform_command(gui: &mut Gui, cmd: GCmd) { match cmd { GCmd::OpenPerspectiveWindow => gui.win.perspectives.open.set(true), GCmd::SpawnCommand { mut args, look_for_proc, } => { let cmd = args.remove(0); match Command::new(cmd).args(args).spawn() { Ok(child) => { gui.win.open_process.open.set(true); match look_for_proc { Some(procname) => { gui.win .open_process .sys .refresh_processes(ProcessesToUpdate::All, true); gui.win.open_process.filters.proc_name = procname; } None => { gui.win.open_process.selected_pid = Some(sysinfo::Pid::from_u32(child.id())); } } } Err(e) => { msg_fail(&e, "Failed to spawn command", &mut gui.msg_dialog); } } } } } ================================================ FILE: src/gui/dialogs/auto_save_reload.rs ================================================ use { crate::{app::App, gui::Dialog, session_prefs::Autoreload}, mlua::Lua, }; #[derive(Debug)] pub struct AutoSaveReloadDialog; impl Dialog for AutoSaveReloadDialog { fn title(&self) -> &str { "Auto save/reload" } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, _gui: &mut crate::gui::Gui, _lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { egui::ComboBox::from_label("Auto reload") .selected_text(app.preferences.auto_reload.label()) .show_ui(ui, |ui| { ui.selectable_value( &mut app.preferences.auto_reload, Autoreload::Disabled, Autoreload::Disabled.label(), ); ui.selectable_value( &mut app.preferences.auto_reload, Autoreload::All, Autoreload::All.label(), ); ui.selectable_value( &mut app.preferences.auto_reload, Autoreload::Visible, Autoreload::Visible.label(), ); }); ui.horizontal(|ui| { ui.label("Interval (ms)"); ui.add(egui::DragValue::new( &mut app.preferences.auto_reload_interval_ms, )); }); ui.separator(); ui.checkbox(&mut app.preferences.auto_save, "Auto save") .on_hover_text("Save every time an editing action is finished"); ui.separator(); !(ui.button("Close (enter/esc)").clicked() || ui.input(|inp| inp.key_pressed(egui::Key::Escape)) || ui.input(|inp| inp.key_pressed(egui::Key::Enter))) } } ================================================ FILE: src/gui/dialogs/jump.rs ================================================ use { crate::{ app::App, gui::Dialog, parse_radix::{Relativity, parse_offset_maybe_relative}, shell::msg_fail, }, mlua::Lua, }; #[derive(Debug, Default)] pub struct JumpDialog { string_buf: String, absolute: bool, just_opened: bool, } impl Dialog for JumpDialog { fn title(&self) -> &str { "Jump" } fn on_open(&mut self) { self.just_opened = true; } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, gui: &mut crate::gui::Gui, _lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { ui.horizontal(|ui| { ui.label("Offset"); let re = ui.text_edit_singleline(&mut self.string_buf); if self.just_opened { re.request_focus(); } }); self.just_opened = false; ui.label( "Accepts both decimal and hexadecimal.\nPrefix with `0x` to force hex.\n\ Prefix with `+` to add to current offset, `-` to subtract", ); if let Some(hard_seek) = app.src_args.hard_seek { ui.checkbox(&mut self.absolute, "Absolute") .on_hover_text("Subtract the offset from hard-seek"); let label = format!("hard-seek is at {hard_seek} (0x{hard_seek:X})"); ui.text_edit_multiline(&mut &label[..]); } if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { // Just close the dialog without error on empty text input if self.string_buf.trim().is_empty() { return false; } match parse_offset_maybe_relative(&self.string_buf) { Ok((offset, relativity)) => { let offset = match relativity { Relativity::Absolute => { if let Some(hard_seek) = app.src_args.hard_seek && self.absolute { offset.saturating_sub(hard_seek) } else { offset } } Relativity::RelAdd => app.edit_state.cursor.saturating_add(offset), Relativity::RelSub => app.edit_state.cursor.saturating_sub(offset), }; app.edit_state.cursor = offset; app.center_view_on_offset(offset); app.hex_ui.flash_cursor(); false } Err(e) => { msg_fail(&e, "Failed to parse offset", &mut gui.msg_dialog); true } } } else { !(ui.input(|inp| inp.key_pressed(egui::Key::Escape))) } } } ================================================ FILE: src/gui/dialogs/lua_color.rs ================================================ use { crate::{app::App, gui::Dialog, value_color::ColorMethod}, mlua::{Function, Lua}, }; pub struct LuaColorDialog { script: String, err_string: String, auto_exec: bool, } impl Default for LuaColorDialog { fn default() -> Self { const DEFAULT_SCRIPT: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/lua/color.lua")); Self { script: DEFAULT_SCRIPT.into(), err_string: String::new(), auto_exec: Default::default(), } } } impl Dialog for LuaColorDialog { fn title(&self) -> &str { "Lua color" } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, _gui: &mut crate::gui::Gui, lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { let color_data = match app.hex_ui.focused_view { Some(view_key) => { let view = &mut app.meta_state.meta.views[view_key].view; match &mut view.presentation.color_method { ColorMethod::Custom(color_data) => &mut color_data.0, _ => { ui.label("Please select \"Custom\" as color scheme for the current view"); return !ui.button("Close").clicked(); } } } None => { ui.label("No active view"); return !ui.button("Close").clicked(); } }; egui::TextEdit::multiline(&mut self.script) .code_editor() .desired_width(f32::INFINITY) .show(ui); ui.horizontal(|ui| { if ui.button("Execute").clicked() || self.auto_exec { let chunk = lua.load(&self.script); let res = try { let fun = chunk.eval::()?; for (i, c) in color_data.iter_mut().enumerate() { let rgb: [u8; 3] = fun.call((i,))?; *c = rgb; } }; if let Err(e) = res { self.err_string = e.to_string(); } else { self.err_string.clear(); } } ui.checkbox(&mut self.auto_exec, "Auto execute"); }); if !self.err_string.is_empty() { ui.label(egui::RichText::new(&self.err_string).color(egui::Color32::RED)); } if ui.button("Close").clicked() { return false; } true } } ================================================ FILE: src/gui/dialogs/lua_fill.rs ================================================ use { crate::{app::App, gui::Dialog, shell::msg_if_fail}, egui_code_editor::{CodeEditor, Syntax}, mlua::{Function, Lua}, std::time::Instant, }; #[derive(Debug, Default)] pub struct LuaFillDialog { result_info_string: String, err: bool, } impl Dialog for LuaFillDialog { fn title(&self) -> &str { "Lua fill" } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, gui: &mut crate::gui::Gui, lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { let Some(sel) = app.hex_ui.selection() else { ui.heading("No active selection"); return !ui.button("Close").clicked(); }; let ctrl_enter = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::Enter)); let ctrl_s = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::S)); if ctrl_s { msg_if_fail( app.save(&mut gui.msg_dialog), "Failed to save", &mut gui.msg_dialog, ); } egui::ScrollArea::vertical() // 100.0 is an estimation of ui size below. // If we don't subtract that, the text edit tries to expand // beyond window height .max_height(ui.available_height() - 100.0) .show(ui, |ui| { CodeEditor::default() .with_syntax(Syntax::lua()) .show(ui, &mut app.meta_state.meta.misc.fill_lua_script); }); if ui.button("Execute").clicked() || ctrl_enter { let start_time = Instant::now(); let chunk = lua.load(&app.meta_state.meta.misc.fill_lua_script); let res = try { let f = chunk.eval::()?; for (i, b) in app.data[sel.begin..=sel.end].iter_mut().enumerate() { *b = f.call((i, *b))?; } app.data.dirty_region = Some(sel); }; if let Err(e) = res { self.result_info_string = e.to_string(); self.err = true; } else { self.result_info_string = format!("Script took {} ms", start_time.elapsed().as_millis()); self.err = false; } } if app.data.dirty_region.is_some() { ui.label( egui::RichText::new("Unsaved changes") .italics() .color(egui::Color32::YELLOW) .code(), ); } else { ui.label(egui::RichText::new("No unsaved changes").color(egui::Color32::GREEN).code()); } ui.label("ctrl+enter to execute, ctrl+s to save file"); if !self.result_info_string.is_empty() { if self.err { ui.label(egui::RichText::new(&self.result_info_string).color(egui::Color32::RED)); } else { ui.label(&self.result_info_string); } } true } fn has_close_button(&self) -> bool { true } } ================================================ FILE: src/gui/dialogs/pattern_fill.rs ================================================ use { crate::{ app::App, damage_region::DamageRegion, find_util, gui::{Dialog, message_dialog::Icon}, slice_ext::SliceExt as _, }, mlua::Lua, }; #[derive(Debug, Default)] pub struct PatternFillDialog { pattern_string: String, just_opened: bool, } impl Dialog for PatternFillDialog { fn title(&self) -> &str { "Selection pattern fill" } fn on_open(&mut self) { self.just_opened = true; } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, gui: &mut crate::gui::Gui, _lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { let re = ui.add( egui::TextEdit::singleline(&mut self.pattern_string) .hint_text("Hex pattern (e.g. `00 ff 00`)"), ); if self.just_opened { re.request_focus(); } self.just_opened = false; if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { let values: Result, _> = find_util::parse_hex_string(&self.pattern_string); match values { Ok(values) => { for reg in app.hex_ui.selected_regions() { let range = reg.to_range(); let Some(data_slice) = app.data.get_mut(range.clone()) else { gui.msg_dialog.open(Icon::Error, "Pattern fill error", format!("Invalid range for fill.\nRequested range: {range:?}\nData length: {}", app.data.len())); return false; }; data_slice.pattern_fill(&values); app.data.widen_dirty_region(DamageRegion::RangeInclusive(range)); } false } Err(e) => { gui.msg_dialog.open(Icon::Error, "Fill parse error", e.to_string()); true } } } else { true } } fn has_close_button(&self) -> bool { true } } ================================================ FILE: src/gui/dialogs/truncate.rs ================================================ use { crate::{app::App, gui::Dialog, meta::region::Region}, egui::{Button, DragValue}, mlua::Lua, }; pub struct TruncateDialog { begin: usize, end: usize, } impl TruncateDialog { pub fn new(data_len: usize, selection: Option) -> Self { let (begin, end) = match selection { Some(region) => (region.begin, region.end), None => (0, data_len.saturating_sub(1)), }; Self { begin, end } } } impl Dialog for TruncateDialog { fn title(&self) -> &str { "Truncate/Extend" } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, _gui: &mut crate::gui::Gui, _lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { ui.horizontal(|ui| { ui.label("Begin"); ui.add(DragValue::new(&mut self.begin).range(0..=self.end.saturating_sub(1))); if ui .add_enabled( self.begin != app.edit_state.cursor, Button::new("From cursor"), ) .clicked() { self.begin = app.edit_state.cursor; } }); ui.horizontal(|ui| { ui.label("End"); ui.add(DragValue::new(&mut self.end)); if ui .add_enabled( self.end != app.edit_state.cursor, Button::new("From cursor"), ) .clicked() { self.end = app.edit_state.cursor; } }); let new_len = (self.end + 1) - self.begin; let mut text = egui::RichText::new(format!("New length: {new_len}")); match new_len.cmp(&app.data.orig_data_len) { std::cmp::Ordering::Less => text = text.color(egui::Color32::RED), std::cmp::Ordering::Equal => {} std::cmp::Ordering::Greater => text = text.color(egui::Color32::YELLOW), } ui.label(text); if let Some(sel) = app.hex_ui.selection() { if ui .add_enabled( !(sel.begin == self.begin && sel.end == self.end), Button::new("From selection"), ) .clicked() { self.begin = sel.begin; self.end = sel.end; } } else { ui.add_enabled(false, Button::new("From selection")); } ui.separator(); let text = egui::RichText::new("⚠ Truncate/Extend ⚠").color(egui::Color32::RED); let mut retain = true; ui.horizontal(|ui| { if ui .button(text) .on_hover_text("This will change the length of the data") .clicked() { app.data.resize(self.end + 1, 0); app.data.drain(0..self.begin); app.hex_ui.clear_selections(); app.data.dirty_region = Some(Region { begin: 0, end: app.data.len(), }); } if ui.button("Close").clicked() { retain = false; } }); retain } } ================================================ FILE: src/gui/dialogs/x86_asm.rs ================================================ use { crate::{app::App, gui::Dialog}, egui::Button, iced_x86::{Decoder, Formatter as _, NasmFormatter}, mlua::Lua, }; pub struct X86AsmDialog { decoded: Vec, bitness: u32, } impl X86AsmDialog { pub fn new() -> Self { Self { decoded: Vec::new(), bitness: 64, } } } impl Dialog for X86AsmDialog { fn title(&self) -> &str { "X86 assembly" } fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, _gui: &mut crate::gui::Gui, _lua: &Lua, _font_size: u16, _line_spacing: u16, ) -> bool { let mut retain = true; egui::ScrollArea::vertical() .auto_shrink(false) .max_height(320.0) .show(ui, |ui| { egui::Grid::new("asm_grid").num_columns(2).show(ui, |ui| { for instr in &self.decoded { let Some(sel_begin) = app.hex_ui.selection().map(|sel| sel.begin) else { ui.label("No selection"); return; }; let instr_off = instr.offset + sel_begin; if ui.link(instr_off.to_string()).clicked() { app.search_focus(instr_off); } ui.label(&instr.string); ui.end_row(); } }); }); ui.separator(); match app.hex_ui.selection() { Some(sel) => { if ui.button("Disassemble").clicked() { self.decoded = disasm(&app.data[sel.begin..=sel.end], self.bitness); } } None => { ui.add_enabled(false, Button::new("Disassemble")); } } ui.horizontal(|ui| { ui.label("Bitness"); ui.radio_value(&mut self.bitness, 16, "16"); ui.radio_value(&mut self.bitness, 32, "32"); ui.radio_value(&mut self.bitness, 64, "64"); }); if ui.button("Close").clicked() { retain = false; } retain } } struct DecodedInstr { string: String, offset: usize, } fn disasm(data: &[u8], bitness: u32) -> Vec { let mut decoder = Decoder::new(bitness, data, 0); let mut fmt = NasmFormatter::default(); let mut vec = Vec::new(); while decoder.can_decode() { let offset = decoder.position(); let instr = decoder.decode(); let mut string = String::new(); fmt.format(&instr, &mut string); vec.push(DecodedInstr { string, offset }); } vec } ================================================ FILE: src/gui/dialogs.rs ================================================ mod auto_save_reload; mod jump; mod lua_color; mod lua_fill; pub mod pattern_fill; mod truncate; mod x86_asm; pub use { auto_save_reload::AutoSaveReloadDialog, jump::JumpDialog, lua_color::LuaColorDialog, lua_fill::LuaFillDialog, pattern_fill::PatternFillDialog, truncate::TruncateDialog, x86_asm::X86AsmDialog, }; ================================================ FILE: src/gui/egui_ui_ext.rs ================================================ pub trait EguiResponseExt { fn on_hover_text_deferred(self, text_fun: F) -> Self where F: FnOnce() -> R, R: Into; } impl EguiResponseExt for egui::Response { fn on_hover_text_deferred(self, text_fun: F) -> Self where F: FnOnce() -> R, R: Into, { // Yoinked from egui source self.on_hover_ui(|ui| { // Prevent `Area` auto-sizing from shrinking tooltips with dynamic content. // See https://github.com/emilk/egui/issues/5167 ui.set_max_width(ui.spacing().tooltip_width); ui.add(egui::Label::new(text_fun())); }) } } ================================================ FILE: src/gui/file_ops.rs ================================================ use { crate::{ app::App, args::{MmapMode, SourceArgs}, gui::{message_dialog::MessageDialog, windows::FileDiffResultWindow}, meta::{ViewKey, region::Region}, result_ext::AnyhowConv as _, shell::{msg_fail, msg_if_fail}, source::Source, util::human_size_u64, value_color::{self, ColorMethod}, }, anyhow::Context as _, egui_file_dialog::FileDialog, std::{ io::Write as _, path::{Path, PathBuf}, }, strum::IntoEnumIterator as _, }; struct EntInfo { meta: std::io::Result, mime: Option<&'static str>, } type PreviewCache = PathCache; pub struct FileOps { pub dialog: FileDialog, pub op: Option, preview_cache: PreviewCache, file_dialog_source_args: SourceArgs, } impl Default for FileOps { fn default() -> Self { Self { dialog: FileDialog::new() .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0., 0.)) .allow_path_edit_to_save_file_without_extension(true), op: Default::default(), preview_cache: PathCache::default(), file_dialog_source_args: SourceArgs::default(), } } } pub struct PathCache { key: PathBuf, value: Option, } impl Default for PathCache { fn default() -> Self { Self { key: PathBuf::default(), value: None, } } } impl PathCache { fn get_or_compute V>(&mut self, k: &Path, f: F) -> &V { if self.key != k { self.key = k.to_path_buf(); self.value.insert(f(k)) } else { self.value.get_or_insert_with(|| { self.key = k.to_path_buf(); f(k) }) } } } #[derive(Debug)] pub enum FileOp { LoadMetaFile, LoadFile, LoadPaletteForView(ViewKey), LoadPaletteFromImageForView(ViewKey), DiffWithFile, LoadLuaScript, SavePaletteForView(ViewKey), SaveFileAs, SaveLuaScript, SaveMetaFileAs, SaveSelectionToFile(Region), } impl FileOps { pub fn update( &mut self, ctx: &egui::Context, app: &mut App, msg: &mut MessageDialog, file_diff_result_window: &mut FileDiffResultWindow, font_size: u16, line_spacing: u16, ) { self.dialog.update_with_right_panel_ui(ctx, &mut |ui, dia| { let src_args = self .op .as_ref() .is_some_and(|op| matches!(op, FileOp::LoadFile)) .then_some(&mut self.file_dialog_source_args); right_panel_ui(ui, dia, &mut self.preview_cache, src_args); }); if let Some(path) = self.dialog.take_picked() && let Some(op) = self.op.take() { match op { FileOp::LoadMetaFile => { msg_if_fail( app.consume_meta_from_file(path, false), "Failed to load metafile", msg, ); } FileOp::LoadFile => { self.file_dialog_source_args.file = Some(path); app.load_file_args( self.file_dialog_source_args.clone(), None, msg, font_size, line_spacing, None, ); } FileOp::LoadPaletteForView(key) => match value_color::load_palette(&path) { Ok(pal) => { let view = &mut app.meta_state.meta.views[key].view; view.presentation.color_method = ColorMethod::Custom(Box::new(pal)); } Err(e) => msg_fail(&e, "Failed to load pal", msg), }, FileOp::LoadPaletteFromImageForView(key) => { let view = &mut app.meta_state.meta.views[key].view; let ColorMethod::Custom(pal) = &mut view.presentation.color_method else { return; }; let result = try { let img = image::open(path).context("Failed to load image")?.to_rgb8(); let (width, height) = (img.width(), img.height()); let sel = app.hex_ui.selection().context("Missing app selection")?; let mut i = 0; for y in 0..height { for x in 0..width { let &image::Rgb(rgb) = img.get_pixel(x, y); let Some(byte) = app.data.get(sel.begin + i) else { break; }; pal.0[*byte as usize] = rgb; i += 1; } } }; msg_if_fail(result, "Failed to load palette from reference image", msg); } FileOp::DiffWithFile => { msg_if_fail( app.diff_with_file(path, file_diff_result_window), "Failed to diff", msg, ); } FileOp::LoadLuaScript => { let res = try { app.meta_state.meta.misc.exec_lua_script = std::fs::read_to_string(path).how()?; }; msg_if_fail(res, "Failed to load script", msg); } FileOp::SavePaletteForView(key) => { let view = &mut app.meta_state.meta.views[key].view; let ColorMethod::Custom(pal) = &view.presentation.color_method else { return; }; msg_if_fail( value_color::save_palette(pal, &path), "Failed to save pal", msg, ); } FileOp::SaveFileAs => { let result = try { let mut f = std::fs::OpenOptions::new() .create(true) .truncate(true) .read(true) .write(true) .open(&path) .how()?; f.write_all(&app.data).how()?; app.source = Some(Source::file(f)); app.src_args.file = Some(path); app.cfg.recent.use_(SourceArgs { file: app.src_args.file.clone(), jump: None, hard_seek: None, take: None, read_only: false, stream: false, stream_buffer_size: None, unsafe_mmap: None, mmap_len: None, }); }; msg_if_fail(result, "Failed to save as", msg); } FileOp::SaveLuaScript => { msg_if_fail( std::fs::write(path, &app.meta_state.meta.misc.exec_lua_script), "Failed to save script", msg, ); } FileOp::SaveMetaFileAs => { msg_if_fail( app.save_meta_to_file(path, false), "Failed to save metafile", msg, ); } FileOp::SaveSelectionToFile(sel) => { let result = std::fs::write(path, &app.data[sel.begin..=sel.end]); msg_if_fail(result, "Failed to save selection to file", msg); } } } } pub fn load_file(&mut self, source_file: Option<&Path>) { if let Some(path) = source_file && let Some(parent) = path.parent() { let cfg = self.dialog.config_mut(); parent.clone_into(&mut cfg.initial_directory); } self.dialog.pick_file(); self.op = Some(FileOp::LoadFile); } pub fn load_meta_file(&mut self) { self.dialog.pick_file(); self.op = Some(FileOp::LoadMetaFile); } pub fn load_palette_for_view(&mut self, key: ViewKey) { self.dialog.pick_file(); self.op = Some(FileOp::LoadPaletteForView(key)); } pub fn load_palette_from_image_for_view(&mut self, view_key: ViewKey) { self.dialog.pick_file(); self.op = Some(FileOp::LoadPaletteFromImageForView(view_key)); } pub fn diff_with_file(&mut self, source_file: Option<&Path>) { if let Some(path) = source_file && let Some(parent) = path.parent() { self.dialog.config_mut().initial_directory = parent.to_owned(); } self.dialog.pick_file(); self.op = Some(FileOp::DiffWithFile); } pub fn load_lua_script(&mut self) { self.dialog.pick_file(); self.op = Some(FileOp::LoadLuaScript); } pub fn save_palette_for_view(&mut self, view_key: ViewKey) { self.dialog.save_file(); self.op = Some(FileOp::SavePaletteForView(view_key)); } pub(crate) fn save_file_as(&mut self) { self.dialog.save_file(); self.op = Some(FileOp::SaveFileAs); } pub(crate) fn save_lua_script(&mut self) { self.dialog.save_file(); self.op = Some(FileOp::SaveLuaScript); } pub(crate) fn save_metafile_as(&mut self) { self.dialog.save_file(); self.op = Some(FileOp::SaveMetaFileAs); } pub(crate) fn save_selection_to_file(&mut self, region: Region) { self.dialog.save_file(); self.op = Some(FileOp::SaveSelectionToFile(region)); } } fn right_panel_ui( ui: &mut egui::Ui, dia: &FileDialog, preview_cache: &mut PreviewCache, src_args: Option<&mut SourceArgs>, ) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); if let Some(highlight) = dia.selected_entry() { if let Some(parent) = highlight.as_path().parent() { ui.label(egui::RichText::new(parent.display().to_string()).small()); } if let Some(filename) = highlight.as_path().file_name() { ui.label(filename.to_string_lossy()); } ui.separator(); let ent_info = preview_cache.get_or_compute(highlight.as_path(), |path| EntInfo { meta: std::fs::metadata(path), mime: tree_magic_mini::from_filepath(path), }); if let Some(mime) = ent_info.mime { ui.label(mime); } match &ent_info.meta { Ok(meta) => { let ft = meta.file_type(); if ft.is_file() { ui.label(format!("Size: {}", human_size_u64(meta.len()))); } if ft.is_symlink() { ui.label("Symbolic link"); } if !(ft.is_file() || ft.is_dir()) { ui.label(format!("Special (size: {})", meta.len())); } } Err(e) => { ui.label(e.to_string()); } } ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if ui.button("📋 Copy path to clipboard").clicked() { ui.ctx().copy_text(highlight.as_path().display().to_string()); } } else { ui.heading("Hexerator"); } ui.separator(); if let Some(src_args) = src_args { src_args_ui(ui, src_args); } } fn src_args_ui(ui: &mut egui::Ui, src_args: &mut SourceArgs) { opt( ui, &mut src_args.jump, "jump", "Jump to offset on startup", |ui, jump| { ui.add(egui::DragValue::new(jump)); }, ); opt( ui, &mut src_args.hard_seek, "hard seek", "Seek to offset, consider it beginning of the file in the editor", |ui, hard_seek| { ui.add(egui::DragValue::new(hard_seek)); }, ); opt( ui, &mut src_args.take, "take", "Read only this many bytes", |ui, take| { ui.add(egui::DragValue::new(take)); }, ); ui.checkbox(&mut src_args.read_only, "read-only") .on_hover_text("Open file as read-only"); if ui .checkbox(&mut src_args.stream, "stream") .on_hover_text( "Specify source as a streaming source (for example, standard streams).\n\ Sets read-only attribute", ) .changed() { src_args.read_only = src_args.stream; } ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); opt( ui, &mut src_args.unsafe_mmap, "⚠ mmap", MMAP_LABEL, |ui, mode| { let label = <&'static str>::from(&*mode); egui::ComboBox::new("mmap_cbox", "mode").selected_text(label).show_ui(ui, |ui| { for variant in MmapMode::iter() { let label = <&'static str>::from(&variant); ui.selectable_value(mode, variant, label); } }); }, ); if src_args.unsafe_mmap == Some(MmapMode::DangerousMut) { ui.label(DANGEROUS_MUT_LABEL); } } const MMAP_LABEL: &str = "Open as memory mapped file\n\ \n\ WARNING \n\ Memory mapped i/o is inherently unsafe. To ensure no undefined behavior, make sure you have exclusive access to the file. There is no warranty for any damage you might cause to your system. "; const DANGEROUS_MUT_LABEL: &str = "⚠ WARNING ⚠\n\ \n\ File will be opened with a direct mutable memory map. Any changes made to the file will be IMMEDIATE. THERE IS NO WAY TO UNDO ANY CHANGES. "; fn opt( ui: &mut egui::Ui, val: &mut Option, label: &str, desc: &str, f: impl FnOnce(&mut egui::Ui, &mut V), ) { ui.horizontal(|ui| { let mut checked = val.is_some(); ui.checkbox(&mut checked, label).on_hover_text(desc); if checked { f(ui, val.get_or_insert_with(Default::default)); } else { *val = None; } }); } ================================================ FILE: src/gui/inspect_panel.rs ================================================ use { super::message_dialog::{Icon, MessageDialog}, crate::{ app::{App, interact_mode::InteractMode}, damage_region::DamageRegion, result_ext::AnyhowConv as _, shell::msg_if_fail, view::ViewportVec, }, anyhow::bail, egui::Ui, slotmap::Key as _, std::{array::TryFromSliceError, marker::PhantomData}, thiserror::Error, }; #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum Format { Decimal, Hex, Bin, } impl Format { fn label(&self) -> &'static str { match self { Self::Decimal => "Decimal", Self::Hex => "Hex", Self::Bin => "Binary", } } } pub struct InspectPanel { input_thingies: [Box; 11], /// True if an input thingy was changed by the user. Should update the others changed_one: bool, big_endian: bool, format: Format, seek_relativity: SeekRelativity, /// Edit buffer for user value for seek relative offset seek_user_buf: String, /// Computed user offset for seek relative offset seek_user_offs: usize, /// The value of the cursor on the previous frame. Used to determine when the cursor changes pub prev_frame_inspect_offset: usize, } /// Relativity of seeking to an offset #[derive(Clone, Copy, PartialEq)] enum SeekRelativity { /// Absolute offset in the file Absolute, /// Relative to hard-seek HardSeek, /// Relative to a user-defined offset User, } impl SeekRelativity { fn label(&self) -> &'static str { match self { Self::Absolute => "Absolute", Self::HardSeek => "Hard seek", Self::User => "User", } } } impl std::fmt::Debug for InspectPanel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InspectPanel").finish() } } impl Default for InspectPanel { fn default() -> Self { Self { input_thingies: [ Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), Box::>::default(), ], changed_one: false, big_endian: false, format: Format::Decimal, seek_relativity: SeekRelativity::Absolute, prev_frame_inspect_offset: 0, seek_user_buf: String::new(), seek_user_offs: 0, } } } trait InputThingyTrait { fn update(&mut self, data: &[u8], offset: usize, be: bool, format: Format); fn label(&self) -> &'static str; fn buf_mut(&mut self) -> &mut String; fn write_data( &self, data: &mut [u8], offset: usize, be: bool, format: Format, msg: &mut MessageDialog, ) -> Option; } impl InputThingyTrait for InputThingy { fn update(&mut self, data: &[u8], offset: usize, be: bool, format: Format) { T::update_buf(&mut self.string, data, offset, be, format); } fn label(&self) -> &'static str { T::label() } fn buf_mut(&mut self) -> &mut String { &mut self.string } fn write_data( &self, data: &mut [u8], offset: usize, be: bool, format: Format, msg: &mut MessageDialog, ) -> Option { T::convert_and_write(&self.string, data, offset, be, format, msg) } } #[derive(Error, Debug)] enum FromBytesError { #[error("Error converting from slice")] TryFromSlice(#[from] TryFromSliceError), #[error("Error indexing slice")] SliceIndexError, } trait NumBytesManip: std::fmt::Display + Sized { type ToBytes: AsRef<[u8]>; fn label() -> &'static str; fn from_le_bytes(bytes: &[u8]) -> Result; fn from_be_bytes(bytes: &[u8]) -> Result; fn to_le_bytes(&self) -> Self::ToBytes; fn to_be_bytes(&self) -> Self::ToBytes; fn to_hex_string(&self) -> String; fn to_bin_string(&self) -> String; fn from_str(input: &str, format: Format) -> Result; } macro_rules! num_bytes_manip_impl { ($t:ty) => { impl NumBytesManip for $t { type ToBytes = [u8; <$t>::BITS as usize / 8]; fn label() -> &'static str { stringify!($t) } fn from_le_bytes(bytes: &[u8]) -> Result { match bytes.get(..<$t>::BITS as usize / 8) { Some(slice) => Ok(Self::from_le_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn from_be_bytes(bytes: &[u8]) -> Result { match bytes.get(..<$t>::BITS as usize / 8) { Some(slice) => Ok(Self::from_be_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn to_le_bytes(&self) -> Self::ToBytes { <$t>::to_le_bytes(*self) } fn to_be_bytes(&self) -> Self::ToBytes { <$t>::to_be_bytes(*self) } fn to_hex_string(&self) -> String { format!("{:x}", self) } fn to_bin_string(&self) -> String { format!("{:0w$b}", self, w = <$t>::BITS as usize) } fn from_str(input: &str, format: Format) -> Result { let this = match format { Format::Decimal => input.parse()?, Format::Hex => Self::from_str_radix(input, 16)?, Format::Bin => Self::from_str_radix(input, 2)?, }; Ok(this) } } }; } num_bytes_manip_impl!(i8); num_bytes_manip_impl!(u8); num_bytes_manip_impl!(i16); num_bytes_manip_impl!(u16); num_bytes_manip_impl!(i32); num_bytes_manip_impl!(u32); num_bytes_manip_impl!(i64); num_bytes_manip_impl!(u64); impl NumBytesManip for f32 { type ToBytes = [u8; 32 / 8]; fn label() -> &'static str { "f32" } fn from_le_bytes(bytes: &[u8]) -> Result { match bytes.get(..32 / 8) { Some(slice) => Ok(Self::from_le_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn from_be_bytes(bytes: &[u8]) -> Result { match bytes.get(..32 / 8) { Some(slice) => Ok(Self::from_be_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn to_le_bytes(&self) -> Self::ToBytes { Self::to_le_bytes(*self) } fn to_be_bytes(&self) -> Self::ToBytes { Self::to_be_bytes(*self) } fn to_hex_string(&self) -> String { "".into() } fn to_bin_string(&self) -> String { "".into() } fn from_str(input: &str, format: Format) -> Result { let this = match format { Format::Decimal => input.parse()?, Format::Hex => bail!("Float doesn't support parsing hex"), Format::Bin => bail!("Float doesn't support parsing bin"), }; Ok(this) } } impl NumBytesManip for f64 { type ToBytes = [u8; 8]; fn label() -> &'static str { "f64" } fn from_le_bytes(bytes: &[u8]) -> Result { match bytes.get(..8) { Some(slice) => Ok(Self::from_le_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn from_be_bytes(bytes: &[u8]) -> Result { match bytes.get(..8) { Some(slice) => Ok(Self::from_be_bytes(slice.try_into()?)), None => Err(FromBytesError::SliceIndexError), } } fn to_le_bytes(&self) -> Self::ToBytes { Self::to_le_bytes(*self) } fn to_be_bytes(&self) -> Self::ToBytes { Self::to_le_bytes(*self) } fn to_hex_string(&self) -> String { "".into() } fn to_bin_string(&self) -> String { "".into() } fn from_str(input: &str, format: Format) -> Result { let this = match format { Format::Decimal => input.parse()?, Format::Hex => bail!("Float doesn't support parsing hex"), Format::Bin => bail!("Float doesn't support parsing bin"), }; Ok(this) } } impl BytesManip for T { fn update_buf(buf: &mut String, data: &[u8], offset: usize, be: bool, format: Format) { if let Some(slice) = &data.get(offset..) { let result = if be { T::from_be_bytes(slice) } else { T::from_le_bytes(slice) }; *buf = match result { Ok(value) => match format { Format::Decimal => value.to_string(), Format::Hex => value.to_hex_string(), Format::Bin => value.to_bin_string(), }, Err(e) => e.to_string(), } } } fn label() -> &'static str { ::label() } fn convert_and_write( buf: &str, data: &mut [u8], offset: usize, be: bool, format: Format, msg: &mut MessageDialog, ) -> Option { match Self::from_str(buf, format) { Ok(this) => { let bytes = if be { this.to_be_bytes() } else { this.to_le_bytes() }; let range = offset..offset + bytes.as_ref().len(); match data.get_mut(range.clone()) { Some(slice) => { slice.copy_from_slice(bytes.as_ref()); Some(DamageRegion::Range(range)) } None => None, } } Err(e) => { msg.open(Icon::Error, "Convert error", e.to_string()); None } } } } impl BytesManip for Ascii { fn update_buf(buf: &mut String, data: &[u8], offset: usize, _be: bool, _format: Format) { if let Some(slice) = &data.get(offset..) { let valid_ascii_end = find_valid_ascii_end(slice); match String::from_utf8(data[offset..offset + valid_ascii_end].to_vec()) { Ok(ascii) => *buf = ascii, Err(e) => *buf = format!("[ascii error]: {e}"), } } } fn label() -> &'static str { "ascii" } fn convert_and_write( buf: &str, data: &mut [u8], offset: usize, _be: bool, _format: Format, msg: &mut MessageDialog, ) -> Option { let len = buf.len(); let range = offset..offset + len; match data.get_mut(range.clone()) { Some(slice) => { slice.copy_from_slice(buf.as_bytes()); Some(DamageRegion::Range(range)) } None => { msg.open( Icon::Error, "Convert and write error", "Failed to write data: Out of bounds", ); None } } } } struct InputThingy { string: String, _phantom: PhantomData, } impl Default for InputThingy { fn default() -> Self { Self { string: Default::default(), _phantom: Default::default(), } } } trait BytesManip { fn update_buf(buf: &mut String, data: &[u8], offset: usize, be: bool, format: Format); fn label() -> &'static str; fn convert_and_write( buf: &str, data: &mut [u8], offset: usize, be: bool, format: Format, msg: &mut MessageDialog, ) -> Option; } struct Ascii; enum Action { GoToOffset(usize), AddDirty(DamageRegion), JumpForward(usize), } pub fn ui(ui: &mut Ui, app: &mut App, gui: &mut crate::gui::Gui, mouse_pos: ViewportVec) { if app.hex_ui.current_layout.is_null() { ui.label("No active layout"); return; } let offset = match app.hex_ui.interact_mode { InteractMode::View if !ui.egui_wants_pointer_input() => { if let Some((off, _view_idx)) = app.byte_offset_at_pos(mouse_pos.x, mouse_pos.y) { let mut add = 0; match gui.inspect_panel.seek_relativity { SeekRelativity::Absolute => {} SeekRelativity::HardSeek => { add = app.src_args.hard_seek.unwrap_or(0); } SeekRelativity::User => { add = gui.inspect_panel.seek_user_offs; } } ui.link(format!("offset: {} (0x{:x})", off + add, off + add)) .context_menu(|ui| { if ui.button("Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &format!("{:x}", off + add), ); } }); off } else { edit_offset(app, gui, ui) } } _ => edit_offset(app, gui, ui), }; egui::ComboBox::new("seek_rela_cb", "Seek relativity") .selected_text(gui.inspect_panel.seek_relativity.label()) .show_ui(ui, |ui| { ui.selectable_value( &mut gui.inspect_panel.seek_relativity, SeekRelativity::Absolute, SeekRelativity::Absolute.label(), ); ui.selectable_value( &mut gui.inspect_panel.seek_relativity, SeekRelativity::HardSeek, SeekRelativity::HardSeek.label(), ); ui.selectable_value( &mut gui.inspect_panel.seek_relativity, SeekRelativity::User, SeekRelativity::User.label(), ); }); let re = ui.add_enabled( gui.inspect_panel.seek_relativity == SeekRelativity::User, egui::TextEdit::singleline(&mut gui.inspect_panel.seek_user_buf), ); if re.changed() && let Ok(num) = gui.inspect_panel.seek_user_buf.parse() { gui.inspect_panel.seek_user_offs = num; } if app.data.is_empty() { return; } for thingy in &mut gui.inspect_panel.input_thingies { thingy.update( &app.data[..], offset, gui.inspect_panel.big_endian, gui.inspect_panel.format, ); } gui.inspect_panel.changed_one = false; let mut actions = Vec::new(); for thingy in &mut gui.inspect_panel.input_thingies { ui.horizontal(|ui| { ui.label(thingy.label()); if ui.button("📋").on_hover_text("copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, thingy.buf_mut(), ); } if ui.button("⬇").on_hover_text("go to offset").clicked() { let result = try { let offset = match gui.inspect_panel.format { Format::Decimal => thingy.buf_mut().parse().how()?, Format::Hex => usize::from_str_radix(thingy.buf_mut(), 16).how()?, Format::Bin => usize::from_str_radix(thingy.buf_mut(), 2).how()?, }; actions.push(Action::GoToOffset(offset)); }; msg_if_fail(result, "Failed to go to offset", &mut gui.msg_dialog); } if ui.button("➡").on_hover_text("jump forward").clicked() { let result = try { let offset = match gui.inspect_panel.format { Format::Decimal => thingy.buf_mut().parse().how()?, Format::Hex => usize::from_str_radix(thingy.buf_mut(), 16).how()?, Format::Bin => usize::from_str_radix(thingy.buf_mut(), 2).how()?, }; actions.push(Action::JumpForward(offset)); }; msg_if_fail(result, "Failed to jump forward", &mut gui.msg_dialog); } }); if ui.text_edit_singleline(thingy.buf_mut()).lost_focus() && ui.input(|inp| inp.key_pressed(egui::Key::Enter)) && let Some(range) = thingy.write_data( &mut app.data, offset, gui.inspect_panel.big_endian, gui.inspect_panel.format, &mut gui.msg_dialog, ) { gui.inspect_panel.changed_one = true; actions.push(Action::AddDirty(range)); } } ui.horizontal(|ui| { if ui.checkbox(&mut gui.inspect_panel.big_endian, "Big endian").clicked() { // Changing this should refresh everything gui.inspect_panel.changed_one = true; } let prev_fmt = gui.inspect_panel.format; egui::ComboBox::new("format_combo", "format") .selected_text(gui.inspect_panel.format.label()) .show_ui(ui, |ui| { ui.selectable_value( &mut gui.inspect_panel.format, Format::Decimal, Format::Decimal.label(), ); ui.selectable_value( &mut gui.inspect_panel.format, Format::Hex, Format::Hex.label(), ); ui.selectable_value( &mut gui.inspect_panel.format, Format::Bin, Format::Bin.label(), ); }); if gui.inspect_panel.format != prev_fmt { // Changing the format should refresh everything gui.inspect_panel.changed_one = true; } }); for action in actions { match action { Action::GoToOffset(offset) => { match gui.inspect_panel.seek_relativity { SeekRelativity::Absolute => { app.edit_state.set_cursor(offset); } SeekRelativity::HardSeek => { app.edit_state.set_cursor(offset - app.src_args.hard_seek.unwrap_or(0)); } SeekRelativity::User => { app.edit_state.set_cursor(offset - gui.inspect_panel.seek_user_offs); } } app.center_view_on_offset(app.edit_state.cursor); app.hex_ui.flash_cursor(); } Action::AddDirty(damage) => app.data.widen_dirty_region(damage), Action::JumpForward(amount) => { app.edit_state.set_cursor(app.edit_state.cursor + amount); app.center_view_on_offset(app.edit_state.cursor); app.hex_ui.flash_cursor(); } } } gui.inspect_panel.prev_frame_inspect_offset = offset; } fn edit_offset(app: &mut App, gui: &mut crate::gui::Gui, ui: &mut Ui) -> usize { let mut off = app.edit_state.cursor; match gui.inspect_panel.seek_relativity { SeekRelativity::Absolute => {} SeekRelativity::HardSeek => { off += app.src_args.hard_seek.unwrap_or(0); } SeekRelativity::User => { off += gui.inspect_panel.seek_user_offs; } } ui.link(format!("offset: {off} ({off:x}h)")).context_menu(|ui| { if ui.button("Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &format!("{off:x}"), ); } }); app.edit_state.cursor } fn find_valid_ascii_end(data: &[u8]) -> usize { // Don't try to take too many characters, as that degrades performance const MAX_TAKE: usize = 50; data.iter() .take(MAX_TAKE) .position(|&b| b == 0 || b > 127) .unwrap_or_else(|| std::cmp::min(MAX_TAKE, data.len())) } ================================================ FILE: src/gui/message_dialog.rs ================================================ use { crate::app::command::CommandQueue, core::f32, egui::Color32, std::{backtrace::Backtrace, collections::VecDeque}, }; #[derive(Default)] pub struct MessageDialog { payloads: VecDeque, } pub struct Payload { pub title: String, pub desc: String, pub icon: Icon, pub buttons_ui_fn: Option>, pub backtrace: Option, pub show_backtrace: bool, pub close: bool, } #[derive(Default)] pub enum Icon { #[default] None, Info, Warn, Error, } pub(crate) type UiFn = dyn FnMut(&mut egui::Ui, &mut Payload, &mut CommandQueue); // Colors and icon text are copied from egui-toast, for visual consistency // https://github.com/urholaukkarinen/egui-toast impl Icon { fn color(&self) -> Color32 { match self { Self::None => Color32::default(), Self::Info => Color32::from_rgb(0, 155, 255), Self::Warn => Color32::from_rgb(255, 212, 0), Self::Error => Color32::from_rgb(255, 32, 0), } } fn utf8(&self) -> &'static str { match self { Self::None => "", Self::Info => "ℹ", Self::Warn => "⚠", Self::Error => "❗", } } fn hover_text(&self) -> String { let label = match self { Self::None => "", Self::Info => "Info", Self::Warn => "Warning", Self::Error => "Error", }; format!("{label}\n\nClick to copy message to clipboard") } fn is_set(&self) -> bool { !matches!(self, Self::None) } } impl MessageDialog { pub(crate) fn open(&mut self, icon: Icon, title: impl Into, desc: impl Into) { self.payloads.push_back(Payload { title: title.into(), desc: desc.into(), icon, buttons_ui_fn: None, backtrace: None, show_backtrace: false, close: false, }); } pub(crate) fn custom_button_row_ui(&mut self, f: Box) { if let Some(front) = self.payloads.front_mut() { front.buttons_ui_fn = Some(f); } } pub(crate) fn show( &mut self, ctx: &egui::Context, cb: &mut arboard::Clipboard, cmd: &mut CommandQueue, ) { let payloads_len = self.payloads.len(); let Some(payload) = self.payloads.front_mut() else { return; }; let mut close = false; egui::Modal::new("msg_dialog_popup".into()).show(ctx, |ui| { ui.horizontal(|ui| { ui.heading(&payload.title); if payloads_len > 1 { ui.label(format!("({} more)", payloads_len - 1)); } }); ui.vertical_centered_justified(|ui| { ui.horizontal(|ui| { if payload.icon.is_set() && ui .add( egui::Label::new( egui::RichText::new(payload.icon.utf8()) .color(payload.icon.color()) .size(32.0), ) .sense(egui::Sense::click()), ) .on_hover_text(payload.icon.hover_text()) .clicked() && let Err(e) = cb.set_text(payload.desc.clone()) { gamedebug_core::per!("Clipboard set error: {e:?}"); } ui.label(&payload.desc); }); if let Some(bt) = &payload.backtrace { ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { ui.checkbox(&mut payload.show_backtrace, "Show backtrace"); if payload.show_backtrace { let bt = bt.to_string(); egui::ScrollArea::both().max_height(300.0).show(ui, |ui| { ui.add( egui::TextEdit::multiline(&mut bt.as_str()) .code_editor() .desired_width(f32::INFINITY), ); }); } }); } let (enter_pressed, esc_pressed) = ui.input_mut(|inp| { ( // Consume enter and escape, so when the dialog is closed // using these keys, the normal UI won't receive these keys right away. // Receiving the keys could for example cause a text parse box // that parses on enter press to parse again right away with the // same error when the message box is closed with enter. inp.consume_key(egui::Modifiers::default(), egui::Key::Enter), inp.consume_key(egui::Modifiers::default(), egui::Key::Escape), ) }); let mut buttons_ui_fn = payload.buttons_ui_fn.take(); match &mut buttons_ui_fn { Some(f) => f(ui, payload, cmd), None => { if ui.button("Ok").clicked() || enter_pressed || esc_pressed { payload.backtrace = None; close = true; } } } payload.buttons_ui_fn = buttons_ui_fn; }); }); if close || payload.close { self.payloads.pop_front(); } } pub fn set_backtrace_for_top(&mut self, bt: Backtrace) { if let Some(front) = self.payloads.front_mut() { front.backtrace = Some(bt); } } } ================================================ FILE: src/gui/ops.rs ================================================ //! Various common operations that are triggered by gui interactions use crate::{gui::windows::RegionsWindow, meta::region::Region, meta_state::MetaState}; pub fn add_region_from_selection( selection: Region, app_meta_state: &mut MetaState, gui_regions_window: &mut RegionsWindow, ) { let key = app_meta_state.meta.add_region_from_selection(selection); gui_regions_window.open.set(true); gui_regions_window.selected_key = Some(key); gui_regions_window.activate_rename = true; } ================================================ FILE: src/gui/root_ctx_menu.rs ================================================ use { super::Gui, crate::{app::App, meta::ViewKey, view::ViewportScalar}, constcat::concat, egui_phosphor::regular as ic, }; const L_SELECTION: &str = concat!(ic::SELECTION, " Selection"); const L_REGION_PROPS: &str = concat!(ic::RULER, " Region properties..."); const L_VIEW_PROPS: &str = concat!(ic::EYE, " View properties..."); const L_CHANGE_THIS_VIEW: &str = concat!(ic::SWAP, " Change this view to"); const L_REMOVE_FROM_LAYOUT: &str = concat!(ic::TRASH, " Remove from layout"); const L_OPEN_BOOKMARK: &str = concat!(ic::BOOKMARK, " Open bookmark"); const L_ADD_BOOKMARK: &str = concat!(ic::BOOKMARK, " Add bookmark"); const L_LAYOUT_PROPS: &str = concat!(ic::LAYOUT, " Layout properties..."); const L_LAYOUTS: &str = concat!(ic::LAYOUT, " Layouts"); pub struct ContextMenu { pos: egui::Pos2, data: ContextMenuData, } impl ContextMenu { pub fn new(mx: ViewportScalar, my: ViewportScalar, data: ContextMenuData) -> Self { Self { pos: egui::pos2(f32::from(mx), f32::from(my)), data, } } } pub struct ContextMenuData { pub view: Option, pub byte_off: Option, } /// Yoinked from egui source code fn set_menu_style(style: &mut egui::Style) { style.spacing.button_padding = egui::vec2(2.0, 0.0); style.visuals.widgets.active.bg_stroke = egui::Stroke::NONE; style.visuals.widgets.hovered.bg_stroke = egui::Stroke::NONE; style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::TRANSPARENT; style.visuals.widgets.inactive.bg_stroke = egui::Stroke::NONE; style.wrap_mode = Some(egui::TextWrapMode::Extend); } /// Returns whether to keep root context menu open #[must_use] pub(super) fn show(menu: &ContextMenu, ctx: &egui::Context, app: &mut App, gui: &mut Gui) -> bool { let mut close = false; egui::Area::new("root_ctx_menu".into()) .kind(egui::UiKind::Menu) .order(egui::Order::Foreground) .fixed_pos(menu.pos) .default_width(ctx.global_style().spacing.menu_width) .sense(egui::Sense::hover()) .show(ctx, |ui| { set_menu_style(ui.style_mut()); egui::Frame::menu(ui.style()).show(ui, |ui| { ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| { menu_inner_ui(app, ui, gui, &mut close, menu); }); }); }); !close } fn menu_inner_ui( app: &mut App, ui: &mut egui::Ui, gui: &mut Gui, close: &mut bool, menu: &ContextMenu, ) { if let Some(sel) = app.hex_ui.selection() { ui.separator(); if crate::gui::selection_menu::selection_menu( L_SELECTION, ui, app, &mut gui.dialogs, &mut gui.msg_dialog, &mut gui.win.regions, sel, &mut gui.fileops, ) { *close = true; } } if let Some(view) = menu.data.view { ui.separator(); if ui.button(L_REGION_PROPS).clicked() { gui.win.regions.selected_key = Some(app.region_key_for_view(view)); gui.win.regions.open.set(true); *close = true; } if ui.button(L_VIEW_PROPS).clicked() { gui.win.views.selected = view; gui.win.views.open.set(true); *close = true; } ui.menu_button(L_CHANGE_THIS_VIEW, |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let Some(layout) = app.meta_state.meta.layouts.get_mut(app.hex_ui.current_layout) else { return; }; for (k, v) in app.meta_state.meta.views.iter().filter(|(k, _)| !layout.contains_view(*k)) { if ui.button(&v.name).clicked() { layout.change_view_type(view, k); *close = true; return; } } }); if ui.button(L_REMOVE_FROM_LAYOUT).clicked() && let Some(layout) = app.meta_state.meta.layouts.get_mut(app.hex_ui.current_layout) { layout.remove_view(view); if app.hex_ui.focused_view == Some(view) { let first_view = layout.view_grid.first().and_then(|row| row.first()); app.hex_ui.focused_view = first_view.cloned(); } *close = true; } } if let Some(byte_off) = menu.data.byte_off { ui.separator(); match app.meta_state.meta.bookmarks.iter().position(|bm| bm.offset == byte_off) { Some(pos) => { if ui.button(L_OPEN_BOOKMARK).clicked() { gui.win.bookmarks.open.set(true); gui.win.bookmarks.selected = Some(pos); *close = true; } } None => { if ui.button(L_ADD_BOOKMARK).clicked() { crate::gui::add_new_bookmark(app, gui, byte_off); *close = true; } } } } ui.separator(); if ui.button(L_LAYOUT_PROPS).clicked() { gui.win.layouts.open.toggle(); *close = true; } ui.menu_button(L_LAYOUTS, |ui| { for (key, layout) in app.meta_state.meta.layouts.iter() { if ui.button(&layout.name).clicked() { App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, key); *close = true; } } }); } ================================================ FILE: src/gui/selection_menu.rs ================================================ use { crate::{ app::App, damage_region::DamageRegion, gui::{ Gui, dialogs::{LuaFillDialog, PatternFillDialog, X86AsmDialog}, file_ops::FileOps, message_dialog::MessageDialog, windows::RegionsWindow, }, shell::msg_fail, }, constcat::concat, egui::Button, egui_phosphor::regular as ic, rand::Rng as _, std::fmt::Write as _, }; const L_UNSELECT: &str = concat!(ic::SELECTION_SLASH, " Unselect"); const L_ZERO_FILL: &str = concat!(ic::NUMBER_SQUARE_ZERO, " Zero fill"); const L_PATTERN_FILL: &str = concat!(ic::BINARY, " Pattern fill..."); const L_LUA_FILL: &str = concat!(ic::MOON, " Lua fill..."); const L_RANDOM_FILL: &str = concat!(ic::SHUFFLE, " Random fill"); const L_COPY_AS_HEX_TEXT: &str = concat!(ic::COPY, " Copy as hex text"); const L_COPY_AS_UTF8: &str = concat!(ic::COPY, " Copy as utf-8 text"); const L_ADD_AS_REGION: &str = concat!(ic::RULER, " Add as region"); const L_SAVE_TO_FILE: &str = concat!(ic::FLOPPY_DISK, " Save to file"); const L_X86_ASM: &str = concat!(ic::PIPE_WRENCH, " X86 asm"); /// Returns whether anything was clicked pub fn selection_menu( title: &str, ui: &mut egui::Ui, app: &mut App, gui_dialogs: &mut crate::gui::Dialogs, gui_msg_dialog: &mut MessageDialog, gui_regions_window: &mut RegionsWindow, sel: crate::meta::region::Region, file_ops: &mut FileOps, ) -> bool { let mut clicked = false; ui.menu_button(title, |ui| { if ui.add(Button::new(L_UNSELECT).shortcut_text("Esc")).clicked() { app.hex_ui.clear_selections(); clicked = true; } if ui.add(Button::new(L_ZERO_FILL).shortcut_text("Del")).clicked() { app.data.zero_fill_region(sel); clicked = true; } if ui.button(L_PATTERN_FILL).clicked() { Gui::add_dialog(gui_dialogs, PatternFillDialog::default()); clicked = true; } if ui.button(L_LUA_FILL).clicked() { Gui::add_dialog(gui_dialogs, LuaFillDialog::default()); clicked = true; } if ui.button(L_RANDOM_FILL).clicked() { for region in app.hex_ui.selected_regions() { if let Some(data) = app.data.get_mut(region.to_range()) { rand::rng().fill_bytes(data); app.data.widen_dirty_region(DamageRegion::RangeInclusive(region.to_range())); } } clicked = true; } if ui.button(L_COPY_AS_HEX_TEXT).clicked() { let mut s = String::new(); let result = try { for &byte in &app.data[sel.begin..=sel.end] { write!(&mut s, "{byte:02x} ")?; } }; match result { Ok(()) => { crate::app::set_clipboard_string( &mut app.clipboard, gui_msg_dialog, s.trim_end(), ); } Err(e) => { msg_fail(&e, "Failed to copy as hex text", gui_msg_dialog); } } clicked = true; } if ui.button(L_COPY_AS_UTF8).clicked() { let s = String::from_utf8_lossy(&app.data[sel.begin..=sel.end]); crate::app::set_clipboard_string(&mut app.clipboard, gui_msg_dialog, &s); clicked = true; } if ui.button(L_ADD_AS_REGION).clicked() { crate::gui::ops::add_region_from_selection( sel, &mut app.meta_state, gui_regions_window, ); clicked = true; } if ui.button(L_SAVE_TO_FILE).clicked() { file_ops.save_selection_to_file(sel); clicked = true; } if ui.button(L_X86_ASM).clicked() { Gui::add_dialog(gui_dialogs, X86AsmDialog::new()); clicked = true; } }); clicked } ================================================ FILE: src/gui/top_menu/analysis.rs ================================================ use { crate::{ app::App, gui::{Gui, message_dialog::Icon}, shell::msg_if_fail, }, constcat::concat, egui_phosphor::regular as ic, }; const L_DETERMINE_DATA_MIME: &str = concat!(ic::SEAL_QUESTION, " Determine data mime type under cursor"); const L_DETERMINE_DATA_MIME_SEL: &str = concat!(ic::SEAL_QUESTION, " Determine data mime type of selection"); const L_DIFF_WITH_FILE: &str = concat!(ic::GIT_DIFF, " Diff with file..."); const L_DIFF_WITH_SOURCE_FILE: &str = concat!(ic::GIT_DIFF, " Diff with source file"); const L_DIFF_WITH_BACKUP: &str = concat!(ic::GIT_DIFF, " Diff with backup"); const L_FIND_MEMORY_POINTERS: &str = concat!(ic::ARROW_UP_RIGHT, " Find memory pointers..."); const L_ZERO_PARTITION: &str = concat!(ic::BINARY, " Zero partition..."); pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &App) { if ui.button(L_DETERMINE_DATA_MIME).clicked() { gui.msg_dialog.open( Icon::Info, "Data mime type under cursor", tree_magic_mini::from_u8(&app.data[app.edit_state.cursor..]).to_string(), ); } if let Some(region) = app.hex_ui.selection() && ui.button(L_DETERMINE_DATA_MIME_SEL).clicked() { gui.msg_dialog.open( Icon::Info, "Data mime type of selection", tree_magic_mini::from_u8(&app.data[region.begin..=region.end]).to_string(), ); } ui.separator(); if ui.button(L_DIFF_WITH_FILE).clicked() { gui.fileops.diff_with_file(app.source_file()); } if ui.button(L_DIFF_WITH_SOURCE_FILE).clicked() && let Some(path) = app.source_file() { let path = path.to_owned(); msg_if_fail( app.diff_with_file(path, &mut gui.win.file_diff_result), "Failed to diff", &mut gui.msg_dialog, ); } match app.backup_path() { Some(path) if path.exists() => { if ui.button(L_DIFF_WITH_BACKUP).clicked() { msg_if_fail( app.diff_with_file(path, &mut gui.win.file_diff_result), "Failed to diff", &mut gui.msg_dialog, ); } } _ => { ui.add_enabled(false, egui::Button::new(L_DIFF_WITH_BACKUP)); } } ui.separator(); if ui .add_enabled( gui.win.open_process.selected_pid.is_some(), egui::Button::new(L_FIND_MEMORY_POINTERS), ) .on_disabled_hover_text("Requires open process") .clicked() { gui.win.find_memory_pointers.open.toggle(); } if ui .button(L_ZERO_PARTITION) .on_hover_text("Find regions of non-zero data separated by zeroed regions") .clicked() { gui.win.zero_partition.open.toggle(); } } ================================================ FILE: src/gui/top_menu/cursor.rs ================================================ use { crate::{ app::App, gui::{Gui, dialogs::JumpDialog}, }, constcat::concat, egui::Button, egui_phosphor::regular as ic, }; const L_RESET: &str = concat!(ic::ARROW_U_UP_LEFT, " Reset"); const L_JUMP: &str = concat!(ic::SHARE_FAT, " Jump..."); const L_FLASH_CURSOR: &str = concat!(ic::LIGHTBULB, " Flash cursor"); const L_CENTER_VIEW_ON_CURSOR: &str = concat!(ic::CROSSHAIR, " Center view on cursor"); pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { let re = ui.button(L_RESET).on_hover_text( "Set to initial position.\n\ This will be --jump argument, if one was provided, 0 otherwise", ); if re.clicked() { app.set_cursor_init(); } if ui.add(Button::new(L_JUMP).shortcut_text("Ctrl+J")).clicked() { Gui::add_dialog(&mut gui.dialogs, JumpDialog::default()); } if ui.button(L_FLASH_CURSOR).clicked() { app.preferences.hide_cursor = false; app.hex_ui.flash_cursor(); } if ui.button(L_CENTER_VIEW_ON_CURSOR).clicked() { app.preferences.hide_cursor = false; app.center_view_on_offset(app.edit_state.cursor); app.hex_ui.flash_cursor(); } ui.checkbox(&mut app.preferences.hide_cursor, "Hide cursor"); } ================================================ FILE: src/gui/top_menu/edit.rs ================================================ use { crate::{ app::{ App, command::{Cmd, perform_command}, }, gui::{Gui, dialogs::TruncateDialog, message_dialog::Icon}, result_ext::AnyhowConv as _, shell::msg_if_fail, }, constcat::concat, egui::Button, egui_phosphor::regular as ic, mlua::Lua, }; const L_FIND: &str = concat!(ic::MAGNIFYING_GLASS, " Find..."); const L_SELECTION: &str = concat!(ic::SELECTION, " Selection"); const L_SELECT_A: &str = "🅰 Set select a"; const L_SELECT_B: &str = "🅱 Set select b"; const L_SELECT_ALL: &str = concat!(ic::SELECTION_ALL, " Select all in region"); const L_SELECT_ROW: &str = concat!(ic::ARROWS_HORIZONTAL, " Select row"); const L_SELECT_COL: &str = concat!(ic::ARROWS_VERTICAL, " Select column"); const L_EXTERNAL_COMMAND: &str = concat!(ic::TERMINAL_WINDOW, " External command..."); const L_INC_BYTE: &str = concat!(ic::PLUS, " Inc byte(s)"); const L_DEC_BYTE: &str = concat!(ic::MINUS, " Dec byte(s)"); const L_PASTE_AT_CURSOR: &str = concat!(ic::CLIPBOARD_TEXT, " Paste at cursor"); const L_TRUNCATE_EXTEND: &str = concat!(ic::SCISSORS, " Truncate/Extend..."); pub fn ui( ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, lua: &Lua, font_size: u16, line_spacing: u16, ) { if ui.add(Button::new(L_FIND).shortcut_text("Ctrl+F")).clicked() { gui.win.find.open.toggle(); } ui.separator(); match app.hex_ui.selection() { Some(sel) => { if crate::gui::selection_menu::selection_menu( L_SELECTION, ui, app, &mut gui.dialogs, &mut gui.msg_dialog, &mut gui.win.regions, sel, &mut gui.fileops, ) {} } None => { ui.label(""); } } if ui.add(Button::new(L_SELECT_A).shortcut_text("shift+1")).clicked() { app.hex_ui.select_a = Some(app.edit_state.cursor); } if ui.add(Button::new(L_SELECT_B).shortcut_text("shift+2")).clicked() { app.hex_ui.select_b = Some(app.edit_state.cursor); } if ui.add(Button::new(L_SELECT_ALL).shortcut_text("Ctrl+A")).clicked() { app.focused_view_select_all(); } if ui.add(Button::new(L_SELECT_ROW)).clicked() { app.focused_view_select_row(); } if ui.add(Button::new(L_SELECT_COL)).clicked() { app.focused_view_select_col(); } ui.separator(); if ui.add(Button::new(L_EXTERNAL_COMMAND).shortcut_text("Ctrl+E")).clicked() { gui.win.external_command.open.toggle(); } ui.separator(); if ui .add(Button::new(L_INC_BYTE).shortcut_text("Ctrl+=")) .on_hover_text("Increase byte(s) of selection or at cursor") .clicked() { app.inc_byte_or_bytes(); } if ui .add(Button::new(L_DEC_BYTE).shortcut_text("Ctrl+-")) .on_hover_text("Decrease byte(s) of selection or at cursor") .clicked() { app.dec_byte_or_bytes(); } ui.menu_button(L_PASTE_AT_CURSOR, |ui| { if ui.button("Hex text from clipboard").clicked() { let s = crate::app::get_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog); let cursor = app.edit_state.cursor; let result = try { let bytes = s .split_ascii_whitespace() .map(|s| u8::from_str_radix(s, 16)) .collect::, _>>() .how()?; if cursor + bytes.len() < app.data.len() { perform_command( app, Cmd::PasteBytes { at: cursor, bytes }, gui, lua, font_size, line_spacing, ); } else { gui.msg_dialog.open( Icon::Warn, "Prompt", "Paste overflows the document. What do do?", ); gui.msg_dialog.custom_button_row_ui(Box::new(move |ui, payload, cmd| { if ui.button("Cancel paste").clicked() { payload.close = true; } else if ui.button("Extend document").clicked() { cmd.push(Cmd::ExtendDocument { new_len: cursor + bytes.len(), }); cmd.push(Cmd::PasteBytes { at: cursor, bytes: bytes.clone(), }); payload.close = true; } else if ui.button("Shorten paste").clicked() { } })); } }; msg_if_fail(result, "Hex text paste error", &mut gui.msg_dialog); } }); ui.separator(); ui.checkbox(&mut app.preferences.move_edit_cursor, "Move edit cursor") .on_hover_text( "With the cursor keys in edit mode, move edit cursor by default.\n\ Otherwise, block cursor is moved. Can use ctrl+cursor keys for the other behavior.", ); ui.checkbox(&mut app.preferences.quick_edit, "Quick edit").on_hover_text( "Immediately apply editing results, instead of having to type a \ value to completion or press enter", ); ui.checkbox(&mut app.preferences.sticky_edit, "Sticky edit") .on_hover_text("Don't automatically move cursor after editing is finished"); ui.separator(); if ui.button(L_TRUNCATE_EXTEND).clicked() { Gui::add_dialog( &mut gui.dialogs, TruncateDialog::new(app.data.len(), app.hex_ui.selection()), ); } } ================================================ FILE: src/gui/top_menu/file.rs ================================================ use { crate::{ app::{App, set_clipboard_string}, gui::{Gui, dialogs::AutoSaveReloadDialog}, shell::msg_if_fail, }, constcat::concat, egui::Button, egui_phosphor::regular as ic, }; const L_LOPEN: &str = concat!(ic::FOLDER_OPEN, " Open..."); const L_OPEN_PROCESS: &str = concat!(ic::CPU, " Open process..."); const L_OPEN_PREVIOUS: &str = concat!(ic::ARROWS_LEFT_RIGHT, " Open previous"); const L_SAVE: &str = concat!(ic::FLOPPY_DISK, " Save"); const L_SAVE_AS: &str = concat!(ic::FLOPPY_DISK_BACK, " Save as..."); const L_RELOAD: &str = concat!(ic::ARROW_COUNTER_CLOCKWISE, " Reload"); const L_RECENT: &str = concat!(ic::CLOCK_COUNTER_CLOCKWISE, " Recent"); const L_AUTO_SAVE_RELOAD: &str = concat!(ic::MAGNET, " Auto save/reload..."); const L_CREATE_BACKUP: &str = concat!(ic::CLOUD_ARROW_UP, " Create backup"); const L_RESTORE_BACKUP: &str = concat!(ic::CLOUD_ARROW_DOWN, " Restore backup"); const L_PREFERENCES: &str = concat!(ic::GEAR_SIX, " Preferences"); const L_CLOSE: &str = concat!(ic::X_SQUARE, " Close"); const L_QUIT: &str = concat!(ic::SIGN_OUT, " Quit"); pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, font_size: u16, line_spacing: u16) { if ui.add(Button::new(L_LOPEN).shortcut_text("Ctrl+O")).clicked() { gui.fileops.load_file(app.source_file()); } if ui.button(L_OPEN_PROCESS).clicked() { gui.win.open_process.open.toggle(); } let mut load = None; if ui .add_enabled( !app.cfg.recent.is_empty(), Button::new(L_OPEN_PREVIOUS).shortcut_text("Ctrl+P"), ) .on_hover_text("Can be used to switch between 2 files quickly for comparison") .clicked() { crate::shell::open_previous(app, &mut load); } ui.checkbox(&mut app.preferences.keep_meta, "Keep metadata") .on_hover_text("Keep metadata when loading a new file"); ui.menu_button(L_RECENT, |ui| { app.cfg.recent.retain(|entry| { let mut retain = true; let path = entry.file.as_ref().map_or_else( || String::from("Unnamed file"), |path| path.display().to_string(), ); ui.horizontal(|ui| { if ui.button(&path).clicked() { load = Some(entry.clone()); } ui.separator(); if ui.button("📋").clicked() { set_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog, &path); } if ui.button("🗑").clicked() { retain = false; } }); ui.separator(); retain }); ui.separator(); ui.horizontal(|ui| { let mut cap = app.cfg.recent.capacity(); if ui.add(egui::DragValue::new(&mut cap).prefix("list capacity: ")).changed() { app.cfg.recent.set_capacity(cap); } ui.separator(); if ui.add_enabled(!app.cfg.recent.is_empty(), Button::new("🗑 Clear all")).clicked() { app.cfg.recent.clear(); } }); }); if let Some(args) = load { app.load_file_args( args, None, &mut gui.msg_dialog, font_size, line_spacing, None, ); } ui.separator(); if ui .add_enabled( matches!(&app.source, Some(src) if src.attr.permissions.write) && app.data.dirty_region.is_some(), Button::new(L_SAVE).shortcut_text("Ctrl+S"), ) .clicked() { msg_if_fail( app.save(&mut gui.msg_dialog), "Failed to save", &mut gui.msg_dialog, ); } if ui.button(L_SAVE_AS).clicked() { gui.fileops.save_file_as(); } if ui.add(Button::new(L_RELOAD).shortcut_text("Ctrl+R")).clicked() { msg_if_fail(app.reload(), "Failed to reload", &mut gui.msg_dialog); } if ui.button(L_AUTO_SAVE_RELOAD).clicked() { Gui::add_dialog(&mut gui.dialogs, AutoSaveReloadDialog); } ui.separator(); if ui.button(L_CREATE_BACKUP).clicked() { msg_if_fail( app.create_backup(), "Failed to create backup", &mut gui.msg_dialog, ); } if ui.button(L_RESTORE_BACKUP).clicked() { msg_if_fail( app.restore_backup(), "Failed to restore backup", &mut gui.msg_dialog, ); } ui.separator(); if ui.button(L_PREFERENCES).clicked() { gui.win.preferences.open.toggle(); } ui.separator(); if ui.add(Button::new(L_CLOSE).shortcut_text("Ctrl+W")).clicked() { app.close_file(); } if ui.button(L_QUIT).clicked() { app.quit_requested = true; } } ================================================ FILE: src/gui/top_menu/help.rs ================================================ use { crate::{gui::Gui, shell::msg_if_fail}, constcat::concat, egui::{Button, Ui}, egui_phosphor::regular as ic, gamedebug_core::{IMMEDIATE, PERSISTENT}, }; const L_HEXERATOR_BOOK: &str = concat!(ic::BOOK_OPEN_TEXT, " Hexerator book"); const L_DEBUG_PANEL: &str = concat!(ic::BUG, " Debug panel..."); const L_ABOUT: &str = concat!(ic::QUESTION, " About Hexerator..."); pub fn ui(ui: &mut Ui, gui: &mut Gui) { if ui.button(L_HEXERATOR_BOOK).clicked() { msg_if_fail( open::that(crate::gui::BOOK_URL), "Failed to open help", &mut gui.msg_dialog, ); } if ui.add(Button::new(L_DEBUG_PANEL).shortcut_text("F12")).clicked() { IMMEDIATE.toggle(); PERSISTENT.toggle(); } ui.separator(); if ui.button(L_ABOUT).clicked() { gui.win.about.open.toggle(); } } ================================================ FILE: src/gui/top_menu/meta.rs ================================================ use { crate::{ app::App, gui::{Gui, egui_ui_ext::EguiResponseExt as _}, shell::msg_if_fail, }, constcat::concat, egui::Button, egui_phosphor::regular as ic, }; const L_PERSPECTIVES: &str = concat!(ic::PERSPECTIVE, " Perspectives..."); const L_REGIONS: &str = concat!(ic::RULER, " Regions..."); const L_BOOKMARKS: &str = concat!(ic::BOOKMARK, " Bookmarks..."); const L_VARIABLES: &str = concat!(ic::CALCULATOR, " Variables..."); const L_STRUCTS: &str = concat!(ic::BLUEPRINT, " Structs..."); const L_RELOAD: &str = concat!(ic::ARROW_COUNTER_CLOCKWISE, " Reload"); const L_LOAD_FROM_FILE: &str = concat!(ic::FOLDER_OPEN, " Load from file..."); const L_LOAD_FROM_BACKUP: &str = concat!(ic::CLOUD_ARROW_DOWN, " Load from temp backup"); const L_CLEAR: &str = concat!(ic::BROOM, " Clear"); const L_DIFF_WITH_CLEAN_META: &str = concat!(ic::GIT_DIFF, " Diff with clean meta"); const L_SAVE: &str = concat!(ic::FLOPPY_DISK, " Save"); const L_SAVE_AS: &str = concat!(ic::FLOPPY_DISK_BACK, " Save as..."); const L_ASSOCIATE_WITH_CURRENT: &str = concat!(ic::FLOW_ARROW, " Associate with current file"); pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, font_size: u16, line_spacing: u16) { if ui.add(Button::new(L_PERSPECTIVES).shortcut_text("F7")).clicked() { gui.win.perspectives.open.toggle(); } if ui.add(Button::new(L_REGIONS).shortcut_text("F8")).clicked() { gui.win.regions.open.toggle(); } if ui.add(Button::new(L_BOOKMARKS).shortcut_text("F9")).clicked() { gui.win.bookmarks.open.toggle(); } if ui.add(Button::new(L_VARIABLES).shortcut_text("F10")).clicked() { gui.win.vars.open.toggle(); } if ui.add(Button::new(L_STRUCTS).shortcut_text("F11")).clicked() { gui.win.structs.open.toggle(); } ui.separator(); if ui .button(L_DIFF_WITH_CLEAN_META) .on_hover_text("See and manage changes to metafile") .clicked() { gui.win.meta_diff.open.toggle(); } ui.separator(); if ui .add_enabled( !app.meta_state.current_meta_path.as_os_str().is_empty(), Button::new(L_RELOAD), ) .on_hover_text_deferred(|| { format!("Reload from {}", app.meta_state.current_meta_path.display()) }) .clicked() { msg_if_fail( app.consume_meta_from_file(app.meta_state.current_meta_path.clone(), false), "Failed to load metafile", &mut gui.msg_dialog, ); } if ui.button(L_LOAD_FROM_FILE).clicked() { gui.fileops.load_meta_file(); } if ui .button(L_LOAD_FROM_BACKUP) .on_hover_text("Load from temporary backup (auto generated on save/exit)") .clicked() { msg_if_fail( app.consume_meta_from_file(crate::app::temp_metafile_backup_path(), true), "Failed to load temp metafile", &mut gui.msg_dialog, ); } if ui .button(L_CLEAR) .on_hover_text("Replace current meta with default one") .clicked() { app.clear_meta(font_size, line_spacing); } ui.separator(); if ui .add_enabled( !app.meta_state.current_meta_path.as_os_str().is_empty(), Button::new(L_SAVE).shortcut_text("Ctrl+M"), ) .on_hover_text_deferred(|| { format!("Save to {}", app.meta_state.current_meta_path.display()) }) .clicked() { msg_if_fail( app.save_meta(), "Failed to save metafile", &mut gui.msg_dialog, ); } if ui.button(L_SAVE_AS).clicked() { gui.fileops.save_metafile_as(); } ui.separator(); match ( app.source_file(), app.meta_state.current_meta_path.as_os_str().is_empty(), ) { (Some(src), false) => { if ui .button(L_ASSOCIATE_WITH_CURRENT) .on_hover_text("When you open this file, it will use this metafile") .clicked() { app.cfg .meta_assocs .insert(src.to_owned(), app.meta_state.current_meta_path.clone()); } } _ => { ui.add_enabled(false, Button::new(L_ASSOCIATE_WITH_CURRENT)) .on_disabled_hover_text("Both file and metafile need to have a path"); } } } ================================================ FILE: src/gui/top_menu/perspective.rs ================================================ ================================================ FILE: src/gui/top_menu/plugins.rs ================================================ use crate::{ app::App, gui::{Gui, message_dialog::Icon}, plugin::PluginContainer, shell::msg_fail, }; pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { let mut plugins = std::mem::take(&mut app.plugins); let mut reload = None; if plugins.is_empty() { ui.add_enabled(false, egui::Label::new("No plugins loaded")); } plugins.retain_mut(|plugin| { let mut retain = true; ui.horizontal(|ui| { ui.label(plugin.plugin.name()).on_hover_text(plugin.plugin.desc()); if ui.button("🗑").on_hover_text("Unload").clicked() { retain = false; } if ui.button("↺").on_hover_text("Reload").clicked() { retain = false; reload = Some(plugin.path.clone()); } }); for method in &plugin.methods { let name = if let Some(name) = method.human_name { name } else { method.method_name }; let hover_ui = |ui: &mut egui::Ui| { ui.horizontal(|ui| { ui.style_mut().spacing.item_spacing.x = 0.; ui.label( egui::RichText::new(method.method_name) .strong() .color(egui::Color32::WHITE), ); ui.label(egui::RichText::new("(").strong().color(egui::Color32::WHITE)); for param in method.params { ui.label(format!("{}: {},", param.name, param.ty.label())); } ui.label(egui::RichText::new(")").strong().color(egui::Color32::WHITE)); }); ui.indent("indent", |ui| { ui.label(method.desc); }); }; if ui.button(name).on_hover_ui(hover_ui).clicked() { match plugin.plugin.on_method_called(method.method_name, &[], app) { Ok(val) => { if let Some(val) = val { let strval = match val { hexerator_plugin_api::Value::U64(n) => n.to_string(), hexerator_plugin_api::Value::String(s) => s.to_string(), hexerator_plugin_api::Value::F64(n) => n.to_string(), }; gui.msg_dialog.open( Icon::Info, "Method call result", format!("{}: {}", method.method_name, strval), ); } } Err(e) => { msg_fail(&e, "Method call failed", &mut gui.msg_dialog); } } } } retain }); if let Some(path) = reload { // Safety: This will cause UB on a bad plugin. Nothing we can do. // // It's up to the user not to load bad plugins. unsafe { match PluginContainer::new(path) { Ok(plugin) => { plugins.push(plugin); } Err(e) => msg_fail(&e, "Failed to reload plugin", &mut gui.msg_dialog), } } } std::mem::swap(&mut app.plugins, &mut plugins); } ================================================ FILE: src/gui/top_menu/scripting.rs ================================================ use { crate::{app::App, gui::Gui, shell::msg_if_fail}, mlua::Lua, }; pub fn ui( ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, lua: &Lua, font_size: u16, line_spacing: u16, ) { if ui.button("🖹 Lua editor").clicked() { gui.win.lua_editor.open.toggle(); } if ui.button("📃 Script manager").clicked() { gui.win.script_manager.open.toggle(); } if ui.button("🖳 Quick eval window").clicked() { gui.win.lua_console.open.toggle(); } if ui.button("👁 New watch window").clicked() { gui.win.add_lua_watch_window(); } if ui.button("? Hexerator Lua API").clicked() { gui.win.lua_help.open.toggle(); } ui.separator(); let mut scripts = std::mem::take(&mut app.meta_state.meta.scripts); for (key, script) in scripts.iter() { if ui.button(&script.name).clicked() { let result = crate::scripting::exec_lua( lua, &script.content, app, gui, "", Some(key), font_size, line_spacing, ); msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); } } std::mem::swap(&mut app.meta_state.meta.scripts, &mut scripts); } ================================================ FILE: src/gui/top_menu/view.rs ================================================ use { crate::{app::App, gui::Gui, hex_ui::Ruler, meta::LayoutMapExt as _}, constcat::concat, egui::{ Button, color_picker::{Alpha, color_picker_color32}, containers::menu::{MenuConfig, SubMenuButton}, }, egui_phosphor::regular as ic, }; const L_LAYOUT: &str = concat!(ic::LAYOUT, " Layout"); const L_RULER: &str = concat!(ic::RULER, " Ruler"); const L_LAYOUTS: &str = concat!(ic::LAYOUT, " Layouts..."); const L_FOCUS_PREV: &str = concat!(ic::ARROW_FAT_LEFT, " Focus previous"); const L_FOCUS_NEXT: &str = concat!(ic::ARROW_FAT_RIGHT, " Focus next"); const L_VIEWS: &str = concat!(ic::EYE, " Views..."); pub fn ui(ui: &mut egui::Ui, gui: &mut Gui, app: &mut App) { if ui.add(Button::new(L_VIEWS).shortcut_text("F6")).clicked() { gui.win.views.open.toggle(); } if ui.add(Button::new(L_FOCUS_PREV).shortcut_text("Shift+Tab")).clicked() { app.focus_prev_view_in_layout(); } if ui.add(Button::new(L_FOCUS_NEXT).shortcut_text("Tab")).clicked() { app.focus_next_view_in_layout(); } ui.menu_button(L_RULER, |ui| match app.focused_view_mut() { Some((key, _view)) => match app.hex_ui.rulers.get_mut(&key) { Some(ruler) => { if ui.button("Remove").clicked() { app.hex_ui.rulers.remove(&key); return; } ruler.color.with_as_egui_mut(|c| { // Customized color SubMenuButton (taken from the egui demo) let is_bright = c.intensity() > 0.5; let text_color = if is_bright { egui::Color32::BLACK } else { egui::Color32::WHITE }; let mut color_button = SubMenuButton::new(egui::RichText::new("Color").color(text_color)).config( MenuConfig::new() .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside), ); color_button.button = color_button.button.fill(*c); color_button.ui(ui, |ui| { ui.spacing_mut().slider_width = 200.0; color_picker_color32(ui, c, Alpha::Opaque); }); }); ui.label("Frequency"); ui.add(egui::DragValue::new(&mut ruler.freq)); ui.label("Horizontal offset"); ui.add(egui::DragValue::new(&mut ruler.hoffset)); ui.menu_button("struct", |ui| { for (i, struct_) in app.meta_state.meta.structs.iter().enumerate() { if ui.selectable_label(ruler.struct_idx == Some(i), &struct_.name).clicked() { ruler.struct_idx = Some(i); } } ui.separator(); if ui.button("Unassociate").clicked() { ruler.struct_idx = None; } }); } None => { if ui.button("Add ruler for current view").clicked() { app.hex_ui.rulers.insert(key, Ruler::default()); } } }, None => { ui.label(""); } }); ui.separator(); ui.menu_button(L_LAYOUT, |ui| { if ui.add(Button::new(L_LAYOUTS).shortcut_text("F5")).clicked() { gui.win.layouts.open.toggle(); } if ui.button("➕ Add new").clicked() { app.hex_ui.current_layout = app.meta_state.meta.layouts.add_new_default(); gui.win.layouts.open.set(true); } ui.separator(); for (k, v) in &app.meta_state.meta.layouts { if ui .selectable_label( app.hex_ui.current_layout == k, [ic::LAYOUT, " ", v.name.as_str()].concat(), ) .clicked() { App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, k); } } }); ui.checkbox( &mut app.preferences.col_change_lock_col, "Lock col on col change", ); ui.checkbox( &mut app.preferences.col_change_lock_row, "Lock row on col change", ); } ================================================ FILE: src/gui/top_menu.rs ================================================ use {crate::shell::msg_if_fail, mlua::Lua}; mod analysis; mod cursor; pub mod edit; mod file; mod help; mod meta; mod perspective; mod plugins; mod scripting; mod view; use { crate::{app::App, source::SourceProvider}, egui::Layout, }; pub fn top_menu( ui: &mut egui::Ui, gui: &mut crate::gui::Gui, app: &mut App, lua: &Lua, font_size: u16, line_spacing: u16, ) { ui.horizontal(|ui| { ui.menu_button("File", |ui| file::ui(ui, gui, app, font_size, line_spacing)); ui.menu_button("Edit", |ui| { edit::ui(ui, gui, app, lua, font_size, line_spacing); }); ui.menu_button("Cursor", |ui| cursor::ui(ui, gui, app)); ui.menu_button("View", |ui| view::ui(ui, gui, app)); ui.menu_button("Meta", |ui| meta::ui(ui, gui, app, font_size, line_spacing)); ui.menu_button("Analysis", |ui| analysis::ui(ui, gui, app)); ui.menu_button("Lua scripting", |ui| { scripting::ui(ui, gui, app, lua, font_size, line_spacing); }); ui.menu_button("Plugins", |ui| plugins::ui(ui, gui, app)); ui.menu_button("Help", |ui| help::ui(ui, gui)); ui.with_layout( Layout::right_to_left(egui::Align::Center), |ui| match &app.source { Some(src) => { match src.provider { SourceProvider::File(_) => { match &app.src_args.file { Some(file) => { let s = file.display().to_string(); let ctx_menu = |ui: &mut egui::Ui| { if ui.button("Open").clicked() { try_open_file(file, gui); } if ui.button("Copy path to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &s, ); } if let Some(parent) = file.parent() { if ui.button("Open containing folder").clicked() { let result = open::that(parent); msg_if_fail( result, "Failed to open folder", &mut gui.msg_dialog, ); } if ui.button("Copy folder path to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &parent.display().to_string(), ); } } }; if !app.meta_state.current_meta_path.as_os_str().is_empty() { ui.label( egui::RichText::new("🇲").color(egui::Color32::YELLOW), ) .on_hover_text( format!( "Metafile: {}", app.meta_state.current_meta_path.display() ), ); } else { ui.label("?").on_hover_text( "There is no metafile associated with this file", ); } let mut re = ui.add(egui::Label::new(&s).sense(egui::Sense::click())); re.context_menu(ctx_menu); re = re.on_hover_ui(|ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(offset) = &app.src_args.hard_seek { ui.label(format!("Hard seek: {offset} ({offset:X})")); } if let Some(len) = &app.src_args.take { ui.label(format!("Take: {len}")); } ui.collapsing("Src args", |ui| { ui.code(format!("{:#?}", app.src_args)); }); ui.collapsing("Source", |ui| { ui.code(format!("{src:#?}")); }); ui.collapsing("Data provider", |ui| { ui.code(format!("{:#?}", app.data)); }); ui.label("Right click for context menu"); }); if re.clicked() { try_open_file(file, gui); } } None => { ui.label("File path unknown"); } }; } SourceProvider::Stdin(_) => { ui.label("Standard input"); } #[cfg(windows)] SourceProvider::WinProc { handle, .. } => { ui.label(format!("Windows process: {:p}", handle)); } } if src.attr.stream { if src.state.stream_end { ui.label("[finished stream]"); } else { ui.spinner(); ui.label("[streaming]"); } } } None => { ui.label("No source"); } }, ); }); } fn try_open_file(file: &std::path::Path, gui: &mut super::Gui) { let result = open::that(file); msg_if_fail(result, "Failed to open file", &mut gui.msg_dialog); } ================================================ FILE: src/gui/top_panel.rs ================================================ use { super::{ Gui, dialogs::LuaColorDialog, egui_ui_ext::EguiResponseExt as _, message_dialog::Icon, top_menu::top_menu, }, crate::{ app::App, color::RgbColor, util::human_size, value_color::{ColorMethod, Palette}, }, anyhow::Context as _, egui::{ComboBox, Layout, Ui}, mlua::Lua, }; pub fn ui(ui: &mut Ui, gui: &mut Gui, app: &mut App, lua: &Lua, font_size: u16, line_spacing: u16) { top_menu(ui, gui, app, lua, font_size, line_spacing); ui.horizontal(|ui| { if app.hex_ui.select_a.is_some() || app.hex_ui.select_b.is_some() { ui.label("Selection"); } let mut action_focus = None; if let Some(a) = &mut app.hex_ui.select_a { if ui.link("a").clicked() { action_focus = Some(*a); } ui.add(egui::DragValue::new(a)); } if let Some(b) = &mut app.hex_ui.select_b { if ui.link("b").clicked() { action_focus = Some(*b); } ui.add(egui::DragValue::new(b)); } if let Some(off) = action_focus { app.search_focus(off); } if let Some(sel) = app.hex_ui.selection() && let Some(view_key) = app.hex_ui.focused_view { let view = &app.meta_state.meta.views[view_key].view; let [rows, rem] = app.meta_state.meta.low.perspectives[view.perspective].region_row_span(sel); ui.label(format!( "{rows} rows * {} cols + {rem} = {}", app.meta_state.meta.low.perspectives[view.perspective].cols, sel.len() )) .on_hover_text_deferred(|| human_size(sel.len())); #[expect(clippy::collapsible_if)] if ui.button("⬅ prev chunk").clicked() { if let Some(chk) = sel.prev_chunk() { app.hex_ui.select_a = Some(chk.begin); app.hex_ui.select_b = Some(chk.end); } } if ui.button("next chunk ➡").clicked() { let chk = sel.next_chunk(); app.hex_ui.select_a = Some(chk.begin); app.hex_ui.select_b = Some(chk.end); } if ui.button("Clear").clicked() { app.hex_ui.clear_selections(); } } if !app.hex_ui.extra_selections.is_empty() { ui.label(format!( "({} extra selections)", app.hex_ui.extra_selections.len() )); } if !gui.highlight_set.is_empty() { ui.label(format!("{} bytes highlighted", gui.highlight_set.len())); if ui.button("Clear").clicked() { gui.highlight_set.clear(); } } if let Some(view_key) = app.hex_ui.focused_view { let presentation = &mut app.meta_state.meta.views[view_key].view.presentation; ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { ui.checkbox(&mut presentation.invert_color, "invert"); ComboBox::new("color_combo", "Color") .selected_text(presentation.color_method.name()) .show_ui(ui, |ui| { ui.selectable_value( &mut presentation.color_method, ColorMethod::Default, ColorMethod::Default.name(), ); ui.selectable_value( &mut presentation.color_method, ColorMethod::Pure, ColorMethod::Pure.name(), ); ui.selectable_value( &mut presentation.color_method, ColorMethod::Mono(RgbColor::WHITE), ColorMethod::Mono(RgbColor::WHITE).name(), ); ui.selectable_value( &mut presentation.color_method, ColorMethod::Rgb332, ColorMethod::Rgb332.name(), ); ui.selectable_value( &mut presentation.color_method, ColorMethod::Vga13h, ColorMethod::Vga13h.name(), ); ui.selectable_value( &mut presentation.color_method, ColorMethod::BrightScale(RgbColor::WHITE), ColorMethod::BrightScale(RgbColor::WHITE).name(), ); if ui .selectable_label( matches!(&presentation.color_method, ColorMethod::Custom(..)), "custom", ) .clicked() { #[expect( clippy::cast_possible_truncation, reason = "The array is 256 elements long" )] let arr = std::array::from_fn(|i| { let c = presentation .color_method .byte_color(i as u8, presentation.invert_color); [c.r, c.g, c.b] }); presentation.color_method = ColorMethod::Custom(Box::new(Palette(arr))); } }); ui.color_edit_button_rgb(&mut app.preferences.bg_color); ui.label("Bg color"); if let ColorMethod::Mono(color) | ColorMethod::BrightScale(color) = &mut presentation.color_method { let mut rgb = [color.r, color.g, color.b]; ui.color_edit_button_srgb(&mut rgb); [color.r, color.g, color.b] = rgb; ui.label("Text color"); } if let ColorMethod::Custom(arr) = &mut presentation.color_method { let Some(&byte) = app.data.get(app.edit_state.cursor) else { return; }; let col = &mut arr.0[byte as usize]; ui.color_edit_button_srgb(col); ui.label("Byte color"); if ui.button("#").on_hover_text("From hex code on clipboard").clicked() { match color_from_hexcode(&crate::app::get_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, )) { Ok(new) => *col = new, Err(e) => { gui.msg_dialog.open( Icon::Error, "Color parse error", e.to_string(), ); } } } if ui.button("Lua").on_hover_text("From lua script").clicked() { Gui::add_dialog(&mut gui.dialogs, LuaColorDialog::default()); } if ui.button("Save").clicked() { gui.fileops.save_palette_for_view(view_key); } if ui.button("Load").clicked() { gui.fileops.load_palette_for_view(view_key); } let tooltip = "\ From image file.\n\ \n\ Pixel by pixel, the image's colors will become the byte colors. "; if ui .add_enabled(app.hex_ui.selection().is_some(), egui::Button::new("img")) .on_hover_text(tooltip) .clicked() { gui.fileops.load_palette_from_image_for_view(view_key); } } }); } }); } fn color_from_hexcode(mut src: &str) -> anyhow::Result<[u8; 3]> { let mut out = [0; 3]; src = src.trim_start_matches('#'); for (i, byte) in out.iter_mut().enumerate() { let src_idx = i * 2; *byte = u8::from_str_radix(src.get(src_idx..src_idx + 2).context("Indexing error")?, 16)?; } Ok(out) } #[test] #[expect(clippy::unwrap_used)] fn test_color_from_hexcode() { assert_eq!(color_from_hexcode("#ffffff").unwrap(), [255, 255, 255]); assert_eq!(color_from_hexcode("ff00ff").unwrap(), [255, 0, 255]); } ================================================ FILE: src/gui/windows/about.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{result_ext::AnyhowConv as _, shell::msg_if_fail}, egui_extras::{Column, TableBuilder}, std::fmt::Write as _, sysinfo::System, }; type InfoPair = (&'static str, String); pub struct AboutWindow { pub open: WindowOpen, sys: System, info: [InfoPair; 14], os_name: String, os_ver: String, } impl Default for AboutWindow { fn default() -> Self { Self { open: Default::default(), sys: Default::default(), info: Default::default(), os_name: System::name().unwrap_or_else(|| "Unknown".into()), os_ver: System::os_version().unwrap_or_else(|| "Unknown version".into()), } } } const MIB: u64 = 1_048_576; macro_rules! optenv { ($name:literal) => { option_env!($name).unwrap_or("").to_string() }; } impl super::Window for AboutWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if self.open.just_now() { self.sys.refresh_cpu_all(); self.sys.refresh_memory(); self.info = [ ("Hexerator", String::new()), ("Version", optenv!("CARGO_PKG_VERSION")), ("Git SHA", optenv!("VERGEN_GIT_SHA")), ( "Commit date", optenv!("VERGEN_GIT_COMMIT_TIMESTAMP") .split('T') .next() .unwrap_or("error") .into(), ), ( "Build date", optenv!("VERGEN_BUILD_TIMESTAMP").split('T').next().unwrap_or("error").into(), ), ("Target", optenv!("VERGEN_CARGO_TARGET_TRIPLE")), ("Debug", optenv!("VERGEN_CARGO_DEBUG")), ("Opt-level", optenv!("VERGEN_CARGO_OPT_LEVEL")), ("Built with rustc", optenv!("VERGEN_RUSTC_SEMVER")), ("System", String::new()), ("OS", format!("{} {}", self.os_name, self.os_ver)), ( "Total memory", format!("{} MiB", self.sys.total_memory() / MIB), ), ( "Used memory", format!("{} MiB", self.sys.used_memory() / MIB), ), ( "Available memory", format!("{} MiB", self.sys.available_memory() / MIB), ), ]; } info_table(ui, &self.info); ui.separator(); ui.vertical_centered_justified(|ui| { if ui.button("Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &clipfmt_info(&self.info), ); } }); ui.separator(); ui.heading("Links"); ui.vertical_centered_justified(|ui| { let result = try { if ui.link("📖 Book").clicked() { open::that(crate::gui::BOOK_URL).how()?; } if ui.link(" Git repository").clicked() { open::that("https://github.com/crumblingstatue/hexerator/").how()?; } if ui.link("💬 Discussions forum").clicked() { open::that("https://github.com/crumblingstatue/hexerator/discussions").how()?; } }; msg_if_fail(result, "Failed to open link", &mut gui.msg_dialog); ui.separator(); if ui.button("Close").clicked() { self.open.set(false); } }); } fn title(&self) -> &str { "About Hexerator" } } fn info_table(ui: &mut egui::Ui, info: &[InfoPair]) { ui.push_id(info.as_ptr(), |ui| { let body_height = ui.text_style_height(&egui::TextStyle::Body); TableBuilder::new(ui) .column(Column::auto()) .column(Column::remainder()) .resizable(true) .striped(true) .body(|mut body| { for (k, v) in info { body.row(body_height + 2.0, |mut row| { row.col(|ui| { ui.label(*k); }); row.col(|ui| { ui.label(v); }); }); } }); }); } fn clipfmt_info(info: &[InfoPair]) -> String { let mut out = String::new(); for (k, v) in info { let _ = writeln!(out, "{k}: {v}"); } out } ================================================ FILE: src/gui/windows/bookmarks.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::set_clipboard_string, damage_region::DamageRegion, data::Data, gui::{message_dialog::MessageDialog, windows::regions::region_context_menu}, meta::{ Bookmark, find_most_specific_region_for_offset, value_type::{ EndianedPrimitive, F32Be, F32Le, F64Be, F64Le, I8, I16Be, I16Le, I32Be, I32Le, I64Be, I64Le, StringMap, U8, U16Be, U16Le, U32Be, U32Le, U64Be, U64Le, ValueType, }, }, result_ext::AnyhowConv as _, shell::{msg_fail, msg_if_fail}, }, anyhow::Context as _, egui::{ScrollArea, Ui, text::CCursorRange}, egui_extras::{Column, TableBuilder}, gamedebug_core::per, num_traits::AsPrimitive, std::mem::discriminant, }; #[derive(Default)] pub struct BookmarksWindow { pub open: WindowOpen, pub selected: Option, pub edit_name: bool, pub focus_text_edit: bool, value_type_string_buf: String, name_filter_string: String, autoreload: bool, } impl super::Window for BookmarksWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut self.name_filter_string) .hint_text("Filter by name"), ); if ui.button("Highlight all").clicked() { gui.highlight_set.clear(); for bm in &app.meta_state.meta.bookmarks { gui.highlight_set.insert(bm.offset); } } ui.checkbox(&mut self.autoreload, "Autoreload") .on_hover_text("Automatically reload data every frame for the visible bookmarks"); }); let mut action = Action::None; ScrollArea::vertical().max_height(500.0).show(ui, |ui| { TableBuilder::new(ui) .columns(Column::auto(), 4) .column(Column::remainder()) .striped(true) .resizable(true) .header(24.0, |mut row| { row.col(|ui| { ui.label("Name"); }); row.col(|ui| { ui.label("Offset"); }); row.col(|ui| { ui.label("Type"); }); row.col(|ui| { ui.label("Value"); }); row.col(|ui| { ui.label("Region"); }); }) .body(|body| { // Sort by offset let mut keys: Vec = (0..app.meta_state.meta.bookmarks.len()).collect(); keys.sort_by_key(|&idx| app.meta_state.meta.bookmarks[idx].offset); keys.retain(|&k| { self.name_filter_string.is_empty() || app.meta_state.meta.bookmarks[k] .label .to_ascii_lowercase() .contains(&self.name_filter_string.to_ascii_lowercase()) }); body.rows(20.0, keys.len(), |mut row| { let idx = keys[row.index()]; row.col(|ui| { let re = ui.selectable_label( self.selected == Some(idx), &app.meta_state.meta.bookmarks[idx].label, ); re.context_menu(|ui| { if ui.button("Copy name to clipboard").clicked() { set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &app.meta_state.meta.bookmarks[idx].label, ); } }); if re.clicked() { self.selected = Some(idx); } }); row.col(|ui| { let offset = app.meta_state.meta.bookmarks[idx].offset; let ctx_menu = |ui: &mut Ui| { if ui.button("Copy to clipboard").clicked() { set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &offset.to_string(), ); } if ui .button("Reoffset all bookmarks") .on_hover_text("Assume that the cursor is at the correct offset for this bookmark.\n\ Reoffset all the other bookmarks based on that assumption.").clicked() { app.reoffset_bookmarks_cursor_diff(offset); } }; let re = ui.link(offset.to_string()); re.context_menu(ctx_menu); if re.clicked() { action = Action::Goto(offset); } }); row.col(|ui| { ui.label(app.meta_state.meta.bookmarks[idx].value_type.label()); }); row.col(|ui| { let bookmark = &app.meta_state.meta.bookmarks[idx]; let offs = bookmark.offset; if self.autoreload && let Err(e) = app.reload_range(offs, offs) { eprintln!("Bookmark autoreload fail: {e}"); } let bookmark = &app.meta_state.meta.bookmarks[idx]; let action = value_ui( bookmark, &mut app.data, ui, &mut app.clipboard, &mut gui.msg_dialog, ); match action { Action::None => {} Action::Goto(offset) => app.search_focus(offset), } }); row.col(|ui| { let off = app.meta_state.meta.bookmarks[idx].offset; if let Some(region_key) = find_most_specific_region_for_offset( &app.meta_state.meta.low.regions, off, ) { let region = &app.meta_state.meta.low.regions[region_key]; let ctx_menu = |ui: &mut Ui| { region_context_menu( ui, region, region_key, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); }; let re = ui.link(®ion.name).on_hover_text(®ion.desc); re.context_menu(ctx_menu); if re.clicked() { gui.win.regions.open.set(true); gui.win.regions.selected_key = Some(region_key); } } else { ui.label(""); } }); }); }); }); if let Some(idx) = self.selected { let Some(mark) = app.meta_state.meta.bookmarks.get_mut(idx) else { per!("Invalid bookmark selection: {idx}"); self.selected = None; return; }; ui.separator(); ui.horizontal(|ui| { if self.edit_name { let mut out = egui::TextEdit::singleline(&mut mark.label).show(ui); if out.response.lost_focus() { self.edit_name = false; } if self.focus_text_edit { out.response.request_focus(); out.state .cursor .set_char_range(Some(CCursorRange::select_all(&out.galley))); out.state.store(ui.ctx(), out.response.id); self.focus_text_edit = false; } } else { ui.heading(&mark.label); } if ui.button("✏").clicked() { self.edit_name ^= true; if self.edit_name { self.focus_text_edit = true; } } if ui.button("⮩").on_hover_text("Jump").clicked() { action = Action::Goto(mark.offset); } }); ui.horizontal(|ui| { ui.label("Offset"); ui.add(egui::DragValue::new(&mut mark.offset)); if ui.button("👆").on_hover_text("Set to cursor position").clicked() { mark.offset = app.edit_state.cursor; } }); egui::ComboBox::new("type_combo", "value type") .selected_text(mark.value_type.label()) .show_ui(ui, |ui| { macro_rules! int_sel_vals { ($($t:ident,)*) => { $( ui.selectable_value( &mut mark.value_type, ValueType::$t($t), ValueType::$t($t).label(), ); )* } } ui.selectable_value( &mut mark.value_type, ValueType::None, ValueType::None.label(), ); int_sel_vals! { I8, U8, I16Le, U16Le, I16Be, U16Be, I32Le, U32Le, I32Be, U32Be, I64Le, U64Le, I64Be, U64Be, F32Le, F32Be, F64Le, F64Be, } let val = ValueType::StringMap(Default::default()); if ui .selectable_label( discriminant(&mark.value_type) == discriminant(&val), val.label(), ) .clicked() { mark.value_type = val; } }); ui.horizontal(|ui| { ui.label("Value"); let value_ui_action = value_ui( mark, &mut app.data, ui, &mut app.clipboard, &mut gui.msg_dialog, ); match (&value_ui_action, &action) { (Action::None, Action::None) => {} (Action::None, Action::Goto(_)) => {} (Action::Goto(_), Action::None) => action = value_ui_action, (Action::Goto(_), Action::Goto(_)) => { msg_fail( &"Conflicting goto action", "Ui Action error", &mut gui.msg_dialog, ); } } }); #[expect(clippy::single_match, reason = "Want to add more variants in future")] match &mut mark.value_type { ValueType::StringMap(list) => { let text_edit_finished = ui .add( egui::TextEdit::singleline(&mut self.value_type_string_buf) .hint_text("key = value"), ) .lost_focus() && ui.input(|inp| inp.key_pressed(egui::Key::Enter)); if text_edit_finished || ui.button("Set key = value").clicked() { let result = try { let s = &self.value_type_string_buf; let (k, v) = s.split_once('=').context("Missing `=`")?; let k: u8 = k.trim().parse().how()?; let v = v.trim().to_owned(); list.insert(k, v); }; msg_if_fail( result, "Failed to set value list kvpair", &mut gui.msg_dialog, ); } } _ => {} } ui.heading("Description"); ScrollArea::vertical().id_salt("desc_scroll").max_height(200.0).show(ui, |ui| { ui.add(egui::TextEdit::multiline(&mut mark.desc).code_editor()); }); if ui.button("Delete").clicked() { app.meta_state.meta.bookmarks.remove(idx); self.selected = None; } } ui.separator(); if ui.button("Add new at cursor").clicked() { app.meta_state.meta.bookmarks.push(Bookmark { offset: app.edit_state.cursor, label: format!("New bookmark at {}", app.edit_state.cursor), desc: String::new(), value_type: ValueType::None, }); self.selected = Some(app.meta_state.meta.bookmarks.len() - 1); } match action { Action::None => {} Action::Goto(off) => { app.edit_state.cursor = off; app.center_view_on_offset(off); app.hex_ui.flash_cursor(); } } } fn title(&self) -> &str { "Bookmarks" } } fn value_ui( bm: &Bookmark, data: &mut Data, ui: &mut Ui, cb: &mut arboard::Clipboard, msg: &mut MessageDialog, ) -> Action { macro_rules! val_ui_dispatch { ($i:ident) => { $i.value_ui_for_self(bm, data, ui, cb, msg).to_action() }; } match &bm.value_type { ValueType::None => Action::None, ValueType::I8(v) => val_ui_dispatch!(v), ValueType::U8(v) => val_ui_dispatch!(v), ValueType::I16Le(v) => val_ui_dispatch!(v), ValueType::U16Le(v) => val_ui_dispatch!(v), ValueType::I16Be(v) => val_ui_dispatch!(v), ValueType::U16Be(v) => val_ui_dispatch!(v), ValueType::I32Le(v) => val_ui_dispatch!(v), ValueType::U32Le(v) => val_ui_dispatch!(v), ValueType::I32Be(v) => val_ui_dispatch!(v), ValueType::U32Be(v) => val_ui_dispatch!(v), ValueType::I64Le(v) => val_ui_dispatch!(v), ValueType::U64Le(v) => val_ui_dispatch!(v), ValueType::I64Be(v) => val_ui_dispatch!(v), ValueType::U64Be(v) => val_ui_dispatch!(v), ValueType::F32Le(v) => val_ui_dispatch!(v), ValueType::F32Be(v) => val_ui_dispatch!(v), ValueType::F64Le(v) => val_ui_dispatch!(v), ValueType::F64Be(v) => val_ui_dispatch!(v), ValueType::StringMap(v) => val_ui_dispatch!(v), } } trait ValueTrait: EndianedPrimitive { /// Returns whether the value was changed. fn value_change_ui( &self, ui: &mut Ui, bytes: &mut [u8; Self::BYTE_LEN], cb: &mut arboard::Clipboard, msg: &mut MessageDialog, ) -> ValueUiOutput; fn value_ui_for_self( &self, bm: &Bookmark, data: &mut Data, ui: &mut Ui, cb: &mut arboard::Clipboard, msg: &mut MessageDialog, ) -> UiAction where [(); Self::BYTE_LEN]:, { let range = bm.offset..bm.offset + Self::BYTE_LEN; match data.get_mut(range.clone()) { Some(slice) => { #[expect( clippy::unwrap_used, reason = "If slicing is successful, we're guaranteed to have slice of right length" )] let out = self.value_change_ui(ui, slice.try_into().unwrap(), cb, msg); if out.changed { data.widen_dirty_region(DamageRegion::Range(range)); } out.action } None => { match data.get(range) { Some(slice) => { #[expect( clippy::unwrap_used, reason = "If slicing is successful, we're guaranteed to have slice of right length" )] ui.label(Self::from_bytes(slice.try_into().unwrap()).to_string()); } None => { ui.label("??"); } } UiAction::None } } } } struct ValueUiOutput { changed: bool, action: UiAction, } trait DefaultUi {} impl DefaultUi for I8 {} impl DefaultUi for U8 {} impl DefaultUi for I16Le {} impl DefaultUi for U16Le {} impl DefaultUi for I16Be {} impl DefaultUi for U16Be {} impl DefaultUi for I32Le {} impl DefaultUi for U32Le {} impl DefaultUi for I32Be {} impl DefaultUi for U32Be {} impl DefaultUi for I64Le {} impl DefaultUi for U64Le {} impl DefaultUi for I64Be {} impl DefaultUi for U64Be {} impl DefaultUi for F32Le {} impl DefaultUi for F32Be {} impl DefaultUi for F64Le {} impl DefaultUi for F64Be {} impl ValueTrait for T { fn value_change_ui( &self, ui: &mut Ui, bytes: &mut [u8; Self::BYTE_LEN], cb: &mut arboard::Clipboard, msg: &mut MessageDialog, ) -> ValueUiOutput { let mut val = Self::from_bytes(*bytes); let mut action = UiAction::None; let act_mut = &mut action; let ctx_menu = move |ui: &mut Ui| { if ui.button("Copy").clicked() { set_clipboard_string(cb, msg, &val.to_string()); } if ui.button("Jump").clicked() { *act_mut = UiAction::Goto(val); } }; let re = ui.add(egui::DragValue::new(&mut val)); re.context_menu(ctx_menu); let changed = if re.changed() { bytes.copy_from_slice(&Self::to_bytes(val)); true } else { false }; ValueUiOutput { changed, action } } } impl EndianedPrimitive for StringMap { type Primitive = u8; fn from_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self::Primitive { bytes[0] } fn to_bytes(prim: Self::Primitive) -> [u8; Self::BYTE_LEN] { [prim] } fn label(&self) -> &'static str { "string map" } } impl ValueTrait for StringMap { fn value_change_ui( &self, ui: &mut Ui, bytes: &mut [u8; Self::BYTE_LEN], _cb: &mut arboard::Clipboard, _msg: &mut MessageDialog, ) -> ValueUiOutput { let val = &mut bytes[0]; let mut s = String::new(); let label = self.get(val).unwrap_or_else(|| { s = format!("[unmapped: {val}]"); &s }); let mut changed = false; egui::ComboBox::new("val_combo", "").selected_text(label).show_ui(ui, |ui| { for (k, v) in self { if ui.selectable_value(val, *k, v).clicked() { changed = true; } } }); ValueUiOutput { changed, action: UiAction::None, } } } enum Action { None, Goto(usize), } enum UiAction { None, Goto(T), } impl> UiAction { fn to_action(&self) -> Action { match self { Self::None => Action::None, &Self::Goto(val) => Action::Goto(val.as_()), } } } ================================================ FILE: src/gui/windows/debug.rs ================================================ use { egui::Ui, gamedebug_core::{IMMEDIATE, PERSISTENT}, }; pub fn ui(ui: &mut Ui) { ui.horizontal(|ui| { if ui.button("Clear persistent").clicked() { PERSISTENT.clear(); } }); ui.separator(); egui::ScrollArea::vertical() .max_height(500.) .auto_shrink([false, true]) .show(ui, |ui| { IMMEDIATE.for_each(|msg| { ui.label(msg); }); }); IMMEDIATE.clear(); ui.separator(); egui::ScrollArea::vertical() .id_salt("per_scroll") .max_height(500.0) .show(ui, |ui| { egui::Grid::new("per_grid").striped(true).show(ui, |ui| { PERSISTENT.for_each(|msg| { ui.label( egui::RichText::new(msg.frame.to_string()).color(egui::Color32::DARK_GRAY), ); if let Some(src_loc) = &msg.src_loc { let txt = format!("{}:{}:{}", src_loc.file, src_loc.line, src_loc.column); if ui.link(&txt).on_hover_text("Click to copy to clipboard").clicked() { ui.ctx().copy_text(txt); } } ui.label(&msg.info); ui.end_row(); }); }); }); } ================================================ FILE: src/gui/windows/external_command.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ result_ext::AnyhowConv as _, shell::{msg_fail, msg_if_fail}, str_ext::StrExt as _, }, anyhow::Context as _, core::f32, std::{ ffi::OsString, io::Read as _, path::PathBuf, process::{Child, Command, ExitStatus, Stdio}, }, }; pub struct ExternalCommandWindow { pub open: WindowOpen, cmd_str: String, child: Option, exit_status: Option, err_msg: String, stdout: String, stderr: String, auto_exec: bool, inherited_streams: bool, selection_only: bool, temp_file_name: String, working_dir: WorkingDir, } #[derive(PartialEq)] enum WorkingDir { /// Create a temporary directory for executing the command Temp, /// Execute in the same directory as Hexerator's working dir Hexerator, /// Execute in the same directory as the opened document Document, } impl WorkingDir { fn label(&self) -> &'static str { match self { Self::Temp => "Temp", Self::Hexerator => "Hexerator", Self::Document => "Document", } } } impl Default for ExternalCommandWindow { fn default() -> Self { Self { open: Default::default(), cmd_str: Default::default(), child: Default::default(), exit_status: Default::default(), err_msg: Default::default(), stdout: Default::default(), stderr: Default::default(), auto_exec: Default::default(), inherited_streams: Default::default(), selection_only: true, temp_file_name: String::from("hexerator_data_tmp.bin"), working_dir: WorkingDir::Temp, } } } enum Arg<'src> { TmpFilePath, Custom(&'src str), } impl super::Window for ExternalCommandWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { let re = ui.add( egui::TextEdit::multiline(&mut self.cmd_str) .hint_text("Use {} to substitute filename.\nExample: aplay {} -f s16_le") .desired_width(f32::INFINITY), ); if self.open.just_now() { re.request_focus(); } ui.horizontal(|ui| { egui::ComboBox::new("wd_cb", "Working dir") .selected_text(self.working_dir.label()) .show_ui(ui, |ui| { ui.selectable_value( &mut self.working_dir, WorkingDir::Temp, WorkingDir::Temp.label(), ); ui.selectable_value( &mut self.working_dir, WorkingDir::Document, WorkingDir::Document.label(), ); ui.selectable_value( &mut self.working_dir, WorkingDir::Hexerator, WorkingDir::Hexerator.label(), ); }); if let WorkingDir::Temp = self.working_dir { ui.label("Temp file name"); ui.text_edit_singleline(&mut self.temp_file_name); } }); ui.horizontal(|ui| { ui.add_enabled( app.hex_ui.selection().is_some() && self.working_dir == WorkingDir::Temp, egui::Checkbox::new(&mut self.selection_only, "Selection only"), ); ui.checkbox(&mut self.inherited_streams, "Inherited stdout/stderr") .on_hover_text( "Use this for large amounts of data that could block child processes, like music players, etc." ); }); let exec_enabled = self.child.is_none() && !self.temp_file_name.is_empty_or_ws_only(); if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { self.open.set(false); } ui.horizontal(|ui| { if ui.add_enabled(exec_enabled, egui::Button::new("Execute (ctrl+E)")).clicked() || (exec_enabled && ((ui.input(|inp| { inp.key_pressed(egui::Key::E) && inp.modifiers.ctrl && !self.open.just_now() })) || self.auto_exec)) { let res = try { // Parse args let (cmd, args) = parse(&self.cmd_str)?; // Generate temp file let range = if self.selection_only && let Some(sel) = app.hex_ui.selection() { sel.begin..=sel.end } else { 0..=app.data.len() - 1 }; let dir: PathBuf; let file_path: PathBuf; match self.working_dir { WorkingDir::Temp => { dir = std::env::temp_dir(); let path = dir.join(&self.temp_file_name); let data = app.data.get(range).context("Range out of bounds")?; std::fs::write(&path, data).how()?; file_path = path; } WorkingDir::Hexerator => { dir = std::env::current_dir().how()?; file_path = dir.clone(); } WorkingDir::Document => match &app.src_args.file { Some(path) => { dir = path .parent() .context("Document path has no parent")? .to_path_buf(); file_path = dir.clone(); } None => { do yeet anyhow::anyhow!("Document has no path"); } }, } // Spawn process let mut cmd = Command::new(cmd); cmd.current_dir(&dir).args(resolve_args(args, &file_path)); if self.inherited_streams { cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); } else { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); } let handle = cmd.spawn().how()?; self.child = Some(handle); // Clear output from previous run self.stderr.clear(); self.stdout.clear(); }; if let Err(e) = res { msg_fail(&e, "Failed to spawn command", &mut gui.msg_dialog); self.auto_exec = false; } } ui.checkbox(&mut self.auto_exec, "Auto execute") .on_hover_text("Execute again after process finishes"); }); if let Some(child) = &mut self.child { ui.horizontal(|ui| { ui.spinner(); ui.label(format!("{} running", child.id())); if ui.button("Kill").clicked() { self.auto_exec = false; msg_if_fail(child.kill(), "Failed to kill child", &mut gui.msg_dialog); } }); match child.try_wait() { Ok(opt_status) => { if let Some(status) = opt_status { if let Some(stdout) = &mut child.stdout { self.stdout.clear(); if let Err(e) = stdout.read_to_string(&mut self.stdout) { self.stdout = format!(""); } } if let Some(stderr) = &mut child.stderr { self.stderr.clear(); if let Err(e) = stderr.read_to_string(&mut self.stderr) { self.stderr = format!(""); } } self.child = None; self.exit_status = Some(status); } } Err(e) => self.err_msg = e.to_string(), } } if !self.err_msg.is_empty() { ui.label(egui::RichText::new(&self.err_msg).color(egui::Color32::RED)); } if !self.stdout.is_empty() { ui.label("stdout"); egui::ScrollArea::vertical() .id_salt("stdout") .auto_shrink([false, true]) .max_height(200.0) .show(ui, |ui| { ui.text_edit_multiline(&mut &self.stdout[..]); }); } if !self.stderr.is_empty() { ui.label("stderr"); egui::ScrollArea::vertical() .id_salt("stderr") .auto_shrink([false, true]) .max_height(200.0) .show(ui, |ui| { ui.text_edit_multiline(&mut &self.stderr[..]); }); } } fn title(&self) -> &str { "External command" } } fn resolve_args<'src>( args: impl Iterator> + 'src, path: &'src PathBuf, ) -> impl Iterator + 'src { args.map(|arg| match arg { Arg::TmpFilePath => path.into(), Arg::Custom(c) => c.into(), }) } fn parse(input: &'_ str) -> anyhow::Result<(&'_ str, impl Iterator>)> { let mut tokens = input.split_whitespace(); let cmd = tokens.next().context("Missing command")?; let iter = tokens.map(|tok| { if tok == "{}" { Arg::TmpFilePath } else { Arg::Custom(tok) } }); Ok((cmd, iter)) } ================================================ FILE: src/gui/windows/file_diff_result.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::read_source_to_buf, gui::windows::regions::region_context_menu, meta::{Meta, RegionKey, find_most_specific_region_for_offset}, shell::msg_if_fail, }, egui_extras::Column, std::{path::PathBuf, time::Instant}, }; pub struct FileDiffResultWindow { pub file_data: Vec, pub offsets: Vec, pub open: WindowOpen, pub path: PathBuf, pub auto_refresh: bool, pub auto_refresh_interval_ms: u32, pub last_refresh: Instant, /// Allows filtering differences based on diff threshold pub diff_threshold: u8, /// Display the relative offset values as relative to this value pub base_offset: usize, } impl Default for FileDiffResultWindow { fn default() -> Self { Self { offsets: Default::default(), open: Default::default(), path: Default::default(), auto_refresh: Default::default(), auto_refresh_interval_ms: Default::default(), last_refresh: Instant::now(), file_data: Vec::new(), diff_threshold: 1, base_offset: 0, } } } impl super::Window for FileDiffResultWindow { fn ui( &mut self, WinCtx { ui, gui, app, font_size, line_spacing, .. }: WinCtx, ) { let mut action = Action::None; ui.horizontal(|ui| { if let Some(src_file) = &app.src_args.file { ui.colored_label(egui::Color32::GREEN, src_file.display().to_string()); } ui.label("vs"); ui.colored_label(egui::Color32::RED, self.path.display().to_string()); }); ui.horizontal(|ui| { if ui .button("🔁 Switch") .on_hover_text("Switch to the diffed against file (keeping the current meta)") .clicked() { let prev_pref = app.preferences.keep_meta; let prev_path = app.src_args.file.clone(); app.preferences.keep_meta = true; app.load_file( self.path.clone(), false, &mut gui.msg_dialog, font_size, line_spacing, ); app.preferences.keep_meta = prev_pref; if let Some(path) = prev_path { msg_if_fail( app.diff_with_file(path, self), "Failed to diff", &mut gui.msg_dialog, ); } } if ui.button("🖹 Diff with...").on_hover_text("Diff with another file").clicked() { gui.fileops.diff_with_file(app.source_file()); } }); ui.separator(); ui.horizontal(|ui| { if ui .button("Filter unchanged") .on_hover_text("Keep only the unchanged values") .clicked() { let result = try { let file_data = read_source_to_buf(&self.path, &app.src_args)?; self.offsets.retain(|&offs| self.file_data[offs] == file_data[offs]); }; msg_if_fail(result, "Filter unchanged failed", &mut gui.msg_dialog); } if ui .button("Filter changed") .on_hover_text("Keep only the values that changed") .clicked() { let result = try { let file_data = read_source_to_buf(&self.path, &app.src_args)?; self.offsets.retain(|&offs| self.file_data[offs] != file_data[offs]); }; msg_if_fail(result, "Filter unchanged failed", &mut gui.msg_dialog); } if ui .button("Filter diff>threshold") .on_hover_text( "Keep only the values whose difference is larger than the provided threshold", ) .clicked() { self.offsets.retain(|&offs| { self.file_data[offs].abs_diff(app.data[offs]) > self.diff_threshold }); } ui.add(egui::DragValue::new(&mut self.diff_threshold)); }); ui.horizontal(|ui| { if ui.button("Refresh").clicked() || (self.auto_refresh && self.last_refresh.elapsed().as_millis() >= u128::from(self.auto_refresh_interval_ms)) { self.last_refresh = Instant::now(); let result = try { self.file_data = read_source_to_buf(&self.path, &app.src_args)?; }; msg_if_fail(result, "Refresh failed", &mut gui.msg_dialog); } ui.checkbox(&mut self.auto_refresh, "Auto refresh"); ui.label("Interval"); ui.add(egui::DragValue::new(&mut self.auto_refresh_interval_ms)); if ui.link("Base offset").clicked() { action = Action::Goto(self.base_offset); } ui.add(egui::DragValue::new(&mut self.base_offset)); if ui.button("Set to cursor").clicked() { self.base_offset = app.edit_state.cursor; } }); ui.separator(); if self.offsets.is_empty() { ui.label("No difference"); return; } else { ui.horizontal(|ui| { ui.label(format!("{} bytes different.", self.offsets.len())); if ui.button("Highlight all").clicked() { gui.highlight_set.clear(); for &offs in &self.offsets { gui.highlight_set.insert(offs); if let Some((_, bm)) = Meta::bookmark_for_offset(&app.meta_state.meta.bookmarks, offs) { for i in 1..bm.value_type.byte_len() { gui.highlight_set.insert(offs + i); } } } } #[expect(clippy::collapsible_if)] if !gui.highlight_set.is_empty() { if ui.button("Clear highlight").clicked() { gui.highlight_set.clear(); } } }); ui.separator(); } egui_extras::TableBuilder::new(ui) .columns(Column::auto(), 4) .column(Column::remainder()) .resizable(true) .striped(true) .header(32.0, |mut row| { row.col(|ui| { ui.colored_label(egui::Color32::GREEN, "My value"); }); row.col(|ui| { ui.colored_label(egui::Color32::RED, "File value"); }); row.col(|ui| { ui.label("Offset"); }); row.col(|ui| { ui.label("Region"); }); row.col(|ui| { ui.label("Bookmark"); }); }) .body(|body| { body.rows(20.0, self.offsets.len(), |mut row| { let offs = self.offsets[row.index()]; let bm = Meta::bookmark_for_offset(&app.meta_state.meta.bookmarks, offs) .map(|(_, bm)| bm); row.col(|ui| { let s = match bm { Some(bm) => bm .value_type .read(&app.data[offs..]) .map_or("err".into(), |v| v.to_string()), None => app.data[offs].to_string(), }; ui.label(s); }); row.col(|ui| { let s = match bm { Some(bm) => bm .value_type .read(&self.file_data[offs..]) .map_or("err".into(), |v| v.to_string()), None => self.file_data[offs].to_string(), }; ui.label(s); }); row.col(|ui| { #[expect(clippy::cast_possible_wrap)] let display_offs: isize = offs as isize - self.base_offset as isize; let re = ui.link(display_offs.to_string()); re.context_menu(|ui| { if ui.button("Add bookmark").clicked() { crate::gui::add_new_bookmark(app, gui, offs); } }); if re.clicked() { action = Action::Goto(offs); } }); row.col(|ui| { match find_most_specific_region_for_offset( &app.meta_state.meta.low.regions, offs, ) { Some(reg_key) => { let reg = &app.meta_state.meta.low.regions[reg_key]; ui.menu_button(®.name, |ui| { if ui.button("Remove region from results").clicked() { action = Action::RemoveRegion(reg_key); } }) .response .context_menu(|ui| { region_context_menu( ui, reg, reg_key, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); }); } None => { ui.label("[no region]"); } } }); row.col(|ui| { match app .meta_state .meta .bookmarks .iter() .enumerate() .find(|(_i, b)| b.offset == offs) { Some((idx, bookmark)) => { if ui.link(&bookmark.label).on_hover_text(&bookmark.desc).clicked() { gui.win.bookmarks.open.set(true); gui.win.bookmarks.selected = Some(idx); } } None => { ui.label("-"); } } }); }); }); match action { Action::None => {} Action::Goto(off) => { app.center_view_on_offset(off); app.edit_state.set_cursor(off); app.hex_ui.flash_cursor(); } Action::RemoveRegion(key) => self.offsets.retain(|&offs| { let reg = find_most_specific_region_for_offset(&app.meta_state.meta.low.regions, offs); reg != Some(key) }), } } fn title(&self) -> &str { "File Diff results" } } enum Action { None, Goto(usize), RemoveRegion(RegionKey), } ================================================ FILE: src/gui/windows/find_dialog.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::{get_clipboard_string, set_clipboard_string}, damage_region::DamageRegion, gui::{ message_dialog::{Icon, MessageDialog}, windows::region_context_menu, }, hex_ui::HexUi, meta::{ Bookmark, Meta, find_most_specific_region_for_offset, region::Region, value_type::{ EndianedPrimitive, F32Be, F32Le, F64Be, F64Le, I8, I16Be, I16Le, I32Be, I32Le, I64Be, I64Le, U8, U16Be, U16Le, U32Be, U32Le, U64Be, U64Le, ValueType, }, }, parse_radix::parse_guess_radix, shell::{msg_fail, msg_if_fail}, }, egui::{Align, Ui}, egui_extras::{Column, Size, StripBuilder, TableBuilder}, itertools::Itertools as _, std::{collections::HashMap, error::Error, str::FromStr}, strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr}, }; #[derive(Default, Debug, PartialEq, Eq, EnumIter, IntoStaticStr)] pub enum FindType { I8, #[default] U8, I16Le, I16Be, U16Le, U16Be, I32Le, I32Be, U32Le, U32Be, I64Le, I64Be, U64Le, U64Be, F32Le, F32Be, F64Le, F64Be, Ascii, StringDiff, /// Equivalence pattern EqPattern, HexString, } impl FindType { fn to_value_type(&self) -> ValueType { match self { Self::I8 => ValueType::I8(I8), Self::U8 => ValueType::U8(U8), Self::I16Le => ValueType::I16Le(I16Le), Self::I16Be => ValueType::I16Be(I16Be), Self::U16Le => ValueType::U16Le(U16Le), Self::U16Be => ValueType::U16Be(U16Be), Self::I32Le => ValueType::I32Le(I32Le), Self::I32Be => ValueType::I32Be(I32Be), Self::U32Le => ValueType::U32Le(U32Le), Self::U32Be => ValueType::U32Be(U32Be), Self::I64Le => ValueType::I64Le(I64Le), Self::I64Be => ValueType::I64Be(I64Be), Self::U64Le => ValueType::U64Le(U64Le), Self::U64Be => ValueType::U64Be(U64Be), Self::F32Le => ValueType::F32Le(F32Le), Self::F32Be => ValueType::F32Be(F32Be), Self::F64Le => ValueType::F64Le(F64Le), Self::F64Be => ValueType::F64Be(F64Be), Self::Ascii => ValueType::None, Self::StringDiff => ValueType::None, Self::EqPattern => ValueType::None, Self::HexString => ValueType::U8(U8), } } fn help_str(&self) -> &'static str { match self { Self::I8 => "signed 8 bit integer", Self::U8 => "unsigned 8 bit integer", Self::I16Le => "signed 16 bit integer (little endian)", Self::I16Be => "signed 16 bit integer (big endian)", Self::U16Le => "unsigned 16 bit integer (little endian)", Self::U16Be => "unsigned 16 bit integer (big endian)", Self::I32Le => "signed 32 bit integer (little endian)", Self::I32Be => "signed 32 bit integer (big endian)", Self::U32Le => "unsigned 32 bit integer (little endian)", Self::U32Be => "unsigned 32 bit integer (big endian)", Self::I64Le => "signed 64 bit integer (little endian)", Self::I64Be => "signed 64 bit integer (big endian)", Self::U64Le => "unsigned 64 bit integer (little endian)", Self::U64Be => "unsigned 64 bit integer (big endian)", Self::F32Le => "32 bit float (little endian)", Self::F32Be => "32 bit float (big endian)", Self::F64Le => "64 bit float (little endian)", Self::F64Be => "64 bit float (big endian)", Self::Ascii => "Ascii string", Self::StringDiff => { "Searches the string difference pattern of your ascii input Useful to find alphabetic data in non-ascii character encodings. Takes advantage of the fact that A-Z and 0-9, etc, are usually next to each other regardless of the encoding." } Self::EqPattern => { "Searches the byte equivalence pattern of your ascii input For example if you type `aabbca` then it will match inputs like `00 00 FF FF CC 00`" } Self::HexString => "Search a hex string e.g. `ff 00 ff`", } } } #[derive(Default)] pub struct FindDialog { pub open: WindowOpen, pub find_input: String, pub replace_input: String, /// Results, as a Vec that can be indexed. Needed because of search cursor. pub results_vec: Vec, /// Used to keep track of previous/next result to go to pub result_cursor: usize, /// When Some, the results list should be scrolled to the offset of that result pub scroll_to: Option, pub filter_results: bool, pub rapid_eq_filter: bool, pub find_type: FindType, /// Used for increased/decreased unknown value search pub data_snapshot: Vec, /// Reload the source before search pub reload_before_search: bool, /// Only search in selection pub selection_only: bool, } impl super::Window for FindDialog { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { ui.horizontal(|ui| { let re = egui::ComboBox::new("type_combo", "Data type") .selected_text(<&str>::from(&self.find_type)) .show_ui(ui, |ui| { for type_ in FindType::iter() { let label = <&str>::from(&type_); let help = type_.help_str(); let re = ui.selectable_value(&mut self.find_type, type_, label); re.on_hover_text(help); } }); re.response.on_hover_text(self.find_type.help_str()); ui.checkbox(&mut self.reload_before_search, "Reload") .on_hover_text("Reload source before every search"); ui.checkbox(&mut self.selection_only, "Selection only") .on_hover_text("Only search in selection"); }); let re = ui.add(egui::TextEdit::singleline(&mut self.find_input).hint_text("🔍 Find")); if self.open.just_now() { re.request_focus(); } if re.lost_focus() && ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { if self.reload_before_search { self.reload_data(app, gui); } let (data, offs) = self.data_to_search(app); msg_if_fail( do_search(data, offs, self, gui), "Search failed", &mut gui.msg_dialog, ); if let Some(&off) = self.results_vec.first() { app.search_focus(off); } } if self.find_type == FindType::Ascii || self.find_type == FindType::HexString { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut self.replace_input).hint_text("🔁 Replace")); if ui .add_enabled( !self.results_vec.is_empty(), egui::Button::new("Replace all"), ) .clicked() { let bytes_buf; let replace_data = if self.find_type == FindType::Ascii { self.replace_input.as_bytes() } else { match crate::find_util::parse_hex_string(&self.replace_input) { Ok(bytes) => { bytes_buf = bytes; &bytes_buf } Err(e) => { msg_fail(&e, "Failed to parse hex string", &mut gui.msg_dialog); return; } } }; for &offset in &self.results_vec { app.data[offset..offset + replace_data.len()].copy_from_slice(replace_data); } } }); } ui.horizontal(|ui| { ui.checkbox(&mut self.filter_results, "Filter results") .on_hover_text("Base search on existing results"); ui.add_enabled( self.filter_results, egui::Checkbox::new(&mut self.rapid_eq_filter, "Rapid '=' filter"), ) .on_hover_text("Filter every frame for data that hasn't changed"); }); if self.rapid_eq_filter { if self.reload_before_search { self.reload_data(app, gui); } let (data, offset) = self.data_to_search(app); eq_filter(self, data, offset); } StripBuilder::new(ui) .size(Size::initial(400.0)) .size(Size::exact(20.0)) .size(Size::exact(20.0)) .vertical(|mut strip| { strip.cell(|ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let mut action = Action::None; TableBuilder::new(ui) .striped(true) .columns(Column::auto(), 3) .column(Column::remainder()) .resizable(true) .header(16.0, |mut row| { row.col(|ui| { ui.label("Offset"); }); row.col(|ui| { ui.label("Value"); }); row.col(|ui| { ui.label("Region"); }); row.col(|ui| { ui.label("Bookmark"); }); }) .body(|body| { body.rows(20.0, self.results_vec.len(), |mut row| { let i = row.index(); let off = self.results_vec[i]; let (_, col1_re) = row.col(|ui| { let re = ui .selectable_label(self.result_cursor == i, off.to_string()); re.context_menu(|ui| { if ui.button("Remove from results").clicked() { action = Action::RemoveIdxFromResults(i); } }); if re.clicked() { app.search_focus(off); self.result_cursor = i; } }); row.col(|ui| { let damage = match self.find_type { FindType::I8 => { data_value_label::(ui, &mut app.data, off) } FindType::U8 => { data_value_label::(ui, &mut app.data, off) } FindType::I16Le => { data_value_label::(ui, &mut app.data, off) } FindType::I16Be => { data_value_label::(ui, &mut app.data, off) } FindType::U16Le => { data_value_label::(ui, &mut app.data, off) } FindType::U16Be => { data_value_label::(ui, &mut app.data, off) } FindType::I32Le => { data_value_label::(ui, &mut app.data, off) } FindType::I32Be => { data_value_label::(ui, &mut app.data, off) } FindType::U32Le => { data_value_label::(ui, &mut app.data, off) } FindType::U32Be => { data_value_label::(ui, &mut app.data, off) } FindType::I64Le => { data_value_label::(ui, &mut app.data, off) } FindType::I64Be => { data_value_label::(ui, &mut app.data, off) } FindType::U64Le => { data_value_label::(ui, &mut app.data, off) } FindType::U64Be => { data_value_label::(ui, &mut app.data, off) } FindType::F32Le => { data_value_label::(ui, &mut app.data, off) } FindType::F32Be => { data_value_label::(ui, &mut app.data, off) } FindType::F64Le => { data_value_label::(ui, &mut app.data, off) } FindType::F64Be => { data_value_label::(ui, &mut app.data, off) } FindType::Ascii => { data_value_label::(ui, &mut app.data, off) } FindType::HexString => { data_value_label::(ui, &mut app.data, off) } FindType::StringDiff => { data_value_label::(ui, &mut app.data, off) } FindType::EqPattern => { data_value_label::(ui, &mut app.data, off) } }; if let Some(damage) = damage { app.data.widen_dirty_region(damage); } }); row.col(|ui| { match find_most_specific_region_for_offset( &app.meta_state.meta.low.regions, off, ) { Some(key) => { let reg = &app.meta_state.meta.low.regions[key]; let ctx_menu = |ui: &mut Ui| { region_context_menu( ui, reg, key, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); ui.separator(); if ui.button("Remove region from results").clicked() { action = Action::RemoveRegionFromResults(key); } }; let re = ui.link(®.name); re.context_menu(ctx_menu); if re.clicked() { gui.win.regions.open.set(true); gui.win.regions.selected_key = Some(key); } } None => { ui.label("[no region]"); } } }); row.col(|ui| { match Meta::bookmark_for_offset( &app.meta_state.meta.bookmarks, off, ) { Some((bm_idx, bm)) => { if ui.link(&bm.label).on_hover_text(&bm.desc).clicked() { gui.win.bookmarks.open.set(true); gui.win.bookmarks.selected = Some(bm_idx); } } None => { if ui .button("✚") .on_hover_text("Add new bookmark") .clicked() { let idx = app.meta_state.meta.bookmarks.len(); app.meta_state.meta.bookmarks.push(Bookmark { offset: off, label: "New bookmark".into(), desc: String::new(), value_type: self.find_type.to_value_type(), }); gui.win.bookmarks.open.set(true); gui.win.bookmarks.selected = Some(idx); gui.win.bookmarks.edit_name = true; gui.win.bookmarks.focus_text_edit = true; } } } }); if let Some(scroll_off) = self.scroll_to && scroll_off == i { // We use center align, because it keeps the selected element in // view at all times, preventing the issue of it becoming out // of view, and scroll_to_me not being called because of that. col1_re.scroll_to_me(Some(Align::Center)); self.scroll_to = None; } }); }); match action { Action::None => {} Action::RemoveRegionFromResults(key) => { let reg = &app.meta_state.meta.low.regions[key]; self.results_vec.retain(|&idx| !reg.region.contains(idx)); } Action::RemoveIdxFromResults(idx) => { self.results_vec.remove(idx); } } }); strip.cell(|ui| { ui.horizontal(|ui| { if self.results_vec.is_empty() { ui.disable(); } if (ui.button("Previous (P)").clicked() || ui.input(|inp| inp.key_pressed(egui::Key::P))) && self.result_cursor > 0 && !self.results_vec.is_empty() { self.result_cursor -= 1; let off = self.results_vec[self.result_cursor]; app.search_focus(off); self.scroll_to = Some(self.result_cursor); } ui.label((self.result_cursor + 1).to_string()); if (ui.button("Next (N)").clicked() || ui.input(|inp| inp.key_pressed(egui::Key::N))) && self.result_cursor + 1 < self.results_vec.len() { self.result_cursor += 1; let off = self.results_vec[self.result_cursor]; app.search_focus(off); self.scroll_to = Some(self.result_cursor); } ui.label(format!("{} results", self.results_vec.len())); }); }); strip.cell(|ui| { ui.horizontal(|ui| { if ui.button("Copy offsets").clicked() { let s = self.results_vec.iter().map(ToString::to_string).join(" "); set_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog, &s); } if ui.button("Paste offsets").clicked() { let s = get_clipboard_string(&mut app.clipboard, &mut gui.msg_dialog); let offsets: Result, _> = s.split_ascii_whitespace().map(|s| s.parse()).collect(); match offsets { Ok(offs) => self.results_vec = offs, Err(e) => { msg_fail(&e, "failed to parse offsets", &mut gui.msg_dialog); } } } if ui.button("🗑 Clear").clicked() { self.results_vec.clear(); } // We don't want to highlight results by default, because // it (at the very least) doubles memory usage for find results, // which can be catastrophic for really large searches. if ui.button("💡 Highlight").clicked() { gui.highlight_set.clear(); for &offset in &self.results_vec { gui.highlight_set.insert(offset); } } }); }); }); } fn title(&self) -> &str { "Find" } } impl FindDialog { fn search_region(&self, app_hex_ui: &HexUi) -> Option { if self.selection_only { app_hex_ui.selection() } else { None } } fn data_to_search<'a>(&self, app: &'a crate::app::App) -> (&'a [u8], usize) { match self.search_region(&app.hex_ui) { Some(reg) => (&app.data[reg.begin..=reg.end], reg.begin), None => (&app.data[..], 0), } } fn reload_data(&self, app: &mut crate::app::App, gui: &mut crate::gui::Gui) { let result = match self.search_region(&app.hex_ui) { Some(reg) => app.reload_range(reg.begin, reg.end), None => app.reload(), }; msg_if_fail(result, "Failed to reload", &mut gui.msg_dialog); } } trait SliceExt { fn get_array(&self, offset: usize) -> Option<&[T; N]>; fn get_array_mut(&mut self, offset: usize) -> Option<&mut [T; N]>; } impl SliceExt for [T] { fn get_array(&self, offset: usize) -> Option<&[T; N]> { self.get(offset..offset + N)?.try_into().ok() } fn get_array_mut(&mut self, offset: usize) -> Option<&mut [T; N]> { self.get_mut(offset..offset + N)?.try_into().ok() } } fn data_value_label( ui: &mut Ui, data: &mut crate::data::Data, off: usize, ) -> Option where [(); N::BYTE_LEN]:, { let Some(data) = data.get_array_mut(off) else { if let Some(immut) = data.get_array(off) { ui.label(N::from_bytes(*immut).to_string()); } else { ui.label("!!").on_hover_text("Out of bounds"); } return None; }; let mut n = N::from_bytes(*data); if ui.add(egui::DragValue::new(&mut n)).changed() { *data = N::to_bytes(n); return Some(DamageRegion::Range(off..off + N::BYTE_LEN)); } None } enum Action { None, RemoveRegionFromResults(crate::meta::RegionKey), RemoveIdxFromResults(usize), } fn do_search( data: &[u8], initial_offset: usize, win: &mut FindDialog, gui: &mut crate::gui::Gui, ) -> anyhow::Result<()> { // Reset the result cursor, so it's not out of bounds if new results_vec is smaller // TODO: Review everything to use `initial_offset` correctly win.result_cursor = 0; if !win.filter_results { win.results_vec.clear(); } match win.find_type { FindType::I8 => find_num::(win, data)?, FindType::U8 => find_u8(win, data, initial_offset, &mut gui.msg_dialog), FindType::I16Le => find_num::(win, data)?, FindType::I16Be => find_num::(win, data)?, FindType::U16Le => find_num::(win, data)?, FindType::U16Be => find_num::(win, data)?, FindType::I32Le => find_num::(win, data)?, FindType::I32Be => find_num::(win, data)?, FindType::U32Le => find_num::(win, data)?, FindType::U32Be => find_num::(win, data)?, FindType::I64Le => find_num::(win, data)?, FindType::I64Be => find_num::(win, data)?, FindType::U64Le => find_num::(win, data)?, FindType::U64Be => find_num::(win, data)?, FindType::F32Le => find_num::(win, data)?, FindType::F32Be => find_num::(win, data)?, FindType::F64Le => find_num::(win, data)?, FindType::F64Be => find_num::(win, data)?, FindType::Ascii => { for offset in memchr::memmem::find_iter(data, &win.find_input) { win.results_vec.push(initial_offset + offset); } } FindType::HexString => { let fun = |offset| { win.results_vec.push(initial_offset + offset); }; let result = crate::find_util::find_hex_string(&win.find_input, data, fun); msg_if_fail(result, "Hex string search error", &mut gui.msg_dialog); } FindType::StringDiff => { let diff = ascii_to_diff_pattern(win.find_input.as_bytes()); let mut off = 0; while let Some(offset) = find_diff_pattern(&data[off..], &diff) { off += offset; win.results_vec.push(initial_offset + off); off += diff.len(); } } FindType::EqPattern => { let needle = make_eq_pattern_needle(&win.find_input); let mut off = 0; while let Some(offset) = find_eq_pattern_needle(&needle, &data[off..]) { off += offset; win.results_vec.push(initial_offset + off); off += needle.len(); } } } Ok(()) } fn make_eq_pattern_needle(pattern: &str) -> Vec { let mut needle = Vec::new(); let mut map = HashMap::new(); let mut uniq_counter = 0u8; for b in pattern.bytes() { let val = map.entry(b).or_insert_with(|| { let val = uniq_counter; uniq_counter += 1; val }); needle.push(*val); } needle } #[test] fn test_make_eq_pattern_needle() { assert_eq!( make_eq_pattern_needle("ABCDBEFFG"), &[0, 1, 2, 3, 1, 4, 5, 5, 6] ); assert_eq!( make_eq_pattern_needle("abcdefggheijkbbl"), &[0, 1, 2, 3, 4, 5, 6, 6, 7, 4, 8, 9, 10, 1, 1, 11] ); } #[cfg(test)] fn find_eq_pattern(pattern: &str, data: &[u8]) -> Option { let needle = make_eq_pattern_needle(pattern); find_eq_pattern_needle(&needle, data) } fn find_eq_pattern_needle(needle: &[u8], data: &[u8]) -> Option { for window in data.windows(needle.len()) { if eq_pattern_needle_matches(needle, window) { return Some(window.as_ptr() as usize - data.as_ptr() as usize); } } None } fn eq_pattern_needle_matches(needle: &[u8], data: &[u8]) -> bool { for (n1, d1) in needle.iter().zip(data.iter()) { for (n2, d2) in needle.iter().zip(data.iter()) { if (n1 == n2) != (d1 == d2) { return false; } } } true } #[test] fn test_find_eq_pattern() { assert_eq!(find_eq_pattern("ABCDBEFFG", b"I AM GOOD"), Some(0)); assert_eq!( find_eq_pattern("abcdefggheijkbbk", b"Hello world, very cool indeed"), Some(13) ); } #[expect(clippy::cast_possible_wrap)] fn ascii_to_diff_pattern(ascii: &[u8]) -> Vec { ascii.array_windows().map(|[a, b]| *b as i8 - *a as i8).collect() } #[expect(clippy::cast_possible_wrap)] fn find_diff_pattern(haystack: &[u8], pat: &[i8]) -> Option { assert!(pat.len() <= haystack.len()); let mut pat_cur = 0; for (i, [a, b]) in haystack.array_windows().enumerate() { let Some(diff) = (*b as i8).checked_sub(*a as i8) else { pat_cur = 0; continue; }; if diff == pat[pat_cur] { pat_cur += 1; } else { pat_cur = 0; } if pat_cur == pat.len() { return Some((i + 1) - pat.len()); } } None } #[test] fn test_ascii_to_diff_pattern() { assert_eq!( ascii_to_diff_pattern(b"jonathan"), vec![5, -1, -13, 19, -12, -7, 13] ); } #[test] #[expect(clippy::unwrap_used)] fn test_find_diff_pattern() { let key = "jonathan"; let pat = ascii_to_diff_pattern(key.as_bytes()); let s = "I handed the key to jonathan. He didn't like the way I said jonathan to him."; let mut off = 0; off += find_diff_pattern(&s.as_bytes()[off..], &pat).unwrap(); assert_eq!(&s[off..off + key.len()], key); off += pat.len(); off += find_diff_pattern(&s.as_bytes()[off..], &pat).unwrap(); assert_eq!(&s[off..off + key.len()], key); } fn find_num(win: &mut FindDialog, data: &[u8]) -> Result<(), anyhow::Error> where [(); N::BYTE_LEN]:, <::Primitive as FromStr>::Err: Error + Send + Sync, { find_num_raw::(&win.find_input, data, |offset| { win.results_vec.push(offset); }) } pub(crate) fn find_num_raw( input: &str, data: &[u8], mut f: impl FnMut(usize), ) -> anyhow::Result<()> where [(); N::BYTE_LEN]:, <::Primitive as FromStr>::Err: Error + Send + Sync, { let n: N::Primitive = input.parse()?; let bytes = N::to_bytes(n); for offset in memchr::memmem::find_iter(data, &bytes) { f(offset); } Ok(()) } fn find_u8(dia: &mut FindDialog, data: &[u8], initial_offset: usize, msg: &mut MessageDialog) { // TODO: This is probably a minefield for initial_offset shenanigans. // Need to review carefully match dia.find_input.as_str() { "?" => { dia.data_snapshot = data.to_vec(); dia.results_vec.clear(); for i in 0..data.len() { dia.results_vec.push(initial_offset + i); } } ">" => { if dia.filter_results { dia.results_vec.retain(|&offset| { data[offset - initial_offset] > dia.data_snapshot[offset - initial_offset] }); } else { for (i, (&new, &old)) in data.iter().zip(dia.data_snapshot.iter()).enumerate() { if new > old { dia.results_vec.push(i); } } } dia.data_snapshot = data.to_vec(); } "=" => { eq_filter(dia, data, initial_offset); } "!=" => { if dia.filter_results { dia.results_vec.retain(|&offset| { data[offset - initial_offset] != dia.data_snapshot[offset - initial_offset] }); } else { for (i, (&new, &old)) in data.iter().zip(dia.data_snapshot.iter()).enumerate() { if new == old { dia.results_vec.push(i); } } } dia.data_snapshot = data.to_vec(); } "<" => { if dia.filter_results { dia.results_vec.retain(|&offset| { data[offset - initial_offset] < dia.data_snapshot[offset - initial_offset] }); } else { for (i, (&new, &old)) in data.iter().zip(dia.data_snapshot.iter()).enumerate() { if new < old { dia.results_vec.push(i); } } } dia.data_snapshot = data.to_vec(); } _ => match parse_guess_radix(&dia.find_input) { Ok(needle) => { if dia.filter_results { let results_vec_clone = dia.results_vec.clone(); dia.results_vec.clear(); u8_search( dia, results_vec_clone.iter().map(|&off| (off, data[off])), initial_offset, needle, ); } else { u8_search( dia, data.iter().cloned().enumerate(), initial_offset, needle, ); } } Err(e) => msg.open(Icon::Error, "Parse error", e.to_string()), }, } } fn eq_filter(dia: &mut FindDialog, data: &[u8], initial_offset: usize) { if dia.filter_results { dia.results_vec.retain(|&offset| { data[offset - initial_offset] == dia.data_snapshot[offset - initial_offset] }); } else { for (i, (&new, &old)) in data.iter().zip(dia.data_snapshot.iter()).enumerate() { if new == old { dia.results_vec.push(i); } } } dia.data_snapshot = data.to_vec(); } fn u8_search( dialog: &mut FindDialog, haystack: impl Iterator, initial_offset: usize, needle: u8, ) { for (offset, byte) in haystack { if byte == needle { dialog.results_vec.push(initial_offset + offset); } } } ================================================ FILE: src/gui/windows/find_memory_pointers.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::shell::msg_fail, egui_extras::{Column, TableBuilder}, }; #[derive(Default)] pub struct FindMemoryPointersWindow { pub open: WindowOpen, pointers: Vec, filter_write: bool, filter_exec: bool, } #[derive(Clone, Copy)] struct PtrEntry { src_idx: usize, ptr: usize, range_idx: usize, write: bool, execute: bool, } impl super::Window for FindMemoryPointersWindow { fn ui( &mut self, WinCtx { ui, gui, app, font_size, line_spacing, .. }: WinCtx, ) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let Some(pid) = gui.win.open_process.selected_pid else { ui.label("No selected pid."); return; }; if self.open.just_now() { for (i, wnd) in app.data.array_windows::<{ (usize::BITS / 8) as usize }>().enumerate() { let ptr = usize::from_le_bytes(*wnd); if let Some(pos) = gui.win.open_process.map_ranges.iter().position(|range| { range.is_read() && range.start() <= ptr && range.start() + range.size() >= ptr }) { let range = &gui.win.open_process.map_ranges[pos]; self.pointers.push(PtrEntry { src_idx: i, ptr, range_idx: pos, write: range.is_write(), execute: range.is_exec(), }); } } } let mut action = Action::None; TableBuilder::new(ui) .column(Column::auto()) .column(Column::auto()) .column(Column::auto()) .column(Column::remainder()) .striped(true) .resizable(true) .header(20.0, |mut row| { row.col(|ui| { ui.label("Location"); }); row.col(|ui| { if ui.button("Region").clicked() { self.pointers.sort_by_key(|p| { gui.win.open_process.map_ranges[p.range_idx].filename() }); } }); row.col(|ui| { ui.menu_button("w/x", |ui| { ui.checkbox(&mut self.filter_write, "Write"); ui.checkbox(&mut self.filter_exec, "Execute"); }); }); row.col(|ui| { if ui.button("Pointer").clicked() { self.pointers.sort_by_key(|p| p.ptr); } }); }) .body(|body| { let mut filtered = self.pointers.clone(); filtered.retain(|ptr| { if self.filter_exec && !ptr.execute { return false; } if self.filter_write && !ptr.write { return false; } true }); body.rows(20.0, filtered.len(), |mut row| { let en = &filtered[row.index()]; row.col(|ui| { if ui.link(format!("{:X}", en.src_idx)).clicked() { action = Action::Goto(en.src_idx); } }); row.col(|ui| { let range = &gui.win.open_process.map_ranges[en.range_idx]; ui.label(range.filename().map_or_else( || format!(" @ {:X} (size: {})", range.start(), range.size()), |p| p.display().to_string(), )); }); row.col(|ui| { let range = &gui.win.open_process.map_ranges[en.range_idx]; ui.label(format!( "{}{}", if range.is_write() { "w" } else { "" }, if range.is_exec() { "x" } else { "" } )); }); row.col(|ui| { let range = &gui.win.open_process.map_ranges[en.range_idx]; if ui.link(format!("{:X}", en.ptr)).clicked() { match app.load_proc_memory( pid, range.start(), range.size(), range.is_write(), &mut gui.msg_dialog, font_size, line_spacing, ) { Ok(()) => action = Action::Goto(en.ptr - range.start()), Err(e) => { msg_fail(&e, "failed to load proc memory", &mut gui.msg_dialog); } } } }); }); }); match action { Action::Goto(off) => { app.center_view_on_offset(off); app.edit_state.set_cursor(off); app.hex_ui.flash_cursor(); } Action::None => {} } } fn title(&self) -> &str { "Find memory pointers" } } enum Action { Goto(usize), None, } ================================================ FILE: src/gui/windows/layouts.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::App, meta::{LayoutKey, LayoutMapExt as _, MetaLow, NamedView, ViewKey, ViewMap}, view::{HexData, TextData, View, ViewKind}, }, constcat::concat, egui_phosphor::regular as ic, egui_sf2g::sf2g::graphics::Font, slotmap::Key as _, }; const L_NEW_FROM_PERSPECTIVE: &str = concat!(ic::PLUS, " New from perspective"); const L_HEX: &str = concat!(ic::HEXAGON, " Hex"); const L_TEXT: &str = concat!(ic::TEXT_AA, " Text"); const L_BLOCK: &str = concat!(ic::RECTANGLE, " Block"); const L_ADD_TO_NEW_ROW: &str = concat!(ic::PLUS, ic::ARROW_BEND_DOWN_RIGHT); const L_ADD_TO_CURRENT_ROW: &str = concat!(ic::PLUS, ic::ARROW_LEFT); #[derive(Default)] pub struct LayoutsWindow { pub open: WindowOpen, selected: LayoutKey, swap_a: ViewKey, edit_name: bool, } impl super::Window for LayoutsWindow { fn ui( &mut self, WinCtx { ui, gui, app, font_size, font, .. }: WinCtx, ) { if self.open.just_now() { self.selected = app.hex_ui.current_layout; } for (k, v) in &app.meta_state.meta.layouts { if ui.selectable_label(self.selected == k, &v.name).clicked() { self.selected = k; App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, k); } } if !self.selected.is_null() { ui.separator(); let Some(layout) = app.meta_state.meta.layouts.get_mut(self.selected) else { self.selected = LayoutKey::null(); return; }; ui.horizontal(|ui| { if self.edit_name { if ui.text_edit_singleline(&mut layout.name).lost_focus() { self.edit_name = false; } } else { ui.heading(&layout.name); } if ui.button("✏").clicked() { self.edit_name ^= true; } }); let unused_views: Vec = app .meta_state .meta .views .keys() .filter(|&k| !layout.iter().any(|k2| k2 == k)) .collect(); egui::Grid::new("view_grid").show(ui, |ui| { let mut swap = None; layout.view_grid.retain_mut(|row| { let mut retain_row = true; row.retain_mut(|view_key| { let mut retain = true; let view = &app.meta_state.meta.views[*view_key]; if self.swap_a == *view_key { if ui.selectable_label(true, &view.name).clicked() { self.swap_a = ViewKey::null(); } } else if !self.swap_a.is_null() { if ui.button(format!("🔃 {}", view.name)).clicked() { swap = Some((self.swap_a, *view_key)); } } else { ui.menu_button([ic::EYE, " ", view.name.as_str()].concat(), |ui| { for &k in &unused_views { if ui.button(&app.meta_state.meta.views[k].name).clicked() { *view_key = k; } } if unused_views.is_empty() { ui.label(egui::RichText::new("No unused views").italics()); } }) .response .context_menu(|ui| { if ui.button("🔃 Swap").clicked() { self.swap_a = *view_key; } if ui.button("🗑 Remove").clicked() { retain = false; } if ui.button("👁 View properties").clicked() { gui.win.views.open.set(true); gui.win.views.selected = *view_key; } }); } retain }); ui.menu_button(L_ADD_TO_CURRENT_ROW, |ui| { for &k in &unused_views { if ui .button( [ic::EYE, " ", app.meta_state.meta.views[k].name.as_str()] .concat(), ) .clicked() { row.push(k); } } if let Some(k) = add_new_view_menu( ui, &app.meta_state.meta.low, &mut app.meta_state.meta.views, font_size, font, ) { row.push(k); } }) .response .on_hover_text("Add to current row"); if ui.button("🗑").on_hover_text("Delete row").clicked() { retain_row = false; } ui.end_row(); if row.is_empty() { retain_row = false; } retain_row }); if let Some((a, b)) = swap && let Some([a_row, a_col]) = layout.idx_of_key(a) && let Some([b_row, b_col]) = layout.idx_of_key(b) { let addr_a = &raw mut layout.view_grid[a_row][a_col]; let addr_b = &raw mut layout.view_grid[b_row][b_col]; // Safety: `addr_a` and `addr_b` are r/w valid and well-aligned unsafe { std::ptr::swap(addr_a, addr_b); } self.swap_a = ViewKey::null(); } ui.menu_button(L_ADD_TO_NEW_ROW, |ui| { for &k in &unused_views { if ui .button( [ic::EYE, " ", app.meta_state.meta.views[k].name.as_str()].concat(), ) .clicked() { layout.view_grid.push(vec![k]); } } if let Some(k) = add_new_view_menu( ui, &app.meta_state.meta.low, &mut app.meta_state.meta.views, font_size, font, ) { layout.view_grid.push(vec![k]); app.hex_ui.focused_view = Some(k); } }) .response .on_hover_text("Add to new row") }); ui.horizontal(|ui| { ui.label("Margin"); ui.label("x"); ui.add(egui::DragValue::new(&mut layout.margin.x).range(3..=64)); ui.label("y"); ui.add(egui::DragValue::new(&mut layout.margin.y).range(3..=64)); }); } ui.separator(); if ui.button("New layout").clicked() { let key = app.meta_state.meta.layouts.add_new_default(); self.selected = key; App::switch_layout(&mut app.hex_ui, &app.meta_state.meta, key); } } fn title(&self) -> &str { "Layouts" } } fn add_new_view_menu( ui: &mut egui::Ui, low: &MetaLow, views: &mut ViewMap, font_size: u16, font: &Font, ) -> Option { let mut ret_key = None; ui.separator(); ui.menu_button(L_NEW_FROM_PERSPECTIVE, |ui| { for (per_key, per) in &low.perspectives { ui.menu_button([ic::PERSPECTIVE, " ", per.name.as_str()].concat(), |ui| { let mut new = None; if ui.button(L_HEX).clicked() { let view = View::new(ViewKind::Hex(HexData::with_font_size(font_size)), per_key); new = Some(("hex", view)); } if ui.button(L_TEXT).clicked() { let view = View::new( #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)] ViewKind::Text(TextData::with_font_info( font.line_spacing(font_size.into()) as _, font_size, )), per_key, ); new = Some(("text", view)); } if ui.button(L_BLOCK).clicked() { let view = View::new(ViewKind::Block, per_key); new = Some(("block", view)); } if let Some((label, view)) = new { let view_key = views.insert(NamedView { view, name: [per.name.as_str(), " ", label].concat(), }); ret_key = Some(view_key); } }); } }); ret_key } ================================================ FILE: src/gui/windows/lua_console.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{meta::ScriptKey, scripting::exec_lua, shell::msg_if_fail}, std::{collections::HashMap, fmt::Write as _}, }; type MsgBuf = Vec; type MsgBufMap = HashMap; #[derive(Default)] pub struct LuaConsoleWindow { pub open: WindowOpen, pub msg_bufs: MsgBufMap, pub eval_buf: String, pub active_msg_buf: Option, pub default_msg_buf: MsgBuf, } impl LuaConsoleWindow { fn msg_buf(&mut self) -> &mut MsgBuf { match self.active_msg_buf { Some(key) => self.msg_bufs.get_mut(&key).unwrap_or(&mut self.default_msg_buf), None => &mut self.default_msg_buf, } } pub fn msg_buf_for_key(&mut self, key: Option) -> &mut MsgBuf { match key { Some(key) => self.msg_bufs.entry(key).or_default(), None => &mut self.default_msg_buf, } } } pub enum ConMsg { Plain(String), OffsetLink { text: String, offset: usize, }, RangeLink { text: String, start: usize, end: usize, }, } impl super::Window for LuaConsoleWindow { fn ui( &mut self, WinCtx { ui, gui, app, lua, font_size, line_spacing, .. }: WinCtx, ) { ui.horizontal(|ui| { if ui.selectable_label(self.active_msg_buf.is_none(), "Default").clicked() { self.active_msg_buf = None; } for k in self.msg_bufs.keys() { if ui .selectable_label( self.active_msg_buf == Some(*k), &app.meta_state.meta.scripts[*k].name, ) .clicked() { self.active_msg_buf = Some(*k); } } }); ui.separator(); ui.horizontal(|ui| { let re = ui.text_edit_singleline(&mut self.eval_buf); if ui.button("x").on_hover_text("Clear input").clicked() { self.eval_buf.clear(); } if ui.button("Eval").clicked() || (ui.input(|inp| inp.key_pressed(egui::Key::Enter)) && re.lost_focus()) { let code = &self.eval_buf.clone(); if let Err(e) = exec_lua( lua, code, app, gui, "", self.active_msg_buf, font_size, line_spacing, ) { self.msg_buf().push(ConMsg::Plain(e.to_string())); } } if ui.button("Clear log").clicked() { self.msg_buf().clear(); } if ui.button("Copy to clipboard").clicked() { let mut buf = String::new(); for msg in self.msg_buf() { match msg { ConMsg::Plain(s) => { buf.push_str(s); buf.push('\n'); } ConMsg::OffsetLink { text, offset } => { let _ = writeln!(&mut buf, "{offset}: {text}"); } ConMsg::RangeLink { text, start, end } => { let _ = writeln!(&mut buf, "{start}..={end}: {text}"); } } } msg_if_fail( app.clipboard.set_text(buf), "Failed to copy clipboard text", &mut gui.msg_dialog, ); } }); ui.separator(); egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| { for msg in &*self.msg_buf() { match msg { ConMsg::Plain(text) => { ui.label(text); } ConMsg::OffsetLink { text, offset } => { if ui.link(text).clicked() { app.search_focus(*offset); } } ConMsg::RangeLink { text, start, end } => { if ui.link(text).clicked() { app.hex_ui.select_a = Some(*start); app.hex_ui.select_b = Some(*end); app.search_focus(*start); } } } } }); } fn title(&self) -> &str { "Lua quick eval" } } ================================================ FILE: src/gui/windows/lua_editor.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::App, gui::Gui, meta::{Script, ScriptKey}, scripting::SCRIPT_ARG_FMT_HELP_STR, shell::msg_if_fail, str_ext::StrExt as _, }, egui::TextBuffer as _, egui_code_editor::{CodeEditor, Syntax}, egui_extras::{Size, StripBuilder}, mlua::Lua, std::time::Instant, }; #[derive(Default)] pub struct LuaEditorWindow { pub open: WindowOpen, result_info_string: String, err: bool, new_script_name: String, args_string: String, edit_key: Option, } impl super::Window for LuaEditorWindow { fn ui(&mut self, ctx: WinCtx) { let WinCtx { ui, gui, app, lua, font_size, line_spacing, .. } = ctx; let ctrl_enter = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::Enter)); let ctrl_s = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::CTRL, egui::Key::S)); if ctrl_s { msg_if_fail( app.save(&mut gui.msg_dialog), "Failed to save", &mut gui.msg_dialog, ); } StripBuilder::new(ui).size(Size::remainder()).size(Size::exact(300.0)).vertical( |mut strip| { strip.cell(|ui| { egui::ScrollArea::vertical().show(ui, |ui| { let lua; match self.edit_key { Some(key) => match app.meta_state.meta.scripts.get_mut(key) { Some(script) => lua = &mut script.content, None => { eprintln!( "Edit key is no longer in meta state. Setting to None." ); self.edit_key = None; return; } }, None => lua = &mut app.meta_state.meta.misc.exec_lua_script, } CodeEditor::default().with_syntax(Syntax::lua()).show(ui, lua); }); }); strip.cell(|ui| { ui.separator(); ui.horizontal(|ui| { if ui.button("⚡ Execute").on_hover_text("Ctrl+Enter").clicked() || ctrl_enter { self.exec_lua(app, lua, gui, font_size, line_spacing); } let script_label = match &self.edit_key { Some(key) => { let scr = &app.meta_state.meta.scripts[*key]; &scr.name } None => "", }; egui::ComboBox::from_label("Script").selected_text(script_label).show_ui( ui, |ui| { if ui .selectable_label(self.edit_key.is_none(), "") .clicked() { self.edit_key = None; } ui.separator(); for (k, v) in app.meta_state.meta.scripts.iter() { if ui .selectable_label(self.edit_key == Some(k), &v.name) .clicked() { self.edit_key = Some(k); } } }, ); if ui.button("🖴 Load from file...").clicked() { gui.fileops.load_lua_script(); } if ui.button("💾 Save to file...").clicked() { gui.fileops.save_lua_script(); } if ui.button("? Help").clicked() { gui.win.lua_help.open.toggle(); } }); ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut self.new_script_name) .hint_text("New script name"), ); if ui .add_enabled( !self.new_script_name.is_empty_or_ws_only(), egui::Button::new("Add named script"), ) .clicked() { let key = app.meta_state.meta.scripts.insert(Script { name: self.new_script_name.take(), desc: String::new(), content: app.meta_state.meta.misc.exec_lua_script.clone(), }); self.edit_key = Some(key); } }); ui.horizontal(|ui| { ui.label(format!("Args ({SCRIPT_ARG_FMT_HELP_STR})")); ui.text_edit_singleline(&mut self.args_string); }); ui.separator(); if app.data.dirty_region.is_some() { ui.label( egui::RichText::new("Unsaved changes") .italics() .color(egui::Color32::YELLOW) .code(), ); } else { ui.label( egui::RichText::new("No unsaved changes") .color(egui::Color32::GREEN) .code(), ); } if !self.result_info_string.is_empty() { if self.err { ui.label( egui::RichText::new(&self.result_info_string) .color(egui::Color32::RED), ); } else { ui.label(&self.result_info_string); } } }); }, ); } fn title(&self) -> &str { "Lua Editor" } } impl LuaEditorWindow { fn exec_lua( &mut self, app: &mut App, lua: &Lua, gui: &mut Gui, font_size: u16, line_spacing: u16, ) { let start_time = Instant::now(); let lua_script = self .edit_key .map_or(&app.meta_state.meta.misc.exec_lua_script, |key| { &app.meta_state.meta.scripts[key].content }) .clone(); let result = crate::scripting::exec_lua( lua, &lua_script, app, gui, &self.args_string, self.edit_key, font_size, line_spacing, ); if let Err(e) = result { self.result_info_string = e.to_string(); self.err = true; } else { self.result_info_string = format!("Script took {} ms", start_time.elapsed().as_millis()); self.err = false; } } } ================================================ FILE: src/gui/windows/lua_help.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::scripting::*, egui::Color32, }; #[derive(Default)] pub struct LuaHelpWindow { pub open: WindowOpen, pub filter: String, } impl super::Window for LuaHelpWindow { fn ui(&mut self, WinCtx { ui, .. }: WinCtx) { ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text("🔍 Filter")); egui::ScrollArea::vertical().max_height(500.0).show(ui, |ui| { macro_rules! add_help { ($t:ty) => { 'block: { let filter_lower = &self.filter.to_ascii_lowercase(); if !(<$t>::NAME.to_ascii_lowercase().contains(filter_lower) || <$t>::HELP.to_ascii_lowercase().contains(filter_lower)) { break 'block; } ui.horizontal(|ui| { ui.style_mut().spacing.item_spacing = egui::vec2(0., 0.); ui.label("hx:"); ui.label( egui::RichText::new(<$t>::API_SIG).color(Color32::WHITE).strong(), ); }); ui.indent("doc_indent", |ui| { ui.label(<$t>::HELP); }); } }; } for_each_method!(add_help); }); } fn title(&self) -> &str { "Lua help" } } ================================================ FILE: src/gui/windows/lua_watch.rs ================================================ use {super::WinCtx, crate::scripting::exec_lua}; pub struct LuaWatchWindow { pub name: String, expr: String, watch: bool, } impl Default for LuaWatchWindow { fn default() -> Self { Self { name: "New watch window".into(), expr: String::new(), watch: false, } } } impl super::Window for LuaWatchWindow { fn ui( &mut self, WinCtx { ui, gui, app, lua, font_size, line_spacing, .. }: WinCtx, ) { ui.text_edit_singleline(&mut self.name); ui.text_edit_singleline(&mut self.expr); ui.checkbox(&mut self.watch, "watch"); if self.watch { match exec_lua(lua, &self.expr, app, gui, "", None, font_size, line_spacing) { Ok(ret) => { if let Some(s) = ret { ui.label(s); } else { ui.label("No output"); } } Err(e) => { ui.label(e.to_string()); } } } } fn title(&self) -> &str { &self.name } } ================================================ FILE: src/gui/windows/meta_diff.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ layout::Layout, meta::{ LayoutKey, NamedRegion, NamedView, PerspectiveKey, RegionKey, ViewKey, perspective::Perspective, }, }, itertools::{EitherOrBoth, Itertools as _}, slotmap::SlotMap, std::fmt::Debug, }; #[derive(Default)] pub struct MetaDiffWindow { pub open: WindowOpen, } impl super::Window for MetaDiffWindow { fn ui(&mut self, WinCtx { ui, app, .. }: WinCtx) { let this = &mut app.meta_state.meta; let clean = &app.meta_state.clean_meta; ui.heading("Regions"); diff_slotmap(ui, &mut this.low.regions, &clean.low.regions); ui.heading("Perspectives"); diff_slotmap(ui, &mut this.low.perspectives, &clean.low.perspectives); ui.heading("Views"); diff_slotmap(ui, &mut this.views, &clean.views); ui.heading("Layouts"); diff_slotmap(ui, &mut this.layouts, &clean.layouts); } fn title(&self) -> &str { "Diff against clean meta" } } trait SlotmapDiffItem: PartialEq + Eq + Clone + Debug { type Key: slotmap::Key; type SortKey: Ord; fn label(&self) -> &str; fn sort_key(&self) -> Self::SortKey; } impl SlotmapDiffItem for NamedRegion { type Key = RegionKey; fn label(&self) -> &str { &self.name } type SortKey = usize; fn sort_key(&self) -> Self::SortKey { self.region.begin } } impl SlotmapDiffItem for Perspective { type Key = PerspectiveKey; type SortKey = String; fn label(&self) -> &str { &self.name } fn sort_key(&self) -> Self::SortKey { self.name.clone() } } impl SlotmapDiffItem for NamedView { type Key = ViewKey; type SortKey = String; fn label(&self) -> &str { &self.name } fn sort_key(&self) -> Self::SortKey { self.name.clone() } } impl SlotmapDiffItem for Layout { type Key = LayoutKey; type SortKey = String; fn label(&self) -> &str { &self.name } fn sort_key(&self) -> Self::SortKey { self.name.to_owned() } } fn diff_slotmap( ui: &mut egui::Ui, this: &mut SlotMap, clean: &SlotMap, ) { let mut this_keys: Vec<_> = this.keys().collect(); this_keys.sort_by_key(|&k| this[k].sort_key()); let mut clean_keys: Vec<_> = clean.keys().collect(); clean_keys.sort_by_key(|&k| clean[k].sort_key()); let mut any_changed = false; for zip_item in this_keys.into_iter().zip_longest(clean_keys) { match zip_item { EitherOrBoth::Both(this_key, clean_key) => { if this_key != clean_key { ui.label("-"); any_changed = true; continue; } let this_item = &this[this_key]; let clean_item = &clean[clean_key]; if this_item != clean_item { any_changed = true; ui.label(format!( "{}: {:?}\n=>\n{:?}", this_item.label(), this_item, clean_item )); } } EitherOrBoth::Left(this_key) => { any_changed = true; ui.label(format!("New {}", this[this_key].label())); } EitherOrBoth::Right(clean_key) => { any_changed = true; ui.label(format!("Deleted {}", clean[clean_key].label())); } } } if any_changed { if ui.button("Restore").clicked() { this.clone_from(clean); } } else { ui.label("No changes"); } } ================================================ FILE: src/gui/windows/open_process.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ gui::{egui_ui_ext::EguiResponseExt as _, message_dialog::MessageDialog}, shell::{msg_fail, msg_if_fail}, util::human_size, }, egui_extras::{Column, TableBuilder}, egui_file_dialog::FileDialog, proc_maps::MapRange, std::{fmt::Write as _, path::PathBuf, process::Command}, sysinfo::{ProcessesToUpdate, Signal}, }; type MapRanges = Vec; #[derive(Default)] pub struct OpenProcessWindow { pub open: WindowOpen, pub sys: sysinfo::System, pub selected_pid: Option, pub map_ranges: MapRanges, pid_sort: Sort, addr_sort: Sort, size_sort: Sort, maps_sort_col: MapsSortColumn, pub filters: Filters, modal: Option, find: FindState, pub default_meta_path: Option, use_default_meta_path: bool = true, } #[derive(Default)] pub struct Filters { pub path: String, pub addr: String, pub proc_name: String, pub perms: PermFilters, } #[derive(Default)] struct FindState { open: bool, input: String, results: Vec, } struct MapFindResults { map: MapRange, offsets: Vec, } #[derive(Default)] pub struct PermFilters { read: bool, write: bool, execute: bool, } #[derive(Default, Clone, Copy)] enum Sort { #[default] Ascending, Descending, } impl Sort { fn flip(&mut self) { *self = match *self { Self::Ascending => Self::Descending, Self::Descending => Self::Ascending, } } } fn sort_button(ui: &mut egui::Ui, label: &str, active: bool, sort: Sort) -> egui::Response { let arrow_str = if active { match sort { Sort::Ascending => "⏶", Sort::Descending => "⏷", } } else { "=" }; if active { ui.style_mut().visuals.faint_bg_color = egui::Color32::RED; } ui.button(format!("{label} {arrow_str}")) } #[derive(Default, PartialEq, Eq)] enum MapsSortColumn { #[default] StartOffset, Size, } enum Modal { RunCommand(RunCommand), } impl Modal { fn run_command() -> Self { Self::RunCommand(RunCommand { command: String::new(), just_opened: true, file_dialog: FileDialog::new(), }) } } struct RunCommand { command: String, just_opened: bool, file_dialog: FileDialog, } impl super::Window for OpenProcessWindow { fn ui( &mut self, WinCtx { ui, gui, app, font_size, line_spacing, .. }: WinCtx, ) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(modal) = &mut self.modal { let mut close_modal = false; ui.horizontal(|ui| match modal { Modal::RunCommand(run_command) => { run_command.file_dialog.update(ui.ctx()); ui.label("Command"); if let Some(file_path) = run_command.file_dialog.take_picked() { let _ = writeln!(&mut run_command.command, "\"{}\"", file_path.display()); } let re = ui.text_edit_singleline(&mut run_command.command); if run_command.just_opened { re.request_focus(); run_command.just_opened = false; } let enter = ui.input(|inp| inp.key_pressed(egui::Key::Enter)); match shlex::split(&run_command.command) { Some(tokens) => { let mut tokens = tokens.into_iter(); if (ui.button("Run").clicked() || (re.lost_focus() && enter)) && let Some(first) = tokens.next() { match Command::new(first).args(tokens).spawn() { Ok(child) => { let pid = child.id(); self.selected_pid = Some(sysinfo::Pid::from_u32(pid)); refresh_proc_maps( pid, &mut self.map_ranges, &mut gui.msg_dialog, ); // Make sure this process is visible for sysinfo to kill/stop/etc. self.sys.refresh_processes(ProcessesToUpdate::All, true); close_modal = true; } Err(e) => { msg_fail(&e, "Run command error", &mut gui.msg_dialog); } } } } None => { ui.add_enabled(false, egui::Button::new("Run")); } } if ui.button("Add file...").clicked() { run_command.file_dialog.pick_file(); } if ui.button("Cancel").clicked() { close_modal = true; } } }); if close_modal { self.modal = None; } ui.disable(); } ui.horizontal(|ui| { match self.selected_pid { None => { if self.open.just_now() || ui.button("Refresh processes").clicked() { self.sys.refresh_processes(ProcessesToUpdate::All, true); } } Some(pid) => { if ui.button("Refresh memory maps").clicked() { refresh_proc_maps(pid.as_u32(), &mut self.map_ranges, &mut gui.msg_dialog); } if ui .selectable_label(self.find.open, "🔍 Find...") .on_hover_text("Find values across all map ranges") .clicked() { self.find.open ^= true; } } } if ui.button("Run command...").clicked() { self.modal = Some(Modal::run_command()); } if let Some(path) = &self.default_meta_path { ui.checkbox( &mut self.use_default_meta_path, format!("Use metafile {}", path.display()), ); } }); if let &Some(pid) = &self.selected_pid { if self.find.open { ui.text_edit_singleline(&mut self.find.input); match self.find.input.parse::() { Ok(num) => { if ui.button("Find").clicked() { self.find.results.clear(); for range in self .map_ranges .iter() .filter(|range| should_retain_range(&self.filters, range)) { match app.load_proc_memory( pid, range.start(), range.size(), range.is_write(), &mut gui.msg_dialog, font_size, line_spacing, ) { Ok(()) => { let mut offsets = Vec::new(); for offset in memchr::memchr_iter(num, &app.data) { offsets.push(offset); } self.find.results.push(MapFindResults { map: range.clone(), offsets, }); } Err(e) => msg_fail(&e, "Error", &mut gui.msg_dialog), } } } if !self.find.results.is_empty() && ui.button("Retain").clicked() { self.find.results.retain_mut(|result| { match app.load_proc_memory( pid, result.map.start(), result.map.size(), result.map.is_write(), &mut gui.msg_dialog, font_size, line_spacing, ) { Ok(()) => { result.offsets.retain(|offset| { app.data.get(*offset).is_some_and(|byte| *byte == num) }); !result.offsets.is_empty() } Err(e) => { msg_fail(&e, "Error", &mut gui.msg_dialog); false } } }); } } Err(e) => { ui.add_enabled(false, egui::Button::new("Find")) .on_disabled_hover_text(format!("{e}")); } } let result_count: usize = self.find.results.iter().map(|res| res.offsets.len()).sum(); if result_count < 30 { for (i, result) in self.find.results.iter().enumerate() { let label = format!( "{}..={} ({}) @ {:?}", result.map.start(), result.map.start() + result.map.size(), result.map.size(), result.map.filename(), ); let map_open = app .src_args .hard_seek .is_some_and(|offset| offset == result.map.start()); let _ = ui.selectable_label(map_open, label); ui.indent(egui::Id::new("result_ident").with(i), |ui| { for offset in &result.offsets { ui.horizontal(|ui| { if ui.button(format!("{offset:X}")).clicked() { if !map_open { match app.load_proc_memory( pid, result.map.start(), result.map.size(), result.map.is_write(), &mut gui.msg_dialog, font_size, line_spacing, ) { Ok(()) => { app.search_focus(*offset); } Err(e) => { msg_fail(&e, "Error", &mut gui.msg_dialog); } } if let Some(path) = &self.default_meta_path && self.use_default_meta_path { let result = app.consume_meta_from_file(path.clone(), false); msg_if_fail( result, "Failed to consume metafile", &mut gui.msg_dialog, ); } } else { app.search_focus(*offset); } } if map_open { let mut s = String::new(); ui.label(app.data.get(*offset).map_or("??", |off| { s = off.to_string(); s.as_str() })); } }); } }); } } ui.label(format!("{result_count} Results")); return; } ui.heading(format!("Virtual memory maps for pid {pid}")); if ui.link("Back to process list").clicked() { self.sys.refresh_processes(ProcessesToUpdate::All, true); self.selected_pid = None; } if let Some(proc) = self.sys.process(pid) { ui.horizontal(|ui| { if ui.button("Stop").clicked() { proc.kill_with(Signal::Stop); } if ui.button("Continue").clicked() { proc.kill_with(Signal::Continue); } if ui.button("Kill").clicked() { proc.kill(); } }); } let mut filtered = self.map_ranges.clone(); TableBuilder::new(ui) .max_scroll_height(400.0) .column(Column::auto()) .column(Column::auto()) .column(Column::auto()) .column(Column::remainder()) .striped(true) .resizable(true) .header(20.0, |mut row| { row.col(|ui| { ui.horizontal(|ui| { if sort_button( ui, "", self.maps_sort_col == MapsSortColumn::StartOffset, self.addr_sort, ) .clicked() { self.maps_sort_col = MapsSortColumn::StartOffset; self.addr_sort.flip(); } ui.add( egui::TextEdit::singleline(&mut self.filters.addr) .hint_text("🔎 Addr"), ); }); }); row.col(|ui| { if sort_button( ui, "size", self.maps_sort_col == MapsSortColumn::Size, self.size_sort, ) .clicked() { self.maps_sort_col = MapsSortColumn::Size; self.size_sort.flip(); } }); row.col(|ui| { ui.add(egui::Label::new("r/w/x").sense(egui::Sense::click())).context_menu( |ui| { ui.label("Filter"); ui.separator(); ui.checkbox(&mut self.filters.perms.read, "Read"); ui.checkbox(&mut self.filters.perms.write, "Write"); ui.checkbox(&mut self.filters.perms.execute, "Execute"); }, ); }); row.col(|ui| { ui.horizontal(|ui| { ui.add( egui::TextEdit::singleline(&mut self.filters.path) .hint_text("🔎 Path"), ); if ui.button("🗑").on_hover_text("Remove filtered paths").clicked() { self.map_ranges.retain(|range| { let mut retain = true; if let Some(filename) = range.filename() && filename .display() .to_string() .contains(&self.filters.path) { retain = false; } retain }); self.filters.path.clear(); } }); }); }) .body(|body| { filtered.retain(|range| should_retain_range(&self.filters, range)); filtered.sort_by(|range1, range2| match self.maps_sort_col { MapsSortColumn::Size => match self.size_sort { Sort::Ascending => range1.size().cmp(&range2.size()), Sort::Descending => range1.size().cmp(&range2.size()).reverse(), }, MapsSortColumn::StartOffset => match self.addr_sort { Sort::Ascending => range1.start().cmp(&range2.start()), Sort::Descending => range1.start().cmp(&range2.start()).reverse(), }, }); body.rows(20.0, filtered.len(), |mut row| { let map_range = filtered[row.index()].clone(); // This range is likely open in the editor (range contains hard_seek) let mut likely_open = false; if let Some(hard_seek) = app.src_args.hard_seek && hard_seek >= map_range.start() && hard_seek < map_range.start() + map_range.size() { likely_open = true; } row.col(|ui| { let txt = format!("{:X}", map_range.start()); let mut rich_txt = egui::RichText::new(&txt); if likely_open { rich_txt = rich_txt.color(egui::Color32::YELLOW); } let mut is_button = false; let re = if map_range.is_read() { is_button = true; ui.add(egui::Button::new(rich_txt)) } else { ui.add(egui::Label::new(rich_txt).sense(egui::Sense::click())) }; re.context_menu(|ui| { if ui.button("📋 Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &txt, ); } }); if re.clicked() && is_button { msg_if_fail( app.load_proc_memory( pid, map_range.start(), map_range.size(), map_range.is_write(), &mut gui.msg_dialog, font_size, line_spacing, ), "Failed to load process memory", &mut gui.msg_dialog, ); if let Some(path) = &self.default_meta_path && self.use_default_meta_path { let result = app.consume_meta_from_file(path.clone(), false); msg_if_fail( result, "Failed to consume metafile", &mut gui.msg_dialog, ); } if let Ok(off) = usize::from_str_radix(&self.filters.addr, 16) { let off = off - app.src_args.hard_seek.unwrap_or(0); app.edit_state.set_cursor(off); app.center_view_on_offset(off); app.hex_ui.flash_cursor(); } } }); row.col(|ui| { let size = map_range.size(); let txt = size.to_string(); ui.add(egui::Label::new(&txt).sense(egui::Sense::click())) .on_hover_text_deferred(|| human_size(size)) .context_menu(|ui| { if ui.button("📋 Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &txt, ); } }); }); row.col(|ui| { ui.label(format!( "{}{}{}", if map_range.is_read() { "r" } else { "" }, if map_range.is_write() { "w" } else { "" }, if map_range.is_exec() { "x" } else { "" } )); }); row.col(|ui| { let txt = map_range .filename() .map(|p| p.display().to_string()) .unwrap_or_default(); ui.add(egui::Label::new(&txt).sense(egui::Sense::click())) .context_menu(|ui| { if ui.button("📋 Copy to clipboard").clicked() { crate::app::set_clipboard_string( &mut app.clipboard, &mut gui.msg_dialog, &txt, ); } }); }); }); }); ui.separator(); ui.label(format!( "{}/{} maps shown ({})", filtered.len(), self.map_ranges.len(), human_size(filtered.iter().map(|range| range.size()).sum::()) )); } else { TableBuilder::new(ui) .column(Column::auto()) .column(Column::remainder()) .resizable(true) .striped(true) .header(20.0, |mut row| { row.col(|ui| { if sort_button(ui, "pid", true, self.pid_sort).clicked() { self.pid_sort.flip(); } }); row.col(|ui| { ui.add( egui::TextEdit::singleline(&mut self.filters.proc_name) .hint_text("🔎 Name"), ); }); }) .body(|body| { let procs = self.sys.processes(); let filt_str = self.filters.proc_name.to_ascii_lowercase(); let mut pids: Vec<&sysinfo::Pid> = procs .keys() .filter(|&pid| { procs[pid] .name() .to_string_lossy() .to_ascii_lowercase() .contains(&filt_str) }) .collect(); pids.sort_by(|pid1, pid2| match self.pid_sort { Sort::Ascending => pid1.cmp(pid2), Sort::Descending => pid1.cmp(pid2).reverse(), }); body.rows(20.0, pids.len(), |mut row| { let pid = pids[row.index()]; row.col(|ui| { if ui .selectable_label(Some(*pid) == self.selected_pid, pid.to_string()) .clicked() { self.selected_pid = Some(*pid); match pid.to_string().parse() { Ok(pid) => refresh_proc_maps( pid, &mut self.map_ranges, &mut gui.msg_dialog, ), Err(e) => msg_fail( &e, "Failed to parse pid of process", &mut gui.msg_dialog, ), } } }); row.col(|ui| { ui.label(procs[pid].name().to_string_lossy()); }); }); }); } } fn title(&self) -> &str { "Open process" } } fn should_retain_range(filters: &Filters, range: &MapRange) -> bool { if filters.perms.read && !range.is_read() { return false; } if filters.perms.write && !range.is_write() { return false; } if filters.perms.execute && !range.is_exec() { return false; } if let Ok(addr) = usize::from_str_radix(&filters.addr, 16) && !(range.start() <= addr && range.start() + range.size() >= addr) { return false; } if filters.path.is_empty() { return true; } match range.filename() { Some(path) => path.display().to_string().contains(&filters.path), None => false, } } fn refresh_proc_maps(pid: u32, win_map_ranges: &mut MapRanges, msg: &mut MessageDialog) { #[cfg_attr( windows, expect(clippy::useless_conversion, reason = "lossless on windows") )] match proc_maps::get_process_maps(pid.try_into().expect("Couldnt't convert process id")) { Ok(ranges) => { *win_map_ranges = ranges; } Err(e) => msg_fail(&e, "Failed to get map ranges for process", msg), } } ================================================ FILE: src/gui/windows/perspectives.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::command::Cmd, gui::windows::regions::region_context_menu, meta::PerspectiveKey, shell::msg_if_fail, }, egui_extras::{Column, TableBuilder}, slotmap::Key as _, }; #[derive(Default)] pub struct PerspectivesWindow { pub open: WindowOpen, pub rename_idx: PerspectiveKey, } impl super::Window for PerspectivesWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); TableBuilder::new(ui) .columns(Column::auto(), 4) .column(Column::remainder()) .striped(true) .resizable(true) .header(24.0, |mut row| { row.col(|ui| { ui.label("Name"); }); row.col(|ui| { ui.label("Region"); }); row.col(|ui| { ui.label("Columns"); }); row.col(|ui| { ui.label("Rows"); }); row.col(|ui| { ui.label("Flip row order"); }); }) .body(|body| { let keys: Vec<_> = app.meta_state.meta.low.perspectives.keys().collect(); body.rows(20.0, keys.len(), |mut row| { let idx = row.index(); row.col(|ui| { if self.rename_idx == keys[idx] { let re = ui.text_edit_singleline( &mut app.meta_state.meta.low.perspectives[keys[idx]].name, ); if re.lost_focus() { self.rename_idx = PerspectiveKey::null(); } else { re.request_focus(); } } else { let name = &app.meta_state.meta.low.perspectives[keys[idx]].name; ui.menu_button(name, |ui| { if ui.button("✏ Rename").clicked() { self.rename_idx = keys[idx]; } if ui.button("🗑 Delete").clicked() { app.cmd.push(Cmd::RemovePerspective(keys[idx])); } if ui.button("Create view").clicked() { app.cmd.push(Cmd::CreateView { perspective_key: keys[idx], name: name.to_owned(), }); } ui.menu_button("Containing views", |ui| { for (view_key, view) in app.meta_state.meta.views.iter() { if view.view.perspective == keys[idx] && ui.button(&view.name).clicked() { gui.win.views.open.set(true); gui.win.views.selected = view_key; } } }); if ui.button("Copy name to clipboard").clicked() { let res = app.clipboard.set_text(name); msg_if_fail( res, "Failed to copy to clipboard", &mut gui.msg_dialog, ); } }); } }); row.col(|ui| { let per = &app.meta_state.meta.low.perspectives[keys[idx]]; let reg = &app.meta_state.meta.low.regions[per.region]; let re = ui.link(®.name).on_hover_text(®.desc); re.context_menu(|ui| { region_context_menu( ui, reg, per.region, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); }); if re.clicked() { gui.win.regions.open.set(true); gui.win.regions.selected_key = Some(per.region); } }); row.col(|ui| { let per = &mut app.meta_state.meta.low.perspectives[keys[idx]]; let reg = &app.meta_state.meta.low.regions[per.region]; ui.add(egui::DragValue::new(&mut per.cols).range(1..=reg.region.len())); }); row.col(|ui| { let per = &app.meta_state.meta.low.perspectives[keys[idx]]; let reg = &app.meta_state.meta.low.regions[per.region]; let reg_len = reg.region.len(); let cols = per.cols; let rows = reg_len / cols; let rem = reg_len % cols; let rem_str: &str = if rem == 0 { "" } else { &format!(" (rem: {rem})") }; ui.label(format!("{rows}{rem_str}")); }); row.col(|ui| { ui.checkbox( &mut app.meta_state.meta.low.perspectives[keys[idx]].flip_row_order, "", ); }); }); }); ui.separator(); ui.menu_button("New from region", |ui| { for (key, region) in app.meta_state.meta.low.regions.iter() { if ui.button(®ion.name).clicked() { app.cmd.push(Cmd::CreatePerspective { region_key: key, name: region.name.clone(), }); return; } } }); } fn title(&self) -> &str { "Perspectives" } } ================================================ FILE: src/gui/windows/preferences.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::{App, backend_command::BackendCmd}, config::{self, Config, ProjectDirsExt as _}, gui::message_dialog::{Icon, MessageDialog}, }, egui_colors::{Colorix, tokens::ThemeColor}, egui_fontcfg::{CustomFontPaths, FontCfgUi, FontDefsUiMsg}, rand::RngExt as _, }; #[derive(Default)] pub struct PreferencesWindow { pub open: WindowOpen, tab: Tab, font_cfg: FontCfgUi, font_defs: egui::FontDefinitions, temp_custom_font_paths: CustomFontPaths, } #[derive(Default, PartialEq)] enum Tab { #[default] Video, Style, Fonts, } impl Tab { fn label(&self) -> &'static str { match self { Self::Video => "Video", Self::Style => "Style", Self::Fonts => "Fonts", } } } impl super::Window for PreferencesWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { if self.open.just_now() { self.font_defs = ui.ctx().fonts(|f| f.definitions().clone()); self.temp_custom_font_paths.clone_from(&app.cfg.custom_font_paths); let _ = egui_fontcfg::load_custom_fonts( &app.cfg.custom_font_paths, &mut self.font_defs.font_data, ); } ui.horizontal(|ui| { ui.selectable_value(&mut self.tab, Tab::Video, Tab::Video.label()); ui.selectable_value(&mut self.tab, Tab::Style, Tab::Style.label()); ui.selectable_value(&mut self.tab, Tab::Fonts, Tab::Fonts.label()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Open config dir").clicked() { match config::project_dirs() { Some(dirs) => { if let Err(e) = open::that(dirs.config_dir()) { gui.msg_dialog.open( Icon::Error, "Error opening config dir", e.to_string(), ); } } None => gui.msg_dialog.open( Icon::Error, "Error opening config dir", "Missing config dir", ), } } }); }); ui.separator(); match self.tab { Tab::Video => video_ui(ui, app), Tab::Style => style_ui(app, ui, &mut gui.colorix, &mut gui.msg_dialog), Tab::Fonts => fonts_ui( ui, &mut self.font_cfg, &mut self.font_defs, &mut app.cfg, &mut self.temp_custom_font_paths, &mut gui.msg_dialog, ), } } fn title(&self) -> &str { "Preferences" } } fn video_ui(ui: &mut egui::Ui, app: &mut App) { if ui.checkbox(&mut app.cfg.vsync, "Vsync").clicked() { app.backend_cmd.push(BackendCmd::ApplyVsyncCfg); } ui.horizontal(|ui| { ui.label("FPS limit (0 to disable)"); ui.add(egui::DragValue::new(&mut app.cfg.fps_limit)); if ui.button("Set").clicked() { app.backend_cmd.push(BackendCmd::ApplyFpsLimit); } }); } fn style_ui( app: &mut App, ui: &mut egui::Ui, opt_colorix: &mut Option, msg_dia: &mut MessageDialog, ) { ui.group(|ui| { let style = &mut app.cfg.style; ui.heading("Font sizes"); let mut any_changed = false; ui.horizontal(|ui| { ui.label("heading"); any_changed |= ui .add(egui::DragValue::new(&mut style.font_sizes.heading).range(3..=100)) .changed(); }); ui.horizontal(|ui| { ui.label("body"); any_changed |= ui .add(egui::DragValue::new(&mut style.font_sizes.body).range(3..=100)) .changed(); }); ui.horizontal(|ui| { ui.label("monospace"); any_changed |= ui .add(egui::DragValue::new(&mut style.font_sizes.monospace).range(3..=100)) .changed(); }); ui.horizontal(|ui| { ui.label("button"); any_changed |= ui .add(egui::DragValue::new(&mut style.font_sizes.button).range(3..=100)) .changed(); }); ui.horizontal(|ui| { ui.label("small"); any_changed |= ui .add(egui::DragValue::new(&mut style.font_sizes.small).range(3..=100)) .changed(); }); if ui.button("Reset default").clicked() { *style = config::Style::default(); any_changed = true; } if any_changed { crate::gui::set_font_sizes_ctx(ui.ctx(), style); } }); ui.group(|ui| { let colorix = match opt_colorix { Some(colorix) => colorix, None => { if ui.button("Activate custom colors").clicked() { opt_colorix.insert(Colorix::global(ui.ctx(), egui_colors::utils::EGUI_THEME)) } else { return; } } }; let mut clear = false; ui.horizontal(|ui| { colorix.themes_dropdown(ui, None, false); ui.group(|ui| { ui.label("light dark toggle"); colorix.light_dark_toggle_button(ui, 30.0); }); if ui.button("Random theme").clicked() { let mut rng = rand::rng(); *colorix = Colorix::global( ui.ctx(), std::array::from_fn(|_| ThemeColor::Custom(rng.random::<[u8; 3]>())), ); } }); ui.separator(); colorix.ui_combo_12(ui, true); if let Some(dirs) = config::project_dirs() { ui.separator(); ui.horizontal(|ui| { if ui.button("Save").clicked() { let data: [[u8; 3]; 12] = colorix.theme().map(|theme| theme.rgb()); if let Err(e) = std::fs::write(dirs.color_theme_path(), data.as_flattened()) { msg_dia.open(Icon::Error, "Failed to save theme", e.to_string()); } }; if ui.button("Remove custom colors").clicked() { if let Err(e) = std::fs::remove_file(dirs.color_theme_path()) { msg_dia.open(Icon::Error, "Failed to delete theme file", e.to_string()); } clear = true; } }); } if clear { ui.ctx().set_visuals(egui::Visuals::dark()); *opt_colorix = None; } }); } fn fonts_ui( ui: &mut egui::Ui, font_cfg_ui: &mut FontCfgUi, font_defs: &mut egui::FontDefinitions, cfg: &mut Config, temp_custom_font_paths: &mut CustomFontPaths, msg_dia: &mut MessageDialog, ) { let msg = font_cfg_ui.show(ui, font_defs, Some(temp_custom_font_paths)); if matches!(msg, FontDefsUiMsg::SaveRequest) { cfg.font_families = font_defs.families.clone(); cfg.custom_font_paths.clone_from(temp_custom_font_paths); msg_dia.open( Icon::Info, "Config saved", "Your font configuration has been saved.", ); } } ================================================ FILE: src/gui/windows/regions.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::command::{Cmd, CommandQueue}, gui::command::{GCmd, GCommandQueue}, meta::{Meta, NamedRegion, RegionKey}, util::human_size, }, egui::TextBuffer as _, egui_extras::{Column, TableBuilder}, egui_phosphor::regular as ic, }; #[derive(Default)] pub struct RegionsWindow { pub open: WindowOpen, pub focus_rename: bool, pub selected_key: Option, pub select_active: bool, pub rename_buffer: Option, pub activate_rename: bool, } pub fn region_context_menu( ui: &mut egui::Ui, reg: &NamedRegion, key: RegionKey, meta: &Meta, cmd: &mut CommandQueue, gcmd: &mut GCommandQueue, ) { ui.menu_button("Containing layouts", |ui| { for (key, layout) in meta.layouts.iter() { if let Some(v) = layout.view_containing_region(®.region, meta) && ui.button(&layout.name).clicked() { cmd.push(Cmd::SetLayout(key)); cmd.push(Cmd::FocusView(v)); cmd.push(Cmd::SetAndFocusCursor(reg.region.begin)); } } }); ui.menu_button("Containing perspectives", |ui| { for (_per_key, per) in meta.low.perspectives.iter() { if per.region == key && ui.button(&per.name).clicked() { gcmd.push(GCmd::OpenPerspectiveWindow); } } }); if ui.button("Select").clicked() { cmd.push(Cmd::SetSelection(reg.region.begin, reg.region.end)); } if ui.button("Create perspective").clicked() { cmd.push(Cmd::CreatePerspective { region_key: key, name: reg.name.clone(), }); } } impl super::Window for RegionsWindow { fn ui(&mut self, WinCtx { ui, gui, app, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.horizontal(|ui| { let button = egui::Button::new("Add selection as region"); match app.hex_ui.selection() { Some(sel) => { if ui.add(button).clicked() { crate::gui::ops::add_region_from_selection(sel, &mut app.meta_state, self); } } None => { ui.add_enabled(false, button); } } if ui.button("Add file-sized region").clicked() { app.meta_state.meta.low.regions.insert(NamedRegion::new( "New (file sized)".into(), 0, app.data.len().saturating_sub(1), )); } }); if let &Some(key) = &self.selected_key { ui.separator(); let reg = &mut app.meta_state.meta.low.regions[key]; if std::mem::take(&mut self.activate_rename) { self.rename_buffer = Some(reg.name.clone()); } let mut unset_rename_buf = false; ui.horizontal(|ui| match &mut self.rename_buffer { Some(buf) => { let re = ui.text_edit_singleline(buf); if self.open.just_now() { self.focus_rename = true; } if std::mem::take(&mut self.focus_rename) { re.request_focus(); } ui.add_enabled(false, egui::Label::new("")); if ui.button(ic::X).clicked() { unset_rename_buf = true; } if ui.button(ic::CHECK).clicked() || ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { reg.name = buf.take(); self.rename_buffer = None; } } None => { ui.heading(®.name); if ui.button(ic::PENCIL).on_hover_text("Rename").clicked() { self.rename_buffer = Some(reg.name.clone()); self.focus_rename = true; } } }); if unset_rename_buf { self.rename_buffer = None; } ui.horizontal(|ui| { ui.label("First byte"); ui.add(egui::DragValue::new(&mut reg.region.begin)).context_menu(|ui| { if ui.button("Set to cursor").clicked() { reg.region.begin = app.edit_state.cursor; } }); ui.label("Last byte"); ui.add(egui::DragValue::new(&mut reg.region.end)).context_menu(|ui| { if ui.button("Set to cursor").clicked() { reg.region.end = app.edit_state.cursor; } }); }); ui.label(format!( "Length: {} ({})", reg.region.len(), human_size(reg.region.len()) )); if self.select_active { app.hex_ui.select_a = Some(reg.region.begin); app.hex_ui.select_b = Some(reg.region.end); } if ui.checkbox(&mut self.select_active, "Select").clicked() { app.hex_ui.clear_selections(); } if let Some(sel) = app.hex_ui.selection() { if ui.button("Set to selection").clicked() { reg.region = sel; } } else { ui.add_enabled(false, egui::Button::new("Set to selection")); } if ui.button("Reset").on_hover_text("Encompass the whole document").clicked() { reg.region.begin = 0; reg.region.end = app.data.len() - 1; } ui.label("Description"); ui.text_edit_multiline(&mut reg.desc); if ui.button("Delete").clicked() { app.meta_state.meta.low.regions.remove(key); app.remove_dangling(); self.selected_key = None; } } ui.separator(); TableBuilder::new(ui) .striped(true) .resizable(true) .column(Column::auto()) .column(Column::auto()) .column(Column::auto()) .column(Column::remainder()) .header(20.0, |mut header| { header.col(|ui| { ui.label("Name"); }); header.col(|ui| { ui.label("First byte"); }); header.col(|ui| { ui.label("Last byte"); }); header.col(|ui| { ui.label("Length"); }); }) .body(|body| { let mut keys: Vec = app.meta_state.meta.low.regions.keys().collect(); let mut action = Action::None; keys.sort_by_key(|k| app.meta_state.meta.low.regions[*k].region.begin); body.rows(20.0, keys.len(), |mut row| { let k = keys[row.index()]; let reg = &app.meta_state.meta.low.regions[k]; row.col(|ui| { let ctx_menu = |ui: &mut egui::Ui| { region_context_menu( ui, reg, k, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); }; let re = ui .selectable_label(self.selected_key == Some(k), ®.name) .on_hover_text(®.desc); re.context_menu(ctx_menu); if re.clicked() { self.selected_key = Some(k); } }); row.col(|ui| { let re = ui.link(reg.region.begin.to_string()); re.context_menu(|ui| { if ui.button("Set to cursor").clicked() { action = Action::SetRegionBegin { key: k, begin: app.edit_state.cursor, }; } }); if re.clicked() { action = Action::Goto(reg.region.begin); } }); row.col(|ui| { let re = ui.link(reg.region.end.to_string()); re.context_menu(|ui| { if ui.button("Set to cursor").clicked() { action = Action::SetRegionEnd { key: k, end: app.edit_state.cursor, }; } }); if re.clicked() { action = Action::Goto(reg.region.end); } }); row.col( |ui| match (reg.region.end + 1).checked_sub(reg.region.begin) { Some(len) => { ui.label(len.to_string()); } None => { ui.label("Overflow!"); } }, ); }); match action { Action::None => {} Action::Goto(off) => { app.center_view_on_offset(off); app.edit_state.set_cursor(off); app.hex_ui.flash_cursor(); } Action::SetRegionBegin { key, begin } => { app.meta_state.meta.low.regions[key].region.begin = begin; } Action::SetRegionEnd { key, end } => { app.meta_state.meta.low.regions[key].region.end = end; } } }); } fn title(&self) -> &str { "Regions" } } enum Action { None, Goto(usize), SetRegionBegin { key: RegionKey, begin: usize }, SetRegionEnd { key: RegionKey, end: usize }, } ================================================ FILE: src/gui/windows/script_manager.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::App, gui::Gui, meta::{ScriptKey, ScriptMap}, scripting::exec_lua, shell::msg_if_fail, }, egui_code_editor::{CodeEditor, Syntax}, mlua::Lua, }; #[derive(Default)] pub struct ScriptManagerWindow { pub open: WindowOpen, selected: Option, } impl super::Window for ScriptManagerWindow { fn ui( &mut self, WinCtx { ui, gui, app, lua, font_size, line_spacing, .. }: WinCtx, ) { let mut scripts = std::mem::take(&mut app.meta_state.meta.scripts); scripts.retain(|key, script| { let mut retain = true; ui.horizontal(|ui| { if app.meta_state.meta.onload_script == Some(key) { ui.label("⚡").on_hover_text("This script executes on document load"); } if ui.selectable_label(self.selected == Some(key), &script.name).clicked() { self.selected = Some(key); } if ui.button("⚡ Execute").clicked() { let result = exec_lua( lua, &script.content, app, gui, "", Some(key), font_size, line_spacing, ); msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); } if ui.button("Delete").clicked() { retain = false; } }); retain }); if scripts.is_empty() { ui.label("There are no saved scripts."); } if ui.link("Open lua editor").clicked() { gui.win.lua_editor.open.set(true); } ui.separator(); self.selected_script_ui(ui, gui, app, lua, &mut scripts, font_size, line_spacing); std::mem::swap(&mut app.meta_state.meta.scripts, &mut scripts); } fn title(&self) -> &str { "Script manager" } } impl ScriptManagerWindow { fn selected_script_ui( &mut self, ui: &mut egui::Ui, gui: &mut Gui, app: &mut App, lua: &Lua, scripts: &mut ScriptMap, font_size: u16, line_spacing: u16, ) { let Some(key) = self.selected else { return; }; let Some(scr) = scripts.get_mut(key) else { self.selected = None; return; }; ui.label("Description"); ui.text_edit_multiline(&mut scr.desc); ui.label("Code"); egui::ScrollArea::vertical().show(ui, |ui| { CodeEditor::default().with_syntax(Syntax::lua()).show(ui, &mut scr.content); }); if ui.button("⚡ Execute").clicked() { let result = exec_lua( lua, &scr.content, app, gui, "", Some(key), font_size, line_spacing, ); msg_if_fail(result, "Failed to execute script", &mut gui.msg_dialog); } if ui.button("⚡ Set as onload script").clicked() { app.meta_state.meta.onload_script = Some(key); } } } ================================================ FILE: src/gui/windows/structs.rs ================================================ use { super::WindowOpen, crate::{ meta::Meta, struct_meta_item::{Endian, StructMetaItem, StructPrimitive, StructTy}, }, egui_code_editor::{CodeEditor, Syntax}, }; #[derive(Default)] pub struct StructsWindow { pub open: WindowOpen, struct_text_buf: String, parsed_struct: Option, error_label: String, selected_idx: usize, tab: Tab = Tab::Fields, } #[derive(PartialEq)] enum Tab { Fields, AtRow, } impl super::Window for StructsWindow { fn ui(&mut self, super::WinCtx { ui, app, .. }: super::WinCtx) { if self.open.just_now() && let Some(struct_) = app.meta_state.meta.structs.get(self.selected_idx) { self.struct_text_buf = struct_.src.clone(); self.parsed_struct = Some(struct_.clone()); } let top_h = ui.available_height() - 32.0; ui.horizontal(|ui| { ui.set_max_height(top_h); ui.vertical(|ui| { self.picker_ui(&app.meta_state.meta, ui); }); ui.separator(); self.editor_ui(ui); ui.separator(); ui.vertical(|ui| { self.parsed_struct_ui(ui, app); }); }); ui.separator(); self.bottom_bar_ui(ui, app); } fn title(&self) -> &str { "Structs" } } impl StructsWindow { fn refresh(&mut self, meta: &Meta) { self.struct_text_buf.clear(); self.parsed_struct = None; if let Some(struct_) = meta.structs.get(self.selected_idx) { self.struct_text_buf = struct_.src.clone(); self.parsed_struct = Some(struct_.clone()); } } fn picker_ui(&mut self, meta: &Meta, ui: &mut egui::Ui) { for (i, struct_) in meta.structs.iter().enumerate() { if ui.selectable_label(self.selected_idx == i, &struct_.name).clicked() { self.selected_idx = i; self.struct_text_buf = struct_.src.clone(); self.parsed_struct = Some(struct_.clone()); } } } fn editor_ui(&mut self, ui: &mut egui::Ui) { let re = CodeEditor::default() .with_syntax(Syntax::rust()) .desired_width(300.0) .show(ui, &mut self.struct_text_buf) .response; if re.changed() { self.error_label.clear(); match structparse::Struct::parse(&self.struct_text_buf) { Ok(struct_) => match StructMetaItem::new(struct_, self.struct_text_buf.clone()) { Ok(struct_) => { self.parsed_struct = Some(struct_); } Err(e) => { self.error_label = format!("Resolve error: {e}"); } }, Err(e) => { self.parsed_struct = None; self.error_label = format!("Parse error: {e}"); } } } } fn parsed_struct_ui(&mut self, ui: &mut egui::Ui, app: &mut crate::app::App) { egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| { if let Some(struct_) = &mut self.parsed_struct { ui.horizontal(|ui| { ui.selectable_value(&mut self.tab, Tab::Fields, "Fields"); if let Some([row, _col]) = app.row_col_of_cursor() && let Some(reg) = app.row_region(row) { let bm_name = app .meta_state .meta .bookmarks .iter() .find(|bm| bm.offset == reg.begin) .map_or(String::new(), |bm| format!(" ({})", bm.label)); ui.selectable_value( &mut self.tab, Tab::AtRow, format!("At row {row}{bm_name}"), ); } }); ui.separator(); match self.tab { Tab::Fields => fields_ui(struct_, ui), Tab::AtRow => at_row_ui(struct_, ui, app), } } if !self.error_label.is_empty() { let label = egui::Label::new( egui::RichText::new(&self.error_label).color(egui::Color32::RED), ) .extend(); ui.add(label); } }); } fn bottom_bar_ui(&mut self, ui: &mut egui::Ui, app: &mut crate::app::App) { match &mut self.parsed_struct { Some(struct_) => { let mut del = false; let mut refresh = false; ui.horizontal(|ui| { if ui.button("Save").clicked() { struct_.src = self.struct_text_buf.clone(); if let Some(s) = app.meta_state.meta.structs.iter_mut().find(|s| s.name == struct_.name) { *s = struct_.clone(); } else { app.meta_state.meta.structs.push(struct_.clone()); } } if ui.button("Delete").clicked() { del = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Restore all").clicked() { app.meta_state.meta.structs = app.meta_state.clean_meta.structs.clone(); refresh = true; } }); }); if del { if self.selected_idx < app.meta_state.meta.structs.len() { app.meta_state.meta.structs.remove(self.selected_idx); } self.selected_idx = self.selected_idx.saturating_sub(1); self.refresh(&app.meta_state.meta); } if refresh { self.refresh(&app.meta_state.meta); } } None => { ui.add_enabled(false, egui::Button::new("Save")); } } } } fn fields_ui(struct_: &mut StructMetaItem, ui: &mut egui::Ui) { for (_off, field) in struct_.fields_with_offsets_mut() { ui.horizontal(|ui| { ui.label(format!( "{}: {} [size: {}]", field.name, field.ty, field.ty.size() )); let en = field.ty.endian_mut(); if ui.checkbox(&mut matches!(en, Endian::Be), en.label()).clicked() { en.toggle(); } }); } } fn at_row_ui(struct_: &mut StructMetaItem, ui: &mut egui::Ui, app: &mut crate::app::App) { if let Some([row, _]) = app.row_col_of_cursor() && let Some(reg) = app.row_region(row) { for (off, field) in struct_.fields_with_offsets_mut() { ui.horizontal(|ui| { let data_off = reg.begin + off; if ui.link(off.to_string()).clicked() { app.search_focus(data_off); } ui.label(&field.name); let field_bytes_len = field.ty.size(); if let Some(byte_slice) = app.data.get_mut(data_off..data_off + field_bytes_len) { field_edit_ui(ui, field, byte_slice); } else { ui.label(""); } if ui.button("select").clicked() { app.hex_ui.select_a = Some(data_off); app.hex_ui.select_b = Some(data_off + field.ty.size().saturating_sub(1)); } }); } } } trait ToFromBytes: Sized { const LEN: usize = size_of::(); fn from_bytes(bytes: [u8; Self::LEN], endian: Endian) -> Self; fn to_bytes(&self, endian: Endian) -> [u8; Self::LEN]; } fn with_bytes_as_primitive(bytes: &mut [u8], endian: Endian, mut fun: F) where T: ToFromBytes, F: FnMut(&mut T), [(); T::LEN]:, { if let Ok(arr) = bytes.try_into() { let mut prim = T::from_bytes(arr, endian); fun(&mut prim); bytes.copy_from_slice(prim.to_bytes(endian).as_slice()); } } macro_rules! to_from_impl { ($prim:ty) => { impl ToFromBytes for $prim { fn from_bytes(bytes: [u8; Self::LEN], endian: Endian) -> Self { match endian { Endian::Le => <$prim>::from_le_bytes(bytes), Endian::Be => <$prim>::from_be_bytes(bytes), } } fn to_bytes(&self, endian: Endian) -> [u8; Self::LEN] { match endian { Endian::Le => self.to_le_bytes(), Endian::Be => self.to_be_bytes(), } } } }; } to_from_impl!(i8); to_from_impl!(u8); to_from_impl!(i16); to_from_impl!(u16); to_from_impl!(i32); to_from_impl!(u32); to_from_impl!(i64); to_from_impl!(u64); to_from_impl!(f32); to_from_impl!(f64); fn field_edit_ui( ui: &mut egui::Ui, field: &crate::struct_meta_item::StructField, byte_slice: &mut [u8], ) { match &field.ty { StructTy::Primitive { ty, endian } => match ty { StructPrimitive::I8 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut i8| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::U8 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut u8| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::I16 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut i16| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::U16 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut u16| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::I32 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut i32| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::U32 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut u32| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::I64 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut i64| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::U64 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut u64| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::F32 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut f32| { ui.add(egui::DragValue::new(num)); }); } StructPrimitive::F64 => { with_bytes_as_primitive(byte_slice, *endian, |num: &mut f64| { ui.add(egui::DragValue::new(num)); }); } }, StructTy::Array { .. } => { ui.label(""); } } } ================================================ FILE: src/gui/windows/vars.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::meta::{VarEntry, VarVal}, egui::TextBuffer as _, egui_extras::Column, }; #[derive(Default)] pub struct VarsWindow { pub open: WindowOpen, pub new_var_name: String, pub new_val_val: VarVal = VarVal::U64(0), } impl super::Window for VarsWindow { fn ui(&mut self, WinCtx { ui, app, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.group(|ui| { ui.label("New"); ui.horizontal(|ui| { ui.label("Name"); ui.text_edit_singleline(&mut self.new_var_name); ui.label("Type"); let sel_txt = var_val_label(&self.new_val_val); egui::ComboBox::new("type_select", "Type").selected_text(sel_txt).show_ui( ui, |ui| { ui.selectable_value(&mut self.new_val_val, VarVal::U64(0), "U64"); ui.selectable_value(&mut self.new_val_val, VarVal::I64(0), "I64"); }, ); if ui.button("Add").clicked() { app.meta_state.meta.vars.insert( self.new_var_name.take(), VarEntry { val: self.new_val_val.clone(), desc: String::new(), }, ); } }); }); egui_extras::TableBuilder::new(ui) .columns(Column::auto(), 4) .resizable(true) .header(32.0, |mut row| { row.col(|ui| { ui.label("Name"); }); row.col(|ui| { ui.label("Type"); }); row.col(|ui| { ui.label("Description"); }); row.col(|ui| { ui.label("Value"); }); }) .body(|mut body| { for (key, var_ent) in &mut app.meta_state.meta.vars { body.row(32.0, |mut row| { row.col(|ui| { ui.label(key); }); row.col(|ui| { ui.label(var_val_label(&var_ent.val)); }); row.col(|ui| { ui.text_edit_singleline(&mut var_ent.desc); }); row.col(|ui| { match &mut var_ent.val { VarVal::I64(var) => ui.add(egui::DragValue::new(var)), VarVal::U64(var) => ui.add(egui::DragValue::new(var)), }; }); }); } }); } fn title(&self) -> &str { "Variables" } } fn var_val_label(var_val: &VarVal) -> &str { match var_val { VarVal::I64(_) => "i64", VarVal::U64(_) => "u64", } } ================================================ FILE: src/gui/windows/views.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ app::{App, command::Cmd}, gui::windows::regions::region_context_menu, meta::ViewKey, view::{HexData, TextData, TextKind, ViewKind}, }, egui::emath::Numeric, egui_extras::{Column, TableBuilder}, slotmap::Key as _, std::{hash::Hash, ops::RangeInclusive}, }; #[derive(Default)] pub struct ViewsWindow { pub open: WindowOpen, pub selected: ViewKey, rename: bool, } impl ViewKind { const HEX_NAME: &'static str = "Hex"; const DEC_NAME: &'static str = "Decimal"; const TEXT_NAME: &'static str = "Text"; const BLOCK_NAME: &'static str = "Block"; fn name(&self) -> &'static str { match *self { Self::Hex(_) => Self::HEX_NAME, Self::Dec(_) => Self::DEC_NAME, Self::Text(_) => Self::TEXT_NAME, Self::Block => Self::BLOCK_NAME, } } } pub const MIN_FONT_SIZE: u16 = 5; pub const MAX_FONT_SIZE: u16 = 256; impl super::Window for ViewsWindow { fn ui( &mut self, WinCtx { ui, gui, app, font_size, line_spacing, font, .. }: WinCtx, ) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if self.open.just_now() && // Don't override selected key if there already is one // For example, it could be set by the context menu "view properties". self.selected.is_null() && let Some(view_key) = app.hex_ui.focused_view { self.selected = view_key; } let mut removed_idx = None; if app.meta_state.meta.views.is_empty() { ui.label("No views"); new_from_perspective_button(ui, app); return; } TableBuilder::new(ui) .columns(Column::auto(), 3) .column(Column::remainder()) .resizable(true) .header(24.0, |mut row| { row.col(|ui| { ui.label("Name"); }); row.col(|ui| { ui.label("Kind"); }); row.col(|ui| { ui.label("Perspective"); }); row.col(|ui| { ui.label("Region"); }); }) .body(|body| { let keys: Vec = app.meta_state.meta.views.keys().collect(); body.rows(20.0, keys.len(), |mut row| { let view_key = keys[row.index()]; let view = &app.meta_state.meta.views[view_key]; row.col(|ui| { let ctx_menu = |ui: &mut egui::Ui| { ui.menu_button("Containing layouts", |ui| { for (key, layout) in app.meta_state.meta.layouts.iter() { if layout.contains_view(view_key) && ui.button(&layout.name).clicked() { App::switch_layout( &mut app.hex_ui, &app.meta_state.meta, key, ); app.hex_ui.focused_view = Some(view_key); } } }); }; let re = ui.selectable_label(view_key == self.selected, &view.name); re.context_menu(ctx_menu); if re.clicked() { self.selected = view_key; } if re.double_clicked() { App::focus_first_view_of_key( &mut app.hex_ui, &app.meta_state.meta, view_key, ); } }); row.col(|ui| { ui.label(egui::RichText::new(view.view.kind.name()).code()); }); row.col(|ui| { if ui .link(&app.meta_state.meta.low.perspectives[view.view.perspective].name) .clicked() { gui.win.perspectives.open.set(true); } }); row.col(|ui| { let per = &app.meta_state.meta.low.perspectives[view.view.perspective]; let reg = &app.meta_state.meta.low.regions[per.region]; let ctx_menu = |ui: &mut egui::Ui| { region_context_menu( ui, reg, per.region, &app.meta_state.meta, &mut app.cmd, &mut gui.cmd, ); }; let re = ui.link(®.name).on_hover_text(®.desc); re.context_menu(ctx_menu); if re.clicked() { gui.win.regions.open.set(true); gui.win.regions.selected_key = Some(per.region); } }); }); }); ui.separator(); new_from_perspective_button(ui, app); ui.separator(); if let Some(view) = app.meta_state.meta.views.get_mut(self.selected) { ui.horizontal(|ui| { if self.rename { if ui .add(egui::TextEdit::singleline(&mut view.name).desired_width(150.0)) .lost_focus() { self.rename = false; } } else { ui.heading(&view.name); } if ui.button("✏").on_hover_text("Rename").clicked() { self.rename ^= true; } if view_combo( egui::Id::new("view_combo"), &mut view.view.kind, ui, font_size, line_spacing, ) { view.view.adjust_state_to_kind(); } }); egui::ComboBox::new("new_perspective_combo", "Perspective") .selected_text(&app.meta_state.meta.low.perspectives[view.view.perspective].name) .show_ui(ui, |ui| { for k in app.meta_state.meta.low.perspectives.keys() { if ui .selectable_label( k == view.view.perspective, &app.meta_state.meta.low.perspectives[k].name, ) .clicked() { view.view.perspective = k; } } }); ui.group(|ui| { let mut adjust_block_size = false; match &mut view.view.kind { ViewKind::Hex(HexData { font_size, .. }) | ViewKind::Dec(HexData { font_size, .. }) | ViewKind::Text(TextData { font_size, .. }) => { ui.horizontal(|ui| { ui.label("Font size"); if ui .add( egui::DragValue::new(font_size) .range(MIN_FONT_SIZE..=MAX_FONT_SIZE), ) .changed() { adjust_block_size = true; }; }); if let ViewKind::Text(text) = &mut view.view.kind { let mut changed = false; egui::ComboBox::new(egui::Id::new("text_combo"), "Text kind") .selected_text(text.text_kind.name()) .show_ui(ui, |ui| { changed |= ui .selectable_value( &mut text.text_kind, TextKind::Ascii, TextKind::Ascii.name(), ) .clicked(); changed |= ui .selectable_value( &mut text.text_kind, TextKind::Utf16Le, TextKind::Utf16Le.name(), ) .clicked(); changed |= ui .selectable_value( &mut text.text_kind, TextKind::Utf16Be, TextKind::Utf16Be.name(), ) .clicked(); }); if changed { view.view.bytes_per_block = text.text_kind.bytes_needed(); } ui.label("Ascii offset"); ui.add(egui::DragValue::new(&mut text.offset)); } } ViewKind::Block => {} } if adjust_block_size { // We expect line spacing to be a positive integer that fits into u16 #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] if let ViewKind::Text(data) = &mut view.view.kind { data.line_spacing = font.line_spacing(u32::from(data.font_size)) as u16; } view.view.adjust_block_size(); } ui.horizontal(|ui| { labelled_drag(ui, "col w", &mut view.view.col_w, 1..=128); labelled_drag(ui, "row h", &mut view.view.row_h, 1..=128); }); labelled_drag( ui, "bytes per block", &mut view.view.bytes_per_block, 1..=64, ); }); if ui.button("Delete").clicked() { removed_idx = Some(self.selected); } } if let Some(rem_key) = removed_idx { app.meta_state.meta.remove_view(rem_key); app.hex_ui.focused_view = None; } } fn title(&self) -> &str { "Views" } } fn new_from_perspective_button(ui: &mut egui::Ui, app: &mut App) { ui.menu_button("New from perspective", |ui| { for (key, perspective) in app.meta_state.meta.low.perspectives.iter() { if ui.button(&perspective.name).clicked() { app.cmd.push(Cmd::CreateView { perspective_key: key, name: perspective.name.to_owned(), }); } } }); } /// Returns whether the value was changed fn view_combo( id: impl Hash, kind: &mut ViewKind, ui: &mut egui::Ui, font_size: u16, line_spacing: u16, ) -> bool { let mut changed = false; egui::ComboBox::new(id, "kind").selected_text(kind.name()).show_ui(ui, |ui| { if ui .selectable_label(kind.name() == ViewKind::HEX_NAME, ViewKind::HEX_NAME) .clicked() { *kind = ViewKind::Hex(HexData::with_font_size(font_size)); changed = true; } if ui .selectable_label(kind.name() == ViewKind::DEC_NAME, ViewKind::DEC_NAME) .clicked() { *kind = ViewKind::Dec(HexData::with_font_size(font_size)); changed = true; } if ui .selectable_label(kind.name() == ViewKind::TEXT_NAME, ViewKind::TEXT_NAME) .clicked() { *kind = ViewKind::Text(TextData::with_font_info(line_spacing, font_size)); changed = true; } if ui .selectable_label(kind.name() == ViewKind::BLOCK_NAME, ViewKind::BLOCK_NAME) .clicked() { *kind = ViewKind::Block; changed = true; } }); changed } fn labelled_drag( ui: &mut egui::Ui, label: &str, val: &mut T, range: impl Into>>, ) -> egui::Response { ui.horizontal(|ui| { ui.label(label); let mut dv = egui::DragValue::new(val); if let Some(range) = range.into() { dv = dv.range(range); } ui.add(dv) }) .inner } ================================================ FILE: src/gui/windows/zero_partition.rs ================================================ use { super::{WinCtx, WindowOpen}, crate::{ gui::egui_ui_ext::EguiResponseExt as _, meta::region::Region, shell::msg_if_fail, util::human_size, }, egui_extras::{Column, TableBuilder}, }; pub struct ZeroPartition { pub open: WindowOpen, threshold: usize, regions: Vec, reload: bool, } impl Default for ZeroPartition { fn default() -> Self { Self { open: Default::default(), threshold: 4096, regions: Default::default(), reload: false, } } } impl super::Window for ZeroPartition { fn ui(&mut self, WinCtx { ui, app, gui, .. }: WinCtx) { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.horizontal(|ui| { ui.label("Threshold"); ui.add(egui::DragValue::new(&mut self.threshold)); if ui.button("Go").clicked() { if self.reload { msg_if_fail(app.reload(), "Failed to reload", &mut gui.msg_dialog); } self.regions = zero_partition(&app.data, self.threshold); } ui.checkbox(&mut self.reload, "reload") .on_hover_text("Auto reload data before partitioning"); if !self.regions.is_empty() { ui.label(format!("{} results", self.regions.len())); } }); if self.regions.is_empty() { return; } ui.separator(); TableBuilder::new(ui) .columns(Column::auto(), 4) .auto_shrink([false, true]) .striped(true) .header(24.0, |mut row| { row.col(|ui| { if ui.button("begin").clicked() { self.regions.sort_by_key(|r| r.begin); } }); row.col(|ui| { if ui.button("end").clicked() { self.regions.sort_by_key(|r| r.end); } }); row.col(|ui| { if ui.button("size").clicked() { self.regions.sort_by_key(|r| r.len()); } }); }) .body(|body| { body.rows(24.0, self.regions.len(), |mut row| { let reg = &self.regions[row.index()]; if reg.contains(app.edit_state.cursor) { row.set_selected(true); } row.col(|ui| { if ui .link(reg.begin.to_string()) .on_hover_text_deferred(|| human_size(reg.begin)) .clicked() { app.search_focus(reg.begin); } }); row.col(|ui| { if ui .link(reg.end.to_string()) .on_hover_text_deferred(|| human_size(reg.end)) .clicked() { app.search_focus(reg.end); } }); row.col(|ui| { ui.label(reg.len().to_string()) .on_hover_text_deferred(|| human_size(reg.len())); }); row.col(|ui| { if ui.button("Select").clicked() { app.hex_ui.select_a = Some(reg.begin); app.hex_ui.select_b = Some(reg.end); } }); }); }); } fn title(&self) -> &str { "Zero partition" } } fn zero_partition(data: &[u8], threshold: usize) -> Vec { if data.is_empty() { return Vec::new(); } let mut regions = Vec::new(); let mut reg = Region { begin: 0, end: 0 }; let mut in_zero = if threshold == 1 { data[0] == 0 } else { false }; let mut zero_counter = 0; for (i, &byte) in data.iter().enumerate() { if byte == 0 { zero_counter += 1; if zero_counter == threshold { if i > threshold && !in_zero { reg.end = i.saturating_sub(threshold); regions.push(reg); } in_zero = true; } } else { zero_counter = 0; if in_zero { in_zero = false; reg.begin = i; } } } if !in_zero { reg.end = data.len() - 1; regions.push(reg); } regions } #[test] fn test_zero_partition() { assert_eq!( zero_partition(&[1, 1, 0, 0, 0, 1, 2, 3], 3), vec![Region { begin: 0, end: 1 }, Region { begin: 5, end: 7 }] ); assert_eq!( zero_partition(&[1, 1, 0, 0, 0, 1, 2, 3], 4), vec![Region { begin: 0, end: 7 }] ); assert_eq!( zero_partition( &[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1], 3 ), vec![ Region { begin: 0, end: 4 }, Region { begin: 11, end: 14 }, Region { begin: 18, end: 18 } ] ); assert_eq!( zero_partition( &[0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1], 1 ), vec![ Region { begin: 1, end: 4 }, Region { begin: 11, end: 14 }, Region { begin: 18, end: 18 } ] ); // head and tail that exceed threshold assert_eq!( zero_partition( &[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 0, 4, 5, 6, 0, 0, 0, 0, 0, 0 ], 4 ), vec![Region { begin: 10, end: 12 }, Region { begin: 17, end: 19 },] ); } ================================================ FILE: src/gui/windows.rs ================================================ pub use self::{ file_diff_result::FileDiffResultWindow, lua_console::{ConMsg, LuaConsoleWindow}, regions::{RegionsWindow, region_context_menu}, }; use { self::{ about::AboutWindow, bookmarks::BookmarksWindow, external_command::ExternalCommandWindow, find_dialog::FindDialog, find_memory_pointers::FindMemoryPointersWindow, layouts::LayoutsWindow, lua_help::LuaHelpWindow, lua_watch::LuaWatchWindow, meta_diff::MetaDiffWindow, open_process::OpenProcessWindow, perspectives::PerspectivesWindow, preferences::PreferencesWindow, script_manager::ScriptManagerWindow, structs::StructsWindow, vars::VarsWindow, views::ViewsWindow, zero_partition::ZeroPartition, }, super::Gui, crate::app::App, egui_sf2g::sf2g::graphics::Font, lua_editor::LuaEditorWindow, }; mod about; mod bookmarks; pub mod debug; mod external_command; mod file_diff_result; mod find_dialog; mod find_memory_pointers; mod layouts; mod lua_console; mod lua_editor; mod lua_help; mod lua_watch; mod meta_diff; mod open_process; mod perspectives; mod preferences; mod regions; mod script_manager; mod structs; mod vars; mod views; mod zero_partition; #[derive(Default)] pub struct Windows { pub layouts: LayoutsWindow, pub views: ViewsWindow, pub regions: RegionsWindow, pub bookmarks: BookmarksWindow, pub find: FindDialog, pub perspectives: PerspectivesWindow, pub file_diff_result: FileDiffResultWindow, pub open_process: OpenProcessWindow, pub find_memory_pointers: FindMemoryPointersWindow, pub external_command: ExternalCommandWindow, pub preferences: PreferencesWindow, pub about: AboutWindow, pub vars: VarsWindow, pub lua_editor: LuaEditorWindow, pub lua_help: LuaHelpWindow, pub lua_console: LuaConsoleWindow, pub lua_watch: Vec, pub script_manager: ScriptManagerWindow, pub meta_diff: MetaDiffWindow, pub zero_partition: ZeroPartition, pub structs: StructsWindow, } #[derive(Default)] pub(crate) struct WindowOpen { open: bool, just_opened: bool, } impl WindowOpen { /// Open if closed, close if opened pub fn toggle(&mut self) { self.open ^= true; if self.open { self.just_opened = true; } } /// Wheter the window is open fn is(&self) -> bool { self.open } /// Set whether the window is open pub fn set(&mut self, open: bool) { if !self.open && open { self.just_opened = true; } self.open = open; } /// Whether the window was opened just now (this frame) fn just_now(&self) -> bool { self.just_opened } } struct WinCtx<'a> { ui: &'a mut egui::Ui, gui: &'a mut Gui, app: &'a mut App, lua: &'a mlua::Lua, font_size: u16, line_spacing: u16, font: &'a Font, } trait Window { fn ui(&mut self, ctx: WinCtx); fn title(&self) -> &str; } impl Windows { pub(crate) fn update( ctx: &egui::Context, gui: &mut Gui, app: &mut App, lua: &mlua::Lua, font_size: u16, line_spacing: u16, font: &Font, ) { let mut open; macro_rules! windows { ($($field:ident,)*) => { $( let mut win = std::mem::take(&mut gui.win.$field); open = win.open.is(); egui::Window::new(win.title()).open(&mut open).show(ctx, |ui| win.ui(WinCtx{ ui, gui, app, lua, font_size, line_spacing, font })); win.open.just_opened = false; if !open { win.open.set(false); } std::mem::swap(&mut gui.win.$field, &mut win); )* }; } windows!( find, regions, bookmarks, layouts, views, vars, perspectives, file_diff_result, meta_diff, open_process, find_memory_pointers, external_command, preferences, lua_editor, lua_help, lua_console, script_manager, about, zero_partition, structs, ); let mut watch_windows = std::mem::take(&mut gui.win.lua_watch); let mut i = 0; watch_windows.retain_mut(|win| { let mut retain = true; egui::Window::new(&win.name) .id(egui::Id::new("watch_w").with(i)) .open(&mut retain) .show(ctx, |ui| { win.ui(WinCtx { ui, gui, app, lua, font_size, line_spacing, font, }); }); i += 1; retain }); std::mem::swap(&mut gui.win.lua_watch, &mut watch_windows); } pub fn add_lua_watch_window(&mut self) { self.lua_watch.push(LuaWatchWindow::default()); } } ================================================ FILE: src/gui.rs ================================================ pub use self::windows::ConMsg; use { self::{ command::GCommandQueue, file_ops::FileOps, inspect_panel::InspectPanel, message_dialog::MessageDialog, windows::Windows, }, crate::{ app::App, config::Style, meta::{ Bookmark, value_type::{self, ValueType}, }, view::{ViewportScalar, ViewportVec}, }, egui::{ FontFamily::{self, Proportional}, FontId, Panel, TextStyle::{Body, Button, Heading, Monospace, Small}, Window, }, egui_colors::Colorix, egui_sf2g::{ SfEgui, sf2g::graphics::{Font, RenderWindow}, }, gamedebug_core::{IMMEDIATE, PERSISTENT}, mlua::Lua, root_ctx_menu::ContextMenu, std::{ any::TypeId, collections::{HashMap, HashSet}, }, }; mod bottom_panel; pub mod command; pub mod dialogs; mod egui_ui_ext; pub mod file_ops; pub mod inspect_panel; pub mod message_dialog; mod ops; pub mod root_ctx_menu; pub mod selection_menu; pub mod top_menu; mod top_panel; pub mod windows; const BOOK_URL: &str = "https://crumblingstatue.github.io/hexerator-book/0.4.0"; type Dialogs = HashMap>; pub type HighlightSet = HashSet; #[derive(Default)] pub struct Gui { pub inspect_panel: InspectPanel, pub dialogs: Dialogs, pub context_menu: Option, pub msg_dialog: MessageDialog, /// What to highlight in addition to selection. Can be updated by various actions that want to highlight stuff pub highlight_set: HighlightSet, pub cmd: GCommandQueue, pub fileops: FileOps, pub win: Windows, pub colorix: Option, pub show_quick_scroll_popup: bool, } pub trait Dialog { fn title(&self) -> &str; /// Do the ui for this dialog. Returns whether to keep this dialog open. fn ui( &mut self, ui: &mut egui::Ui, app: &mut App, gui: &mut Gui, lua: &Lua, font_size: u16, line_spacing: u16, ) -> bool; /// Called when dialog is opened. Can be used to set just-opened flag, etc. fn on_open(&mut self) {} fn has_close_button(&self) -> bool { false } } impl Gui { pub fn add_dialog(gui_dialogs: &mut Dialogs, mut dialog: D) { dialog.on_open(); gui_dialogs.insert(TypeId::of::(), Box::new(dialog)); } } /// The bool indicates whether the application should continue running pub fn do_egui( sf_egui: &mut SfEgui, gui: &mut Gui, app: &mut App, mouse_pos: ViewportVec, lua: &Lua, rwin: &mut RenderWindow, font_size: u16, line_spacing: u16, font: &Font, ) -> anyhow::Result<(egui_sf2g::DrawInput, bool)> { let di = sf_egui.run(rwin, |_rwin, ui| { let mut open = IMMEDIATE.enabled() || PERSISTENT.enabled(); let was_open = open; if open { app.imm_debug_fun(); } Window::new("Debug").open(&mut open).show(ui, windows::debug::ui); if was_open && !open { IMMEDIATE.toggle(); PERSISTENT.toggle(); } gui.msg_dialog.show(ui, &mut app.clipboard, &mut app.cmd); app.flush_command_queue(gui, lua, font_size, line_spacing); Windows::update(ui, gui, app, lua, font_size, line_spacing, font); // Context menu if let Some(menu) = gui.context_menu.take() && root_ctx_menu::show(&menu, ui, app, gui) { gui.context_menu = Some(menu); } // Panels let top_re = Panel::top("top_panel").show_inside(ui, |ui| { top_panel::ui(ui, gui, app, lua, font_size, line_spacing); }); let bot_re = Panel::bottom("bottom_panel") .show_inside(ui, |ui| bottom_panel::ui(ui, app, mouse_pos, gui)); let right_re = Panel::right("right_panel") .show_inside(ui, |ui| inspect_panel::ui(ui, app, gui, mouse_pos)) .response; let padding = 2; app.hex_ui.hex_iface_rect.x = padding; #[expect( clippy::cast_possible_truncation, reason = "Window size can't exceed i16" )] { app.hex_ui.hex_iface_rect.y = top_re.response.rect.bottom() as ViewportScalar + padding; } #[expect( clippy::cast_possible_truncation, reason = "Window size can't exceed i16" )] { app.hex_ui.hex_iface_rect.w = right_re.rect.left() as ViewportScalar - padding * 2; } #[expect( clippy::cast_possible_truncation, reason = "Window size can't exceed i16" )] { app.hex_ui.hex_iface_rect.h = (bot_re.response.rect.top() as ViewportScalar - app.hex_ui.hex_iface_rect.y) - padding * 2; } let mut dialogs = std::mem::take(&mut gui.dialogs); dialogs.retain(|_k, dialog| { let mut retain = true; let mut win = Window::new(dialog.title()) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0., 0.]); let mut open = true; if dialog.has_close_button() { win = win.open(&mut open); } win.show(ui, |ui| { retain = dialog.ui(ui, app, gui, lua, font_size, line_spacing); }); if !open { retain = false; } retain }); std::mem::swap(&mut gui.dialogs, &mut dialogs); // File dialog gui.fileops.update( ui, app, &mut gui.msg_dialog, &mut gui.win.file_diff_result, font_size, line_spacing, ); })?; Ok((di, true)) } pub fn set_font_sizes_ctx(ctx: &egui::Context, style: &Style) { let mut egui_style = (*ctx.global_style()).clone(); set_font_sizes_style(&mut egui_style, style); ctx.set_global_style(egui_style); } pub fn set_font_sizes_style(egui_style: &mut egui::Style, style: &Style) { egui_style.text_styles = [ ( Heading, FontId::new(style.font_sizes.heading.into(), Proportional), ), ( Body, FontId::new(style.font_sizes.body.into(), Proportional), ), ( Monospace, FontId::new(style.font_sizes.monospace.into(), FontFamily::Monospace), ), ( Button, FontId::new(style.font_sizes.button.into(), Proportional), ), ( Small, FontId::new(style.font_sizes.small.into(), Proportional), ), ] .into(); } fn add_new_bookmark(app: &mut App, gui: &mut Gui, byte_off: usize) { let bms = &mut app.meta_state.meta.bookmarks; let idx = bms.len(); bms.push(Bookmark { offset: byte_off, label: format!("New @ offset {byte_off}"), desc: String::new(), value_type: ValueType::U8(value_type::U8), }); gui.win.bookmarks.open.set(true); gui.win.bookmarks.selected = Some(idx); gui.win.bookmarks.edit_name = true; gui.win.bookmarks.focus_text_edit = true; } ================================================ FILE: src/hex_conv.rs ================================================ fn byte_16_digits(byte: u8) -> [u8; 2] { [byte / 16, byte % 16] } #[test] fn test_byte_16_digits() { assert_eq!(byte_16_digits(255), [15, 15]); } pub fn byte_to_hex_digits(byte: u8) -> [u8; 2] { const TABLE: &[u8; 16] = b"0123456789ABCDEF"; let [l, r] = byte_16_digits(byte); [TABLE[l as usize], TABLE[r as usize]] } #[test] fn test_byte_to_hex_digits() { let pairs = [ (255, b"FF"), (0, b"00"), (15, b"0F"), (16, b"10"), (154, b"9A"), (167, b"A7"), (6, b"06"), (64, b"40"), ]; for (byte, hex) in pairs { assert_eq!(byte_to_hex_digits(byte), *hex); } } fn digit_to_byte(digit: u8) -> Option { Some(match digit { b'0' => 0, b'1' => 1, b'2' => 2, b'3' => 3, b'4' => 4, b'5' => 5, b'6' => 6, b'7' => 7, b'8' => 8, b'9' => 9, b'a' | b'A' => 10, b'b' | b'B' => 11, b'c' | b'C' => 12, b'd' | b'D' => 13, b'e' | b'E' => 14, b'f' | b'F' => 15, _ => return None, }) } pub fn merge_hex_halves(first: u8, second: u8) -> Option { Some(digit_to_byte(first)? * 16 + digit_to_byte(second)?) } #[test] fn test_merge_halves() { assert_eq!(merge_hex_halves(b'0', b'0'), Some(0)); assert_eq!(merge_hex_halves(b'0', b'f'), Some(15)); assert_eq!(merge_hex_halves(b'3', b'2'), Some(50)); assert_eq!(merge_hex_halves(b'f', b'0'), Some(240)); assert_eq!(merge_hex_halves(b'f', b'f'), Some(255)); } ================================================ FILE: src/hex_ui.rs ================================================ use { crate::{ app::interact_mode::InteractMode, color::RgbaColor, meta::{LayoutKey, ViewKey, region::Region}, timer::Timer, view::ViewportRect, }, slotmap::Key as _, std::{collections::HashMap, time::Duration}, }; /// State related to the hex view ui, different from the egui gui overlay #[derive(Default)] pub struct HexUi { /// "a" point of selection. Could be smaller or larger than "b". /// The length of selection is absolute difference between a and b pub select_a: Option, /// "b" point of selection. Could be smaller or larger than "a". /// The length of selection is absolute difference between a and b pub select_b: Option, /// Extra selections on top of the a-b selection pub extra_selections: Vec, pub interact_mode: InteractMode = InteractMode::View, pub current_layout: LayoutKey, /// The currently focused view (appears with a yellow border around it) #[doc(alias = "current_view")] pub focused_view: Option, /// The rectangle area that's available for the hex interface pub hex_iface_rect: ViewportRect, pub flash_cursor_timer: Timer, /// Whether to scissor views when drawing them. Useful to disable when debugging rendering. pub scissor_views: bool = true, /// When alt is being held, it shows things like names of views as overlays pub show_alt_overlay: bool, pub rulers: HashMap, /// If `Some`, contains the last byte offset the cursor was clicked at, while lmb is being held down pub lmb_drag_offset: Option, } #[derive(Default)] pub struct Ruler { pub color: RgbaColor = RgbaColor { r: 255, g: 255, b: 0,a: 255}, /// Horizontal offset in pixels pub hoffset: i16, /// Frequency of ruler lines pub freq: u8 = 1, /// If set, it will try to layout ruler based on the struct fields pub struct_idx: Option, } impl HexUi { pub fn selection(&self) -> Option { if let Some(a) = self.select_a && let Some(b) = self.select_b { Some(Region { begin: a.min(b), end: a.max(b), }) } else { None } } pub fn selected_regions(&self) -> impl Iterator { self.selection().into_iter().chain(self.extra_selections.iter().cloned()) } pub fn clear_selections(&mut self) { self.select_a = None; self.select_b = None; self.extra_selections.clear(); } /// Clear existing meta references pub fn clear_meta_refs(&mut self) { self.current_layout = LayoutKey::null(); self.focused_view = None; } pub fn flash_cursor(&mut self) { self.flash_cursor_timer = Timer::set(Duration::from_millis(1500)); } /// If the cursor should be flashing, returns a timer value that can be used to color cursor pub fn cursor_flash_timer(&self) -> Option { #[expect( clippy::cast_possible_truncation, reason = " The duration will never be higher than u32 limit. It doesn't make sense to set the cursor timer to extremely high values, only a few seconds at most. " )] self.flash_cursor_timer.overtime().map(|dur| dur.as_millis() as u32) } } ================================================ FILE: src/input.rs ================================================ use { egui_sf2g::sf2g::window::{Event, Key}, std::collections::HashSet, }; #[derive(Default, Debug)] pub struct Input { key_down: HashSet, } impl Input { pub fn update_from_event(&mut self, event: &Event) { match event { Event::KeyPressed { code, .. } => { self.key_down.insert(*code); } Event::KeyReleased { code, .. } => { self.key_down.remove(code); } _ => {} } } pub fn key_down(&self, key: Key) -> bool { self.key_down.contains(&key) } pub(crate) fn clear(&mut self) { self.key_down.clear(); } } ================================================ FILE: src/layout.rs ================================================ use { crate::{ meta::{PerspectiveMap, RegionMap, ViewKey, ViewMap, region::Region}, view::{ViewportRect, ViewportVec}, }, serde::{Deserialize, Serialize}, std::cmp::{max, min}, }; /// A view layout grid for laying out views. #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Layout { pub name: String, pub view_grid: Vec>, /// Margin around views #[serde(default = "default_margin")] pub margin: ViewportVec, } pub const fn default_margin() -> ViewportVec { ViewportVec { x: 6, y: 6 } } impl Layout { /// Iterate through all view keys pub fn iter(&self) -> impl Iterator + '_ { self.view_grid.iter().flatten().cloned() } pub(crate) fn idx_of_key(&self, key: ViewKey) -> Option<[usize; 2]> { self.view_grid.iter().enumerate().find_map(|(row_idx, row)| { let col_pos = row.iter().position(|k| *k == key)?; Some([row_idx, col_pos]) }) } pub(crate) fn view_containing_region( &self, reg: &Region, meta: &crate::meta::Meta, ) -> Option { self.iter() .find(|view_key| meta.views[*view_key].view.contains_region(reg, meta)) } pub(crate) fn contains_view(&self, key: ViewKey) -> bool { self.iter().any(|k| k == key) } pub(crate) fn remove_view(&mut self, rem_key: ViewKey) { self.view_grid.retain_mut(|row| { row.retain(|view_key| *view_key != rem_key); !row.is_empty() }); } pub(crate) fn remove_dangling(&mut self, map: &ViewMap) { self.view_grid.retain_mut(|row| { row.retain(|view_key| { let mut retain = true; if !map.contains_key(*view_key) { eprintln!( "Removed dangling view {:?} from layout {}", view_key, self.name ); retain = false; } retain }); !row.is_empty() }); } pub(crate) fn change_view_type(&mut self, current: ViewKey, new: ViewKey) { if let Some(current_key) = self.view_grid.iter_mut().flatten().find(|k| **k == current) { *current_key = new; } } } pub fn do_auto_layout( layout: &Layout, view_map: &mut ViewMap, hex_iface_rect: &ViewportRect, perspectives: &PerspectiveMap, regions: &RegionMap, ) { let layout_n_rows = i16::try_from(layout.view_grid.len()).expect("Too many rows in layout"); let mut total_h = 0; // Determine sizes for row in &layout.view_grid { let max_allowed_h = (hex_iface_rect.h - (layout.margin.y * (layout_n_rows + 1))) / layout_n_rows; let row_n_cols = i16::try_from(row.len()).expect("Too many columns in layout"); let mut total_row_w = 0; let mut max_h = 0; for &view_key in row { let max_allowed_w = (hex_iface_rect.w - (layout.margin.x * (row_n_cols + 1))) / row_n_cols; let view = &mut view_map[view_key].view; let max_needed_size = view.max_needed_size(perspectives, regions); let w = min(max_needed_size.x, max_allowed_w); let h = min(max_needed_size.y, max_allowed_h); view.viewport_rect.w = w; total_row_w += w; view.viewport_rect.h = h; max_h = max(max_h, view.viewport_rect.h); } total_h += max_h; // Distribute remaining width to views in order let w_to_fill_viewport = hex_iface_rect.w - (layout.margin.x * (row_n_cols + 1)); let mut w_remaining = w_to_fill_viewport - total_row_w; for &view_key in row { if w_remaining <= 0 { break; } let view = &mut view_map[view_key].view; let max_needed_w = view.max_needed_size(perspectives, regions).x; let missing_for_max_needed = max_needed_w - view.viewport_rect.w; let can_add = min(missing_for_max_needed, w_remaining); view.viewport_rect.w += can_add; w_remaining -= can_add; } } // Distribute remaining height to rows in order let h_to_fill_viewport = hex_iface_rect.h - (layout.margin.y * (layout_n_rows + 1)); let mut h_remaining = h_to_fill_viewport - total_h; for row in &layout.view_grid { if h_remaining <= 0 { break; } let mut max_can_add = 0; for &view_key in row { let view = &mut view_map[view_key].view; let max_needed_h = view.max_needed_size(perspectives, regions).y; let missing_for_max_needed = max_needed_h - view.viewport_rect.h; let can_add = min(missing_for_max_needed, h_remaining); max_can_add = max(max_can_add, can_add); view.viewport_rect.h += can_add; } h_remaining -= max_can_add; } // Lay out let mut x_cursor = hex_iface_rect.x + layout.margin.x; let mut y_cursor = hex_iface_rect.y + layout.margin.y; for row in &layout.view_grid { let mut max_h = 0; for &view_key in row { let view = &mut view_map[view_key].view; view.viewport_rect.x = x_cursor; view.viewport_rect.y = y_cursor; x_cursor += view.viewport_rect.w + layout.margin.x; max_h = max(max_h, view.viewport_rect.h); } x_cursor = hex_iface_rect.x + layout.margin.x; y_cursor += max_h + layout.margin.y; } } ================================================ FILE: src/main.rs ================================================ #![doc(html_no_source)] #![feature( try_blocks, generic_const_exprs, macro_metavar_expr_concat, default_field_values, yeet_expr, cmp_minmax )] #![warn( unused_qualifications, redundant_imports, trivial_casts, trivial_numeric_casts, unsafe_op_in_unsafe_fn, clippy::unwrap_used, clippy::cast_lossless, clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss, clippy::cast_possible_wrap, clippy::panic, clippy::needless_pass_by_ref_mut, clippy::semicolon_if_nothing_returned, clippy::items_after_statements, clippy::unused_trait_names, clippy::undocumented_unsafe_blocks, clippy::uninlined_format_args, clippy::format_push_string, clippy::unnecessary_wraps, clippy::map_unwrap_or, clippy::use_self, clippy::redundant_clone )] #![cfg_attr(test, allow(clippy::unwrap_used))] #![expect( incomplete_features, // It's hard to reconcile lack of partial borrows with few arguments clippy::too_many_arguments )] #![windows_subsystem = "windows"] use { crate::app::App, anyhow::Context as _, args::Args, clap::Parser as _, config::{Config, LoadedConfig, PinnedDir, ProjectDirsExt as _}, constcat::concat, core::f32, egui_colors::{Colorix, tokens::ThemeColor}, egui_file_dialog::PinnedFolder, egui_phosphor::regular as ic, egui_sf2g::{ SfEgui, sf2g::{ graphics::{Color, Font, RenderTarget as _, RenderWindow}, system::Vector2, window::{ContextSettings, Event, Style, VideoMode}, }, }, gui::{Gui, command::GCmd, message_dialog::Icon}, mlua::Lua, std::{ backtrace::{Backtrace, BacktraceStatus}, io::IsTerminal as _, time::Duration, }, }; mod app; mod args; mod backend; mod color; mod config; mod damage_region; mod data; mod dec_conv; pub mod edit_buffer; mod find_util; mod gui; mod hex_conv; mod hex_ui; mod input; mod layout; mod meta; mod meta_state; mod parse_radix; mod plugin; mod result_ext; mod scripting; mod session_prefs; mod shell; mod slice_ext; mod source; mod str_ext; mod struct_meta_item; mod timer; mod update; mod util; mod value_color; mod view; #[cfg(windows)] mod windows; const L_CONTINUE: &str = concat!(ic::WARNING, " Continue"); const L_ABORT: &str = concat!(ic::X_CIRCLE, "Abort"); fn print_version_info() { eprintln!( "Hexerator {} ({} {}), built on {}", env!("CARGO_PKG_VERSION"), env!("VERGEN_GIT_SHA"), env!("VERGEN_GIT_COMMIT_TIMESTAMP"), env!("VERGEN_BUILD_TIMESTAMP") ); } fn try_main() -> anyhow::Result<()> { // Show arg parse diagnostics in GUI window if stderr is not a terminal. // // This is the only way to get arg parse diagnostics on windows, due to windows_subsystem=windows let mut args = if std::io::stderr().is_terminal() { Args::parse() } else { match Args::try_parse() { Ok(args) => args, Err(e) => { do_fatal_error_report( "Arg parse error", &e.to_string(), &Backtrace::force_capture(), ); return Ok(()); } } }; if args.debug { gamedebug_core::IMMEDIATE.set_enabled(true); gamedebug_core::PERSISTENT.set_enabled(true); } if args.version { print_version_info(); return Ok(()); } let desktop_mode = VideoMode::desktop_mode(); let mut window = RenderWindow::new( desktop_mode, "Hexerator", Style::RESIZE | Style::CLOSE, &ContextSettings::default(), )?; let LoadedConfig { config: mut cfg, old_config_err, } = Config::load_or_default()?; window.set_vertical_sync_enabled(cfg.vsync); window.set_framerate_limit(cfg.fps_limit); window.set_position(Vector2::new(0, 0)); let mut sf_egui = SfEgui::new(&window); sf_egui.context().options_mut(|opts| { opts.zoom_with_keyboard = false; }); let mut style = egui::Style::default(); style.interaction.show_tooltips_only_when_still = true; let font = Font::from_memory_static(include_bytes!("../DejaVuSansMono.ttf")) .context("Failed to load font")?; let mut gui = Gui::default(); gui.win.open_process.default_meta_path.clone_from(&args.meta); transfer_pinned_folders_to_file_dialog(&mut gui, &mut cfg); if !args.spawn_command.is_empty() { gui.cmd.push(GCmd::SpawnCommand { args: std::mem::take(&mut args.spawn_command), look_for_proc: args.look_for_proc.take(), }); } if let Some(e) = old_config_err { gui.msg_dialog.open( Icon::Error, "Failed to load old config", format!("Old config failed to load with error: {e}.\n\ If you don't want to overwrite the old config, you should probably not continue."), ); gui.msg_dialog.custom_button_row_ui(Box::new(|ui, payload, _cmd| { if ui.button(L_CONTINUE).clicked() { payload.close = true; } if ui.button(L_ABORT).clicked() { std::process::abort(); } })); } let mut font_defs = egui::FontDefinitions::default(); egui_phosphor::add_to_fonts(&mut font_defs, egui_phosphor::Variant::Regular); egui_fontcfg::load_custom_fonts(&cfg.custom_font_paths, &mut font_defs.font_data)?; // Manually ensure that there are no entries in the font config that don't have font data. // Egui panics if this is the case, but we don't want to panic, so we just remove the offending // font families. cfg.font_families.retain(|_k, v| { let mut retain = true; for font_name in v { if !font_defs.font_data.contains_key(font_name) { eprintln!("Error: No font data for {font_name}. Removing."); retain = false; } } retain }); if !cfg.font_families.is_empty() { font_defs.families = cfg.font_families.clone(); } sf_egui.context().set_fonts(font_defs); let font_size = 14; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "It's extremely unlikely that the line spacing is not between 0..u16::MAX" )] let line_spacing = font.line_spacing(u32::from(font_size)) as u16; let mut app = App::new(args, cfg, font_size, line_spacing, &mut gui)?; let lua = Lua::default(); gui::set_font_sizes_style(&mut style, &app.cfg.style); sf_egui.context().set_global_style(style); // Custom egui_colors theme load if let Some(project_dirs) = config::project_dirs() { let path = project_dirs.color_theme_path(); if path.exists() { match std::fs::read(path) { Ok(data) => { let mut chunks = data.as_chunks().0.iter().copied(); let theme = std::array::from_fn(|_| { ThemeColor::Custom(chunks.next().unwrap_or_default()) }); gui.colorix = Some(Colorix::global(sf_egui.context(), theme)); } Err(e) => { eprintln!("Failed to load custom theme: {e}"); } } } } let mut vertex_buffer = Vec::new(); while window.is_open() { if !update::do_frame( &mut app, &mut gui, &mut sf_egui, &mut window, &mut vertex_buffer, &lua, &font, )? { return Ok(()); } // Save a metafile backup every so often if app.meta_state.last_meta_backup.get().elapsed() >= Duration::from_secs(60) && let Err(e) = app.save_temp_metafile_backup() { gamedebug_core::per!("Failed to save temp metafile backup: {}", e); } } app.close_file(); transfer_pinned_folders_to_config(gui, &mut app); app.cfg.save()?; Ok(()) } fn transfer_pinned_folders_to_file_dialog(gui: &mut Gui, cfg: &mut Config) { let dia_store = gui.fileops.dialog.storage_mut(); // Remove them from the config, as later it will be filled with // the pinned dirs from the dialog for dir in cfg.pinned_dirs.drain(..) { dia_store.pinned_folders.push(PinnedFolder { label: dir.label, path: dir.path, }); } } fn transfer_pinned_folders_to_config(mut gui: Gui, app: &mut App) { let storage = gui.fileops.dialog.storage_mut(); for entry in std::mem::take(&mut storage.pinned_folders) { app.cfg.pinned_dirs.push(PinnedDir { path: entry.path, label: entry.label, }); } } fn main() { std::panic::set_hook(Box::new(|panic_info| { let payload = panic_info.payload(); let msg = if let Some(s) = payload.downcast_ref::<&str>() { s } else if let Some(s) = payload.downcast_ref::() { s } else { "Unknown panic payload" }; let (file, line, column) = match panic_info.location() { Some(loc) => (loc.file(), loc.line().to_string(), loc.column().to_string()), None => ("unknown", "unknown".into(), "unknown".into()), }; let bkpath = app::temp_metafile_backup_path(); let bkpath = bkpath.display(); let btrace = Backtrace::force_capture(); do_fatal_error_report( "Hexerator panic", &format!( "\ {msg}\n\n\ Location:\n\ {file}:{line}:{column}\n\n\ Meta Backup path:\n\ {bkpath}", ), &btrace, ); })); if let Err(e) = try_main() { do_fatal_error_report("Fatal error", &e.to_string(), e.backtrace()); } } fn do_fatal_error_report(title: &str, mut desc: &str, backtrace: &Backtrace) { if std::io::stderr().is_terminal() { eprintln!("== {title} =="); eprintln!("{desc}"); if backtrace.status() == BacktraceStatus::Captured { eprintln!("Backtrace:\n{backtrace}"); } return; } let bt_string = if backtrace.status() == BacktraceStatus::Captured { backtrace.to_string() } else { String::new() }; let mut rw = match RenderWindow::new((800, 600), title, Style::CLOSE, &ContextSettings::default()) { Ok(rw) => rw, Err(e) => { eprintln!("Failed to create RenderWindow: {e}"); return; } }; rw.set_vertical_sync_enabled(true); let mut sf_egui = SfEgui::new(&rw); while rw.is_open() { while let Some(ev) = rw.poll_event() { sf_egui.add_event(&ev); if ev == Event::Closed { rw.close(); } } rw.clear(Color::BLACK); #[expect(clippy::unwrap_used)] let di = sf_egui .run(&mut rw, |rw, ui| { egui::CentralPanel::default().show_inside(ui, |ui| { ui.heading(title); ui.separator(); egui::ScrollArea::vertical().auto_shrink(false).max_height(500.).show( ui, |ui| { ui.add( egui::TextEdit::multiline(&mut desc) .code_editor() .desired_width(f32::INFINITY), ); if !bt_string.is_empty() { ui.heading("Backtrace"); ui.add( egui::TextEdit::multiline(&mut bt_string.as_str()) .code_editor() .desired_width(f32::INFINITY), ); } }, ); ui.separator(); ui.horizontal(|ui| { if ui.button("Copy to clipboard").clicked() { ui.copy_text(desc.to_owned()); } if ui.button("Close").clicked() { rw.close(); } }); }); }) .unwrap(); sf_egui.draw(di, &mut rw, None); rw.display(); } } ================================================ FILE: src/meta/perspective.rs ================================================ use { super::region::Region, crate::meta::{RegionKey, RegionMap}, serde::{Deserialize, Serialize}, }; /// A "perspectived" (column count) view of a region #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct Perspective { /// The associated region pub region: RegionKey, /// Column count, a.k.a alignment. The proper alignment can reveal /// patterns to the human eye that aren't otherwise easily recognizable. pub cols: usize, /// Whether row order is flipped. /// /// Sometimes binary files store images or other data "upside-down". /// A row order flipped perspective helps view and manipulate this kind of data better. pub flip_row_order: bool, pub name: String, } impl Perspective { /// Returns the index of the last row pub(crate) fn last_row_idx(&self, rmap: &RegionMap) -> usize { rmap[self.region].region.end / self.cols } /// Returns the index of the last column pub(crate) fn last_col_idx(&self, rmap: &RegionMap) -> usize { rmap[self.region].region.end % self.cols } pub(crate) fn byte_offset_of_row_col(&self, row: usize, col: usize, rmap: &RegionMap) -> usize { rmap[self.region].region.begin + (row * self.cols + col) } pub(crate) fn row_col_of_byte_offset(&self, offset: usize, rmap: &RegionMap) -> [usize; 2] { let reg = &rmap[self.region]; let offset = offset.saturating_sub(reg.region.begin); [offset / self.cols, offset % self.cols] } /// Whether the columns are within `cols` and the calculated offset is within the region pub(crate) fn row_col_within_bound(&self, row: usize, col: usize, rmap: &RegionMap) -> bool { col < self.cols && rmap[self.region].region.contains(self.byte_offset_of_row_col(row, col, rmap)) } pub(crate) fn clamp_cols(&mut self, rmap: &RegionMap) { self.cols = self.cols.clamp(1, rmap[self.region].region.len()); } /// Returns rows spanned by `region`, and the remainder pub(crate) fn region_row_span(&self, region: Region) -> [usize; 2] { [region.len() / self.cols, region.len() % self.cols] } pub(crate) fn n_rows(&self, rmap: &RegionMap) -> usize { let region = &rmap[self.region].region; let mut rows = region.len() / self.cols; if !region.len().is_multiple_of(self.cols) { rows += 1; } rows } pub(crate) fn from_region(key: RegionKey, name: String) -> Self { Self { region: key, cols: 48, flip_row_order: false, name, } } } ================================================ FILE: src/meta/region.rs ================================================ use serde::{Deserialize, Serialize}; /// An inclusive region spanning `begin` to `end` #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Region { pub begin: usize, pub end: usize, } impl Region { pub fn len(&self) -> usize { // Inclusive, so add 1 to end (self.end + 1).saturating_sub(self.begin) } pub(crate) fn contains(&self, idx: usize) -> bool { (self.begin..=self.end).contains(&idx) } pub(crate) fn contains_region(&self, reg: &Self) -> bool { self.begin <= reg.begin && self.end >= reg.end } pub fn to_range(self) -> std::ops::RangeInclusive { self.begin..=self.end } /// The "previous chunk" with the same length pub fn prev_chunk(self) -> Option { let len = self.len(); self.begin.checked_sub(len).map(|new_begin| Self { begin: new_begin, // INVARIANT: begin < end end: self.end - len, }) } /// The "next chunk" with the same length pub fn next_chunk(self) -> Self { let len = self.len(); Self { begin: self.begin + len, end: self.end + len, } } } ================================================ FILE: src/meta/value_type.rs ================================================ use { serde::{Deserialize, Serialize}, std::collections::HashMap, }; #[derive(Serialize, Deserialize, Clone, Default)] pub enum ValueType { #[default] None, I8(I8), U8(U8), I16Le(I16Le), U16Le(U16Le), I16Be(I16Be), U16Be(U16Be), I32Le(I32Le), U32Le(U32Le), I32Be(I32Be), U32Be(U32Be), I64Le(I64Le), U64Le(U64Le), I64Be(I64Be), U64Be(U64Be), F32Le(F32Le), F32Be(F32Be), F64Le(F64Le), F64Be(F64Be), StringMap(StringMap), } impl PartialEq for ValueType { fn eq(&self, other: &Self) -> bool { core::mem::discriminant(self) == core::mem::discriminant(other) } } pub type StringMap = HashMap; impl ValueType { pub fn label(&self) -> &str { match self { Self::None => "none", Self::I8(v) => v.label(), Self::U8(v) => v.label(), Self::I16Le(v) => v.label(), Self::U16Le(v) => v.label(), Self::I16Be(v) => v.label(), Self::U16Be(v) => v.label(), Self::I32Le(v) => v.label(), Self::U32Le(v) => v.label(), Self::I32Be(v) => v.label(), Self::U32Be(v) => v.label(), Self::I64Le(v) => v.label(), Self::U64Le(v) => v.label(), Self::I64Be(v) => v.label(), Self::U64Be(v) => v.label(), Self::F32Le(v) => v.label(), Self::F32Be(v) => v.label(), Self::F64Le(v) => v.label(), Self::F64Be(v) => v.label(), Self::StringMap(v) => v.label(), } } pub(crate) fn byte_len(&self) -> usize { match self { Self::None => 1, Self::I8(_) => 1, Self::U8(_) => 1, Self::I16Le(_) => 2, Self::U16Le(_) => 2, Self::I16Be(_) => 2, Self::U16Be(_) => 2, Self::I32Le(_) => 4, Self::U32Le(_) => 4, Self::I32Be(_) => 4, Self::U32Be(_) => 4, Self::I64Le(_) => 8, Self::U64Le(_) => 8, Self::I64Be(_) => 8, Self::U64Be(_) => 8, Self::F32Le(_) => 4, Self::F32Be(_) => 4, Self::F64Le(_) => 8, Self::F64Be(_) => 8, Self::StringMap(_) => 1, } } pub fn read(&self, data: &[u8]) -> anyhow::Result { macro_rules! r { ($t:ident $($en:ident)?) => { paste::paste! { ReadValue::$t(read::<[<$t $($en)?>]>(data)?) } } } Ok(match self { Self::None => r!(U8), Self::I8(_) => r!(I8), Self::U8(_) => r!(U8), Self::I16Le(_) => r!(I16 Le), Self::U16Le(_) => r!(U16 Le), Self::I16Be(_) => r!(I16 Be), Self::U16Be(_) => r!(U16 Be), Self::I32Le(_) => r!(I32 Le), Self::U32Le(_) => r!(U32 Le), Self::I32Be(_) => r!(I32 Be), Self::U32Be(_) => r!(U32 Be), Self::I64Le(_) => r!(I64 Le), Self::U64Le(_) => r!(U64 Le), Self::I64Be(_) => r!(I64 Be), Self::U64Be(_) => r!(U64 Be), Self::F32Le(_) => r!(F32 Le), Self::F32Be(_) => r!(F32 Be), Self::F64Le(_) => r!(F64 Le), Self::F64Be(_) => r!(F64 Be), Self::StringMap(_) => r!(U8), }) } } fn read(data: &[u8]) -> Result where [(); P::BYTE_LEN]:, { Ok(P::from_bytes(data[..P::BYTE_LEN].try_into()?)) } pub enum ReadValue { I8(i8), U8(u8), I16(i16), U16(u16), I32(i32), U32(u32), I64(i64), U64(u64), F32(f32), F64(f64), } impl std::fmt::Display for ReadValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::U8(v) => v.fmt(f), Self::I8(v) => v.fmt(f), Self::I16(v) => v.fmt(f), Self::U16(v) => v.fmt(f), Self::I32(v) => v.fmt(f), Self::U32(v) => v.fmt(f), Self::I64(v) => v.fmt(f), Self::U64(v) => v.fmt(f), Self::F32(v) => v.fmt(f), Self::F64(v) => v.fmt(f), } } } pub trait EndianedPrimitive { const BYTE_LEN: usize = size_of::(); type Primitive: egui::emath::Numeric + std::fmt::Display + core::str::FromStr; fn from_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self::Primitive; fn to_bytes(prim: Self::Primitive) -> [u8; Self::BYTE_LEN]; fn label(&self) -> &'static str; fn from_byte_slice(slice: &[u8]) -> Option where [(); Self::BYTE_LEN]:, { match slice.try_into() { Ok(slice) => Some(Self::from_bytes(slice)), Err(_) => None, } } } #[derive(Serialize, Deserialize, Clone)] pub struct I8; impl EndianedPrimitive for I8 { type Primitive = i8; fn from_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self::Primitive { i8::from_ne_bytes(bytes) } fn to_bytes(prim: Self::Primitive) -> [u8; Self::BYTE_LEN] { prim.to_ne_bytes() } fn label(&self) -> &'static str { "i8" } } #[derive(Serialize, Deserialize, Clone)] pub struct U8; impl EndianedPrimitive for U8 { type Primitive = u8; fn from_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self::Primitive { u8::from_ne_bytes(bytes) } fn to_bytes(prim: Self::Primitive) -> [u8; Self::BYTE_LEN] { prim.to_ne_bytes() } fn label(&self) -> &'static str { "u8" } } macro_rules! impl_for_num { ($($wrap:ident => $prim:ident $en:ident,)*) => { $( #[derive(Serialize, Deserialize, Clone)] pub struct $wrap; impl EndianedPrimitive for $wrap { type Primitive = $prim; fn from_bytes(bytes: [u8; Self::BYTE_LEN]) -> Self::Primitive { $prim::${concat(from_, $en, _bytes)}(bytes) } fn to_bytes(prim: Self::Primitive) -> [u8; Self::BYTE_LEN] { prim.${concat(to_, $en, _bytes)}() } fn label(&self) -> &'static str { concat!(stringify!($prim), "-", stringify!($en)) } } )* } } impl_for_num! { I16Le => i16 le, U16Le => u16 le, I16Be => i16 be, U16Be => u16 be, I32Le => i32 le, U32Le => u32 le, I32Be => i32 be, U32Be => u32 be, I64Le => i64 le, U64Le => u64 le, I64Be => i64 be, U64Be => u64 be, F32Le => f32 le, F32Be => f32 be, F64Le => f64 le, F64Be => f64 be, } ================================================ FILE: src/meta.rs ================================================ use { self::{perspective::Perspective, region::Region, value_type::ValueType}, crate::{layout::Layout, struct_meta_item::StructMetaItem, view::View}, serde::{Deserialize, Serialize}, slotmap::{SlotMap, new_key_type}, std::{collections::HashMap, io::Write as _}, }; pub mod perspective; pub mod region; pub mod value_type; new_key_type! { pub struct PerspectiveKey; pub struct RegionKey; pub struct ViewKey; pub struct LayoutKey; pub struct ScriptKey; } pub type PerspectiveMap = SlotMap; pub type RegionMap = SlotMap; pub type ViewMap = SlotMap; pub type LayoutMap = SlotMap; pub type ScriptMap = SlotMap; pub type Bookmarks = Vec; pub trait LayoutMapExt { fn add_new_default(&mut self) -> LayoutKey; } impl LayoutMapExt for LayoutMap { fn add_new_default(&mut self) -> LayoutKey { self.insert(Layout { name: "New layout".into(), view_grid: Vec::new(), margin: crate::layout::default_margin(), }) } } /// A bookmark for an offset in a file #[derive(Serialize, Deserialize, Clone)] pub struct Bookmark { /// Offset the bookmark applies to pub offset: usize, /// Short label pub label: String, /// Extended description pub desc: String, /// A bookmark can optionally have a type, which can be used to display its value, etc. #[serde(default)] pub value_type: ValueType, } impl Bookmark { #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, reason = "Not much we can do about cast errors here" )] pub(crate) fn write_int(&self, mut data: &mut [u8], val: i64) -> std::io::Result<()> { match self.value_type { ValueType::None => Err(std::io::Error::other("Bookmark doesn't have value type")), ValueType::I8(_) => data.write_all(&(val as i8).to_ne_bytes()), ValueType::U8(_) => data.write_all(&(val as u8).to_ne_bytes()), ValueType::I16Le(_) => data.write_all(&(val as i16).to_le_bytes()), ValueType::U16Le(_) => data.write_all(&(val as u16).to_le_bytes()), ValueType::I16Be(_) => data.write_all(&(val as i16).to_be_bytes()), ValueType::U16Be(_) => data.write_all(&(val as u16).to_be_bytes()), ValueType::I32Le(_) => data.write_all(&(val as i32).to_le_bytes()), ValueType::U32Le(_) => data.write_all(&(val as u32).to_le_bytes()), ValueType::I32Be(_) => data.write_all(&(val as i32).to_be_bytes()), ValueType::U32Be(_) => data.write_all(&(val as u32).to_be_bytes()), ValueType::I64Le(_) => data.write_all(&(val).to_le_bytes()), ValueType::U64Le(_) => data.write_all(&(val as u64).to_le_bytes()), ValueType::I64Be(_) => data.write_all(&(val).to_be_bytes()), ValueType::U64Be(_) => data.write_all(&(val as u64).to_be_bytes()), ValueType::F32Le(_) => data.write_all(&(val as f32).to_le_bytes()), ValueType::F32Be(_) => data.write_all(&(val as f32).to_be_bytes()), ValueType::F64Le(_) => data.write_all(&(val as f64).to_le_bytes()), ValueType::F64Be(_) => data.write_all(&(val as f64).to_be_bytes()), ValueType::StringMap(_) => data.write_all(&(val as u8).to_ne_bytes()), } } } /// "Low" region of the meta, containing the least dependent data, like regions and perspectives #[derive(Default, Serialize, Deserialize, Clone)] pub struct MetaLow { pub regions: RegionMap, pub perspectives: PerspectiveMap, } impl MetaLow { pub(crate) fn start_offset_of_view(&self, view: &View) -> usize { let p = &self.perspectives[view.perspective]; self.regions[p.region].region.begin } pub(crate) fn end_offset_of_view(&self, view: &View) -> usize { let p = &self.perspectives[view.perspective]; self.regions[p.region].region.end } } /// Meta-information about a file that the user collects. #[derive(Default, Serialize, Deserialize, Clone)] pub struct Meta { pub low: MetaLow, pub views: ViewMap, pub layouts: LayoutMap, pub bookmarks: Bookmarks, pub misc: Misc, #[serde(default)] pub vars: HashMap, #[serde(default)] pub scripts: ScriptMap, /// Script to execute when a document loads #[serde(default)] pub onload_script: Option, #[serde(default)] pub structs: Vec, } #[derive(Serialize, Deserialize, Clone)] pub struct VarEntry { pub val: VarVal, pub desc: String, } #[derive(Serialize, Deserialize, Clone, PartialEq)] pub enum VarVal { I64(i64), U64(u64), } pub(crate) fn find_most_specific_region_for_offset( regions: &RegionMap, off: usize, ) -> Option { let mut most_specific = None; for (key, reg) in regions.iter() { if reg.region.contains(off) { match &mut most_specific { Some(most_spec_key) => { // A region is more specific if it's smaller let most_spec_reg = ®ions[*most_spec_key]; if reg.region.len() < most_spec_reg.region.len() { *most_spec_key = key; } } None => most_specific = Some(key), } } } most_specific } /// Misc information that's worth saving #[derive(Serialize, Deserialize, Clone)] pub struct Misc { /// Lua script for the "Lua fill" feature. /// /// Worth saving because it can be used for binary file change testing, which can /// take a long time over many sessions. pub fill_lua_script: String, /// Lua script for the "execute script" feature. pub exec_lua_script: String, } impl Default for Misc { fn default() -> Self { Self { fill_lua_script: DEFAULT_FILL.into(), exec_lua_script: String::new(), } } } const DEFAULT_FILL: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/lua/fill.lua")); impl Meta { /// Init required after deserializing pub fn post_load_init(&mut self) { for view in self.views.values_mut() { // Needed to initialize edit buffers, etc. view.view.adjust_state_to_kind(); } } /// Returns offset and reference to a bookmark, if it corresponds to an offset pub fn bookmark_for_offset( meta_bookmarks: &Bookmarks, off: usize, ) -> Option<(usize, &Bookmark)> { meta_bookmarks.iter().enumerate().find(|(_i, b)| b.offset == off) } pub(crate) fn add_region_from_selection(&mut self, sel: Region) -> RegionKey { self.low.regions.insert(NamedRegion::new_from_selection(sel)) } pub(crate) fn remove_view(&mut self, rem_key: ViewKey) { self.views.remove(rem_key); for layout in self.layouts.values_mut() { layout.remove_view(rem_key); } } pub(crate) fn bookmark_by_name_mut(&mut self, name: &str) -> Option<&mut Bookmark> { self.bookmarks.iter_mut().find(|bm| bm.label == name) } pub(crate) fn region_by_name_mut(&mut self, name: &str) -> Option<&mut NamedRegion> { self.low.regions.iter_mut().find_map(|(_k, v)| (v.name == name).then_some(v)) } /// Remove anything that contains dangling keys pub(crate) fn remove_dangling(&mut self) { self.low.perspectives.retain(|_k, v| { let mut retain = true; if !self.low.regions.contains_key(v.region) { eprintln!("Removed dangling perspective '{}'", v.name); retain = false; } retain }); self.views.retain(|_k, v| { let mut retain = true; if !self.low.perspectives.contains_key(v.view.perspective) { eprintln!("Removed dangling view '{}'", v.name); retain = false; } retain }); for layout in self.layouts.values_mut() { layout.remove_dangling(&self.views); } } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct NamedRegion { pub name: String, pub region: Region, #[serde(default)] pub desc: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct NamedView { pub name: String, pub view: View, } impl NamedRegion { pub fn new(name: String, begin: usize, end: usize) -> Self { Self { name, region: Region { begin, end }, desc: String::new(), } } pub fn new_from_selection(sel: Region) -> Self { Self { name: format!("New ({}..={})", sel.begin, sel.end), region: sel, desc: String::new(), } } } #[derive(Serialize, Deserialize, Clone)] pub struct Script { pub name: String, pub desc: String, pub content: String, } ================================================ FILE: src/meta_state.rs ================================================ use { crate::meta::Meta, std::{cell::Cell, path::PathBuf, time::Instant}, }; pub struct MetaState { pub last_meta_backup: Cell, pub current_meta_path: PathBuf, /// Clean copy of the metadata from last load/save pub clean_meta: Meta, pub meta: Meta, } impl Default for MetaState { fn default() -> Self { Self { meta: Meta::default(), clean_meta: Meta::default(), last_meta_backup: Cell::new(Instant::now()), current_meta_path: PathBuf::new(), } } } ================================================ FILE: src/parse_radix.rs ================================================ use num_traits::Num; pub fn parse_guess_radix(input: &str) -> Result::FromStrRadixErr> { if let Some(stripped) = input.strip_prefix("0x") { T::from_str_radix(stripped, 16) } else if input.contains(['a', 'b', 'c', 'd', 'e', 'f']) { T::from_str_radix(input, 16) } else { T::from_str_radix(input, 10) } } /// Relativity of an offset pub enum Relativity { Absolute, RelAdd, RelSub, } pub fn parse_offset_maybe_relative( input: &str, ) -> Result<(usize, Relativity), ::FromStrRadixErr> { Ok(if let Some(stripped) = input.strip_prefix('-') { (parse_guess_radix(stripped.trim_end())?, Relativity::RelSub) } else if let Some(stripped) = input.strip_prefix('+') { (parse_guess_radix(stripped.trim_end())?, Relativity::RelAdd) } else { (parse_guess_radix(input.trim_end())?, Relativity::Absolute) }) } ================================================ FILE: src/plugin.rs ================================================ use { crate::{app::App, meta::PerspectiveKey}, hexerator_plugin_api::{HexeratorHandle, PerspectiveHandle, Plugin, PluginMethod}, slotmap::{Key as _, KeyData}, std::path::PathBuf, }; pub struct PluginContainer { pub path: PathBuf, pub plugin: Box, pub methods: Vec, // Safety: Must be last, fields are dropped in decl order. pub _lib: libloading::Library, } impl HexeratorHandle for App { fn debug_log(&self, msg: &str) { gamedebug_core::per!("{msg}"); } fn get_data(&self, start: usize, end: usize) -> Option<&[u8]> { self.data.get(start..=end) } fn get_data_mut(&mut self, start: usize, end: usize) -> Option<&mut [u8]> { self.data.get_mut(start..=end) } fn selection_range(&self) -> Option<[usize; 2]> { self.hex_ui.selection().map(|sel| [sel.begin, sel.end]) } fn perspective(&self, name: &str) -> Option { let key = self .meta_state .meta .low .perspectives .iter() .find_map(|(k, per)| (per.name == name).then_some(k))?; Some(PerspectiveHandle { key_data: key.data().as_ffi(), }) } fn perspective_rows(&self, ph: &PerspectiveHandle) -> Vec<&[u8]> { let key: PerspectiveKey = KeyData::from_ffi(ph.key_data).into(); let per = &self.meta_state.meta.low.perspectives[key]; let regs = &self.meta_state.meta.low.regions; let mut out = Vec::new(); let n_rows = per.n_rows(regs); for row_idx in 0..n_rows { let begin = per.byte_offset_of_row_col(row_idx, 0, regs); out.push(&self.data[begin..begin + per.cols]); } out } } impl PluginContainer { pub unsafe fn new(path: PathBuf) -> anyhow::Result { // Safety: This will cause UB on a bad plugin. Nothing we can do. // // It's up to the user not to load bad plugins. unsafe { let lib = libloading::Library::new(&path)?; let plugin_init = lib.get:: Box>(b"hexerator_plugin_new")?; let plugin = plugin_init(); Ok(Self { path, methods: plugin.methods(), plugin, _lib: lib, }) } } } ================================================ FILE: src/result_ext.rs ================================================ pub trait AnyhowConv where anyhow::Error: From, { fn how(self) -> anyhow::Result; } impl AnyhowConv for Result where anyhow::Error: From, { fn how(self) -> anyhow::Result { self.map_err(anyhow::Error::from) } } ================================================ FILE: src/scripting.rs ================================================ use { crate::{ app::App, gui::{ConMsg, Gui}, meta::{ Bookmark, NamedRegion, ScriptKey, region::Region, value_type::{self, EndianedPrimitive as _, ValueType}, }, slice_ext::SliceExt as _, }, anyhow::Context as _, mlua::{ExternalError as _, ExternalResult as _, IntoLuaMulti, Lua, UserData}, std::collections::HashMap, }; pub struct LuaExecContext<'app, 'gui> { pub app: &'app mut App, pub gui: &'gui mut Gui, pub key: Option, pub font_size: u16, pub line_spacing: u16, } pub(crate) trait Method { /// Name of the method const NAME: &'static str; /// Help text for the method const HELP: &'static str; /// Stringified API signature for help purposes const API_SIG: &'static str; /// Arguments the method takes when called type Args; /// Return type type Ret: IntoLuaMulti; /// The function that gets called fn call(lua: &Lua, exec: &mut LuaExecContext, args: Self::Args) -> mlua::Result; } macro_rules! def_method { ($help:literal $name:ident($lua:ident, $exec:ident, $($argname:ident: $argty:ty),*) -> $ret:ty $block:block) => { #[allow(non_camel_case_types)] pub(crate) enum $name {} impl Method for $name { const NAME: &'static str = stringify!($name); const HELP: &'static str = $help; const API_SIG: &'static str = concat!(stringify!($name), "(", $(stringify!($argname), ": ", stringify!($argty), ", ",)* ")", " -> ", stringify!($ret)); type Args = ($($argty,)*); type Ret = $ret; fn call($lua: &Lua, $exec: &mut LuaExecContext, ($($argname,)*): ($($argty,)*)) -> mlua::Result<$ret> $block } }; } def_method! { "Adds a region to the meta" add_region(_lua, exec, name: String, begin: usize, end: usize) -> () { exec.app.meta_state.meta.low.regions.insert(NamedRegion { name, desc: String::new(), region: Region { begin, end }, }); Ok(()) } } def_method! { "Loads a file" load_file(_lua, exec, path: String) -> () { exec.app .load_file(path.into(), true, &mut exec.gui.msg_dialog, exec.font_size, exec.line_spacing); Ok(()) } } def_method! { "Sets the value pointed to by the bookmark to an integer value" bookmark_set_int(_lua, exec, name: String, val: i64) -> () { let bm = exec .app .meta_state .meta .bookmark_by_name_mut(&name) .ok_or("no such bookmark".into_lua_err())?; bm.write_int(&mut exec.app.data[bm.offset..], val).map_err(|e| e.into_lua_err())?; Ok(()) } } def_method! { "Fills a named region with a pattern" region_pattern_fill(_lua, exec, name: String, pattern: String) -> () { let reg = exec .app .meta_state .meta .region_by_name_mut(&name) .ok_or("no such region".into_lua_err())?; let pat = crate::find_util::parse_hex_string(&pattern).map_err(|e| e.into_lua_err())?; exec.app.data[reg.region.begin..=reg.region.end].pattern_fill(&pat); Ok(()) } } def_method! { "Returns an array containing the offsets of the find results" find_result_offsets(_lua, exec,) -> Vec { Ok(exec.gui.win.find.results_vec.clone()) } } def_method! { "Reads an unsigned 8 bit integer at `offset`" read_u8(_lua, exec, offset: usize) -> u8 { match exec.app.data.get(offset) { Some(byte) => Ok(*byte), None => Err("out of bounds".into_lua_err()), } } } def_method! { "Sets unsigned 8 bit integer at `offset` to `value`" write_u8(_lua, exec, offset: usize, value: u8) -> () { match exec.app.data.get_mut(offset) { Some(byte) => { *byte = value; Ok(()) } None => Err("out of bounds".into_lua_err()) } } } def_method! { "Reads a little endian unsigned 16 bit integer at `offset`" read_u16_le(_lua, exec, offset: usize) -> u16 { match exec .app .data .get(offset..offset + 2) { Some(slice) => value_type::U16Le::from_byte_slice(slice) .ok_or_else(|| "Failed to convert".into_lua_err()), None => Err("out of bounds".into_lua_err()), } } } def_method! { "Reads a little endian unsigned 32 bit integer at `offset`" read_u32_le(_lua, exec, offset: usize) -> u32 { match exec .app .data .get(offset..offset + 4) { Some(slice) => value_type::U32Le::from_byte_slice(slice) .ok_or_else(|| "Failed to convert".into_lua_err()), None => Err("out of bounds".into_lua_err()), } } } def_method! { "Reads a binary blob at `offset` of length `len`" read_blob(_lua, exec, offset: usize, len: usize) -> Vec { match exec .app .data .get(offset..offset + len) { Some(slice) => Ok(slice.to_vec()), None => Err("out of bounds".into_lua_err()), } } } def_method! { "Saves binary blob `blob` to `path` on the filesystem" save_blob(_lua, _exec, blob: Vec, path: String) -> () { std::fs::write(path, blob).into_lua_err() } } def_method! { "Fills a range from `start` to `end` with the value `fill`" fill_range(_lua, exec, start: usize, end: usize, fill: u8) -> () { match exec .app .data .get_mut(start..end) { Some(slice) => { slice.fill(fill); Ok(()) } None => Err("out of bounds".into_lua_err()), } } } def_method! { "Sets the dirty region to `begin..=end`" set_dirty_region(_lua, exec, begin: usize, end: usize) -> () { exec.app.data.dirty_region = Some(Region { begin, end }); Ok(()) } } def_method! { "Save the currently opened document (its dirty ranges)" save(_lua, exec,) -> () { exec.app.save(&mut exec.gui.msg_dialog).into_lua_err()?; Ok(()) } } def_method! { "Returns the offset pointed to by the bookmark `name`" bookmark_offset(_lua, exec, name: String) -> usize { match exec .app .meta_state .meta .bookmark_by_name_mut(&name) { Some(bm) => Ok(bm.offset), None => Err(format!("no such bookmark: {name}").into_lua_err()), } } } def_method! { "Returns the `beginning`, `end` offsets of region `name`" region(_lua, exec, name: String) -> (usize, usize) { match exec .app .meta_state .meta .region_by_name_mut(&name) { Some(reg) => Ok((reg.region.begin, reg.region.end)), None => Err(format!("no such region: {name}").into_lua_err()), } } } def_method! { "Clears all bookmarks" clear_bookmarks(_lua, exec,) -> () { exec.app.meta_state.meta.bookmarks.clear(); Ok(()) } } def_method! { "Adds a bookmark with name `name`, pointing at `offset`" add_bookmark(_lua, exec, offset: usize, name: String) -> () { exec.app.meta_state.meta.bookmarks.push(Bookmark { offset, label: name, desc: String::new(), value_type: ValueType::None, }); Ok(()) } } def_method! { "Finds a hex string in the format '99 aa bb ...' format, and returns its offset" find_hex_string(_lua, exec, hex_string: String) -> Option { let mut offset = None; crate::find_util::find_hex_string(&hex_string, &exec.app.data, |off| { offset = Some(off); }).into_lua_err()?; Ok(offset) } } def_method! { "Set the cursor to `offset`, center the view on the cursor, and flash the cursor" focus_cursor(_lua, exec, offset: usize) -> () { exec.app.search_focus(offset); Ok(()) } } def_method! { "Reoffsets all bookmarks based on the difference between a bookmark's and the cursor's offsets" reoffset_bookmarks_cursor_diff(_lua, exec, bookmark_name: String) -> () { let bookmark = exec.app.meta_state.meta.bookmark_by_name_mut(&bookmark_name).context("No such bookmark").into_lua_err()?; let offset = bookmark.offset; exec.app.reoffset_bookmarks_cursor_diff(offset); Ok(()) } } def_method! { "Prints to the lua console" log(_lua, exec, value: String) -> () { exec.gui.win.lua_console.open.set(true); exec.gui.win.lua_console.active_msg_buf = exec.key; exec.gui.win.lua_console.msg_buf_for_key(exec.key).push(ConMsg::Plain(value)); Ok(()) } } def_method! { "Prints a clickable offset link to the lua console with an optional text" loffset(_lua, exec, offset: usize, text: Option) -> () { exec.gui.win.lua_console.open.set(true); exec.gui.win.lua_console.active_msg_buf = exec.key; exec.gui.win.lua_console.msg_buf_for_key(exec.key).push(ConMsg::OffsetLink { text: text.map_or(offset.to_string(), |text| format!("{offset}: {text}")), offset }); Ok(()) } } def_method! { "Prints a clickable (inclusive) range link to the lua console with an optional text" lrange(_lua, exec, start: usize, end: usize, text: Option) -> () { exec.gui.win.lua_console.open.set(true); exec.gui.win.lua_console.active_msg_buf = exec.key; let fmt = move || { format!("{start}..={end}")}; exec.gui.win.lua_console.msg_buf_for_key(exec.key).push(ConMsg::RangeLink { text: text.map_or_else(fmt, |text| format!("{}: {text}", fmt())), start, end }); Ok(()) } } def_method! { "Returns the start and end offsets of the selection" selection(_lua, exec,) -> (usize, usize) { exec.app.hex_ui.selection().map(|reg| (reg.begin, reg.end)).context("Selection is empty").into_lua_err() } } def_method! { "Gets a named script as a callable function. `hx:require('myscript')()`" require(lua, exec, name: String) -> mlua::Function { let s = exec.app.meta_state.meta.scripts.values().find(|scr| scr.name == name).ok_or_else(|| "no such script".into_lua_err())?; let chunk = lua.load(&s.content); chunk.into_function() } } def_method! { "Executes another script with the provided (optional) arguments" exec(lua, exec, name: String, args: Option) -> () { let args = args.as_deref().unwrap_or(""); if let Some((key, scr)) = exec.app.meta_state.meta.scripts.iter().find(|(_key, scr)| scr.name == name) { let script = scr.content.clone(); exec_lua(lua, &script, exec.app, exec.gui, args, Some(key), exec.font_size, exec.line_spacing).into_lua_err()?; } Ok(()) } } def_method! { "Calls a plugin method" call_plugin(lua, exec, plugin_name: String, method_name: String, args: mlua::Variadic) -> mlua::Value { let method_args: Vec<_> = args.into_iter().map(lua_plugin_value_conv).collect(); let val = exec.app.call_plugin_method(&plugin_name, &method_name, &method_args).into_lua_err()?; match val { None => Ok(mlua::Value::Nil), Some(val) => Ok(plugin_value_lua_conv(val, lua)?), } } } #[expect(clippy::cast_sign_loss)] fn lua_plugin_value_conv(lval: mlua::Value) -> Option { match lval { mlua::Value::Nil => None, mlua::Value::Boolean(_) => todo!(), mlua::Value::LightUserData(_) => todo!(), mlua::Value::Integer(num) => Some(hexerator_plugin_api::Value::U64(num as u64)), mlua::Value::Number(num) => Some(hexerator_plugin_api::Value::F64(num)), mlua::Value::String(_) => todo!(), mlua::Value::Table(_) => todo!(), mlua::Value::Function(_) => todo!(), mlua::Value::Thread(_) => todo!(), mlua::Value::UserData(_) => todo!(), mlua::Value::Error(_) => todo!(), _ => todo!(), } } #[expect(clippy::cast_precision_loss)] fn plugin_value_lua_conv( pval: hexerator_plugin_api::Value, lua: &Lua, ) -> mlua::Result { match pval { hexerator_plugin_api::Value::U64(num) => Ok(mlua::Value::Number(num as f64)), hexerator_plugin_api::Value::F64(num) => Ok(mlua::Value::Number(num)), hexerator_plugin_api::Value::String(s) => Ok(mlua::Value::String(lua.create_string(s)?)), } } macro_rules! for_each_method { ($m:ident) => { $m!(add_region); $m!(load_file); $m!(bookmark_set_int); $m!(region_pattern_fill); $m!(find_result_offsets); $m!(read_u8); $m!(write_u8); $m!(read_u16_le); $m!(read_u32_le); $m!(read_blob); $m!(save_blob); $m!(fill_range); $m!(set_dirty_region); $m!(save); $m!(bookmark_offset); $m!(region); $m!(clear_bookmarks); $m!(add_bookmark); $m!(find_hex_string); $m!(focus_cursor); $m!(reoffset_bookmarks_cursor_diff); $m!(log); $m!(loffset); $m!(lrange); $m!(selection); $m!(require); $m!(exec); $m!(call_plugin); }; } pub(super) use for_each_method; impl UserData for LuaExecContext<'_, '_> { fn add_methods>(methods: &mut T) { macro_rules! add_method { ($t:ty) => { methods.add_method_mut(<$t>::NAME, <$t>::call) }; } for_each_method!(add_method); } } #[derive(thiserror::Error, Debug)] pub enum ExecLuaError { #[error("Failed to parse arguments: {0}")] ArgParse(#[from] ArgParseError), #[error("Failed to execute lua: {0}")] Lua(#[from] mlua::prelude::LuaError), } pub fn exec_lua( lua: &Lua, lua_script: &str, app: &mut App, gui: &mut Gui, args: &str, key: Option, font_size: u16, line_spacing: u16, ) -> Result, ExecLuaError> { let args_table = lua.create_table()?; if !args.is_empty() { let args = parse_script_args(args)?; for (k, v) in args.into_iter() { match v { ScriptArg::String(s) => args_table.set(k, s)?, ScriptArg::Num(n) => args_table.set(k, n)?, } } } let mut out = None; lua.scope(|scope| { let chunk = lua.load(lua_script); let fun = chunk.into_function()?; let app = scope.create_userdata(LuaExecContext { app: &mut *app, gui, key, font_size, line_spacing, })?; if let Some(env) = fun.environment() { env.set("hx", app)?; env.set("args", args_table)?; } out = fun.call(())?; Ok(()) })?; Ok(out) } #[derive(Debug, PartialEq)] pub enum ScriptArg { String(String), Num(f64), } pub const SCRIPT_ARG_FMT_HELP_STR: &str = "mynum = 4.5, mystring = \"hello\""; #[derive(thiserror::Error, Debug)] pub enum ArgParseError { #[error("Argument must be of format 'a=b'")] ArgNotAEqB, #[error("Unterminated string literal")] UnterminatedString, #[error("Error parsing number: {0}")] NumParse(#[from] std::num::ParseFloatError), #[error("Missing value after assignment")] MissingValue, } /// Parse script arguments pub fn parse_script_args(s: &str) -> Result, ArgParseError> { let mut hm = HashMap::new(); let assignments = s.split(','); for assignment in assignments { match assignment.split_once('=') { Some((lhs, rhs)) => { let key = lhs.trim(); let strval = rhs.trim(); let Some(first_byte) = strval.bytes().next() else { return Err(ArgParseError::MissingValue); }; if let Some(strval) = strval.strip_prefix(['"', '\'']) { let Some(end) = strval.find(first_byte as char) else { return Err(ArgParseError::UnterminatedString); }; hm.insert( key.to_string(), ScriptArg::String(strval[..end].to_string()), ); } else { let num: f64 = strval.parse()?; hm.insert(key.to_string(), ScriptArg::Num(num)); } } None => { return Err(ArgParseError::ArgNotAEqB); } } } Ok(hm) } #[test] #[expect(clippy::unwrap_used)] fn test_parse_script_args() { let args = parse_script_args(SCRIPT_ARG_FMT_HELP_STR).unwrap(); assert_eq!(args.get("mynum"), Some(&ScriptArg::Num(4.5))); assert_eq!( args.get("mystring"), Some(&ScriptArg::String("hello".to_string())) ); } #[test] #[expect(clippy::unwrap_used)] fn test_parse_script_args_single_quot() { let args = parse_script_args(" myval = 'hello world' ").unwrap(); assert_eq!( args.get("myval"), Some(&ScriptArg::String("hello world".to_string())) ); } ================================================ FILE: src/session_prefs.rs ================================================ /// Preferences that only last during the current session, they are not saved #[derive(Debug, Default)] pub struct SessionPrefs { /// Move the edit cursor with the cursor keys, instead of block cursor pub move_edit_cursor: bool, /// Immediately apply changes when editing a value, instead of having /// to type everything or press enter pub quick_edit: bool, /// Don't move the cursor after editing is finished pub sticky_edit: bool, /// Automatically save when editing is finished pub auto_save: bool, /// Keep metadata when loading. pub keep_meta: bool, /// Try to stay on current column when changing column count pub col_change_lock_col: bool, /// Try to stay on current row when changing column count pub col_change_lock_row: bool = true, /// Background color (mostly for fun) pub bg_color: [f32; 3] = [0.0; 3], /// If true, auto-reload the current file at specified interval pub auto_reload: Autoreload = Autoreload::Disabled, /// Auto-reload interval in milliseconds pub auto_reload_interval_ms: u32 = 250, /// Hide the edit cursor pub hide_cursor: bool, } /// Autoreload behavior #[derive(Debug, PartialEq)] pub enum Autoreload { /// No autoreload Disabled, /// Autoreload all data All, /// Only autoreload the data visible in the active layout Visible, } impl Autoreload { /// Whether any autoreload is active pub fn is_active(&self) -> bool { !matches!(self, Self::Disabled) } pub fn label(&self) -> &'static str { match self { Self::Disabled => "disabled", Self::All => "all", Self::Visible => "visible only", } } } ================================================ FILE: src/shell.rs ================================================ use { crate::{ app::App, gui::message_dialog::{Icon, MessageDialog}, }, std::backtrace::Backtrace, }; pub fn open_previous(app: &App, load: &mut Option) { if let Some(src_args) = app.cfg.recent.iter().nth(1) { *load = Some(src_args.clone()); } } pub fn msg_if_fail( result: Result, prefix: &str, msg: &mut MessageDialog, ) -> Option { if let Err(e) = result { msg_fail(&e, prefix, msg); Some(e) } else { None } } pub fn msg_fail(e: &E, prefix: &str, msg: &mut MessageDialog) { msg.open(Icon::Error, "Error", format!("{prefix}: {e:#}")); msg.set_backtrace_for_top(Backtrace::force_capture()); } ================================================ FILE: src/slice_ext.rs ================================================ pub trait SliceExt { fn pattern_fill(&mut self, pattern: &Self); } impl SliceExt for [T] { fn pattern_fill(&mut self, pattern: &Self) { for (src, dst) in pattern.iter().cycle().zip(self.iter_mut()) { *dst = *src; } } } #[test] fn test_pattern_fill() { let mut buf = [0u8; 10]; buf.pattern_fill(b"foo"); assert_eq!(&buf, b"foofoofoof"); buf.pattern_fill(b"Hello, World!"); assert_eq!(&buf, b"Hello, Wor"); } ================================================ FILE: src/source.rs ================================================ use std::{ fs::File, io::{Read, Stdin}, }; #[derive(Debug)] pub enum SourceProvider { File(File), Stdin(Stdin), #[cfg(windows)] WinProc { handle: windows_sys::Win32::Foundation::HANDLE, start: usize, size: usize, }, } /// FIXME: Prove this is actually safe #[cfg(windows)] unsafe impl Send for SourceProvider {} #[derive(Debug)] pub struct Source { pub provider: SourceProvider, pub attr: SourceAttributes, pub state: SourceState, } impl Source { pub fn file(f: File) -> Self { Self { provider: SourceProvider::File(f), attr: SourceAttributes { stream: false, permissions: SourcePermissions { write: true }, }, state: SourceState::default(), } } } #[derive(Debug)] pub struct SourceAttributes { /// Whether reading should be done by streaming pub stream: bool, pub permissions: SourcePermissions, } #[derive(Debug, Default)] pub struct SourceState { /// Whether streaming has finished pub stream_end: bool, } #[derive(Debug)] pub struct SourcePermissions { pub write: bool, } impl Clone for SourceProvider { #[expect( clippy::unwrap_used, reason = "Can't really do much else in clone impl" )] fn clone(&self) -> Self { match self { Self::File(file) => Self::File(file.try_clone().unwrap()), Self::Stdin(_) => Self::Stdin(std::io::stdin()), #[cfg(windows)] Self::WinProc { handle, start, size, } => Self::WinProc { handle: *handle, start: *start, size: *size, }, } } } impl Read for SourceProvider { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { match self { Self::File(f) => f.read(buf), Self::Stdin(stdin) => stdin.read(buf), #[cfg(windows)] SourceProvider::WinProc { .. } => { gamedebug_core::per!("Todo: Read unimplemented"); Ok(0) } } } } ================================================ FILE: src/str_ext.rs ================================================ pub trait StrExt { fn is_empty_or_ws_only(&self) -> bool; } impl StrExt for str { fn is_empty_or_ws_only(&self) -> bool { self.trim().is_empty() } } ================================================ FILE: src/struct_meta_item.rs ================================================ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone)] pub struct StructMetaItem { pub name: String, pub src: String, pub fields: Vec, } impl StructMetaItem { pub fn new(parsed: structparse::Struct, src: String) -> anyhow::Result { let fields: anyhow::Result> = parsed.fields.into_iter().map(try_resolve_field).collect(); Ok(Self { name: parsed.name.to_string(), src, fields: fields?, }) } pub fn fields_with_offsets_mut(&mut self) -> impl Iterator { let mut offset = 0; let mut fields = self.fields.iter_mut(); std::iter::from_fn(move || { let field = fields.next()?; let ty_size = field.ty.size(); let item = (offset, &mut *field); offset += ty_size; Some(item) }) } } fn try_resolve_field(field: structparse::Field) -> anyhow::Result { Ok(StructField { name: field.name.to_string(), ty: try_resolve_ty(field.ty)?, }) } fn try_resolve_ty(ty: structparse::Ty) -> anyhow::Result { match ty { structparse::Ty::Ident(ident) => { let prim = match ident { "i8" => StructPrimitive::I8, "u8" => StructPrimitive::U8, "i16" => StructPrimitive::I16, "u16" => StructPrimitive::U16, "i32" => StructPrimitive::I32, "u32" => StructPrimitive::U32, "i64" => StructPrimitive::I64, "u64" => StructPrimitive::U64, "f32" => StructPrimitive::F32, "f64" => StructPrimitive::F64, _ => anyhow::bail!("Unknown type"), }; Ok(StructTy::Primitive { ty: prim, endian: Endian::Le, }) } structparse::Ty::Array(array) => Ok(StructTy::Array { item_ty: Box::new(try_resolve_ty(*array.ty)?), len: array.len.try_into()?, }), } } #[derive(Serialize, Deserialize, Clone)] pub struct StructField { pub name: String, pub ty: StructTy, } #[derive(Serialize, Deserialize, Clone, Copy)] pub enum Endian { Le, Be, } impl Endian { pub fn label(&self) -> &'static str { match self { Self::Le => "le", Self::Be => "be", } } pub(crate) fn toggle(&mut self) { *self = match self { Self::Le => Self::Be, Self::Be => Self::Le, } } } #[derive(Serialize, Deserialize, Clone)] pub enum StructTy { Primitive { ty: StructPrimitive, endian: Endian }, Array { item_ty: Box, len: usize }, } #[derive(Serialize, Deserialize, Clone)] pub enum StructPrimitive { I8, U8, I16, U16, I32, U32, I64, U64, F32, F64, } impl StructPrimitive { fn label(&self) -> &'static str { match self { Self::I8 => "i8", Self::U8 => "u8", Self::I16 => "i16", Self::U16 => "u16", Self::I32 => "i32", Self::U32 => "u32", Self::I64 => "i64", Self::U64 => "u64", Self::F32 => "f32", Self::F64 => "f64", } } } impl StructTy { pub fn size(&self) -> usize { match self { Self::Primitive { ty, .. } => match ty { StructPrimitive::I8 | StructPrimitive::U8 => 1, StructPrimitive::I16 | StructPrimitive::U16 => 2, StructPrimitive::I32 | StructPrimitive::U32 | StructPrimitive::F32 => 4, StructPrimitive::I64 | StructPrimitive::U64 | StructPrimitive::F64 => 8, }, Self::Array { item_ty, len } => item_ty.size() * *len, } } pub fn endian_mut(&mut self) -> &mut Endian { match self { Self::Primitive { endian, .. } => endian, Self::Array { item_ty, .. } => item_ty.endian_mut(), } } } impl std::fmt::Display for StructTy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Primitive { ty, endian } => { let ty = ty.label(); let endian = endian.label(); write!(f, "{ty}-{endian}") } Self::Array { item_ty, len } => { write!(f, "[{item_ty}; {len}]") } } } } ================================================ FILE: src/timer.rs ================================================ use std::time::{Duration, Instant}; #[derive(Debug)] pub struct Timer { init_point: Instant, duration: Duration, } impl Timer { pub fn set(duration: Duration) -> Self { Self { init_point: Instant::now(), duration, } } pub fn overtime(&self) -> Option { let elapsed = self.init_point.elapsed(); if elapsed > self.duration { None } else { Some(elapsed) } } } impl Default for Timer { fn default() -> Self { Self::set(Duration::ZERO) } } ================================================ FILE: src/update.rs ================================================ use { crate::{ app::{App, interact_mode::InteractMode}, damage_region::DamageRegion, gui::{ self, Gui, dialogs::JumpDialog, message_dialog::{Icon, MessageDialog}, root_ctx_menu::{ContextMenu, ContextMenuData}, }, meta::{self, MetaLow, NamedView, region::Region}, shell::{self, msg_if_fail}, view::{self, ViewportVec, try_conv_mp_zero}, }, egui_file_dialog::DialogState, egui_sf2g::{ SfEgui, sf2g::{ graphics::{ Color, Font, Rect, RenderStates, RenderTarget as _, RenderWindow, Text, Vertex, View, }, window::{Event, Key, mouse}, }, }, gamedebug_core::per, mlua::Lua, slotmap::Key as _, }; #[must_use = "Returns false if application should quit"] pub fn do_frame( app: &mut App, gui: &mut Gui, sf_egui: &mut SfEgui, window: &mut RenderWindow, vertex_buffer: &mut Vec, lua: &Lua, font: &Font, ) -> anyhow::Result { let font_size = 14; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "It's extremely unlikely that the line spacing is not between 0..u16::MAX" )] let line_spacing = font.line_spacing(u32::from(font_size)) as u16; // Handle window events let post_egui_evs = handle_events(gui, app, window, sf_egui, font_size, line_spacing); update(app, sf_egui.context().egui_wants_keyboard_input()); app.update(gui, window, lua, font_size, line_spacing); let mp: ViewportVec = try_conv_mp_zero(window.mouse_position()); let (di, cont) = gui::do_egui( sf_egui, gui, app, mp, lua, window, font_size, line_spacing, font, )?; handle_post_egui_events(post_egui_evs, gui, app, sf_egui); if !cont { return Ok(false); } // Here we flush GUI command queue every frame gui.flush_command_queue(); let [r, g, b] = app.preferences.bg_color; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "These should be in 0-1 range, and it's just bg color. Not that important." )] window.clear(Color::rgb( (r * 255.) as u8, (g * 255.) as u8, (b * 255.) as u8, )); draw(app, gui, window, font, vertex_buffer); if let Some((offs, view_key)) = app.byte_offset_at_pos(mp.x, mp.y) { if let Some(bm) = app.meta_state.meta.bookmarks.iter().find(|bm| bm.offset == offs) { let mut txt = Text::new(bm.label.clone(), font, 20); txt.tf.position = [f32::from(mp.x), f32::from(mp.y + 15)]; txt.draw(window, &RenderStates::DEFAULT); } // Mouse drag selection if let Some(a) = app.hex_ui.lmb_drag_offset && offs != a { if app.input.key_down(Key::LAlt) { // Block multi-selection block_select(app, view_key, a, offs); } else { app.hex_ui.extra_selections.clear(); app.hex_ui.select_a = Some(a); app.hex_ui.select_b = Some(offs); } } } sf_egui.draw(di, window, None); window.display(); gamedebug_core::inc_frame(); if app.quit_requested { return Ok(false); } Ok(true) } /// Some events need to be handled after the egui passes, so we can know if /// egui wanted the keyboard or pointer input. fn handle_post_egui_events( post_egui_evs: Vec, gui: &mut Gui, app: &mut App, sf_egui: &SfEgui, ) { let wants_pointer = sf_egui.context().egui_wants_pointer_input(); for ev in post_egui_evs { match ev { Event::MouseButtonPressed { button, x, y } if !wants_pointer => { let mp = try_conv_mp_zero((x, y)); if app.hex_ui.current_layout.is_null() { continue; } if button == mouse::Button::Left { gui.context_menu = None; if let Some((off, _view_idx)) = app.byte_offset_at_pos(mp.x, mp.y) { app.hex_ui.lmb_drag_offset = Some(off); app.edit_state.set_cursor(off); } if let Some(view_idx) = app.view_idx_at_pos(mp.x, mp.y) { app.hex_ui.focused_view = Some(view_idx); gui.win.views.selected = view_idx; } } else if button == mouse::Button::Right { match app.view_at_pos(mp.x, mp.y) { Some(view_key) => match app.view_byte_offset_at_pos(view_key, mp.x, mp.y) { Some(pos) => { gui.context_menu = Some(ContextMenu::new( mp.x, mp.y, ContextMenuData { view: Some(view_key), byte_off: Some(pos), }, )); } None => { gui.context_menu = Some(ContextMenu::new( mp.x, mp.y, ContextMenuData { view: Some(view_key), byte_off: None, }, )); } }, None => { gui.context_menu = Some(ContextMenu::new( mp.x, mp.y, ContextMenuData { view: None, byte_off: None, }, )); } } } } Event::MouseButtonReleased { button: mouse::Button::Left, .. } => { app.hex_ui.lmb_drag_offset = None; } _ => {} } } } fn update(app: &mut App, egui_wants_kb: bool) { app.try_read_stream(); if app.data.is_empty() { return; } app.hex_ui.show_alt_overlay = app.input.key_down(Key::LAlt); if !egui_wants_kb && app.hex_ui.interact_mode == InteractMode::View && !app.input.key_down(Key::LControl) { let Some(key) = app.hex_ui.focused_view else { return; }; let spd = if app.input.key_down(Key::LShift) { 10 } else { 1 }; if app.input.key_down(Key::Left) { app.meta_state.meta.views[key].view.scroll_x(-spd); } else if app.input.key_down(Key::Right) { app.meta_state.meta.views[key].view.scroll_x(spd); } if app.input.key_down(Key::Up) { app.meta_state.meta.views[key].view.scroll_y(-spd); } else if app.input.key_down(Key::Down) { app.meta_state.meta.views[key].view.scroll_y(spd); } } // Sync all other views to active view if let Some(key) = app.hex_ui.focused_view { let src = &app.meta_state.meta.views[key].view; let src_perspective = src.perspective; let (src_row, src_col) = (src.scroll_offset.row(), src.scroll_offset.col()); let (src_yoff, src_xoff) = (src.scroll_offset.pix_yoff(), src.scroll_offset.pix_xoff()); let (src_row_h, src_col_w) = (src.row_h, src.col_w); for NamedView { view, name: _ } in app.meta_state.meta.views.values_mut() { // Only sync views that have the same perspective if view.perspective != src_perspective { continue; } view.sync_to(src_row, src_yoff, src_col, src_xoff, src_row_h, src_col_w); // Also clamp view ranges if view.scroll_offset.row == 0 && view.scroll_offset.pix_yoff < 0 { view.scroll_offset.pix_yoff = 0; } if view.scroll_offset.col == 0 && view.scroll_offset.pix_xoff < 0 { view.scroll_offset.pix_xoff = 0; } let Some(per) = &app.meta_state.meta.low.perspectives.get(view.perspective) else { per!("View doesn't have a perspective. Probably a bug."); continue; }; if view.cols() < 0 { per!("view.cols for some reason is less than 0. Probably a bug."); return; } if view.scroll_offset.col + 1 > per.cols { view.scroll_offset.col = per.cols - 1; view.scroll_offset.pix_xoff = 0; } if view.scroll_offset.row + 1 > per.n_rows(&app.meta_state.meta.low.regions) { view.scroll_offset.row = per.n_rows(&app.meta_state.meta.low.regions).saturating_sub(1); view.scroll_offset.pix_yoff = 0; } } } } fn draw( app: &App, gui: &Gui, window: &mut RenderWindow, font: &Font, vertex_buffer: &mut Vec, ) { if app.hex_ui.current_layout.is_null() { let mut t = Text::new("No active layout".into(), font, 20); t.tf.position = [ f32::from(app.hex_ui.hex_iface_rect.x), f32::from(app.hex_ui.hex_iface_rect.y), ]; t.draw(window, &RenderStates::DEFAULT); return; } for view_key in app.meta_state.meta.layouts[app.hex_ui.current_layout].iter() { view::View::draw(view_key, app, gui, window, vertex_buffer, font); } } /// Returns events that should be processed post-egui #[must_use] fn handle_events( gui: &mut Gui, app: &mut App, window: &mut RenderWindow, sf_egui: &mut SfEgui, font_size: u16, line_spacing: u16, ) -> Vec { let mut post_egui = Vec::new(); while let Some(event) = window.poll_event() { let egui_ctx = sf_egui.context(); let wants_pointer = egui_ctx.egui_wants_pointer_input(); let wants_kb = egui_ctx.egui_wants_keyboard_input() || matches!(gui.fileops.dialog.state(), DialogState::Open); let block_event_from_egui = (matches!(event, Event::KeyPressed { code: Key::Tab, .. }) && !(wants_kb || wants_pointer)); if !block_event_from_egui { sf_egui.add_event(&event); } if wants_kb { if event == Event::Closed { window.close(); } app.input.clear(); continue; } app.input.update_from_event(&event); match event { Event::Closed => window.close(), Event::KeyPressed { code, shift, ctrl, alt, .. } => handle_key_pressed( code, gui, app, KeyMod { ctrl, shift, alt }, wants_kb, font_size, line_spacing, ), Event::TextEntered { unicode } => { handle_text_entered(app, unicode, &mut gui.msg_dialog); } Event::MouseButtonPressed { .. } | Event::MouseButtonReleased { .. } => { post_egui.push(event); } Event::LostFocus => { // When alt-tabbing, keys held down can get "stuck", because the key release events won't reach us app.input.clear(); } Event::Resized { mut width, mut height, } => { const MIN_WINDOW_W: u32 = 920; const MIN_WINDOW_H: u32 = 620; let mut needs_window_resize = false; if width < MIN_WINDOW_W { width = MIN_WINDOW_W; needs_window_resize = true; } if height < MIN_WINDOW_H { height = MIN_WINDOW_H; needs_window_resize = true; } if needs_window_resize { window.set_size((width, height)); } #[expect( clippy::cast_precision_loss, reason = "Window sizes larger than i16::MAX aren't supported." )] match View::from_rect(Rect::new(0., 0., width as f32, height as f32)) { Ok(view) => window.set_view(&view), Err(e) => { gamedebug_core::per!("Failed to create view: {e}"); } } } _ => {} } } post_egui } fn handle_text_entered(app: &mut App, unicode: char, msg: &mut MessageDialog) { if Key::LControl.is_pressed() || Key::LAlt.is_pressed() { return; } match app.hex_ui.interact_mode { InteractMode::Edit => { let Some(focused) = app.hex_ui.focused_view else { return; }; let view = &mut app.meta_state.meta.views[focused].view; view.handle_text_entered( unicode, &mut app.edit_state, &app.preferences, &mut app.data, msg, ); keep_cursor_in_view(view, &app.meta_state.meta.low, app.edit_state.cursor); } InteractMode::View => {} } } struct KeyMod { ctrl: bool, shift: bool, alt: bool, } fn handle_key_pressed( code: Key, gui: &mut Gui, app: &mut App, key_mod: KeyMod, egui_wants_kb: bool, font_size: u16, line_spacing: u16, ) { if code == Key::F12 && !key_mod.shift && !key_mod.ctrl && !key_mod.alt { gamedebug_core::IMMEDIATE.toggle(); gamedebug_core::PERSISTENT.toggle(); } if egui_wants_kb { return; } // Key bindings that should work without any file open match code { Key::O if key_mod.ctrl => { gui.fileops.load_file(app.source_file()); } _ => {} } if app.data.is_empty() { return; } let editing_text = app.hex_ui.interact_mode == InteractMode::Edit && app.focused_view_mut().is_some_and(|(_k, view)| view.kind.is_text()); // Key bindings that should only work with a file open match code { Key::Up => match app.hex_ui.interact_mode { InteractMode::View => { if key_mod.ctrl && let Some(view_key) = app.hex_ui.focused_view { let key = app.meta_state.meta.views[view_key].view.perspective; let reg = &mut app.meta_state.meta.low.regions [app.meta_state.meta.low.perspectives[key].region] .region; reg.begin = reg.begin.saturating_sub(1); } } InteractMode::Edit => { if let Some(view_key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[view_key].view; view.undirty_edit_buffer(); app.edit_state.set_cursor_no_history(app.edit_state.cursor.saturating_sub( app.meta_state.meta.low.perspectives[view.perspective].cols, )); keep_cursor_in_view(view, &app.meta_state.meta.low, app.edit_state.cursor); } } }, Key::Down => match app.hex_ui.interact_mode { InteractMode::View => { if key_mod.ctrl && let Some(view_key) = app.hex_ui.focused_view { let key = app.meta_state.meta.views[view_key].view.perspective; app.meta_state.meta.low.regions [app.meta_state.meta.low.perspectives[key].region] .region .begin += 1; } } InteractMode::Edit => { if let Some(view_key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[view_key].view; view.undirty_edit_buffer(); if app.edit_state.cursor + app.meta_state.meta.low.perspectives[view.perspective].cols < app.data.len() { app.edit_state.offset_cursor( app.meta_state.meta.low.perspectives[view.perspective].cols, ); } keep_cursor_in_view(view, &app.meta_state.meta.low, app.edit_state.cursor); } } }, Key::Left => 'block: { if key_mod.alt { app.cursor_history_back(); break 'block; } if app.hex_ui.interact_mode == InteractMode::Edit { let move_edit = (app.preferences.move_edit_cursor && !key_mod.ctrl) || (!app.preferences.move_edit_cursor && key_mod.ctrl); if let Some(view_key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[view_key]; if move_edit { if let Some(edit_buf) = view.view.edit_buffer_mut() && !edit_buf.move_cursor_back() { edit_buf.move_cursor_end(); edit_buf.dirty = false; app.edit_state.step_cursor_back(); } } else { app.edit_state.step_cursor_back(); keep_cursor_in_view( &mut view.view, &app.meta_state.meta.low, app.edit_state.cursor, ); } } } else if key_mod.ctrl { if key_mod.shift { app.halve_cols(); } else { app.dec_cols(); } } } Key::Right => 'block: { if key_mod.alt { app.cursor_history_forward(); break 'block; } if app.hex_ui.interact_mode == InteractMode::Edit && app.edit_state.cursor + 1 < app.data.len() { let move_edit = (app.preferences.move_edit_cursor && !key_mod.ctrl) || (!app.preferences.move_edit_cursor && key_mod.ctrl); if let Some(view_key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[view_key]; if move_edit { if let Some(edit_buf) = &mut view.view.edit_buffer_mut() && !edit_buf.move_cursor_forward() { edit_buf.move_cursor_begin(); edit_buf.dirty = false; app.edit_state.step_cursor_forward(); } } else { app.edit_state.step_cursor_forward(); keep_cursor_in_view( &mut view.view, &app.meta_state.meta.low, app.edit_state.cursor, ); } } } else if key_mod.ctrl { if key_mod.shift { app.double_cols(); } else { app.inc_cols(); } } } Key::PageUp => { if let Some(key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[key].view; let per = &app.meta_state.meta.low.perspectives[view.perspective]; match app.hex_ui.interact_mode { InteractMode::View => { view.scroll_page_up(); } InteractMode::Edit => { #[expect(clippy::cast_sign_loss, reason = "view::rows is never negative")] { app.edit_state.cursor = app .edit_state .cursor .saturating_sub(view.rows() as usize * per.cols); } keep_cursor_in_view(view, &app.meta_state.meta.low, app.edit_state.cursor); } } } } Key::PageDown => { if let Some(key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[key].view; let per = &app.meta_state.meta.low.perspectives[view.perspective]; match app.hex_ui.interact_mode { InteractMode::View => { app.meta_state.meta.views[key].view.scroll_page_down(); } InteractMode::Edit => { #[expect(clippy::cast_sign_loss, reason = "view::rows is never negative")] { app.edit_state.cursor = app .edit_state .cursor .saturating_add(view.rows() as usize * per.cols); } keep_cursor_in_view(view, &app.meta_state.meta.low, app.edit_state.cursor); } } } } Key::Home => { if let Some(key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[key].view; match app.hex_ui.interact_mode { InteractMode::View if key_mod.ctrl => { view.go_home(); } InteractMode::View => { view.go_home_col(); } InteractMode::Edit if key_mod.ctrl => { view.go_home(); app.edit_state.cursor = app.meta_state.meta.low.start_offset_of_view(view); } InteractMode::Edit => { if let Some(row_start) = app.find_row_start(app.edit_state.cursor) { app.edit_state.cursor = row_start; keep_cursor_in_view( &mut app.meta_state.meta.views[key].view, &app.meta_state.meta.low, app.edit_state.cursor, ); } } } } } Key::End => { if let Some(key) = app.hex_ui.focused_view { let view = &mut app.meta_state.meta.views[key].view; match app.hex_ui.interact_mode { InteractMode::View if key_mod.ctrl => { view.scroll_to_end(&app.meta_state.meta.low); } InteractMode::View => { view.scroll_right_until_bump(&app.meta_state.meta.low); } InteractMode::Edit if key_mod.ctrl => { app.edit_state.cursor = app.meta_state.meta.low.end_offset_of_view(view); app.center_view_on_offset(app.edit_state.cursor); } InteractMode::Edit => { if let Some(row_end) = app.find_row_end(app.edit_state.cursor) { app.edit_state.cursor = row_end; keep_cursor_in_view( &mut app.meta_state.meta.views[key].view, &app.meta_state.meta.low, app.edit_state.cursor, ); } } } } } Key::Delete => { let mut any = false; for sel in app.hex_ui.selected_regions() { app.data.zero_fill_region(sel); any = true; } if !any && let Some(byte) = app.data.get_mut(app.edit_state.cursor) { *byte = 0; app.data.widen_dirty_region(DamageRegion::Single(app.edit_state.cursor)); } } Key::F1 => app.hex_ui.interact_mode = InteractMode::View, Key::F2 => app.hex_ui.interact_mode = InteractMode::Edit, Key::F5 => gui.win.layouts.open.toggle(), Key::F6 => gui.win.views.open.toggle(), Key::F7 => gui.win.perspectives.open.toggle(), Key::F8 => gui.win.regions.open.toggle(), Key::F9 => gui.win.bookmarks.open.toggle(), Key::F10 => gui.win.vars.open.toggle(), Key::F11 => gui.win.structs.open.toggle(), Key::Escape => { gui.context_menu = None; if let Some(view_key) = app.hex_ui.focused_view { app.meta_state.meta.views[view_key].view.cancel_editing(); } app.hex_ui.clear_selections(); } Key::Enter => { if let Some(view_key) = app.hex_ui.focused_view { app.meta_state.meta.views[view_key].view.finish_editing( &mut app.edit_state, &mut app.data, &app.preferences, &mut gui.msg_dialog, ); } } Key::A if key_mod.ctrl => { app.focused_view_select_all(); } Key::E if key_mod.ctrl => { gui.win.external_command.open.set(true); } Key::F if key_mod.ctrl => { gui.win.find.open.toggle(); } Key::S if key_mod.ctrl => match &mut app.source { Some(source) => { if !source.attr.permissions.write { gui.msg_dialog.open( Icon::Warn, "Cannot save", "This source cannot be written to.", ); } else { msg_if_fail( app.save(&mut gui.msg_dialog), "Failed to save", &mut gui.msg_dialog, ); } } None => gui.msg_dialog.open(Icon::Warn, "Cannot save", "No source opened"), }, Key::M if key_mod.ctrl => { msg_if_fail( app.save_meta(), "Failed to save metafile", &mut gui.msg_dialog, ); } Key::R if key_mod.ctrl => { msg_if_fail(app.reload(), "Failed to reload", &mut gui.msg_dialog); } Key::P if key_mod.ctrl => { let mut load = None; shell::open_previous(app, &mut load); if let Some(args) = load { app.load_file_args( args, None, &mut gui.msg_dialog, font_size, line_spacing, None, ); } } Key::W if key_mod.ctrl => app.close_file(), Key::J if key_mod.ctrl => Gui::add_dialog(&mut gui.dialogs, JumpDialog::default()), Key::Num1 if key_mod.shift => { if !editing_text { app.hex_ui.select_a = Some(app.edit_state.cursor); } } Key::Num2 if key_mod.shift => { if !editing_text { app.hex_ui.select_b = Some(app.edit_state.cursor); } } // Block selection with alt+1/2 Key::Num1 if key_mod.alt => { if let Some(b) = app.hex_ui.select_b && let Some((view_key, _)) = app.focused_view_mut() { block_select(app, view_key, app.edit_state.cursor, b); } else { app.hex_ui.select_a = Some(app.edit_state.cursor); } } Key::Num2 if key_mod.alt => { if let Some(a) = app.hex_ui.select_a && let Some((view_key, _)) = app.focused_view_mut() { block_select(app, view_key, app.edit_state.cursor, a); } else { app.hex_ui.select_b = Some(app.edit_state.cursor); } } Key::Tab if key_mod.shift => app.focus_prev_view_in_layout(), Key::Tab => app.focus_next_view_in_layout(), Key::Equal if key_mod.ctrl => app.inc_byte_or_bytes(), Key::Hyphen if key_mod.ctrl => app.dec_byte_or_bytes(), _ => {} } } fn keep_cursor_in_view(view: &mut view::View, meta_low: &MetaLow, cursor: usize) { let view_offs = view.offsets(&meta_low.perspectives, &meta_low.regions); let [cur_row, cur_col] = meta_low.perspectives[view.perspective].row_col_of_byte_offset(cursor, &meta_low.regions); view.scroll_offset.pix_xoff = 0; view.scroll_offset.pix_yoff = 0; if view_offs.row > cur_row { view.scroll_offset.row = cur_row; } #[expect(clippy::cast_sign_loss, reason = "rows is always unsigned")] let view_rows = view.rows() as usize; if (view_offs.row + view_rows) < cur_row.saturating_add(1) { view.scroll_offset.row = (cur_row + 1) - view_rows; } if view_offs.col > cur_col { view.scroll_offset.col = cur_col; } #[expect(clippy::cast_sign_loss, reason = "cols is always unsigned")] let view_cols = view.cols() as usize; if (view_offs.col + view_cols + 1) < cur_col { view.scroll_offset.col = (cur_col - view_cols) + 1; } } fn block_select(app: &mut App, view_key: meta::ViewKey, a: usize, b: usize) { let view = &app.meta_state.meta.views[view_key]; let per = &app.meta_state.meta.low.perspectives[view.view.perspective]; let [a_row, a_col] = per.row_col_of_byte_offset(a, &app.meta_state.meta.low.regions); let [b_row, b_col] = per.row_col_of_byte_offset(b, &app.meta_state.meta.low.regions); let [min_row, max_row] = std::cmp::minmax(a_row, b_row); let [min_col, max_col] = std::cmp::minmax(a_col, b_col); let mut rows = min_row..=max_row; if let Some(row) = rows.next() { let a = per.byte_offset_of_row_col(row, min_col, &app.meta_state.meta.low.regions); app.hex_ui.select_a = Some(a); let b = per.byte_offset_of_row_col(row, max_col, &app.meta_state.meta.low.regions); app.hex_ui.select_b = Some(b); } app.hex_ui.extra_selections.clear(); for row in rows { let a = per.byte_offset_of_row_col(row, min_col, &app.meta_state.meta.low.regions); let b = per.byte_offset_of_row_col(row, max_col, &app.meta_state.meta.low.regions); app.hex_ui.extra_selections.push(Region { begin: a, end: b }); } } ================================================ FILE: src/util.rs ================================================ #[expect( clippy::cast_precision_loss, reason = "This is just an approximation of data size" )] pub fn human_size(size: usize) -> String { human_bytes::human_bytes(size as f64) } #[expect( clippy::cast_precision_loss, reason = "This is just an approximation of data size" )] pub fn human_size_u64(size: u64) -> String { human_bytes::human_bytes(size as f64) } ================================================ FILE: src/value_color.rs ================================================ use { crate::color::{RgbColor, rgb}, serde::{Deserialize, Serialize}, serde_big_array::BigArray, std::path::Path, }; #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] pub enum ColorMethod { Mono(RgbColor), Default, Pure, Rgb332, Vga13h, BrightScale(RgbColor), Custom(Box), } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct Palette(#[serde(with = "BigArray")] pub [[u8; 3]; 256]); pub fn load_palette(path: &Path) -> anyhow::Result { let raw_bytes = std::fs::read(path)?; if raw_bytes.len() != size_of::() { anyhow::bail!("File for palette not the correct size"); } let mut pal = Palette([[0u8; 3]; 256]); for (rgb, pal_slot) in raw_bytes.as_chunks::<3>().0.iter().zip(pal.0.iter_mut()) { *pal_slot = *rgb; } Ok(pal) } pub fn save_palette(pal: &Palette, path: &Path) -> anyhow::Result<()> { let raw_bytes: &[u8] = pal.0.as_flattened(); Ok(std::fs::write(path, raw_bytes)?) } impl ColorMethod { #[must_use] pub fn byte_color(&self, byte: u8, invert: bool) -> RgbColor { let color = match self { Self::Mono(color) => *color, Self::Default => default_color(byte), Self::Pure => hue_color(byte), Self::Rgb332 => rgb332_color(byte), Self::Vga13h => vga_13h_color(byte), Self::BrightScale(color) => color.cap_brightness(byte), Self::Custom(pal) => { let [r, g, b] = pal.0[byte as usize]; rgb(r, g, b) } }; if invert { color.invert() } else { color } } pub(crate) fn name(&self) -> &str { match self { Self::Mono(_) => "monochrome", Self::Default => "default", Self::Pure => "pure hue", Self::Rgb332 => "rgb 3-3-2", Self::Vga13h => "VGA 13h", Self::BrightScale(_) => "brightness scale", Self::Custom(_) => "custom", } } } fn vga_13h_color(byte: u8) -> RgbColor { let c24 = VGA_13H_PALETTE[byte as usize]; let r = c24 >> 16; let g = c24 >> 8; let b = c24; #[expect( clippy::cast_possible_truncation, reason = "This is just playing around with colors. Non-critical." )] rgb(r as u8, g as u8, b as u8) } fn rgb332_color(byte: u8) -> RgbColor { let r = byte & 0b11100000; let g = byte & 0b00011100; let b = byte & 0b00000011; rgb((r >> 5) * 32, (g >> 2) * 32, b * 64) } const VGA_13H_PALETTE: [u32; 256] = [ 0x000000, 0x0000a8, 0x00a800, 0x00a8a8, 0xa80000, 0xa800a8, 0xa85400, 0xa8a8a8, 0x545454, 0x5454fc, 0x54fc54, 0x54fcfc, 0xfc5454, 0xfc54fc, 0xfcfc54, 0xfcfcfc, 0x000000, 0x141414, 0x202020, 0x2c2c2c, 0x383838, 0x444444, 0x505050, 0x606060, 0x707070, 0x808080, 0x909090, 0xa0a0a0, 0xb4b4b4, 0xc8c8c8, 0xe0e0e0, 0xfcfcfc, 0x0000fc, 0x4000fc, 0x7c00fc, 0xbc00fc, 0xfc00fc, 0xfc00bc, 0xfc007c, 0xfc0040, 0xfc0000, 0xfc4000, 0xfc7c00, 0xfcbc00, 0xfcfc00, 0xbcfc00, 0x7cfc00, 0x40fc00, 0x00fc00, 0x00fc40, 0x00fc7c, 0x00fcbc, 0x00fcfc, 0x00bcfc, 0x007cfc, 0x0040fc, 0x7c7cfc, 0x9c7cfc, 0xbc7cfc, 0xdc7cfc, 0xfc7cfc, 0xfc7cdc, 0xfc7cbc, 0xfc7c9c, 0xfc7c7c, 0xfc9c7c, 0xfcbc7c, 0xfcdc7c, 0xfcfc7c, 0xdcfc7c, 0xbcfc7c, 0x9cfc7c, 0x7cfc7c, 0x7cfc9c, 0x7cfcbc, 0x7cfcdc, 0x7cfcfc, 0x7cdcfc, 0x7cbcfc, 0x7c9cfc, 0xb4b4fc, 0xc4b4fc, 0xd8b4fc, 0xe8b4fc, 0xfcb4fc, 0xfcb4e8, 0xfcb4d8, 0xfcb4c4, 0xfcb4b4, 0xfcc4b4, 0xfcd8b4, 0xfce8b4, 0xfcfcb4, 0xe8fcb4, 0xd8fcb4, 0xc4fcb4, 0xb4fcb4, 0xb4fcc4, 0xb4fcd8, 0xb4fce8, 0xb4fcfc, 0xb4e8fc, 0xb4d8fc, 0xb4c4fc, 0x000070, 0x1c0070, 0x380070, 0x540070, 0x700070, 0x700054, 0x700038, 0x70001c, 0x700000, 0x701c00, 0x703800, 0x705400, 0x707000, 0x547000, 0x387000, 0x1c7000, 0x007000, 0x00701c, 0x007038, 0x007054, 0x007070, 0x005470, 0x003870, 0x001c70, 0x383870, 0x443870, 0x543870, 0x603870, 0x703870, 0x703860, 0x703854, 0x703844, 0x703838, 0x704438, 0x705438, 0x706038, 0x707038, 0x607038, 0x547038, 0x447038, 0x387038, 0x387044, 0x387054, 0x387060, 0x387070, 0x386070, 0x385470, 0x384470, 0x505070, 0x585070, 0x605070, 0x685070, 0x705070, 0x705068, 0x705060, 0x705058, 0x705050, 0x705850, 0x706050, 0x706850, 0x707050, 0x687050, 0x607050, 0x587050, 0x507050, 0x507058, 0x507060, 0x507068, 0x507070, 0x506870, 0x506070, 0x505870, 0x000040, 0x100040, 0x200040, 0x300040, 0x400040, 0x400030, 0x400020, 0x400010, 0x400000, 0x401000, 0x402000, 0x403000, 0x404000, 0x304000, 0x204000, 0x104000, 0x004000, 0x004010, 0x004020, 0x004030, 0x004040, 0x003040, 0x002040, 0x001040, 0x202040, 0x282040, 0x302040, 0x382040, 0x402040, 0x402038, 0x402030, 0x402028, 0x402020, 0x402820, 0x403020, 0x403820, 0x404020, 0x384020, 0x304020, 0x284020, 0x204020, 0x204028, 0x204030, 0x204038, 0x204040, 0x203840, 0x203040, 0x202840, 0x2c2c40, 0x302c40, 0x342c40, 0x3c2c40, 0x402c40, 0x402c3c, 0x402c34, 0x402c30, 0x402c2c, 0x40302c, 0x40342c, 0x403c2c, 0x40402c, 0x3c402c, 0x34402c, 0x30402c, 0x2c402c, 0x2c4030, 0x2c4034, 0x2c403c, 0x2c4040, 0x2c3c40, 0x2c3440, 0x2c3040, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, ]; pub fn default_color(byte: u8) -> RgbColor { DEFAULT_COLOR_ARRAY[usize::from(byte)] } fn hue_color(byte: u8) -> RgbColor { let [r, g, b] = egui::ecolor::rgb_from_hsv((f32::from(byte) / 288.0, 1.0, 1.0)); #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "Ranges are in 0-1, they will never be multiplied above 255" )] rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) } #[expect(dead_code, reason = "DEFAULT_COLOR_ARRAY is generated based on this")] fn hue_color_tweaked(byte: u8) -> RgbColor { if byte == 0 { rgb(100, 100, 100) } else if byte == 255 { rgb(210, 210, 210) } else { hue_color(byte) } } /// Color table for default_color. This is used for performance purposes, as it is /// expensive to calculate the default colors. const DEFAULT_COLOR_ARRAY: [RgbColor; 256] = [ rgb(100, 100, 100), rgb(255, 5, 0), rgb(255, 10, 0), rgb(255, 15, 0), rgb(255, 21, 0), rgb(255, 26, 0), rgb(255, 31, 0), rgb(255, 37, 0), rgb(255, 42, 0), rgb(255, 47, 0), rgb(255, 53, 0), rgb(255, 58, 0), rgb(255, 63, 0), rgb(255, 69, 0), rgb(255, 74, 0), rgb(255, 79, 0), rgb(255, 85, 0), rgb(255, 90, 0), rgb(255, 95, 0), rgb(255, 100, 0), rgb(255, 106, 0), rgb(255, 111, 0), rgb(255, 116, 0), rgb(255, 122, 0), rgb(255, 127, 0), rgb(255, 132, 0), rgb(255, 138, 0), rgb(255, 143, 0), rgb(255, 148, 0), rgb(255, 154, 0), rgb(255, 159, 0), rgb(255, 164, 0), rgb(255, 170, 0), rgb(255, 175, 0), rgb(255, 180, 0), rgb(255, 185, 0), rgb(255, 191, 0), rgb(255, 196, 0), rgb(255, 201, 0), rgb(255, 207, 0), rgb(255, 212, 0), rgb(255, 217, 0), rgb(255, 223, 0), rgb(255, 228, 0), rgb(255, 233, 0), rgb(255, 239, 0), rgb(255, 244, 0), rgb(255, 249, 0), rgb(255, 254, 0), rgb(249, 255, 0), rgb(244, 255, 0), rgb(239, 255, 0), rgb(233, 255, 0), rgb(228, 255, 0), rgb(223, 255, 0), rgb(217, 255, 0), rgb(212, 255, 0), rgb(207, 255, 0), rgb(201, 255, 0), rgb(196, 255, 0), rgb(191, 255, 0), rgb(185, 255, 0), rgb(180, 255, 0), rgb(175, 255, 0), rgb(170, 255, 0), rgb(164, 255, 0), rgb(159, 255, 0), rgb(154, 255, 0), rgb(148, 255, 0), rgb(143, 255, 0), rgb(138, 255, 0), rgb(132, 255, 0), rgb(127, 255, 0), rgb(122, 255, 0), rgb(116, 255, 0), rgb(111, 255, 0), rgb(106, 255, 0), rgb(100, 255, 0), rgb(95, 255, 0), rgb(90, 255, 0), rgb(84, 255, 0), rgb(79, 255, 0), rgb(74, 255, 0), rgb(69, 255, 0), rgb(63, 255, 0), rgb(58, 255, 0), rgb(53, 255, 0), rgb(47, 255, 0), rgb(42, 255, 0), rgb(37, 255, 0), rgb(31, 255, 0), rgb(26, 255, 0), rgb(21, 255, 0), rgb(15, 255, 0), rgb(10, 255, 0), rgb(5, 255, 0), rgb(0, 255, 0), rgb(0, 255, 5), rgb(0, 255, 10), rgb(0, 255, 15), rgb(0, 255, 21), rgb(0, 255, 26), rgb(0, 255, 31), rgb(0, 255, 37), rgb(0, 255, 42), rgb(0, 255, 47), rgb(0, 255, 53), rgb(0, 255, 58), rgb(0, 255, 63), rgb(0, 255, 69), rgb(0, 255, 74), rgb(0, 255, 79), rgb(0, 255, 84), rgb(0, 255, 90), rgb(0, 255, 95), rgb(0, 255, 100), rgb(0, 255, 106), rgb(0, 255, 111), rgb(0, 255, 116), rgb(0, 255, 122), rgb(0, 255, 127), rgb(0, 255, 132), rgb(0, 255, 138), rgb(0, 255, 143), rgb(0, 255, 148), rgb(0, 255, 154), rgb(0, 255, 159), rgb(0, 255, 164), rgb(0, 255, 169), rgb(0, 255, 175), rgb(0, 255, 180), rgb(0, 255, 185), rgb(0, 255, 191), rgb(0, 255, 196), rgb(0, 255, 201), rgb(0, 255, 207), rgb(0, 255, 212), rgb(0, 255, 217), rgb(0, 255, 223), rgb(0, 255, 228), rgb(0, 255, 233), rgb(0, 255, 239), rgb(0, 255, 244), rgb(0, 255, 249), rgb(0, 255, 255), rgb(0, 249, 255), rgb(0, 244, 255), rgb(0, 239, 255), rgb(0, 233, 255), rgb(0, 228, 255), rgb(0, 223, 255), rgb(0, 217, 255), rgb(0, 212, 255), rgb(0, 207, 255), rgb(0, 201, 255), rgb(0, 196, 255), rgb(0, 191, 255), rgb(0, 185, 255), rgb(0, 180, 255), rgb(0, 175, 255), rgb(0, 169, 255), rgb(0, 164, 255), rgb(0, 159, 255), rgb(0, 154, 255), rgb(0, 148, 255), rgb(0, 143, 255), rgb(0, 138, 255), rgb(0, 132, 255), rgb(0, 127, 255), rgb(0, 122, 255), rgb(0, 116, 255), rgb(0, 111, 255), rgb(0, 106, 255), rgb(0, 100, 255), rgb(0, 95, 255), rgb(0, 90, 255), rgb(0, 84, 255), rgb(0, 79, 255), rgb(0, 74, 255), rgb(0, 69, 255), rgb(0, 63, 255), rgb(0, 58, 255), rgb(0, 53, 255), rgb(0, 47, 255), rgb(0, 42, 255), rgb(0, 37, 255), rgb(0, 31, 255), rgb(0, 26, 255), rgb(0, 21, 255), rgb(0, 15, 255), rgb(0, 10, 255), rgb(0, 5, 255), rgb(0, 0, 255), rgb(5, 0, 255), rgb(10, 0, 255), rgb(15, 0, 255), rgb(21, 0, 255), rgb(26, 0, 255), rgb(31, 0, 255), rgb(37, 0, 255), rgb(42, 0, 255), rgb(47, 0, 255), rgb(53, 0, 255), rgb(58, 0, 255), rgb(63, 0, 255), rgb(69, 0, 255), rgb(74, 0, 255), rgb(79, 0, 255), rgb(84, 0, 255), rgb(90, 0, 255), rgb(95, 0, 255), rgb(100, 0, 255), rgb(106, 0, 255), rgb(111, 0, 255), rgb(116, 0, 255), rgb(122, 0, 255), rgb(127, 0, 255), rgb(132, 0, 255), rgb(138, 0, 255), rgb(143, 0, 255), rgb(148, 0, 255), rgb(154, 0, 255), rgb(159, 0, 255), rgb(164, 0, 255), rgb(170, 0, 255), rgb(175, 0, 255), rgb(180, 0, 255), rgb(185, 0, 255), rgb(191, 0, 255), rgb(196, 0, 255), rgb(201, 0, 255), rgb(207, 0, 255), rgb(212, 0, 255), rgb(217, 0, 255), rgb(223, 0, 255), rgb(228, 0, 255), rgb(233, 0, 255), rgb(239, 0, 255), rgb(244, 0, 255), rgb(249, 0, 255), rgb(254, 0, 255), rgb(255, 0, 249), rgb(255, 0, 244), rgb(255, 0, 239), rgb(255, 0, 233), rgb(255, 0, 228), rgb(255, 0, 223), rgb(255, 0, 217), rgb(255, 0, 212), rgb(255, 0, 207), rgb(255, 0, 201), rgb(255, 0, 196), rgb(255, 0, 191), rgb(255, 0, 185), rgb(255, 0, 180), rgb(210, 210, 210), ]; ================================================ FILE: src/view/draw.rs ================================================ use { super::View, crate::{ app::{App, presentation::Presentation}, color::RgbColor, dec_conv, gui::Gui, hex_conv, hex_ui::HexUi, meta::{PerspectiveMap, RegionMap, ViewKey, region::Region}, struct_meta_item::StructMetaItem, view::ViewKind, }, egui_sf2g::sf2g::{ graphics::{ Color, Font, PrimitiveType, RenderStates, RenderTarget as _, RenderWindow, Text, Vertex, }, system::Vector2, }, either::Either, slotmap::Key as _, }; struct DrawArgs<'vert, 'data> { vertices: &'vert mut Vec, x: f32, y: f32, data: &'data [u8], idx: usize, color: RgbColor, highlight: bool, } fn draw_view<'f>( view: &View, key: ViewKey, app_perspectives: &PerspectiveMap, app_regions: &RegionMap, app_structs: &[StructMetaItem], app_data: &[u8], app_hex_ui: &HexUi, app_ui: &Gui, vertex_buffer: &mut Vec, overlay_texts: &mut Vec>, font: &'f Font, mut drawfn: impl FnMut(DrawArgs), ) { // Protect against infinite loop lock up when scrolling horizontally out of view if view.scroll_offset.pix_xoff <= -view.viewport_rect.w || view.perspective.is_null() { return; } let perspective = &app_perspectives[view.perspective]; let region = &app_regions[perspective.region].region; let mut idx = region.begin; let start_row: usize = view.scroll_offset.row; idx += start_row * (perspective.cols * usize::from(view.bytes_per_block)); #[expect( clippy::cast_sign_loss, reason = "rows() returning negative is a bug, should be positive." )] let orig = start_row..=start_row + view.rows() as usize; let (row_range, pix_yoff) = if perspective.flip_row_order { (Either::Left(orig.rev()), -view.scroll_offset.pix_yoff) } else { (Either::Right(orig), view.scroll_offset.pix_yoff) }; 'rows: for row in row_range { let y = row * usize::from(view.row_h); #[expect( clippy::cast_possible_wrap, reason = "Files bigger than i64::MAX aren't supported" )] let viewport_y = (i64::from(view.viewport_rect.y) + y as i64) - ((view.scroll_offset.row as i64 * i64::from(view.row_h)) + i64::from(pix_yoff)); let start_col = view.scroll_offset.col; if start_col >= perspective.cols { break; } idx += start_col * usize::from(view.bytes_per_block); for col in start_col..perspective.cols { let x = col * usize::from(view.col_w); #[expect( clippy::cast_possible_wrap, reason = "Files bigger than i64::MAX aren't supported" )] let viewport_x = (i64::from(view.viewport_rect.x) + x as i64) - ((view.scroll_offset.col as i64 * i64::from(view.col_w)) + i64::from(view.scroll_offset.pix_xoff)); if viewport_x > i64::from(view.viewport_rect.x + view.viewport_rect.w) { idx += (perspective.cols - col) * usize::from(view.bytes_per_block); break; } if idx > region.end { break 'rows; } if viewport_y > i64::from(view.viewport_rect.y + view.viewport_rect.h) && !perspective.flip_row_order { break 'rows; } match app_data.get(idx..idx + view.bytes_per_block as usize) { Some(data) => { let c = view .presentation .color_method .byte_color(data[0], view.presentation.invert_color); #[expect( clippy::cast_precision_loss, reason = "At this point, the viewport coordinates should be small enough to fit in viewport" )] drawfn(DrawArgs { vertices: vertex_buffer, x: viewport_x as f32, y: viewport_y as f32, data, idx, color: c, highlight: should_highlight(app_hex_ui.selected_regions(), idx, app_ui), }); /*if gamedebug_core::enabled() { #[expect( clippy::cast_precision_loss, reason = "At this point, the viewport coordinates should be small enough to fit in viewport" )] draw_rect_outline( vertex_buffer, viewport_x as f32, viewport_y as f32, view.col_w.into(), view.row_h.into(), Color::RED, -1.0, ); }*/ idx += usize::from(view.bytes_per_block); } None => { if !perspective.flip_row_order { break 'rows; } } } } } if let Some(ruler) = app_hex_ui.rulers.get(&key) && ruler.freq != 0 { let y = view.viewport_rect.y; let h = view.viewport_rect.h; let base_x = view.viewport_rect.x; let view_p_cols = view.p_cols(app_perspectives); let view_cols = usize::try_from(view.cols()).expect("Bug: view.cols() returned negative number"); let end = view_p_cols.min(view.scroll_offset.col + view_cols); // TODO: Hacky "gap" calculation to try to make rulers look "good" by default // Needs proper way to determine "center of gap between columns", // so we can place the vertical lines there #[expect(clippy::cast_possible_truncation)] let gap = (f64::from(view.col_w) * 0.17) as i16; match ruler.struct_idx { Some(idx) => { let Some(struct_) = app_structs.get(idx) else { gamedebug_core::per!("Dangling struct index: {idx}"); return; }; let mut col = 0; for (i, field) in struct_.fields.iter().enumerate() { // Draw field names if alt overlay is enabled // TODO: Very hacky, needs proper support in the future if app_hex_ui.show_alt_overlay && let Some(line_x) = line_x(view, col) { let mut text = Text::new(field.name.clone(), font, 12); text.set_outline_thickness(1.0); text.set_fill_color(Color::WHITE); text.set_outline_color(Color::BLACK); let y_offs = [48.0, 72.0, 96.0]; let y_off = y_offs[i % y_offs.len()]; let x = base_x + line_x + ruler.hoffset; text.tf.position = [f32::from(x), f32::from(y) + y_off]; overlay_texts.push(text); } col += field.ty.size(); let Some(line_x) = line_x(view, col) else { continue; }; let x = (base_x + line_x + ruler.hoffset) - gap; draw_vline( vertex_buffer, f32::from(x), f32::from(y), f32::from(h), ruler.color.into(), ); } } None => { for col in view.scroll_offset.col..end { if col.is_multiple_of(usize::from(ruler.freq)) { // We want to draw the line after the current column let col = col + 1; let x_offset = i16::try_from(col - view.scroll_offset.col) .expect("Bug: x offset larger than i16::MAX"); let line_x = (x_offset * i16::try_from(view.col_w).expect("Bug: col_w larger than i16::MAX")) - view.scroll_offset.pix_xoff; let x = (base_x + line_x + ruler.hoffset) - gap; draw_vline( vertex_buffer, f32::from(x), f32::from(y), f32::from(h), ruler.color.into(), ); } } } } } } fn line_x(view: &View, col: usize) -> Option { let x_off = col.checked_sub(view.scroll_offset.col)?; let Ok(x_offset) = i16::try_from(x_off) else { gamedebug_core::per!("Bug: x offset ({x_off}) larger than i16::MAX"); return None; }; let line_x = (x_offset * i16::try_from(view.col_w).expect("Bug: col_w larger than i16::MAX")) - view.scroll_offset.pix_xoff; Some(line_x) } fn draw_text_cursor( x: f32, y: f32, vertices: &mut Vec, active: bool, flash_timer: Option, presentation: &Presentation, font_size: u16, ) { let color = cursor_color(active, flash_timer, presentation); draw_rect_outline( vertices, x, y, f32::from(font_size / 2), f32::from(font_size), color, -2.0, ); } fn draw_block_cursor( x: f32, y: f32, vertices: &mut Vec, active: bool, flash_timer: Option, presentation: &Presentation, view: &View, ) { let color = cursor_color(active, flash_timer, presentation); draw_rect( vertices, x, y, f32::from(view.col_w), f32::from(view.row_h), color, ); } #[expect( clippy::cast_possible_truncation, reason = "Deliberate color modulation based on timer value." )] fn cursor_color(active: bool, flash_timer: Option, presentation: &Presentation) -> Color { if active { match flash_timer { Some(timer) => Color::rgb(timer as u8, timer as u8, timer as u8), None => presentation.cursor_active_color.into(), } } else { match flash_timer { Some(timer) => Color::rgb(timer as u8, timer as u8, timer as u8), None => presentation.cursor_color.into(), } } } #[expect( clippy::cast_precision_loss, reason = "These casts deal with texture rect coords. These aren't expected to be larger than what fits into f32" )] fn draw_glyph( font: &Font, font_size: u32, vertices: &mut Vec, mut x: f32, mut y: f32, glyph: u32, color: Color, ) { let glyph = font.glyph(glyph, font_size, false, 0.0); let bounds = glyph.bounds(); let texture_rect = glyph.texture_rect(); let baseline = font_size as f32; let offset = baseline + bounds.top; x += bounds.left; y += offset; vertices.push(Vertex { position: Vector2::new(x, y), color, tex_coords: texture_rect.position().as_other(), }); vertices.push(Vertex { position: Vector2::new(x, y + bounds.height), color, tex_coords: Vector2::new( texture_rect.left as f32, (texture_rect.top + texture_rect.height) as f32, ), }); vertices.push(Vertex { position: Vector2::new(x + bounds.width, y + bounds.height), color, tex_coords: Vector2::new( (texture_rect.left + texture_rect.width) as f32, (texture_rect.top + texture_rect.height) as f32, ), }); vertices.push(Vertex { position: Vector2::new(x + bounds.width, y), color, tex_coords: Vector2::new( (texture_rect.left + texture_rect.width) as f32, texture_rect.top as f32, ), }); } fn draw_rect(vertices: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: Color) { vertices.extend([ Vertex { position: Vector2::new(x, y), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x, y + h), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x + w, y + h), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x + w, y), color, tex_coords: Vector2::default(), }, ]); } fn draw_vline(vertices: &mut Vec, x: f32, y: f32, h: f32, color: Color) { vertices.extend([ Vertex { position: Vector2::new(x, y), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x, y + h), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x + 1.0, y + h), color, tex_coords: Vector2::default(), }, Vertex { position: Vector2::new(x + 1.0, y), color, tex_coords: Vector2::default(), }, ]); } fn draw_rect_outline( vertices: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: Color, thickness: f32, ) { // top draw_rect( vertices, x - thickness, y - thickness, w + thickness, thickness, color, ); // right draw_rect( vertices, x + w, y - thickness, thickness, h + thickness, color, ); // bottom draw_rect( vertices, x - thickness, y + h, w + thickness * 2.0, thickness, color, ); // left draw_rect( vertices, x - thickness, y - thickness, thickness, h + thickness, color, ); } impl View { pub fn draw( key: ViewKey, app: &App, gui: &Gui, window: &mut RenderWindow, vertex_buffer: &mut Vec, font: &Font, ) { vertex_buffer.clear(); let mut rs = RenderStates::default(); let Some(this) = app.meta_state.meta.views.get(key) else { return; }; let mut overlay_texts = Vec::new(); match &this.view.kind { ViewKind::Hex(hex) => { draw_view( &this.view, key, &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, &app.meta_state.meta.structs, &app.data, &app.hex_ui, gui, vertex_buffer, &mut overlay_texts, font, |DrawArgs { vertices, x, y, data, idx, color: c, highlight, }| { if highlight { draw_rect( vertices, x, y, f32::from(this.view.col_w), f32::from(this.view.row_h), this.view.presentation.sel_color.into(), ); } let mut gx = x; for (i, mut d) in hex_conv::byte_to_hex_digits(data[0]).into_iter().enumerate() { if idx == app.edit_state.cursor && hex.edit_buf.dirty { d = hex.edit_buf.buf[i]; } draw_glyph( font, hex.font_size.into(), vertices, gx, y, d.into(), c.into(), ); gx += f32::from(hex.font_size - 4); } let extra_x = hex.edit_buf.cursor * (hex.font_size - 4); if !app.preferences.hide_cursor && idx == app.edit_state.cursor { draw_text_cursor( x + f32::from(extra_x), y, vertices, app.hex_ui.focused_view == Some(key), app.hex_ui.cursor_flash_timer(), &this.view.presentation, hex.font_size, ); } }, ); rs.texture = Some(font.texture(hex.font_size.into())); } ViewKind::Dec(dec) => { draw_view( &this.view, key, &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, &app.meta_state.meta.structs, &app.data, &app.hex_ui, gui, vertex_buffer, &mut overlay_texts, font, |DrawArgs { vertices, x, y, data, idx, color: c, highlight, }| { if highlight { draw_rect( vertices, x, y, f32::from(this.view.col_w), f32::from(this.view.row_h), this.view.presentation.sel_color.into(), ); } let mut gx = x; for (i, mut d) in dec_conv::byte_to_dec_digits(data[0]).into_iter().enumerate() { if idx == app.edit_state.cursor && dec.edit_buf.dirty { d = dec.edit_buf.buf[i]; } draw_glyph( font, dec.font_size.into(), vertices, gx, y, d.into(), c.into(), ); gx += f32::from(dec.font_size - 4); } let extra_x = dec.edit_buf.cursor * (dec.font_size - 4); if !app.preferences.hide_cursor && idx == app.edit_state.cursor { draw_text_cursor( x + f32::from(extra_x), y, vertices, app.hex_ui.focused_view == Some(key), app.hex_ui.cursor_flash_timer(), &this.view.presentation, dec.font_size, ); } }, ); rs.texture = Some(font.texture(dec.font_size.into())); } ViewKind::Text(text) => { draw_view( &this.view, key, &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, &app.meta_state.meta.structs, &app.data, &app.hex_ui, gui, vertex_buffer, &mut overlay_texts, font, |DrawArgs { vertices, x, y, data, idx, color: c, highlight, }| { if highlight { draw_rect( vertices, x, y, f32::from(this.view.col_w), f32::from(this.view.row_h), this.view.presentation.sel_color.into(), ); } let raw_data = match text.text_kind { crate::view::TextKind::Ascii => { u32::from(data[0].wrapping_add_signed(text.offset)) } crate::view::TextKind::Utf16Le => { u32::from(u16::from_le_bytes([data[0], data[1]])) } crate::view::TextKind::Utf16Be => { u32::from(u16::from_be_bytes([data[0], data[1]])) } }; let glyph = match raw_data { 0x00 => '∅' as u32, 0x09 => '⇥' as u32, 0x0A => '⏎' as u32, 0x0D => '⇤' as u32, 0x20 => '␣' as u32, 0xFF => '■' as u32, _ => raw_data, }; draw_glyph(font, text.font_size.into(), vertices, x, y, glyph, c.into()); if !app.preferences.hide_cursor && idx == app.edit_state.cursor { draw_text_cursor( x, y, vertices, app.hex_ui.focused_view == Some(key), app.hex_ui.cursor_flash_timer(), &this.view.presentation, text.font_size, ); } }, ); rs.texture = Some(font.texture(text.font_size.into())); } ViewKind::Block => { draw_view( &this.view, key, &app.meta_state.meta.low.perspectives, &app.meta_state.meta.low.regions, &app.meta_state.meta.structs, &app.data, &app.hex_ui, gui, vertex_buffer, &mut overlay_texts, font, |DrawArgs { vertices, x, y, data: _, idx, color: mut c, highlight, }| { if highlight { c = c.invert(); } draw_rect( vertices, x, y, f32::from(this.view.col_w), f32::from(this.view.row_h), c.into(), ); if !app.preferences.hide_cursor && idx == app.edit_state.cursor { draw_block_cursor( x, y, vertices, app.hex_ui.focused_view == Some(key), app.hex_ui.cursor_flash_timer(), &this.view.presentation, &this.view, ); } }, ); } } draw_rect_outline( vertex_buffer, f32::from(this.view.viewport_rect.x - 2), f32::from(this.view.viewport_rect.y - 2), f32::from(this.view.viewport_rect.w + 3), f32::from(this.view.viewport_rect.h + 3), if Some(key) == app.hex_ui.focused_view { Color::rgb(255, 255, 150) } else { Color::rgb(120, 120, 150) }, -1.0, ); if app.hex_ui.scissor_views { // Safety: It's just some OpenGL calls, it's fine, trust me unsafe { glu_sys::glEnable(glu_sys::GL_SCISSOR_TEST); #[expect( clippy::cast_possible_truncation, reason = "Huge window sizes (>32000) are not supported." )] let vh = window.size().y as i16; let [x, y, w, h] = rect_to_gl_viewport( this.view.viewport_rect.x - 2, this.view.viewport_rect.y - 2, this.view.viewport_rect.w + 3, this.view.viewport_rect.h + 3, vh, ); glu_sys::glScissor(x, y, w, h); } } if app.hex_ui.show_alt_overlay { let per = &app.meta_state.meta.low.perspectives[this.view.perspective]; let mut text = Text::new( format!( "{}\n{}x{}", this.name, per.n_rows(&app.meta_state.meta.low.regions), per.cols ), font, 16, ); text.tf.position = [ f32::from(this.view.viewport_rect.x), f32::from(this.view.viewport_rect.y), ]; let text_bounds = text.global_bounds(); draw_rect( vertex_buffer, text_bounds.left, text_bounds.top, text_bounds.width, text_bounds.height, Color::rgba(32, 32, 32, 200), ); overlay_texts.push(text); } window.draw_primitives(vertex_buffer, PrimitiveType::QUADS, &rs); if app.hex_ui.scissor_views { // Safety: It's an innocent OpenGL call unsafe { glu_sys::glDisable(glu_sys::GL_SCISSOR_TEST); } } for mut text in overlay_texts { text.draw(window, &RenderStates::DEFAULT); } } } fn rect_to_gl_viewport(x: i16, y: i16, w: i16, h: i16, viewport_h: i16) -> [i32; 4] { [x, viewport_h - (y + h), w, h].map(glu_sys::GLint::from) } #[test] fn test_rect_to_gl() { let vh = 1080; assert_eq!(rect_to_gl_viewport(0, 0, 0, 0, vh), [0, 1080, 0, 0]); assert_eq!( rect_to_gl_viewport(100, 480, 300, 400, vh), [100, 200, 300, 400] ); } fn should_highlight( mut app_selection: impl Iterator, idx: usize, gui: &Gui, ) -> bool { app_selection.any(|reg| reg.contains(idx)) || gui.highlight_set.contains(&idx) } ================================================ FILE: src/view.rs ================================================ use { crate::{ app::{edit_state::EditState, presentation::Presentation}, damage_region::DamageRegion, data::Data, edit_buffer::EditBuffer, gui::message_dialog::{Icon, MessageDialog}, hex_conv::merge_hex_halves, meta::{MetaLow, PerspectiveKey, PerspectiveMap, RegionMap, region::Region}, session_prefs::SessionPrefs, }, gamedebug_core::per, serde::{Deserialize, Serialize}, slotmap::Key as _, }; mod draw; /// A rectangular view in the viewport looking through a perspective at the data with a flavor /// of rendering/interaction (hex/ascii/block/etc.) /// /// There can be different views through the same perspective. /// By default they sync their offsets, but each view can show different amounts of data /// depending on block size of its items, and its relative size in the viewport. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct View { /// The rectangle to occupy in the viewport #[serde(skip)] pub viewport_rect: ViewportRect, /// The kind of view (hex, ascii, block, etc) pub kind: ViewKind, /// Width of a column pub col_w: u16, /// Height of a row pub row_h: u16, /// The scrolling offset #[serde(skip)] pub scroll_offset: ScrollOffset, /// The amount scrolled for a single scroll operation, in pixels pub scroll_speed: i16, /// How many bytes are required for a single block in the view pub bytes_per_block: u8, /// The perspective this view is associated with pub perspective: PerspectiveKey, /// Color schemes, etc. pub presentation: Presentation, } impl PartialEq for View { fn eq(&self, other: &Self) -> bool { self.kind == other.kind && self.col_w == other.col_w && self.row_h == other.row_h && self.scroll_speed == other.scroll_speed && self.bytes_per_block == other.bytes_per_block && self.presentation == other.presentation } } impl Eq for View {} impl View { pub fn new(kind: ViewKind, perspective: PerspectiveKey) -> Self { let mut this = Self { viewport_rect: ViewportRect::default(), kind, // TODO: Hack. We're setting this to 4, 4 to avoid zeroed default block view. // Solve in a better way. col_w: 4, row_h: 4, scroll_offset: ScrollOffset::default(), scroll_speed: 0, bytes_per_block: 1, perspective, presentation: Presentation::default(), }; this.adjust_state_to_kind(); this } pub fn scroll_x(&mut self, amount: i16) { #[expect( clippy::cast_possible_wrap, reason = "block size is never greater than i16::MAX" )] scroll_impl( &mut self.scroll_offset.col, &mut self.scroll_offset.pix_xoff, self.col_w as i16, amount, ); } pub fn scroll_y(&mut self, amount: i16) { #[expect( clippy::cast_possible_wrap, reason = "block size is never greater than i16::MAX" )] scroll_impl( &mut self.scroll_offset.row, &mut self.scroll_offset.pix_yoff, self.row_h as i16, amount, ); } pub(crate) fn sync_to( &mut self, src_row: usize, src_yoff: i16, src_col: usize, src_xoff: i16, src_row_h: u16, src_col_w: u16, ) { self.scroll_offset.row = src_row; self.scroll_offset.col = src_col; let y_ratio = f64::from(src_row_h) / f64::from(self.row_h); let x_ratio = f64::from(src_col_w) / f64::from(self.col_w); #[expect( clippy::cast_possible_truncation, reason = "Input values are all low (look at input types)" )] { self.scroll_offset.pix_yoff = (f64::from(src_yoff) / y_ratio) as i16; self.scroll_offset.pix_xoff = (f64::from(src_xoff) / x_ratio) as i16; } } pub(crate) fn scroll_page_down(&mut self) { self.scroll_y(self.viewport_rect.h); } pub(crate) fn scroll_page_up(&mut self) { self.scroll_y(-self.viewport_rect.h); } pub(crate) fn scroll_page_left(&mut self) { self.scroll_x(-self.viewport_rect.w); } pub(crate) fn go_home(&mut self) { self.scroll_offset.row = 0; self.scroll_offset.col = 0; self.scroll_offset.floor(); } pub(crate) fn go_home_col(&mut self) { self.scroll_offset.col = 0; self.scroll_offset.pix_xoff = 0; } /// Scroll so the perspective's last row is visible pub(crate) fn scroll_to_end(&mut self, meta_low: &MetaLow) { // Needs: // - row index of last byte of perspective // - number of rows this view can hold let perspective = &meta_low.perspectives[self.perspective]; let last_row_idx = perspective.last_row_idx(&meta_low.regions); let last_col_idx = perspective.last_col_idx(&meta_low.regions); self.scroll_offset.row = last_row_idx + 1; self.scroll_offset.col = last_col_idx + 1; self.scroll_page_up(); self.scroll_page_left(); self.scroll_offset.floor(); } /// Scrolls the view right until it "bumps" into the right edge of content pub(crate) fn scroll_right_until_bump(&mut self, meta_low: &MetaLow) { let per = &meta_low.perspectives[self.perspective]; #[expect(clippy::cast_sign_loss, reason = "self.cols() is essentially `u15`")] let view_cols = self.cols() as usize; let offset = per.cols.saturating_sub(view_cols); self.scroll_offset.col = offset; self.scroll_offset.floor(); } /// Row/col offset of relative position, including scrolling pub(crate) fn row_col_offset_of_pos( &self, x: i16, y: i16, perspectives: &PerspectiveMap, regions: &RegionMap, ) -> Option<[usize; 2]> { self.viewport_rect .relative_offset_of_pos(x, y) .and_then(|(x, y)| self.row_col_of_rel_pos(x, y, perspectives, regions)) } #[expect( clippy::cast_possible_wrap, reason = "block size is never greater than i16::MAX" )] fn row_col_of_rel_pos( &self, x: i16, y: i16, perspectives: &PerspectiveMap, regions: &RegionMap, ) -> Option<[usize; 2]> { let rel_x = x + self.scroll_offset.pix_xoff; let rel_y = y + self.scroll_offset.pix_yoff; let rel_col = rel_x / self.col_w as i16; let mut rel_row = rel_y / self.row_h as i16; let perspective = match perspectives.get(self.perspective) { Some(per) => per, None => { per!("row_col_of_rel_pos: Invalid perspective key"); return None; } }; if perspective.flip_row_order { rel_row = self.rows() - rel_row; } let row = self.scroll_offset.row; let col = self.scroll_offset.col; #[expect( clippy::cast_sign_loss, reason = "rel_x and rel_y being positive also ensure rel_row and rel_col are" )] if rel_x.is_positive() && rel_y.is_positive() { let abs_row = row + rel_row as usize; let abs_col = col + rel_col as usize; if perspective.row_col_within_bound(abs_row, abs_col, regions) { Some([abs_row, abs_col]) } else { None } } else { None } } pub(crate) fn center_on_offset( &mut self, offset: usize, perspectives: &PerspectiveMap, regions: &RegionMap, ) { let [row, col] = perspectives[self.perspective].row_col_of_byte_offset(offset, regions); self.center_on_row_col(row, col); } fn center_on_row_col(&mut self, row: usize, col: usize) { self.scroll_offset.row = row; self.scroll_offset.col = col; self.scroll_offset.floor(); self.scroll_x(-self.viewport_rect.w / 2); self.scroll_y(-self.viewport_rect.h / 2); } pub fn offsets(&self, perspectives: &PerspectiveMap, regions: &RegionMap) -> Offsets { let row = self.scroll_offset.row; let col = self.scroll_offset.col; Offsets { row, col, byte: perspectives[self.perspective].byte_offset_of_row_col(row, col, regions), } } /// Scroll to byte offset, with control of each axis individually pub(crate) fn scroll_to_byte_offset( &mut self, offset: usize, perspectives: &PerspectiveMap, regions: &RegionMap, do_col: bool, do_row: bool, ) { let [row, col] = perspectives[self.perspective].row_col_of_byte_offset(offset, regions); if do_row { self.scroll_offset.row = row; } if do_col { self.scroll_offset.col = col; } self.scroll_offset.floor(); } #[expect( clippy::cast_sign_loss, reason = "View::rows() being negative is a bug, can expect positive." )] pub(crate) fn bytes_per_page(&self, perspectives: &PerspectiveMap) -> usize { (self.rows() as usize) * perspectives[self.perspective].cols } /// Returns the number of rows this view can display #[expect( clippy::cast_possible_wrap, reason = "block size is never greater than i16::MAX" )] pub(crate) fn rows(&self) -> i16 { // If the viewport rect is smaller than 0, we just return 0 for the rows if self.viewport_rect.h <= 0 { return 0; } self.viewport_rect.h / (self.row_h as i16) } /// Returns the number of columns this view can display visibly at once. /// /// This might not be the total number of columns in the perspective this view is attached to. #[expect( clippy::cast_possible_wrap, reason = "block size is never greater than i16::MAX" )] pub(crate) fn cols(&self) -> i16 { match self.viewport_rect.w.checked_div(self.col_w as i16) { Some(result) => result, None => { per!("Divide by zero in View::cols. Bug."); 0 } } } /// Returns the number of columns of the perspective this view is attached to. pub(crate) fn p_cols(&self, perspectives: &PerspectiveMap) -> usize { match perspectives.get(self.perspective) { Some(per) => per.cols, None => 0, } } pub fn adjust_block_size(&mut self) { (self.col_w, self.row_h) = match &self.kind { ViewKind::Hex(hex) => (hex.font_size * 2 - 2, hex.font_size), ViewKind::Dec(dec) => (dec.font_size * 3 - 6, dec.font_size), ViewKind::Text(data) => (data.font_size, data.line_spacing.max(1)), ViewKind::Block => (self.col_w, self.row_h), } } /// Adjust state after kind was changed pub fn adjust_state_to_kind(&mut self) { self.adjust_block_size(); let glyph_count = self.glyph_count(); match &mut self.kind { ViewKind::Hex(HexData { edit_buf, .. }) | ViewKind::Dec(HexData { edit_buf, .. }) | ViewKind::Text(TextData { edit_buf, .. }) => edit_buf.resize(glyph_count), _ => {} } } /// The number of glyphs per block this view has fn glyph_count(&self) -> u16 { match self.kind { ViewKind::Hex(_) => 2, ViewKind::Dec(_) => 3, ViewKind::Text { .. } => 1, ViewKind::Block => 1, } } pub fn handle_text_entered( &mut self, unicode: char, edit_state: &mut EditState, preferences: &SessionPrefs, data: &mut Data, msg: &mut MessageDialog, ) { if self.char_valid(unicode) { match &mut self.kind { ViewKind::Hex(hex) => { if !hex.edit_buf.dirty { let Some(byte) = data.get(edit_state.cursor) else { return; }; let s = format!("{byte:02X}"); hex.edit_buf.update_from_string(&s); } if hex.edit_buf.enter_byte(unicode.to_ascii_uppercase() as u8) || preferences.quick_edit { self.finish_editing(edit_state, data, preferences, msg); } } ViewKind::Dec(dec) => { if !dec.edit_buf.dirty { let Some(byte) = data.get(edit_state.cursor) else { return; }; let s = format!("{byte:03}"); dec.edit_buf.update_from_string(&s); } if dec.edit_buf.enter_byte(unicode.to_ascii_uppercase() as u8) || preferences.quick_edit { self.finish_editing(edit_state, data, preferences, msg); } } ViewKind::Text(text) => { if text.edit_buf.enter_byte((unicode as u8).wrapping_add_signed(-text.offset)) || preferences.quick_edit { self.finish_editing(edit_state, data, preferences, msg); } } // Block doesn't do any text input ViewKind::Block => {} } } } /// Returns the size needed by this view to display fully pub fn max_needed_size( &self, perspectives: &PerspectiveMap, regions: &RegionMap, ) -> ViewportVec { if self.perspective.is_null() { return ViewportVec { x: 0, y: 0 }; } let p = &perspectives[self.perspective]; let n_rows = p.n_rows(regions); ViewportVec { x: i16::saturating_from(p.cols).saturating_mul(i16::saturating_from(self.col_w)), y: i16::saturating_from(n_rows).saturating_mul(i16::saturating_from(self.row_h)), } } fn char_valid(&self, unicode: char) -> bool { match self.kind { ViewKind::Hex(_) => matches!(unicode, '0'..='9' | 'a'..='f'), ViewKind::Dec(_) => unicode.is_ascii_digit(), ViewKind::Text { .. } => { unicode.is_ascii() && !unicode.is_control() && !matches!(unicode, '\t') } ViewKind::Block => false, } } pub fn finish_editing( &mut self, edit_state: &mut EditState, data: &mut Data, preferences: &SessionPrefs, msg: &mut MessageDialog, ) { match &mut self.kind { ViewKind::Hex(hex) => { match merge_hex_halves(hex.edit_buf.buf[0], hex.edit_buf.buf[1]) { Some(merged) => { if let Some(byte) = data.get_mut(edit_state.cursor) { *byte = merged; } } None => per!("finish_editing: Failed to merge hex halves"), } data.widen_dirty_region(DamageRegion::Single(edit_state.cursor)); } ViewKind::Dec(dec) => { let s = std::str::from_utf8(&dec.edit_buf.buf).expect("Invalid utf-8 in edit buffer"); match s.parse() { Ok(num) => { data[edit_state.cursor] = num; data.widen_dirty_region(DamageRegion::Single(edit_state.cursor)); } Err(e) => msg.open(Icon::Error, "Invalid value", e.to_string()), } } ViewKind::Text(text) => { let Some(byte) = data.get_mut(edit_state.cursor) else { return; }; *byte = text.edit_buf.buf[0]; data.widen_dirty_region(DamageRegion::Single(edit_state.cursor)); } ViewKind::Block => {} } if edit_state.cursor + 1 < data.len() && !preferences.sticky_edit { edit_state.step_cursor_forward(); } self.reset_edit_buf(); } pub fn cancel_editing(&mut self) { self.reset_edit_buf(); } pub fn reset_edit_buf(&mut self) { if let Some(edit_buf) = self.edit_buffer_mut() { edit_buf.reset(); } } pub(crate) fn undirty_edit_buffer(&mut self) { if let Some(edit_buf) = self.edit_buffer_mut() { edit_buf.dirty = false; } } pub(crate) fn edit_buffer_mut(&mut self) -> Option<&mut EditBuffer> { match &mut self.kind { ViewKind::Hex(data) | ViewKind::Dec(data) => Some(&mut data.edit_buf), ViewKind::Text(data) => Some(&mut data.edit_buf), ViewKind::Block => None, } } pub(crate) fn contains_region(&self, reg: &Region, meta: &crate::meta::Meta) -> bool { meta.low.regions[meta.low.perspectives[self.perspective].region] .region .contains_region(reg) } } trait SatFrom { fn saturating_from(src: V) -> Self; } impl SatFrom for i16 { fn saturating_from(src: usize) -> Self { Self::try_from(src).unwrap_or(Self::MAX) } } impl SatFrom for i16 { fn saturating_from(src: u16) -> Self { Self::try_from(src).unwrap_or(Self::MAX) } } pub struct Offsets { pub row: usize, pub col: usize, pub byte: usize, } /// When scrolling past 0 whole, allows unbounded negative pixel offset fn scroll_impl(whole: &mut usize, pixel: &mut i16, pixels_per_whole: i16, scroll_by: i16) { *pixel += scroll_by; if pixel.is_negative() { while *pixel <= -pixels_per_whole { if *whole == 0 { break; } *whole -= 1; *pixel += pixels_per_whole; } } else { while *pixel >= pixels_per_whole { *whole += 1; *pixel -= pixels_per_whole; } } } #[test] fn test_scroll_impl_positive() { let mut whole; let mut pixel; let px_per_whole = 32; // Add 1 whole = 0; pixel = 0; scroll_impl(&mut whole, &mut pixel, px_per_whole, 1); assert_eq!((whole, pixel), (0, 1)); // Add 1000 whole = 0; pixel = 0; scroll_impl(&mut whole, &mut pixel, px_per_whole, 1000); assert_eq!((whole, pixel), (31, 8)); // Add 1 until we get to 1 whole whole = 0; pixel = 0; for _ in 0..32 { scroll_impl(&mut whole, &mut pixel, px_per_whole, 1); } assert_eq!((whole, pixel), (1, 0)); } #[test] fn test_scroll_impl_negative() { let mut whole; let mut pixel; let px_per_whole = 32; // Add -1000 (negative test) whole = 0; pixel = 0; scroll_impl(&mut whole, &mut pixel, px_per_whole, -1000); assert_eq!((whole, pixel), (0, -1000)); // Make 10 wholes 0 whole = 10; pixel = 0; scroll_impl(&mut whole, &mut pixel, px_per_whole, -320); assert_eq!((whole, pixel), (0, 0)); // Make 10 wholes 0, scroll remainder whole = 10; pixel = 0; scroll_impl(&mut whole, &mut pixel, px_per_whole, -640); assert_eq!((whole, pixel), (0, -320)); } #[derive(Debug, Default, Clone, Copy)] pub struct ScrollOffset { /// What column we are at pub col: usize, /// Additional pixel x offset pub pix_xoff: i16, /// What row we are at pub row: usize, /// Additional pixel y offset pub pix_yoff: i16, } impl ScrollOffset { pub fn col(&self) -> usize { self.col } pub fn row(&self) -> usize { self.row } pub fn pix_xoff(&self) -> i16 { self.pix_xoff } pub fn pix_yoff(&self) -> i16 { self.pix_yoff } /// Discard pixel offsets pub(crate) fn floor(&mut self) { self.pix_xoff = 0; self.pix_yoff = 0; } } /// Type for representing viewport magnitudes. /// /// We assume that hexerator will never run on resolutions higher than 32767x32767, /// or get mouse positions higher than that. pub type ViewportScalar = i16; #[derive(Debug, Default, Clone, Copy)] pub struct ViewportRect { pub x: ViewportScalar, pub y: ViewportScalar, pub w: ViewportScalar, pub h: ViewportScalar, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct ViewportVec { pub x: ViewportScalar, pub y: ViewportScalar, } impl TryFrom<(i32, i32)> for ViewportVec { type Error = >::Error; fn try_from(src: (i32, i32)) -> Result { Ok(Self { x: src.0.try_into()?, y: src.1.try_into()?, }) } } /// The kind of view (hex, ascii, block, etc) #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum ViewKind { Hex(HexData), Dec(HexData), Text(TextData), Block, } impl ViewKind { pub(crate) fn is_text(&self) -> bool { matches!(self, Self::Text(_)) } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct TextData { /// The kind of text (ascii/utf16/etc) pub text_kind: TextKind, pub line_spacing: u16, #[serde(skip)] pub edit_buf: EditBuffer, pub font_size: u16, /// Offset from regular ascii offsets. Useful to see custom (single byte) text encodings #[serde(default)] pub offset: i8, } impl PartialEq for TextData { fn eq(&self, other: &Self) -> bool { self.text_kind == other.text_kind && self.line_spacing == other.line_spacing && self.font_size == other.font_size } } impl Eq for TextData {} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct HexData { #[serde(skip)] pub edit_buf: EditBuffer, pub font_size: u16, } impl PartialEq for HexData { fn eq(&self, other: &Self) -> bool { self.font_size == other.font_size } } impl Eq for HexData {} impl HexData { pub fn with_font_size(font_size: u16) -> Self { Self { edit_buf: Default::default(), font_size, } } } impl TextData { pub fn with_font_info(line_spacing: u16, font_size: u16) -> Self { Self { text_kind: TextKind::Ascii, line_spacing, edit_buf: EditBuffer::default(), font_size, offset: 0, } } } #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] pub enum TextKind { Ascii, Utf16Le, Utf16Be, } impl TextKind { pub fn name(&self) -> &'static str { match self { Self::Ascii => "ascii", Self::Utf16Le => "utf-16 le", Self::Utf16Be => "utf-16 be", } } pub(crate) fn bytes_needed(&self) -> u8 { match self { Self::Ascii => 1, Self::Utf16Le => 2, Self::Utf16Be => 2, } } } impl ViewportRect { fn relative_offset_of_pos( &self, x: ViewportScalar, y: ViewportScalar, ) -> Option<(ViewportScalar, ViewportScalar)> { self.contains_pos(x, y).then_some((x - self.x, y - self.y)) } pub fn contains_pos(&self, x: ViewportScalar, y: ViewportScalar) -> bool { x >= self.x && y >= self.y && x <= self.x + self.w && y <= self.y + self.h } } /// Try to convert mouse position to ViewportVec. /// /// Log error and return zeroed vec on conversion error. pub fn try_conv_mp_zero>(src: T) -> ViewportVec where T::Error: std::fmt::Display, { match src.try_into() { Ok(mp) => mp, Err(e) => { per!( "Mouse position conversion error: {}\nHexerator doesn't support extremely high (>32700) mouse positions.", e ); ViewportVec { x: 0, y: 0 } } } } ================================================ FILE: src/windows.rs ================================================ use { crate::{ App, gui::message_dialog::MessageDialog, source::{Source, SourceAttributes, SourcePermissions, SourceProvider, SourceState}, }, anyhow::bail, windows_sys::Win32::System::Threading::*, }; pub fn load_proc_memory( app: &mut App, pid: sysinfo::Pid, start: usize, size: usize, _is_write: bool, font_size: u16, line_spacing: u16, _msg: &mut MessageDialog, ) -> anyhow::Result<()> { let handle; unsafe { let access = PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION; handle = OpenProcess(access, 0, pid.as_u32()); if handle.is_null() { bail!("Failed to open process."); } load_proc_memory_inner(app, handle, start, size, font_size, line_spacing) } } unsafe fn load_proc_memory_inner( app: &mut App, handle: windows_sys::Win32::Foundation::HANDLE, start: usize, size: usize, font_size: u16, line_spacing: u16, ) -> anyhow::Result<()> { unsafe { read_proc_memory(handle, &mut app.data, start, size) }?; app.source = Some(Source { attr: SourceAttributes { permissions: SourcePermissions { write: true }, stream: false, }, provider: SourceProvider::WinProc { handle, start, size, }, state: SourceState::default(), }); if !app.preferences.keep_meta { app.set_new_clean_meta(font_size, line_spacing); } app.src_args.hard_seek = Some(start); app.src_args.take = Some(size); Ok(()) } pub unsafe fn read_proc_memory( handle: windows_sys::Win32::Foundation::HANDLE, data: &mut crate::data::Data, start: usize, size: usize, ) -> anyhow::Result<()> { let mut n_read: usize = 0; data.resize(size, 0); if unsafe { windows_sys::Win32::System::Diagnostics::Debug::ReadProcessMemory( handle, start as _, data.as_mut_ptr() as *mut std::ffi::c_void, size, &mut n_read, ) } == 0 { bail!("Failed to load process memory. Code: {}", unsafe { windows_sys::Win32::Foundation::GetLastError() }); } Ok(()) } ================================================ FILE: test_files/empty-file ================================================ ================================================ FILE: test_files/plaintext.txt ================================================ This is a plain text file used to test text rendering in Hexerator. It uses ISO-8859-1 encoding to fit accented characters in a byte. {Hello} underscored_stuff <- accented characters hyphen-stuff