├── .gitignore ├── .npmignore ├── logo.png ├── preview.webm ├── readme.md ├── tsconfig.json ├── generate-bin.ts ├── src ├── components │ ├── ErrorDialog.tsx │ ├── SaveDialog.tsx │ ├── JumpDialog.tsx │ ├── StatusInfo.tsx │ ├── SearchDialog.tsx │ ├── HelpScreen.tsx │ ├── HexView.tsx │ └── InputField.tsx ├── hooks │ ├── use-movement.ts │ ├── use-edit.ts │ └── use-buffer.ts ├── utils.ts └── index.tsx ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | bin/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | generate-bin.js 3 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francisrstokes/bewitched/HEAD/logo.png -------------------------------------------------------------------------------- /preview.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/francisrstokes/bewitched/HEAD/preview.webm -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 🧙🏻 Bewitched 2 | 3 | 4 | 5 | `Bewitched` is a command line hex editor. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm i -g bewitched 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```bash 16 | bewitched 17 | ``` 18 | 19 | [![asciicast](https://asciinema.org/a/452657.svg)](https://asciinema.org/a/452657) 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitAny": true, 5 | "removeComments": false, 6 | "preserveConstEnums": true, 7 | "sourceMap": false, 8 | "target": "ESNext", 9 | "esModuleInterop": true, 10 | "jsx": "react", 11 | "outDir": "bin", 12 | }, 13 | "include": ["./src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /generate-bin.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | if (process.argv.length < 4) { 4 | console.log('Usage: generate-bin.ts '); 5 | process.exit(1); 6 | } 7 | 8 | const numBytes = Number(process.argv[2]); 9 | const filename = process.argv[3]; 10 | 11 | const randomByte = () => (Math.random() * 256) | 0; 12 | 13 | const bytes = Array.from({ length: numBytes }, randomByte); 14 | const buffer = new Uint8Array(bytes); 15 | 16 | fs.writeFile(filename, buffer).then(() => { 17 | // 👍 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/ErrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, useInput } from "ink"; 2 | import React from "react"; 3 | import { AppState, SetStateFn } from "../utils"; 4 | 5 | type ErrorDialogProps = { 6 | error: string; 7 | setAppState: SetStateFn; 8 | } 9 | export const ErrorDialog = ({ error, setAppState }: ErrorDialogProps) => { 10 | useInput((_, key) => { 11 | if (key.escape) { 12 | setAppState(AppState.Edit); 13 | } 14 | }); 15 | 16 | return 17 | Error: 18 | {error} 19 | ; 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/use-movement.ts: -------------------------------------------------------------------------------- 1 | import {useInput} from 'ink'; 2 | import { CursorCommands } from "./use-buffer"; 3 | 4 | type MovementParams = { 5 | cursorCommands: CursorCommands; 6 | enabled: boolean; 7 | } 8 | export const useMovement = ({cursorCommands, enabled}: MovementParams) => { 9 | useInput((_, key) => { 10 | if (key.leftArrow) return cursorCommands.left(); 11 | if (key.rightArrow) return cursorCommands.right(); 12 | if (key.upArrow) return cursorCommands.up(); 13 | if (key.downArrow) return cursorCommands.down(); 14 | }, {isActive: enabled}); 15 | } 16 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const SCREEN_W = 80; 2 | export const SCREEN_H = 24; 3 | export const HEXVIEW_W = SCREEN_W; 4 | export const HEXVIEW_H = SCREEN_H - 3; 5 | export const BYTES_PER_LINE = 16; 6 | 7 | export const toHex = (n: number, l: number) => n.toString(16).padStart(l, '0'); 8 | 9 | export type SetStateFn = (x: T) => void; 10 | export enum AppState { 11 | Edit = 'Edit', 12 | Save = 'Save', 13 | Help = 'Help', 14 | Jump = 'Jump', 15 | Search = 'Search', 16 | Error = 'Error', 17 | }; 18 | 19 | export enum AlternateAddressViewMode { 20 | Hex, 21 | Decimal 22 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bewitched", 3 | "version": "0.5.0", 4 | "description": "🧙🏻 Command line hex editor", 5 | "main": "bin/index.js", 6 | "bin": { 7 | "bewitched": "./bin/index.js" 8 | }, 9 | "scripts": { 10 | "build": "tsc && chmod +x ./bin/index.js", 11 | "prepack": "npm run build" 12 | }, 13 | "keywords": [], 14 | "author": "Francis Stokes", 15 | "license": "MIT", 16 | "dependencies": { 17 | "ink": "^3.2.0", 18 | "react": "^17.0.2" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^17.0.37", 22 | "ts-node": "^10.4.0", 23 | "typescript": "^4.5.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/SaveDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as fs from 'fs/promises'; 3 | import { AppState, SetStateFn } from "../utils"; 4 | import { InputField } from "./InputField"; 5 | import * as path from 'path'; 6 | 7 | type SaveDialogProps = { 8 | buffer: Uint8Array; 9 | setAppState: SetStateFn; 10 | setErrorMsg: SetStateFn; 11 | openFilePath: string; 12 | } 13 | export const SaveDialog = ({ buffer, setAppState, setErrorMsg, openFilePath }: SaveDialogProps) => { 14 | return { 18 | fs.writeFile(path.resolve(filepath), buffer) 19 | .then(() => setAppState(AppState.Edit)) 20 | .catch(() => { 21 | setErrorMsg(`Error when saving file [${filepath}]`); 22 | setAppState(AppState.Error); 23 | }); 24 | }} 25 | onEscape={() => setAppState(AppState.Edit)} 26 | />; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/JumpDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import { AppState, SetStateFn } from "../utils"; 3 | import { InputField } from "./InputField"; 4 | 5 | type JumpDialogProps = { 6 | setAppState: SetStateFn; 7 | jumpToOffsset: (jumpOffset: number) => void; 8 | } 9 | export const JumpDialog = ({ setAppState, jumpToOffsset }: JumpDialogProps) => { 10 | const [isHex, setIsHex] = useState(false); 11 | return { 15 | setIsHex(address.startsWith('0x')); 16 | }} 17 | onEnter={address => { 18 | if (address.startsWith('0x')) { 19 | if (address.length > 2) jumpToOffsset(parseInt(address, 16)); 20 | } else if (address.length) { 21 | jumpToOffsset(Number(address)); 22 | } 23 | setAppState(AppState.Edit); 24 | }} 25 | onEscape={() => setAppState(AppState.Edit)} 26 | />; 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Francis Stokes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/StatusInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text, useInput } from "ink"; 2 | import React from "react"; 3 | import { AlternateAddressViewMode, SetStateFn, toHex } from "../utils"; 4 | 5 | const toDecimal = (n: number, l = 8) => n.toString().padStart(l, ' '); 6 | 7 | type StatusInfoProps = { 8 | buffer: Uint8Array; 9 | cursor: number; 10 | alternateAddressMode: AlternateAddressViewMode; 11 | setAlternateAddressMode: SetStateFn; 12 | } 13 | export const StatusInfo = ({ 14 | buffer, 15 | cursor, 16 | alternateAddressMode, 17 | setAlternateAddressMode 18 | }: StatusInfoProps) => { 19 | 20 | useInput(input => { 21 | if (input === 'v') { 22 | if (alternateAddressMode === AlternateAddressViewMode.Hex) { 23 | setAlternateAddressMode(AlternateAddressViewMode.Decimal); 24 | } else { 25 | setAlternateAddressMode(AlternateAddressViewMode.Hex); 26 | } 27 | } 28 | }); 29 | 30 | const formatFn = alternateAddressMode === AlternateAddressViewMode.Hex 31 | ? toHex 32 | : toDecimal; 33 | 34 | return 35 | Offset [{formatFn(cursor, 8)}] 36 | ({buffer.byteLength === 0 ? '-' : buffer[cursor]}) 37 | [?] Help 38 | ; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SearchDialog.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import { Text } from 'ink'; 3 | import { AppState, SetStateFn } from "../utils"; 4 | import { InputField } from "./InputField"; 5 | 6 | type SearchDialogProps = { 7 | setAppState: SetStateFn; 8 | searchForSequence: (search: Uint8Array) => boolean; 9 | } 10 | export const SearchDialog = ({ setAppState, searchForSequence }: SearchDialogProps) => { 11 | const [showNotFound, setShowNotFound] = useState(false); 12 | return ( 13 | <> 14 | {showNotFound ? Not found : null} 15 | { 19 | if (showNotFound) { 20 | setShowNotFound(false); 21 | } 22 | }} 23 | onEnter={byteSequence => { 24 | const byteArray = byteSequence.split(/\s/).flatMap(n => { 25 | const out = []; 26 | let p = parseInt(n, 16); 27 | 28 | do { 29 | out.push(p & 0xff); 30 | p >>>= 8; 31 | } while (p > 255); 32 | 33 | return out; 34 | }); 35 | 36 | if (!searchForSequence(new Uint8Array(byteArray))) { 37 | setShowNotFound(true); 38 | } else { 39 | setAppState(AppState.Edit); 40 | } 41 | }} 42 | onEscape={() => setAppState(AppState.Edit)} 43 | /> 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/HelpScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text, useInput } from "ink"; 3 | 4 | type HelpScreenProps = { exit: () => void; } 5 | export const HelpScreen = ({ exit }: HelpScreenProps) => { 6 | useInput((_, key) => { 7 | if (key.escape) { 8 | exit(); 9 | } 10 | }); 11 | 12 | const helpItems: Array<[string, string]> = [ 13 | ['←↑↓→ ', ' Move the cursor'], 14 | ['[a-f0-9] ', ' Edit the currently selected byte'], 15 | ['Del/Backspace', ' Delete the currently selected byte'], 16 | ['/ ', ' Search for the next occurrence of a byte sequence'], 17 | ['i ', ' Insert a zero byte before the cursor'], 18 | ['I ', ' Insert a zero byte after the cursor'], 19 | ['j ', ' Jump to a specific offset in the file'], 20 | ['v ', ' Switch the address display in the status line between hex'], 21 | [' ', ' and decimal'], 22 | ['Ctrl+S ', ' Save this file'], 23 | ['Esc ', ' Exit any menu'], 24 | ['? ', ' Show this help menu'], 25 | ]; 26 | 27 | return 35 | 36 | Bewitched :: Help 37 | 38 | 39 | {helpItems.map(([key, text]) => ( 40 | 41 | {key} 42 | {text} 43 | 44 | ))} 45 | ; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/HexView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text } from "ink"; 3 | import { BYTES_PER_LINE, HEXVIEW_H, toHex } from "../utils"; 4 | 5 | type HexViewProps = { 6 | buffer: Uint8Array; 7 | cursor: number; 8 | offset: number; 9 | } 10 | export const HexView = ({ buffer, cursor, offset: startOffset }: HexViewProps) => { 11 | const lines: React.ReactElement[] = []; 12 | 13 | for (let lineNumber = 0; lineNumber < HEXVIEW_H; lineNumber++) { 14 | const offset = startOffset + (lineNumber * BYTES_PER_LINE); 15 | const slice = [...buffer.slice(offset, offset + BYTES_PER_LINE)]; 16 | 17 | const offsetComponent = {toHex(offset, 8)}: ; 18 | const bytes = slice.map((byte, i) => { 19 | const spacing = ((i + 1) % 4 === 0) ? ' ' : ' '; 20 | if (offset + i === cursor) { 21 | return 22 | {toHex(byte, 2)} 23 | {spacing} 24 | ; 25 | } 26 | return {toHex(byte, 2)}{spacing}; 27 | }); 28 | 29 | if (bytes.length < BYTES_PER_LINE) { 30 | const wordSpaces = 4 - Math.floor(bytes.length / 4); 31 | const diff = BYTES_PER_LINE - bytes.length; 32 | const padding = {' '.repeat(diff)}{' '.repeat(wordSpaces)}; 33 | bytes.push(padding); 34 | } 35 | 36 | const bytesComponent = {bytes}; 37 | const asciiComponent = 38 | | 39 | {slice.map((byte, i) => { 40 | const char = (byte >= 0x20 && byte < 0x7f) 41 | ? String.fromCharCode(byte) 42 | : '.'; 43 | 44 | if (offset + i === cursor) { 45 | return {char} 46 | } 47 | return {char} 48 | })} 49 | | 50 | ; 51 | 52 | lines.push({offsetComponent}{bytesComponent}{asciiComponent}); 53 | } 54 | 55 | return {lines}; 56 | } 57 | -------------------------------------------------------------------------------- /src/hooks/use-edit.ts: -------------------------------------------------------------------------------- 1 | import { useInput } from 'ink'; 2 | import { useEffect, useState } from "react"; 3 | import { AppState, SetStateFn } from '../utils'; 4 | import { BufferCommands, SearchCommands } from './use-buffer'; 5 | 6 | const hexRegex = /[0-9a-f]/; 7 | const isHexChar = (char: string) => hexRegex.test(char); 8 | 9 | enum CommandChar { 10 | InsertByteAtCursor = 'i', 11 | InsertByteAfterCursor = 'I', 12 | } 13 | 14 | type EditParams = { 15 | cursor: number; 16 | buffer: Uint8Array; 17 | bufferCommands: BufferCommands; 18 | searchCommands: SearchCommands; 19 | moveCursorRight: () => void; 20 | setAppState: SetStateFn; 21 | enabled: boolean; 22 | } 23 | export const useEdit = ({ 24 | cursor, 25 | moveCursorRight, 26 | bufferCommands, 27 | searchCommands, 28 | buffer, 29 | setAppState, 30 | enabled 31 | }: EditParams) => { 32 | const [isMSN, setIsMSN] = useState(true); 33 | 34 | useEffect(() => { 35 | setIsMSN(true); 36 | }, [cursor, buffer]); 37 | 38 | useInput((input, key) => { 39 | if (isHexChar(input)) { 40 | const value = parseInt(input, 16); 41 | if (isMSN) { 42 | const newByte = (value << 4) | (buffer[cursor] & 0x0f); 43 | bufferCommands.updateAtCursor(newByte); 44 | } else { 45 | const newByte = (buffer[cursor] & 0xf0) | value; 46 | bufferCommands.updateAtCursor(newByte); 47 | moveCursorRight(); 48 | } 49 | setIsMSN(!isMSN); 50 | return; 51 | } 52 | 53 | if (input === '?') { 54 | return setAppState(AppState.Help); 55 | } 56 | 57 | if (input === 'j') { 58 | return setAppState(AppState.Jump); 59 | } 60 | 61 | if (input === '/') { 62 | return setAppState(AppState.Search); 63 | } 64 | 65 | if (key.delete || key.backspace) { 66 | bufferCommands.delete(); 67 | return; 68 | } 69 | 70 | switch (input) { 71 | case CommandChar.InsertByteAtCursor: { 72 | bufferCommands.insertAtCursor(new Uint8Array([0])); 73 | return; 74 | } 75 | case CommandChar.InsertByteAfterCursor: { 76 | bufferCommands.insertAfterCursor(new Uint8Array([0])); 77 | return; 78 | } 79 | } 80 | 81 | if (key.ctrl && input === 's') { 82 | return setAppState(AppState.Save); 83 | } 84 | }, {isActive: enabled}); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/InputField.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useInput, Text, Box } from "ink"; 3 | 4 | type InputFieldProps = { 5 | onEnter?: (data: string) => void | Promise; 6 | onChange?: (data: string) => void | Promise; 7 | onEscape?: (data: string) => void | Promise; 8 | initialValue?: string; 9 | label?: string; 10 | mask?: RegExp; 11 | } 12 | export const InputField = ({ 13 | label = '', 14 | initialValue = '', 15 | onEnter = () => void 0, 16 | onChange = () => void 0, 17 | onEscape = () => void 0, 18 | mask 19 | }: InputFieldProps) => { 20 | const [value, setValue] = useState(initialValue); 21 | const [cursor, setCursor] = useState(initialValue.length); 22 | 23 | useInput((input, key) => { 24 | if (key.escape) return onEscape(value); 25 | 26 | if (key.backspace || key.delete) { 27 | if (value.length === 0) { 28 | setCursor(0); 29 | return; 30 | } 31 | 32 | const part1 = value.slice(0, cursor - 1); 33 | const part2 = value.slice(cursor); 34 | const newValue = part1 + part2; 35 | 36 | if (mask && !mask.test(newValue)) { 37 | return; 38 | } 39 | 40 | setValue(newValue); 41 | setCursor(cursor - 1); 42 | onChange(newValue); 43 | return; 44 | } 45 | 46 | if (key.leftArrow) return setCursor(Math.max(0, cursor - 1)); 47 | if (key.rightArrow) return setCursor(Math.min(value.length, cursor + 1)); 48 | if (key.upArrow || key.downArrow) return; 49 | 50 | if (key.return) return onEnter(value);; 51 | 52 | if (input !== '') { 53 | const part1 = value.slice(0, cursor); 54 | const part2 = value.slice(cursor); 55 | const newValue = part1 + input + part2; 56 | 57 | if (mask && !mask.test(newValue)) { 58 | return; 59 | } 60 | 61 | setValue(newValue); 62 | setCursor(cursor + input.length); 63 | onChange(newValue); 64 | } 65 | }); 66 | 67 | const textComponents = value.split('').map((char, i) => { 68 | if (i === cursor) { 69 | return {char}; 70 | } 71 | return {char}; 72 | }); 73 | 74 | return 75 | {label} 76 | {textComponents} 77 | {cursor === value.length ? : null} 78 | ; 79 | }; 80 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import React, {useEffect, useState} from 'react'; 3 | import {render, Text, Box} from 'ink'; 4 | import * as fs from 'fs/promises'; 5 | import * as path from 'path'; 6 | import { AlternateAddressViewMode, AppState, SCREEN_W } from './utils'; 7 | import { useBuffer } from './hooks/use-buffer'; 8 | import { useMovement } from './hooks/use-movement'; 9 | import { useEdit } from './hooks/use-edit'; 10 | import { HexView } from './components/HexView'; 11 | import { HelpScreen } from './components/HelpScreen'; 12 | import { SaveDialog } from './components/SaveDialog'; 13 | import { StatusInfo } from './components/StatusInfo'; 14 | import { JumpDialog } from './components/JumpDialog'; 15 | import { ErrorDialog } from './components/ErrorDialog'; 16 | import { SearchDialog } from './components/SearchDialog'; 17 | 18 | if (process.argv.length < 3) { 19 | console.log('Usage: betwitched '); 20 | process.exit(1); 21 | } 22 | 23 | const inputFile = path.resolve(process.argv[2]); 24 | 25 | const App = () => { 26 | const { 27 | buffer, 28 | cursor, 29 | offset, 30 | cursorCommands, 31 | bufferCommands, 32 | searchCommands, 33 | jumpToOffset 34 | } = useBuffer(); 35 | 36 | const [errorMsg, setErrorMsg] = useState(''); 37 | const [appState, setAppState] = useState(AppState.Edit); 38 | const [alternateAddressMode, setAlternateAddressMode] = useState(AlternateAddressViewMode.Hex); 39 | 40 | useEffect(() => { 41 | fs.readFile(inputFile).then(file => { 42 | bufferCommands.insertAtCursor(new Uint8Array(file.buffer)); 43 | }); 44 | }, []); 45 | 46 | useMovement({ 47 | cursorCommands, 48 | enabled: appState === AppState.Edit 49 | }); 50 | 51 | useEdit({ 52 | buffer, 53 | bufferCommands, 54 | searchCommands, 55 | cursor, 56 | moveCursorRight: cursorCommands.right, 57 | setAppState, 58 | enabled: appState === AppState.Edit, 59 | }); 60 | 61 | return appState === AppState.Help ? setAppState(AppState.Edit)} /> : ( 62 | 63 | 64 | 65 | {'-'.repeat(SCREEN_W)} 66 | { 67 | appState === AppState.Edit 68 | ? 74 | : appState === AppState.Save 75 | ? 76 | : appState === AppState.Jump 77 | ? 78 | : appState === AppState.Search 79 | ? 80 | : appState === AppState.Error 81 | ? 82 | : null 83 | } 84 | {'-'.repeat(SCREEN_W)} 85 | 86 | 87 | ); 88 | }; 89 | 90 | render(); 91 | -------------------------------------------------------------------------------- /src/hooks/use-buffer.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react" 2 | import { BYTES_PER_LINE, HEXVIEW_H } from "../utils"; 3 | 4 | export type CursorCommands = { 5 | left: () => void; 6 | right: () => void; 7 | up: () => void; 8 | down: () => void; 9 | }; 10 | 11 | export type BufferCommands = { 12 | updateAtCursor: (byte: number) => void; 13 | insertAtCursor: (bytes: Uint8Array) => void; 14 | insertAfterCursor: (bytes: Uint8Array) => void; 15 | delete: () => void; 16 | }; 17 | 18 | export type SearchCommands = { 19 | searchForSequence: (bytes: Uint8Array) => boolean; 20 | } 21 | 22 | const cursorIsVisible = (cursor: number, offset: number) => ( 23 | cursor >= offset && cursor < (offset + HEXVIEW_H * BYTES_PER_LINE) 24 | ); 25 | 26 | export const useBuffer = () => { 27 | // We'll use a ref instead of a state, because we don't want to create a new Uint8Array with 28 | // every edit. And then we'll use a state to trigger the re-render whenever we mutate the buffer. 29 | const bufferRef = useRef(); 30 | const [_, forceRender] = useState({}); 31 | if (!bufferRef.current) { 32 | bufferRef.current = new Uint8Array(0); 33 | } 34 | // Just make the `useRef` workaround transparent in the rest of the codebase. 35 | const buffer = bufferRef.current; 36 | const setBuffer = (newBuffer: Uint8Array) => { 37 | bufferRef.current = newBuffer; 38 | forceRender({}); 39 | }; 40 | 41 | const [cursor, setCursor] = useState(0); 42 | const [offset, setOffset] = useState(0); 43 | 44 | const cursorCommands: CursorCommands = { 45 | left: () => { 46 | if (buffer.byteLength === 0) return; 47 | const newValue = Math.max(0, cursor - 1); 48 | setCursor(newValue); 49 | if (!cursorIsVisible(newValue, offset)) { 50 | setOffset(Math.max(0, offset - BYTES_PER_LINE)); 51 | } 52 | }, 53 | up: () => { 54 | if (buffer.byteLength === 0) return; 55 | const newValue = Math.max(0, cursor - BYTES_PER_LINE); 56 | setCursor(newValue); 57 | if (!cursorIsVisible(newValue, offset)) { 58 | setOffset(Math.max(0, offset - BYTES_PER_LINE)); 59 | } 60 | }, 61 | right: () => { 62 | if (buffer.byteLength === 0) return; 63 | const newValue = Math.min(buffer.byteLength - 1, cursor + 1); 64 | setCursor(newValue); 65 | if (!cursorIsVisible(newValue, offset)) { 66 | setOffset(Math.min(Math.max(0, buffer.byteLength - 1), offset + BYTES_PER_LINE)); 67 | } 68 | }, 69 | down: () => { 70 | if (buffer.byteLength === 0) return; 71 | const newValue = Math.min(buffer.byteLength - 1, cursor + BYTES_PER_LINE); 72 | setCursor(newValue); 73 | if (!cursorIsVisible(newValue, offset)) { 74 | setOffset(Math.min(buffer.byteLength - 1, offset + BYTES_PER_LINE)); 75 | } 76 | }, 77 | }; 78 | 79 | const updateAtCursor = (byte: number) => { 80 | buffer[cursor] = byte; 81 | setBuffer(buffer); 82 | } 83 | 84 | const insertAtCursor = (bytes: Uint8Array) => { 85 | const newSize = buffer.byteLength + bytes.byteLength; 86 | const newBuffer = new Uint8Array(newSize); 87 | 88 | newBuffer.set(buffer.slice(0, cursor), 0); 89 | newBuffer.set(bytes, cursor); 90 | newBuffer.set(buffer.slice(cursor + bytes.byteLength - 1), cursor + bytes.byteLength); 91 | 92 | setBuffer(newBuffer); 93 | }; 94 | 95 | const insertAfterCursor = (bytes: Uint8Array) => { 96 | const newSize = buffer.byteLength + bytes.byteLength; 97 | const newBuffer = new Uint8Array(newSize); 98 | 99 | newBuffer.set(buffer.slice(0, cursor + 1), 0); 100 | newBuffer.set(bytes, cursor + 1); 101 | newBuffer.set(buffer.slice(cursor + bytes.byteLength), cursor + bytes.byteLength + 1); 102 | 103 | setBuffer(newBuffer); 104 | }; 105 | 106 | const deleteByte = () => { 107 | if (buffer.byteLength === 0) return; 108 | 109 | const newSize = buffer.byteLength - 1; 110 | const newBuffer = new Uint8Array(newSize); 111 | 112 | newBuffer.set(buffer.slice(0, cursor), 0); 113 | newBuffer.set(buffer.slice(cursor + 1), cursor); 114 | 115 | setBuffer(newBuffer); 116 | setCursor(Math.max(0, Math.min(newSize-1, cursor))); 117 | }; 118 | 119 | const jumpToOffset = (jumpOffset: number) => { 120 | if (jumpOffset < 0 || !Number.isInteger(jumpOffset) || buffer.byteLength === 0) return; 121 | setOffset(Math.min(buffer.byteLength - 1, jumpOffset) & 0xfffffff0); 122 | setCursor(Math.min(buffer.byteLength - 1, jumpOffset)); 123 | }; 124 | 125 | const searchForSequence = (search: Uint8Array) => { 126 | for (let bi = 0; bi < buffer.byteLength; bi++) { 127 | let found = true; 128 | 129 | for (let si = 0; si < search.byteLength; si++) { 130 | if (buffer[bi + si] !== search[si]) { 131 | found = false; 132 | break; 133 | } 134 | } 135 | 136 | if (found) { 137 | jumpToOffset(bi); 138 | return true; 139 | } 140 | } 141 | 142 | return false; 143 | } 144 | 145 | const bufferCommands: BufferCommands = { 146 | updateAtCursor, 147 | insertAtCursor, 148 | insertAfterCursor, 149 | delete: deleteByte 150 | }; 151 | 152 | const searchCommands: SearchCommands = { 153 | searchForSequence 154 | } 155 | 156 | return { 157 | buffer, 158 | cursor, 159 | setCursor, 160 | cursorCommands, 161 | bufferCommands, 162 | searchCommands, 163 | offset, 164 | jumpToOffset, 165 | }; 166 | } 167 | --------------------------------------------------------------------------------