22 Commits

Author SHA1 Message Date
alice pellerin 64f8944e59 set OSC 6 (current document) 2026-05-28 18:28:33 -05:00
alice pellerin 9c6f273703 add brew install to readme 2026-05-23 15:22:24 -05:00
alice pellerin 01a0ab5978 click tab bar to go to buffer 2026-05-16 16:46:09 -05:00
alice pellerin 6517de6b61 allow switching buffers in visual mode 2026-05-16 16:29:57 -05:00
alice pellerin 42a83b1245 normal escape stop inspecting 2026-05-16 16:29:57 -05:00
alice pellerin 906a152a21 fix color555 to 888 conversion 2026-05-16 16:14:52 -05:00
alice pellerin b721091c3c Update README.md 2026-05-05 19:53:06 -05:00
alice pellerin a69409a36f update readme 2026-05-05 18:13:46 -05:00
alice pellerin 1f77123cb0 update Node.js-20-depending github actions 2026-05-05 15:23:14 -05:00
alice pellerin c879cdb271 improve export script, add todo.md 2026-05-05 15:18:43 -05:00
alice pellerin 596f3d5c12 add gn/gp next/previous buffer keybinds 2026-05-02 02:11:58 -05:00
alice pellerin 7b5d56dd92 update unsaved changes alert 2026-05-02 01:42:45 -05:00
alice pellerin e94c70fa9e add caching to CI 2026-05-02 00:16:48 -05:00
alice pellerin 8f38d07385 only run build.rs when environment variables are non-empty 2026-05-02 00:12:18 -05:00
alice pellerin e437cecb48 fix windows packaging 2026-05-02 00:05:56 -05:00
alice pellerin 2198c9f787 enable publishing on build success 2026-05-02 00:03:28 -05:00
alice pellerin bd67084b87 fix windows CI 2026-05-02 00:01:17 -05:00
alice pellerin ebdf1061cf fix tar.gz packaging 2026-05-01 23:57:13 -05:00
alice pellerin b755f4a603 fix completion/manpage generation 2026-05-01 23:54:47 -05:00
alice pellerin 5bdb44ffba fix CI syntax 2026-05-01 23:47:40 -05:00
alice pellerin d320e286c1 fix version number 2026-05-01 23:44:27 -05:00
alice pellerin f8f6f7e3ef fix packaging, add completions/manpage 2026-05-01 23:43:26 -05:00
17 changed files with 207 additions and 142 deletions
-21
View File
@@ -1,21 +0,0 @@
name: publish
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC token exchange
steps:
- uses: actions/checkout@v6
- name: check version
# cut off the v part of the tag to only search for the number
# include the " = " to not match on main, only v* tags
run: grep -q " = \"$(echo "${{ github.ref_name }}" | cut -c2-)\"" Cargo.toml
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+56 -15
View File
@@ -6,46 +6,87 @@ on:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ${{ matrix.info.runs-on }}
permissions:
contents: write
strategy:
matrix:
info:
- os: "macOS-arm"
runs-on: "macos-latest"
package-extension: "tar.gz"
executable-extension: ""
- os: "macOS-intel"
runs-on: "macos-26-intel"
package-extension: "tar.gz"
executable-extension: ""
- os: "linux-x86_64"
runs-on: "ubuntu-latest"
package-extension: "tar.gz"
executable-extension: ""
- os: "linux-arm"
runs-on: "ubuntu-24.04-arm"
package-extension: "tar.gz"
executable-extension: ""
- os: "Windows-x86_64"
runs-on: "windows-latest"
package-extension: "zip"
executable-extension: ".exe"
- os: "Windows-arm"
runs-on: "windows-11-arm"
package-extension: "zip"
executable-extension: ".exe"
steps:
- name: checkout
uses: actions/checkout@v6
- name: check version
# cut off the v part of the tag to only search for the number
run: grep --quiet "$(echo "${{ github.ref_name }}" | cut -c2-)" Cargo.toml
- name: cache
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: test
run: cargo test --release
- name: make completion/manpage folders
run: mkdir completions; mkdir manpage
- name: build
run: cargo build --release --locked
env:
HEXAPODA_COMPLETIONS: completions
HEXAPODA_MANPAGE: manpage
- name: package-tar-gz
if: ${{ matrix.info.package-extension == 'tar.gz' }}
run: tar --create --gzip --file "hexapoda-${{ matrix.info.os }}-${{ github.ref_name }}.tar.gz" "completions" "manpage" -C "target/release/" "hexapoda${{ matrix.info.executable-extension }}"
- name: package-zip
if: ${{ matrix.info.package-extension == 'zip' }}
run: tar --create --auto-compress --file "hexapoda-${{ matrix.info.os }}-${{ github.ref_name }}.zip" "completions" "manpage" -C "target/release/" "hexapoda${{ matrix.info.executable-extension }}"
- name: release
uses: softprops/action-gh-release@v3
with:
draft: true
name: "${{ github.ref_name }}"
files: "hexapoda-${{ matrix.info.os }}-${{ github.ref_name }}.${{ matrix.info.package-extension }}"
publish:
runs-on: ubuntu-latest
needs: release
permissions:
id-token: write # Required for OIDC token exchange
steps:
- uses: actions/checkout@v6
- name: check version
# cut off the v part of the tag to only search for the number
run: grep -q "$(echo "${{ github.ref_name }}" | cut -c2-)" Cargo.toml
- run: cargo test --release
- run: cargo build --release --locked
- name: package
# TODO: include completions/man page
run: tar -azcf "hexapoda-${{ matrix.info.os }}-${{ github.ref_name }}.zip" -C "target/release/" "hexapoda${{ matrix.info.executable-extension }}"
- name: release
uses: softprops/action-gh-release@v2
with:
draft: true
name: "${{ github.ref_name }}"
files: hexapoda-${{ matrix.info.os }}-${{ github.ref_name }}.zip
# include the " = " to not match on main, only v* tags
run: grep -q " = \"$(echo "${{ github.ref_name }}" | cut -c2-)\"" Cargo.toml
- uses: rust-lang/crates-io-auth-action@v1
id: auth
- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
Generated
+1 -1
View File
@@ -568,7 +568,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hexapoda"
version = "0.2.1"
version = "0.2.3"
dependencies = [
"clap",
"clap_complete",
+7 -4
View File
@@ -1,7 +1,7 @@
[package]
name = "hexapoda"
# if run manually, CI will check for the string "ain" (lol), so here you go :)
version = "0.2.1"
version = "0.2.3"
description = "a colorful modal hex editor"
repository = "https://github.com/simonomi/hexapoda"
keywords = ["cli", "tui", "hex", "tool", "editor"]
@@ -24,23 +24,26 @@ clap_complete = "4.6.3"
clap_complete_nushell = "4.6.0"
clap_mangen = "0.3.0"
[package.metadata.binstall]
pkg-fmt = "zip"
[package.metadata.binstall.overrides.'cfg(all(target_os = "macos", target_arch = "aarch64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-macOS-arm-v{ version }{ archive-suffix }"
pkg-fmt = "tgz"
[package.metadata.binstall.overrides.'cfg(all(target_os = "macos", target_arch = "x86_64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-macOS-intel-v{ version }{ archive-suffix }"
pkg-fmt = "tgz"
[package.metadata.binstall.overrides.'cfg(all(target_os = "linux", target_arch = "x86_64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-linux-x86_64-v{ version }{ archive-suffix }"
pkg-fmt = "tgz"
[package.metadata.binstall.overrides.'cfg(all(target_os = "linux", target_arch = "aarch64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-linux-arm-v{ version }{ archive-suffix }"
pkg-fmt = "tgz"
[package.metadata.binstall.overrides.'cfg(all(target_os = "windows", target_arch = "x86_64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-Windows-x86_64-v{ version }{ archive-suffix }"
pkg-fmt = "zip"
[package.metadata.binstall.overrides.'cfg(all(target_os = "windows", target_arch = "aarch64" ))']
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-Windows-arm-v{ version }{ archive-suffix }"
pkg-fmt = "zip"
+7 -18
View File
@@ -1,4 +1,4 @@
# <img height=40 align=top src="https://github.com/simonomi/hexapoda/blob/main/icon/bug%20colored%20large.png?raw=true"> hexapoda
# <img height=40 align=top src="https://github.com/simonomi/hexapoda/blob/main/icon/bug%20colored%20large.png?raw=true"> [hexapoda](https://simonomi.dev/hexapoda)
a colorful modal hex editor
@@ -8,24 +8,13 @@ a colorful modal hex editor
## status
currently, hexapoda is very unpolished, and missing some major features. if you'd be interested in using it, please let me know! if enough people want, i'd be willing to make it more accessible and write some docs
still missing some notable features (see [todo.md](https://github.com/simonomi/hexapoda/blob/main/todo.md)), but ready for general use. visit [the website](https://simonomi.dev/hexapoda) for more detailed documentation
## features
## installation
- [color-codes bytes](https://simonomi.dev/blog/color-code-your-bytes) by value
- modal editing
- selection-first, like [Kakoune](https://kakoune.org) and [Helix](https://helix-editor.com)
- multiple selections
- split selection(s) into #-byte chunks
- undo/redo
- inspect the current selection(s)
- signed, unsigned, binary, fixed-point, UTF-8, color
- mark notable offsets
- jump to selected offset
- short answer: `brew install hexapoda`, `cargo binstall hexapoda`, or `cargo install hexapoda`
- [slightly longer answer](https://simonomi.dev/hexapoda/install)
### notable features that are missing (for now)
## config
- search
- diffing
- inserting bytes
- only replacing and deleting right now
see [https://simonomi.dev/hexapoda/config](https://simonomi.dev/hexapoda/config)
+13 -7
View File
@@ -7,20 +7,26 @@ use std::io::Error;
include!("src/arguments.rs");
fn main() -> Result<(), Error> {
let output_folder = match env::var_os("OUT_DIR") {
None => return Ok(()),
Some(output_folder) => output_folder,
let completions_folder = match env::var_os("HEXAPODA_COMPLETIONS") {
Some(folder) if !folder.is_empty() => folder,
_ => return Ok(()),
};
let manpage_folder = match env::var_os("HEXAPODA_MANPAGE") {
Some(folder) if !folder.is_empty() => folder,
_ => return Ok(()),
};
let mut command = Arguments::command();
for &shell in Shell::value_variants() {
generate_to(shell, &mut command, "hexapoda", &output_folder)?;
generate_to(shell, &mut command, "hexapoda", &completions_folder)?;
}
generate_to(Nushell, &mut command, "hexapoda", &output_folder)?;
generate_to(Nushell, &mut command, "hexapoda", &completions_folder)?;
clap_mangen::generate_to(command, &output_folder)?;
clap_mangen::generate_to(command, &manpage_folder)?;
println!("cargo:warning=completions and manpage generated in {output_folder:?}");
println!("cargo:warning=completions generated in {completions_folder:?}");
println!("cargo:warning=manpage generated in {manpage_folder:?}");
println!("cargo:rerun-if-changed=src/arguments.rs");
Ok(())
+22 -13
View File
@@ -29,6 +29,11 @@ func camelCaseToSnakeCase(_ string: Substring) -> String {
return output
}
let cargoTOMLPath = URL(filePath: "Cargo.toml")
let cargoTOML = try String(contentsOf: cargoTOMLPath, encoding: .utf8)
let versionNumber = cargoTOML.matches(of: #/version = "(?'number'[\d\.]+)"/#).first!.output.number
let defaultConfigPath = URL(filePath: "src/config/default.rs")
let lines = try String(contentsOf: defaultConfigPath, encoding: .utf8)
@@ -38,31 +43,35 @@ let lines = try String(contentsOf: defaultConfigPath, encoding: .utf8)
precondition(lines.first!.contains("Mode::Normal"))
var output = """
{%- highlight toml -%}
#:schema https://simonomi.dev/hexapoda/config/schema-v\(versionNumber).json
"""
var mode: String?
for line in lines {
if let match = line.wholeMatch(of: #/.*Mode::(?'mode'\w*),.*/#) {
mode = match.output.mode.lowercased()
} else if line.contains("None") {
print("[\(mode!)]")
output += "[\(mode!)]\n"
} else if let match = line.wholeMatch(of: #/.*PartialAction::(?'partialAction'\w*).*/#) {
let partialAction = match.output.partialAction.lowercased()
print("[\(mode!).\(partialAction)]")
output += "[\(mode!).\(partialAction)]\n"
} else if let match = line.wholeMatch(of: #/.*\(keypress\("(?'keypress'.*?)"\), (?'action'.*?)\.into\(\)\).*/#) {
if match.output.keypress.contains(where: { !($0.isLetter || $0.isNumber) }) {
print(
"\"\(match.output.keypress)\"",
"=",
"\"\(camelCaseToSnakeCase(match.output.action))\""
)
output += "\"\(match.output.keypress)\" = \"\(camelCaseToSnakeCase(match.output.action))\"\n"
} else {
print(
match.output.keypress,
"=",
"\"\(camelCaseToSnakeCase(match.output.action))\""
)
output += "\(match.output.keypress) = \"\(camelCaseToSnakeCase(match.output.action))\"\n"
}
} else {
print()
output += "\n"
}
}
output += "{%- endhighlight -%}\n"
let outputPath = URL(filePath: "~/Documents/programming/websites/simonomi.dev/_includes/hexapoda/hexapoda v\(versionNumber).toml")
try Data(output.utf8).write(to: outputPath)
print("wrote config to \(outputPath.path(percentEncoded: false))")
+8
View File
@@ -190,6 +190,8 @@ pub enum BufferAction {
InspectSelection,
InspectSelectionColor,
StopInspecting,
}
impl BufferAction {
@@ -264,6 +266,8 @@ impl BufferAction {
InspectSelection => true,
InspectSelectionColor => true,
StopInspecting => true,
}
}
}
@@ -339,6 +343,8 @@ impl From<BufferAction> for &str {
InspectSelection => "inspect_selection",
InspectSelectionColor => "inspect_selection_color",
StopInspecting => "stop_inspecting",
}
}
}
@@ -423,6 +429,8 @@ impl TryFrom<&str> for BufferAction {
"inspect_selection" => Ok(InspectSelection),
"inspect_selection_color" => Ok(InspectSelectionColor),
"stop_inspecting" => Ok(StopInspecting),
_ => Err(()),
}
}
+22 -15
View File
@@ -57,7 +57,7 @@ impl App {
let mut buffers: Vec<Buffer> = files
.iter()
.filter_map(|path| {
Buffer::from_file_at(path.clone())
Buffer::from_file_at(path)
.inspect_err(|error| {
error_alert = Some(
Span::raw(format!("error reading '{}': {error}", path.display())).red()
@@ -198,6 +198,7 @@ impl App {
fn handle_mouse(&mut self, mouse_event: MouseEvent) -> bool {
let position = self.mouse_event_position(mouse_event);
let tab_bar_row_count = u16::from(self.buffers.len() > 1);
let current_buffer = &mut self.buffers[self.current_buffer_index];
match mouse_event.kind {
@@ -207,6 +208,8 @@ impl App {
current_buffer.cursors.clear();
current_buffer.clamp_screen_to_primary_cursor(self.window_size);
self.is_dragging_mouse = true;
} else if mouse_event.row < tab_bar_row_count {
self.switch_to_tab_at(mouse_event.column);
}
true
@@ -248,9 +251,10 @@ impl App {
}
fn mouse_event_position(&self, mouse_event: MouseEvent) -> Option<usize> {
let tab_bar_rows = usize::from(self.buffers.len() > 1);
let tab_bar_row_count = usize::from(self.buffers.len() > 1);
if usize::from(mouse_event.row) - tab_bar_rows >= self.window_size.hex_rows() {
if usize::from(mouse_event.row) < tab_bar_row_count ||
usize::from(mouse_event.row) - tab_bar_row_count >= self.window_size.hex_rows() {
return None;
}
@@ -282,20 +286,23 @@ impl App {
byte_column.map(|byte_column| {
current_buffer.scroll_position +
(mouse_event.row as usize - tab_bar_rows) * BYTES_PER_LINE +
(mouse_event.row as usize - tab_bar_row_count) * BYTES_PER_LINE +
byte_column
})
}
// if let Some(byte_column) = byte_column &&
// mouse_event.row as usize - tab_bar_rows < self.window_size.hex_rows()
// {
// Some(
// current_buffer.scroll_position +
// (mouse_event.row as usize - tab_bar_rows) * BYTES_PER_LINE +
// byte_column
// )
// } else {
// None
// }
fn switch_to_tab_at(&mut self, column: u16) {
let mut column: usize = column.into();
for buffer_index in 0..self.buffers.len() {
let tab_width = self.buffers[buffer_index].file_name.len() + 2;
if column < tab_width {
self.current_buffer_index = buffer_index;
return;
}
column -= tab_width;
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ impl App {
self.quit();
} else {
self.buffers[self.current_buffer_index].alert_message = Span::from(
"there are unsaved changes, use Q to override"
"unsaved changes, use <space>w to save or Q to override"
).red();
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
use std::{collections::HashSet, fs::File, io::{self, Read}, path::PathBuf};
use std::{collections::HashSet, fs::File, io::{self, Read}, path::{Path, PathBuf}};
use crossterm::event::KeyEvent;
use ratatui::{style::Stylize, text::Span};
use serde::{Deserialize, Serialize};
@@ -75,7 +75,9 @@ impl TryFrom<&str> for PartialAction {
}
impl Buffer {
pub fn from_file_at(file_path: PathBuf) -> io::Result<Self> {
pub fn from_file_at(file_path: &Path) -> io::Result<Self> {
let file_path = file_path.canonicalize()?;
let mut file = File::open(&file_path)?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
+6 -5
View File
@@ -72,6 +72,8 @@ impl Buffer {
BufferAction::InspectSelection => self.inspect_selection(),
BufferAction::InspectSelectionColor => self.inspect_selection_color(),
BufferAction::StopInspecting => {},
}
}
@@ -949,11 +951,10 @@ const fn is_illegal_control_character(character: char) -> bool {
}
}
const fn color555_to_color888(color555: u16) -> [u8; 3] {
fn color555_to_color888(color555: u16) -> [u8; 3] {
[
// 8 is the ratio between the number of colors in 555 vs 888 (32:256)
(color555 & 0b11111) as u8 * 8,
(color555 >> 5 & 0b11111) as u8 * 8,
(color555 >> 10 & 0b11111) as u8 * 8
(u8::try_from((color555 & 0b11111) * 255 / 31).unwrap()),
(u8::try_from((color555 >> 5 & 0b11111) * 255 / 31).unwrap()),
(u8::try_from((color555 >> 10 & 0b11111) * 255 / 31).unwrap())
]
}
+3
View File
@@ -10,6 +10,9 @@ mod extra_statuses;
impl Widget for &Buffer {
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
// set OSC 6 (current document)
print!("\x1B]6;{}\x07", self.file_path.display());
let screen_end = self.scroll_position + BYTES_PER_LINE * (area.height as usize - 1);
let bytes_end = min(screen_end, self.contents.len());
+10 -12
View File
@@ -1,18 +1,16 @@
use crate::{buffer::{Buffer, Mode}, utilities::CustomGreys};
use ratatui::{style::{Color, Stylize}, text::{Line, Span, Text}};
use ratatui::{style::{Color, Stylize}, text::{Line, Span}};
impl Buffer {
pub fn render_status_line(&self) -> Text<'_> {
Text::from(
Line::from_iter([
self.render_mode(),
" ".into(),
self.render_file_name(),
self.modified_indicator(),
" ".into(),
self.alert_message.clone()
])
)
pub fn render_status_line(&self) -> Line<'_> {
Line::from_iter([
self.render_mode(),
" ".into(),
self.render_file_name(),
self.modified_indicator(),
" ".into(),
self.alert_message.clone()
])
.bg(Color::ui_grey())
}
+16
View File
@@ -92,12 +92,19 @@ impl Default for Config {
(keypress("C- "), InspectSelection.into()),
(keypress("A- "), InspectSelectionColor.into()),
(keypress("escape"), StopInspecting.into()),
].into()),
(Some(PartialAction::Goto), [
(keypress("j"), GotoLineStart.into()),
(keypress("l"), GotoLineEnd.into()),
(keypress("g"), GotoFileStart.into()),
(keypress("i"), GotoFileStart.into()),
(keypress("k"), GotoFileEnd.into()),
(keypress("p"), PreviousBuffer.into()),
(keypress("n"), NextBuffer.into()),
].into()),
(Some(PartialAction::View), [
(keypress("z"), AlignViewCenter.into()),
@@ -153,6 +160,7 @@ impl Default for Config {
(keypress("Q"), Quit.into()),
(keypress("v"), NormalMode.into()),
(keypress("escape"), NormalMode.into()),
(keypress("g"), Goto.into()),
(keypress("z"), View.into()),
@@ -197,6 +205,9 @@ impl Default for Config {
(keypress("u"), Undo.into()),
(keypress("U"), Redo.into()),
(keypress("C-j"), PreviousBuffer.into()),
(keypress("C-l"), NextBuffer.into()),
(keypress("C"), CopySelectionOnNextLine.into()),
(keypress("("), RotateSelectionsBackward.into()),
@@ -230,6 +241,11 @@ impl Default for Config {
(keypress("l"), ExtendLineEnd.into()),
(keypress("g"), ExtendFileStart.into()),
(keypress("i"), ExtendFileStart.into()),
(keypress("k"), ExtendFileEnd.into()),
(keypress("p"), PreviousBuffer.into()),
(keypress("n"), NextBuffer.into()),
].into()),
(Some(PartialAction::View), [
(keypress("z"), AlignViewCenter.into()),
-28
View File
@@ -25,34 +25,6 @@ const CHUNKS_PER_LINE: usize = BYTES_PER_LINE / BYTES_PER_CHUNK;
const LINES_OF_PADDING: usize = 5;
const BYTES_OF_PADDING: usize = LINES_OF_PADDING * BYTES_PER_LINE;
// TODO:
// - `go` goto entered offset
// - search
// - `/` hex, `A-/` ascii
// - if non-hex-digit typed, search ascii
// - inspector translations for varint
// - M mark at selected offset? (like Jm)
// - diffing
// - doesn't have to be anything fancy, just compare each byte 1:1
// - sync scroll ? sync selections ??
// - s/A-k/A-K
// - sm select marks
// - C-a/C-x
// - +/- to edit selected bytes by amount ?
// - operate on entire selection (u16/u32/etc)
// - hex or decimal ?
// - modifications
// - insert/append
// - mode
// - add to edit history when *leaving* insert mode
// - replace-and-keep-going
// - mode
// - change
// - A-r replaces with ASCII
// - jumplist
// - p
// - [/] to cycle view offset?
fn main() {
let arguments = Arguments::parse();
+31
View File
@@ -0,0 +1,31 @@
# todo
- v1.0
- `T` Till
- `go` goto entered offset
- search
- `/` hex, `A-/` ASCII
- if non-hex-digit typed, search ASCII
- `A-*` repeat (entered number) times
- copy/paste (`<space>y`/`<space>p`)
- inspector translations for varint [#1](https://github.com/simonomi/hexapoda/issues/1#issue-4232822634)
- `M` mark at selected offset? (like `Jm`)
- diffing
- doesn't have to be anything fancy, just compare each byte 1:1
- sync scroll ? sync selections ??
- `s`/`A-k`/`A-K`
- sm select marks
- `C-a`/`C-x`
- `+`/`-` to edit selected bytes by amount ?
- operate on entire selection (u16/u32/etc)
- hex or decimal ?
- modifications
- insert/append
- mode
- add to edit history when *leaving* insert mode
- replace-and-keep-going
- mode
- change (basically `dh`)
- `p` put
- `A-r` replaces with ASCII
- jumplist (`C-o`/`C-i`)
- `[`/`]` to cycle view offset?