#! /bin/env python3

import curses
import curses.ascii
from enum import auto, Enum
import os
import struct
import sys
import traceback


# ========================================
#
# Numerical/endian state.


class EditorNumberEndian(Enum):

    NATIVE = "="
    LITTLE = "<"
    BIG = ">"


class EditorNumberBase(Enum):

    BINARY = auto()
    OCTAL = auto()
    DECIMAL = auto()
    HEXIDECIMAL = auto()


NUMBER_BASES = {
    EditorNumberBase.BINARY: {
        "name": "Binary",
        "digits": "01",
        "base": 2,
        "max_length": 8,
        "get_format": lambda n: f"{n:08b}",
    },
    EditorNumberBase.OCTAL: {
        "name": "Octal",
        "digits": "01234567",
        "base": 8,
        "max_length": 3,
        "get_format": lambda n: f"{n:03o}",
    },
    EditorNumberBase.DECIMAL: {
        "name": "Decimal",
        "digits": "0123456789",
        "base": 10,
        "max_length": 3,
        "get_format": lambda n: f"{n:03d}",
    },
    EditorNumberBase.HEXIDECIMAL: {
        "name": "Hexidecimal",
        "digits": "0123456789abcdef",
        "base": 16,
        "max_length": 2,
        "get_format": lambda n: f"{n:02x}",
    },
}


# ========================================
#
# Interface focus.


class EditorFocus(Enum):

    EDIT_BYTES = auto()
    EDIT_ASCII = auto()
    EDIT_CAPTURE_KEY = auto()
    EDIT_MODIFY = auto()
    SELECT_NUMBER_ENDIAN = auto()
    SELECT_NUMBER_BASE = auto()
    SEARCH = auto()
    GOTO_BYTE = auto()
    CONFIRM_EXIT = auto()
    CONFIRM_READ_FILE = auto()
    CONFIRM_WRITE_FILE = auto()


# ========================================
#
# Miscellaneous keys.


KEY_ESCAPE = "\x1b"
KEY_CTRL_B = "\x02"
KEY_CTRL_C = "\x03"
KEY_CTRL_E = "\x05"
KEY_CTRL_G = "\x07"
KEY_CTRL_K = "\x0b"
KEY_CTRL_L = "\x0c"
KEY_CTRL_M = "\x0a"
KEY_CTRL_N = "\x0e"
KEY_CTRL_Q = "\x11"
KEY_CTRL_R = "\x12"
KEY_CTRL_S = "\x13"
KEY_CTRL_T = "\x14"
KEY_CTRL_U = "\x15"
KEY_CTRL_W = "\x17"
KEY_BACKSPACE = "\x7f"
KEY_DELETE = "KEY_DC"
KEY_INSERT = "KEY_IC"


# ========================================
#
# Editor.


class EditorResetFocus(Exception):

    pass


class EditorPopFocus(Exception):

    pass


class EditorReplaceFocus(Exception):

    pass


