├── .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 | [](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 |
--------------------------------------------------------------------------------