class Editor:

    def __init__(self, path):
        # IO.
        self.path = path
        self.last_modification_time = os.path.getmtime(path)

        self.bytes = []

        self.modified = False

        self.readFile(force=True)

        # Config.
        self.bytes_per_row = 16

        # Curses.
        self.is_alive = True

        self.scr = curses.initscr()

        self.scr.keypad(True)

        curses.noecho()
        curses.raw()

        curses.start_color()
        curses.use_default_colors()

        self.initColors()

        self.terminal_size = (1, 1)
        self.terminal_min_size = [5, 80]
        self.terminal_too_small = True

        # Input state.
        self.key = ""
        self.key_ch = ""
        self.key_ord = 0

        # Edit state.
        self.scroll_offset = 0

        # 0=center, 1=top, 2=bottom
        self.scroll_cursor_align = 0

        self.edit_cursor_row = 0
        self.edit_cursor_col = 0

        self.edit_byte_index = 0
        self.edit_overwrite = True

        # Byte editing.
        self.number_endian = EditorNumberEndian.NATIVE
        self.number_base = EditorNumberBase.HEXIDECIMAL

        self.edit_modify_number_base = self.number_base

        # Pre-focus state.
        self.edit_byte_index_original = 0
        self.edit_byte_original = 0

        # Interface focus.
        self.focus_stack = []
        self.focus = None

        self.focus_funcs = {
            EditorFocus.EDIT_BYTES: self.focusEditBytes,
            EditorFocus.EDIT_ASCII: self.focusEditAscii,
            EditorFocus.EDIT_CAPTURE_KEY: self.focusEditCaptureKey,
            EditorFocus.EDIT_MODIFY: self.focusEditModify,
            EditorFocus.SELECT_NUMBER_ENDIAN: self.focusSelectNumberEndian,
            EditorFocus.SELECT_NUMBER_BASE: self.focusSelectNumberBase,
            EditorFocus.SEARCH: self.focusSearch,
            EditorFocus.GOTO_BYTE: self.focusGotoByte,
            EditorFocus.CONFIRM_EXIT: self.focusConfirmExit,
            EditorFocus.CONFIRM_READ_FILE: self.focusConfirmReadFile,
            EditorFocus.CONFIRM_WRITE_FILE: self.focusConfirmWriteFile,
        }

        # EditorFocus.EDIT_BYTES or EditorFocus.EDIT_ASCII
        self.last_edit_focus = None

        # Text entry.
        self.text_notify = ""
        self.text_prompt = ""
        self.text_entry = ""
        self.text_entry_cursor = -1

        # Search.

        # [[byte_index_first, byte_index_last]]
        self.search_results = []

        # Inital refresh.
        self.resize()

    def deinit(self):
        self.scr.keypad(False)
        curses.echo()
        curses.noraw()
        curses.curs_set(1)

        curses.endwin()

    # File read/write.
    def readFile(self, force=False):
        if self.modified and not force:
            self.pushFocus(EditorFocus.CONFIRM_READ_FILE, prompt="Buffer has been modified, overwrite? (Y=overwrite): ")
            return

        if force or not self.modified:
            f = open(self.path, "rb")
            self.bytes = list(f.read())
            f.close()

            self.modified = False
            self.last_modification_time = os.path.getmtime(self.path)

            self.textNotify(f"Read file {self.path}")

    def writeFile(self, force=False):
        modified_externally = os.path.getmtime(self.path) > self.last_modification_time

        if modified_externally and not force:
            self.pushFocus(EditorFocus.CONFIRM_WRITE_FILE, prompt="File has been modified externally, overwrite? (Y=overwrite, R=reload): ")
            return

        if force or not modified_externally:
            if self.modified or force:
                f = open(self.path, "wb")
                f.write(bytes(self.bytes))
                f.close()

                self.textNotify(f"Wrote file {self.path}")
            else:
                self.textNotify(f"No changes need to be saved")

            self.modified = False
            self.last_modification_time = os.path.getmtime(self.path)

    # Terminal handling.
    def resize(self):
        self.terminal_size = self.scr.getmaxyx()

        self.bytes_per_row = int((self.terminal_size[1] - 35) / 4)
        self.bytes_per_row -= self.bytes_per_row % 16
        self.bytes_per_row = max(self.bytes_per_row, 16)


        self.terminal_min_size[1] = 10 + (self.bytes_per_row * 4)

        self.terminal_too_small = self.terminal_size[0] < self.terminal_min_size[0] \
            or self.terminal_size[1] < self.terminal_min_size[1]

        self.handleCursor()

        self.scr.clear()

    def initColors(self):
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Borders.

        curses.init_pair(2, curses.COLOR_BLACK + 8, -1) # Addresses.

        curses.init_pair(4, curses.COLOR_BLUE, -1) # Text entry.

        curses.init_pair(5, curses.COLOR_RED, -1) # Unprintable.
        curses.init_pair(6, curses.COLOR_RED, curses.COLOR_YELLOW + 8) # Unprintable + Search term.
        curses.init_pair(7, -1, curses.COLOR_YELLOW + 8) # Search term.

        self.color_border = curses.color_pair(1)
        self.color_address = curses.color_pair(2)
        self.color_text_prompt = curses.color_pair(4)
        self.color_unprintable = curses.color_pair(5)
        self.color_unprintable_search = curses.color_pair(6)
        self.color_search = curses.color_pair(7)

    # Text entry.
    def textNotify(self, text=""):
        self.text_notify = text

    # Change scroll offset to stay onscreen, as well as preventing
    # underflow of certain values.q
    def handleCursor(self, align=None):
        # Prevent negative scroll or cursor position.
        if self.edit_byte_index < 0:
            self.edit_byte_index = 0

        if self.scroll_offset < 0:
            self.scroll_offset = 0

        # Update cursor row/column.
        self.edit_cursor_row = int(self.edit_byte_index / self.bytes_per_row)
        self.edit_cursor_col = self.edit_byte_index % self.bytes_per_row

        region_height = self.terminal_size[0] - 5

        # This is relative to the byte entry region.
        dist_top = self.edit_cursor_row - self.scroll_offset
        dist_bottom  = dist_top - region_height

        # Handle cursor alignment.
        if align == 0:
            self.scroll_offset += dist_top - (region_height // 2)
        elif align == 1:
            self.scroll_offset += dist_top
        elif align == 2:
            self.scroll_offset += dist_bottom
        else:
            # Just try to keep cursor on screen.
            if dist_top < 0:
                self.scroll_offset += dist_top
            elif dist_bottom > 0:
                self.scroll_offset += dist_bottom

        if self.scroll_offset < 0:
            self.scroll_offset = 0

    def cursorIncrement(self, number=1):
        self.edit_byte_index += number
        self.handleCursor()

    def cursorDecrement(self, number=1):
        self.edit_byte_index -= number
        self.handleCursor()

    def cursorJump(self, pos):
        self.edit_byte_index = pos
        self.handleCursor()

    # Editing.
    def insertByte(self, b):
        if b < 0 or b > 255:
            self.textNotify(f"Value {b} out of range 0, 255")
            return

        if self.edit_byte_index < len(self.bytes):
            if self.edit_overwrite:
                self.bytes[self.edit_byte_index] = b
            else:
                self.bytes.insert(self.edit_byte_index, b)
        else:
            for i in range(self.edit_byte_index - len(self.bytes)):
                self.bytes.append(0)
            self.bytes.append(b)

        self.cursorIncrement()

        self.modified = True

    def deleteByte(self):
        if self.edit_byte_index < len(self.bytes):
            if self.edit_overwrite:
                self.bytes[self.edit_byte_index] = 0
            else:
                del self.bytes[self.edit_byte_index]

            self.modified = True

    def deleteToEnd(self):
        if self.edit_byte_index < len(self.bytes):
            self.bytes[self.edit_byte_index:] = []

            self.modified = True

    def getByte(self, index=None, offset=0):
        if index == None:
            index = self.edit_byte_index

        index += offset

        if index < 0 or index >= len(self.bytes):
            return None

        return self.bytes[index]

    # Search.
    def search(self, bytes_=[]):
        self.search_results = []

        if len(bytes_) < 1:
            return

        match_bytes = 0
        match_first_index = 0
        for i in range(len(self.bytes)):
            if self.getByte(i) == bytes_[match_bytes]:
                if match_bytes == 0:
                    match_first_index = i

                match_bytes += 1
            else:
                match_bytes = 0

            if match_bytes >= len(bytes_):
                match_bytes = 0

                self.search_results.append([match_first_index, i])

    def getSearchResult(self, reverse=False):
        if reverse:
            for r in reversed(self.search_results):
                if r[0] < self.edit_byte_index:
                    return r[0]
        else:
            for r in self.search_results:
                if r[1] > self.edit_byte_index:
                    return r[1]

        return None

    # Drawing.
    def drawStatusline(self):
        text = " "

        # Filename.
        text += os.path.basename(self.path)
        if self.modified:
            text += "*"

        # Numerical base.
        text += f"  ({NUMBER_BASES[self.number_base]['name']}, "
        if self.number_endian == EditorNumberEndian.NATIVE:
            text += "NE"
        elif self.number_endian == EditorNumberEndian.LITTLE:
            text += "LE"
        elif self.number_endian == EditorNumberEndian.BIG:
            text += "BE"
        if self.last_edit_focus == EditorFocus.EDIT_ASCII:
            text += f", ASCII)"
        else:
            text += ")"

        # File size.
        text += f"  ({len(self.bytes):d} bytes)"

        # Overwrite mode.
        if self.edit_overwrite:
            text += "  Overwrite"

        text += "  "

        self.scr.addstr(self.terminal_size[0] - 2, 0, "-" * self.terminal_size[1], curses.A_STANDOUT)
        self.scr.addstr(self.terminal_size[0] - 2, 0, text, curses.A_STANDOUT)

    def drawTextEntry(self):
        self.scr.addstr(self.terminal_size[0] - 1, 0, " " * (self.terminal_size[1] - 1))

        notify_len = len(self.text_notify)
        if notify_len > 0:
            notify_len += 1

        self.scr.addstr(self.terminal_size[0] - 1, 0, self.text_notify)

        if self.text_prompt:
            self.scr.addstr(self.terminal_size[0] - 1, notify_len, self.text_prompt, self.color_text_prompt)
            curses.curs_set(1)
        else:
            curses.curs_set(0)
        if self.text_entry:
            self.scr.addstr(self.terminal_size[0] - 1, notify_len + len(self.text_prompt), self.text_entry)
            if self.text_entry_cursor >= 0:
                self.scr.move(self.terminal_size[0] - 1, notify_len + len(self.text_prompt) + self.text_entry_cursor)

    def drawByteInfo(self):
        # Drawing info.
        info_y = 2
        info_x = 11 + (self.bytes_per_row * 4)

        info_width = 20
        info_height = 19

        # Disable byte info if terminal is too small.
        if self.terminal_size[0] <= (info_y + info_height + 2) \
           or self.terminal_size[1] <= (info_x + info_width):
            return

        # Collect byte sequences.
        bytes_ = bytes()
        for i in range(8):
            b = self.getByte(offset=i)
            bytes_ += bytes([b if b != None else 0])

        bytes_1 = bytes_[:1]
        bytes_2 = bytes_[:2]
        bytes_4 = bytes_[:4]
        bytes_8 = bytes_[:8]

        # Wipe screen region.
        for y in range(info_y, info_y + info_height):
            self.scr.addstr(y, info_x, " " * info_width)

        # Current byte.
        u8 = struct.unpack("B", bytes_1)[0]
        self.scr.addstr(info_y, info_x, "Byte:")
        self.scr.addstr(info_y + 1, info_x + 2, f"0b{u8:08b}")
        self.scr.addstr(info_y + 2, info_x + 2, f"0o{u8:03o}")
        self.scr.addstr(info_y + 3, info_x + 2, f"0x{u8:02x}")

        self.scr.addstr(info_y + 5, info_x, f"U8: {u8:d}")
        self.scr.addstr(info_y + 6, info_x, f"S8 {struct.unpack('b', bytes_1)[0]:d}")
        self.scr.addstr(info_y + 8, info_x, f"U16: {struct.unpack(self.number_endian.value + 'H', bytes_2)[0]:d}")
        self.scr.addstr(info_y + 9, info_x, f"S16: {struct.unpack(self.number_endian.value + 'h', bytes_2)[0]:d}")
        self.scr.addstr(info_y + 11, info_x, f"U32: {struct.unpack(self.number_endian.value + 'I', bytes_4)[0]:d}")
        self.scr.addstr(info_y + 12, info_x, f"S32: {struct.unpack(self.number_endian.value + 'i', bytes_4)[0]:d}")
        self.scr.addstr(info_y + 14, info_x, f"U64: {struct.unpack(self.number_endian.value + 'I', bytes_4)[0]:d}")
        self.scr.addstr(info_y + 15, info_x, f"S64: {struct.unpack(self.number_endian.value + 'i', bytes_4)[0]:d}")
        self.scr.addstr(info_y + 17, info_x, f"F32: {struct.unpack(self.number_endian.value + 'f', bytes_4)[0]:g}")
        self.scr.addstr(info_y + 18, info_x, f"F64: {struct.unpack(self.number_endian.value + 'd', bytes_8)[0]:g}")

    def draw(self):
        if self.terminal_too_small:
            self.scr.clear()
            self.scr.addstr(0, 0, "Terminal is too small")
            return

        def getAttr(is_selected):
            return curses.A_STANDOUT if is_selected else 0

        # Address/byte separator.
        self.scr.addstr(1, 0, "-" * self.terminal_size[1], curses.A_STANDOUT)

        # Draw address column header.
        for col in range(self.bytes_per_row):
            is_selected = col == self.edit_cursor_col

            a = self.edit_byte_index - (self.edit_byte_index % self.bytes_per_row) + col
            a &= 0xff

            self.scr.addstr(0, 9 + (col * 3), f"{a:02x}", getAttr(is_selected) | 0 if is_selected else self.color_address)

        # Current address.
        self.scr.addstr(0, 0, f"{self.edit_byte_index:08x}")

        # Draw rows.
        byte_index = self.scroll_offset * self.bytes_per_row
        for row in range(self.terminal_size[0] - 4):
            byte_row = self.scroll_offset + row

            # Separators.
            self.scr.addch(row + 2, 8 + (self.bytes_per_row * 3), "|", self.color_border)
            if self.last_edit_focus == EditorFocus.EDIT_BYTES:
                self.scr.addch(row + 2, 8, "|", self.color_border)
                self.scr.addch(row + 2, 9 + (self.bytes_per_row * 4), "|")
            else:
                self.scr.addch(row + 2, 8, "|")
                self.scr.addch(row + 2, 9 + (self.bytes_per_row * 4), "|", self.color_border)

            # Address.
            is_selected = byte_row == self.edit_cursor_row
            self.scr.addstr(row + 2, 0, f"{byte_index:08x}", getAttr(is_selected) | 0 if is_selected else self.color_address)

            # Bytes/ASCII.
            for col in range(self.bytes_per_row):
                is_selected = byte_index == self.edit_byte_index

                bytes_attr = getAttr(is_selected)
                ascii_attr = getAttr(is_selected)

                if byte_index < len(self.bytes):
                    b = self.bytes[byte_index]
                    c = chr(b)

                    is_search = False

                    # FIXME: Binary search or something would probably
                    # be a lot faster, since search results are sorted
                    # by index and guarantee no overlaps.
                    for r in self.search_results:
                        if byte_index >= r[0] and byte_index <= r[1]:
                            is_search = True
                            bytes_attr |= self.color_search
                            break

                    if c == " " or not curses.ascii.isprint(c):
                        if is_search:
                            ascii_attr |= self.color_unprintable_search
                        else:
                            ascii_attr |= self.color_unprintable

                        if b == 0:
                            c = "0"
                        else:
                            c = "."
                    elif is_search:
                        ascii_attr |= self.color_search

                    self.scr.addstr(row + 2, 9 + (col * 3), f"{b:02x}", bytes_attr)
                    self.scr.addstr(row + 2, 9 + (self.bytes_per_row * 3) + col, c, ascii_attr)
                else:
                    self.scr.addstr(row + 2, 9 + (col * 3), "  ", bytes_attr)
                    self.scr.addstr(row + 2, 9 + (self.bytes_per_row * 3) + col, " ", ascii_attr)

                byte_index += 1

        #self.scr.addstr(0, 0, f"Pressed: '{self.key}' {self.key_ord} 0x{self.key_ord:x}                    ")

        self.drawByteInfo()
        self.drawStatusline()
        self.drawTextEntry()

    # Focus.
    def executeFocus(self):
        self.draw()

        # Wait for key.
        self.key = self.scr.getkey()

        self.key_ch = self.key.lower()

        self.key_ord = 0
        if len(self.key) == 1:
            self.key_ord = ord(self.key)

        self.textNotify()

        # Global keys.
        if self.key == "KEY_RESIZE":
            self.resize()

        # Any key which is *not* Ctrl-L will reset scroll alignment,
        # so the next Ctrl-L will center the cursor (instead of just
        # being whatever alignment the last Ctrl-L left it in.)
        if self.key != KEY_CTRL_L:
            self.scroll_cursor_align = 0

        if self.focus != EditorFocus.EDIT_CAPTURE_KEY:
            if self.key == KEY_ESCAPE:
                if self.focus == EditorFocus.SEARCH:
                    self.cursorJump(self.edit_byte_index_original)
                elif self.focus == EditorFocus.EDIT_MODIFY:
                    self.bytes[self.edit_byte_index] = self.edit_byte_original

                raise EditorResetFocus
            elif self.key == KEY_CTRL_C and self.focus != EditorFocus.CONFIRM_EXIT:
                if self.modified:
                    self.pushFocus(EditorFocus.CONFIRM_EXIT, prompt=f"Save file {self.path}? (Y=save and exit, Ctrl-C/N=exit without saving): ")
                else:
                    self.is_alive = False
            elif self.key == KEY_CTRL_L:
                self.resize()
                self.draw()

        self.focus_funcs[self.focus]()

    def pushFocus(self, focus, prompt="", entry=""):
        self.focus_stack.append(focus)
        self.focus = focus
        if focus == EditorFocus.EDIT_BYTES or focus == EditorFocus.EDIT_ASCII:
            self.last_edit_focus = focus

        self.textNotify()
        self.text_prompt = prompt
        self.text_entry = entry
        self.text_entry_cursor = -1

        self.search()

        while self.is_alive:
            try:
                # It's possible for the focus to change while still in
                # this loop!
                #
                # Be cautious of this when modifying this code.
                self.executeFocus()
            except EditorPopFocus:
                break
            except EditorReplaceFocus:
                pass

    def popFocus(self):
        if len(self.focus_stack) > 0:
            self.focus_stack.pop()

        if len(self.focus_stack) > 0:
            self.focus = self.focus_stack[-1]
            if self.focus == EditorFocus.EDIT_BYTES or self.focus == EditorFocus.EDIT_ASCII:
                self.last_edit_focus = self.focus
        else:
            self.focus = None

        self.text_prompt = ""
        self.text_entry = ""
        self.text_entry_cursor = -1

        self.search()

        raise EditorPopFocus

    def replaceFocus(self, focus, prompt="", entry=""):
        if len(self.focus_stack) > 0:
            self.focus_stack[-1] = focus
            self.focus = focus
            if focus == EditorFocus.EDIT_BYTES or focus == EditorFocus.EDIT_ASCII:
                self.last_edit_focus = focus

        self.text_prompt = ""
        self.text_entry = entry
        self.text_entry_cursor = -1

        self.textNotify()

        raise EditorReplaceFocus

    # Input helpers.
    def handleEditKeys(self):
        # Mode switching.
        if self.key == KEY_CTRL_E:
            self.pushFocus(EditorFocus.SELECT_NUMBER_ENDIAN, prompt="Select endian (N=native, L=little, B=big): ")
        elif self.key == KEY_CTRL_B:
            self.pushFocus(EditorFocus.SELECT_NUMBER_BASE, prompt="Select numerical base (B=binary, O=octal, D=decimal, H=hexidecimal): ")
        elif self.key == KEY_CTRL_S:
            self.edit_byte_index_original = self.edit_byte_index
            if self.focus == EditorFocus.EDIT_ASCII:
                self.pushFocus(EditorFocus.SEARCH, prompt=f"Search for ASCII: ")
            else:
                self.pushFocus(EditorFocus.SEARCH, prompt=f"Search for bytes ({NUMBER_BASES[self.number_base]['name']}): ")
        elif self.key == KEY_CTRL_G:
            self.pushFocus(EditorFocus.GOTO_BYTE, prompt=f"Goto byte ({NUMBER_BASES[self.number_base]['name']}): ")
        elif self.key == KEY_CTRL_R:
            self.readFile()
        elif self.key == KEY_CTRL_W:
            self.writeFile()

            # Cursor.
        elif self.key == "KEY_UP":
            if self.edit_byte_index >= self.bytes_per_row:
                self.cursorDecrement(self.bytes_per_row)
        elif self.key == "KEY_DOWN":
            self.cursorIncrement(self.bytes_per_row)
        elif self.key == "KEY_LEFT":
            self.cursorDecrement()
        elif self.key == "KEY_RIGHT":
            self.cursorIncrement()
        elif self.key == "KEY_PPAGE": # Pgup.
            self.scroll_offset -= self.terminal_size[0] - 4
            self.cursorDecrement(self.bytes_per_row * (self.terminal_size[0] - 4))
        elif self.key == "KEY_NPAGE": # Pgdn.
            self.scroll_offset += self.terminal_size[0] - 4
            self.cursorIncrement(self.bytes_per_row * (self.terminal_size[0] - 4))
        elif self.key == "KEY_HOME":
            self.cursorJump(0)
        elif self.key == "KEY_END":
            self.cursorJump(len(self.bytes))
        elif self.key == KEY_CTRL_L:
            self.handleCursor(align=self.scroll_cursor_align)
            self.scroll_cursor_align += 1
            if self.scroll_cursor_align > 2:
                self.scroll_cursor_align = 0

            # Editing modes.
        elif self.key == "\t": # Tab.
            if self.focus == EditorFocus.EDIT_ASCII:
                self.replaceFocus(EditorFocus.EDIT_BYTES)
            else:
                self.replaceFocus(EditorFocus.EDIT_ASCII)
        elif self.key == KEY_INSERT:
            self.edit_overwrite = not self.edit_overwrite
        elif self.key == KEY_CTRL_Q:
            self.pushFocus(EditorFocus.EDIT_CAPTURE_KEY, prompt="Press a key to capture: ")
        elif self.key == KEY_CTRL_M or self.key == KEY_CTRL_N:
            self.edit_modify_number_base = self.number_base
            if self.key == KEY_CTRL_N:
                self.edit_modify_number_base = EditorNumberBase.BINARY

            mode = NUMBER_BASES[self.edit_modify_number_base]

            if self.edit_byte_index < len(self.bytes):
                self.edit_byte_original = self.bytes[self.edit_byte_index]
                self.pushFocus(EditorFocus.EDIT_MODIFY, prompt=f"Modify byte ({mode['name']}): ",
                               entry=mode["get_format"](self.edit_byte_original))
            else:
                self.textNotify("Can only modify existing bytes")

            # Data modification.
        elif self.key == KEY_BACKSPACE:
            self.cursorDecrement()
            self.deleteByte()
        elif self.key == KEY_DELETE:
            self.deleteByte()
        elif self.key == KEY_CTRL_K:
            self.deleteToEnd()

        else:
            return False

        # Any key handled here will clear the current text entry.
        self.text_entry = ""
        return True

    def handleTextEntryKeys(self, char=None, max_length=60):
        tel = len(self.text_entry)

        if self.text_entry_cursor < 0:
            self.text_entry_cursor = tel
        elif self.text_entry_cursor > tel:
            self.text_entry_cursor = tel

            # Cursor.
        if self.key == "KEY_HOME":
            self.text_entry_cursor = 0
        elif self.key == "KEY_END":
            self.text_entry_cursor = tel
        elif self.key == "KEY_LEFT":
            if self.text_entry_cursor > 0:
                self.text_entry_cursor -= 1
        elif self.key == "KEY_RIGHT":
            if self.text_entry_cursor < tel:
                self.text_entry_cursor += 1

            # Text modification.
        elif self.key == KEY_BACKSPACE:
            if self.text_entry_cursor > 0:
                self.text_entry_cursor -= 1
                self.text_entry = self.text_entry[:self.text_entry_cursor] + self.text_entry[self.text_entry_cursor + 1:]
        elif self.key == KEY_DELETE:
            self.text_entry = self.text_entry[:self.text_entry_cursor] + self.text_entry[self.text_entry_cursor + 1:]
        elif char != None:
            if self.text_entry_cursor < max_length:
                if len(self.text_entry) < max_length:
                    self.text_entry = self.text_entry[:self.text_entry_cursor] + char + self.text_entry[self.text_entry_cursor:]
                else:
                    self.text_entry = self.text_entry[:self.text_entry_cursor] + char + self.text_entry[self.text_entry_cursor + 1:]
                self.text_entry_cursor += 1
        else:
            return False

        return True

    # Focus states.
    def focusEditBytes(self):
        if self.handleEditKeys():
            return

        mode = NUMBER_BASES[self.number_base]

        if self.key_ch in mode["digits"]:
            self.text_entry += self.key
        elif self.key == " ": # Space clears any digits currently being entered.
            self.text_entry = ""

        # Finalize numerical byte entry.
        if (self.key == "\n" and len(self.text_entry) > 0) or len(self.text_entry) >= mode["max_length"]:
            self.insertByte(int(self.text_entry, mode["base"]))
            self.text_entry = ""

    def focusEditAscii(self):
        if self.handleEditKeys():
            return

        if self.key_ord and curses.ascii.isprint(self.key_ord):
            self.insertByte(self.key_ord)

    def focusEditCaptureKey(self):
        if self.key_ord:
            self.insertByte(self.key_ord)
        else:
            self.textNotify("Captured key was unrecognized")

        self.popFocus()

    def focusEditModify(self):
        mode = NUMBER_BASES[self.edit_modify_number_base]

        def updateByte():
            b = 0
            if len(self.text_entry) > 0:
                b = int(self.text_entry, mode["base"])

            if b > 255:
                self.textNotify("Modification is invalid")
            else:
                self.modified = True
                self.bytes[self.edit_byte_index] = b

        c = None
        if self.key_ch in mode["digits"]:
            c = self.key
        elif self.key == "\n":
            self.popFocus()
            return

        self.handleTextEntryKeys(char=c, max_length=mode["max_length"])
        updateByte()

    def focusSelectNumberEndian(self):
        if self.key_ch == "n":
            self.number_endian = EditorNumberEndian.NATIVE
        elif self.key_ch == "l":
            self.number_endian = EditorNumberEndian.LITTLE
        elif self.key_ch == "b":
            self.number_endian = EditorNumberEndian.BIG
        else:
            self.textNotify("Unrecognized endian")

        self.popFocus()

    def focusSelectNumberBase(self):
        if self.key_ch == "b":
            self.number_base = EditorNumberBase.BINARY
        elif self.key_ch == "o":
            self.number_base = EditorNumberBase.OCTAL
        elif self.key_ch == "d":
            self.number_base = EditorNumberBase.DECIMAL
        elif self.key_ch == "h":
            self.number_base = EditorNumberBase.HEXIDECIMAL
        else:
            self.textNotify("Unrecognized numerical base")

        self.popFocus()

    def focusSearch(self):
        mode = NUMBER_BASES[self.number_base]

        reverse = False

        def doSearch(is_ascii):
            bytes_ = []

            if is_ascii:
                bytes_ = [ord(c) for c in self.text_entry]
            else:
                bytes_ = [int(b, mode["base"]) for b in self.text_entry.split(" ") if b != ""]
                for b in bytes_:
                    if b > 255:
                        self.textNotify("Search term is invalid")
                        return

            self.search(bytes_)

        if self.focus_stack[-2] == EditorFocus.EDIT_BYTES:
            if self.key_ch in mode["digits"]:
                digits = 0
                for i in range(len(self.text_entry) - mode["max_length"], len(self.text_entry)):
                    if i < 0 or self.text_entry[i] == " ":
                        break

                    digits += 1

                if digits >= mode["max_length"]:
                    self.text_entry += " "

                self.text_entry += self.key

                doSearch(False)
            elif self.key == KEY_BACKSPACE:
                if len(self.text_entry) > 0:
                    self.text_entry = self.text_entry[:-1]
                    if len(self.text_entry) > 0 and self.text_entry[-1] == " ":
                        self.text_entry = self.text_entry[:-1]

                doSearch(False)
            elif self.key == " ":
                if len(self.text_entry) > 0 and self.text_entry[-1] != " ":
                    self.text_entry += " "
        else:
            if self.key_ord and curses.ascii.isprint(self.key_ord):
                self.text_entry += self.key
                doSearch(True)
            elif self.key == KEY_BACKSPACE:
                if len(self.text_entry) > 0:
                    self.text_entry = self.text_entry[:-1]

        if self.key == KEY_CTRL_R:
            reverse = True
        elif self.key == "\n":
            self.text_entry = ""
            self.popFocus()

        # Move cursor to next (or previous) search result.
        pos = self.getSearchResult(reverse=reverse)
        if pos == None:
            self.textNotify("No matches")
        else:
            self.textNotify()
            self.cursorJump(pos)

    def focusGotoByte(self):
        mode = NUMBER_BASES[self.number_base]

        if self.key_ch in mode["digits"]:
            self.text_entry += self.key
        elif self.key == KEY_BACKSPACE:
            self.text_entry = self.text_entry[:-1]
        elif self.key == "\n":
            self.cursorJump(int(self.text_entry, mode["base"]))
            self.popFocus()

    def focusConfirmExit(self):
        if self.key_ch == "y":
            self.writeFile()
            self.is_alive = False
        elif self.key_ch == "n" or self.key == KEY_CTRL_C:
            self.is_alive = False
        else:
            self.popFocus()

    def focusConfirmReadFile(self):
        if self.key_ch == "y":
            self.readFile(force=True)

        self.popFocus()

    def focusConfirmWriteFile(self):
        if self.key_ch == "y":
            self.writeFile(force=True)
        elif self.key_ch == "r":
            self.readFile()

        self.popFocus()

    def main(self):
        while self.is_alive:
            try:
                self.pushFocus(EditorFocus.EDIT_BYTES)
            except EditorResetFocus:
                pass


# ========================================
#
# Entrypoint.


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: hecks FILE")
        exit(0)

    ed = Editor(sys.argv[1])

    try:
        ed.main()
    except Exception as e:
        ed.deinit()
        traceback.print_exc()
        exit(1)

    ed.deinit()
