diff --git a/cat_win/util/editor.py b/cat_win/util/editor.py index 9326f9f9..c989be43 100644 --- a/cat_win/util/editor.py +++ b/cat_win/util/editor.py @@ -1,3 +1,7 @@ +""" +editor +""" + try: import curses CURSES_MODULE_ERROR = False @@ -5,12 +9,45 @@ CURSES_MODULE_ERROR = True import sys -from cat_win.util.editorhelper import UNIFY_HOTKEYS, KEY_HOTKEYS, ACTION_HOTKEYS +from cat_win.util.editorhelper import History, Position, UNIFY_HOTKEYS, KEY_HOTKEYS, ACTION_HOTKEYS + +def get_newline(file: str) -> str: + """ + determines the line ending of a given file. + + Parameters: + file (str): + a file (-path) as string representation + + Returns: + (str): + the line ending that the given file is using + (\r or \n or \r\n) + """ + with open(file, 'rb') as _f: + _l = _f.readline() + _l += b'\n' * bool(not _l[-1:] or _l[-1:] not in b'\r\n') + return '\r\n' if _l[-2:] == b'\r\n' else _l[-1:].decode() -class _Editor: +class Editor: + """ + Editor + """ def __init__(self, file: str, file_encoding: str, debug_mode: bool) -> None: + """ + defines an Editor object. + + Parameters: + file (str): + a string representation of a file (-path) + file_encoding: + the encoding to read and write the given file + debug_mode (bool) + if True debug-statements will be printed to stderr + """ self.curse_window = None + self.history = History() self.file = file self.file_encoding = file_encoding @@ -24,190 +61,266 @@ def __init__(self, file: str, file_encoding: str, debug_mode: bool) -> None: self.unsaved_progress = False self.changes_made = False - self.cur_col = 0 - self.cur_row = 0 - self.x = 0 - self.y = 0 + # current cursor position + self.cpos = Position(0, 0) + # window position (top-left) + self.wpos = Position(0, 0) self._setup_file() def _setup_file(self) -> None: + """ + setup the editor content screen by reading the given file. + """ try: - self.line_sep = Editor.get_newline(self.file) - with open(self.file, 'r', encoding=self.file_encoding) as f: - for line in f.read().split('\n'): + self.line_sep = get_newline(self.file) + with open(self.file, 'r', encoding=self.file_encoding) as _f: + for line in _f.read().split('\n'): self.window_content.append([*line]) - except (OSError, UnicodeDecodeError) as e: + except (OSError, UnicodeDecodeError) as exc: self.window_content.append([]) self.status_bar_size = 2 - self.error_bar = str(e) + self.error_bar = str(exc) self.unsaved_progress = True def getxymax(self) -> tuple: + """ + find the size of the window. + + Returns: + (tuple): + the size of the display that is free to use + for text/content + """ max_y, max_x = self.curse_window.getmaxyx() return (max_y-self.status_bar_size, max_x) - def _key_enter(self) -> bool: - new_line = self.window_content[self.cur_row][self.cur_col:] - self.window_content[self.cur_row] = self.window_content[self.cur_row][:self.cur_col] - self.cur_row += 1 - self.cur_col = 0 - self.window_content.insert(self.cur_row, [] + new_line) + def _key_enter(self, _) -> str: + new_line = self.window_content[self.cpos.row][self.cpos.col:] + self.window_content[self.cpos.row] = self.window_content[self.cpos.row][:self.cpos.col] + self.cpos.row += 1 + self.cpos.col = 0 + self.window_content.insert(self.cpos.row, [] + new_line) self.unsaved_progress = True + return '' - def _key_dc(self) -> None: - if self.cur_col < len(self.window_content[self.cur_row]): - del self.window_content[self.cur_row][self.cur_col] + def _key_dc(self, _) -> str: + if self.cpos.col < len(self.window_content[self.cpos.row]): + deleted = self.window_content[self.cpos.row][self.cpos.col] + del self.window_content[self.cpos.row][self.cpos.col] self.unsaved_progress = True - elif self.cur_row < len(self.window_content)-1: - self.window_content[self.cur_row] += self.window_content[self.cur_row+1] - del self.window_content[self.cur_row+1] + return deleted + if self.cpos.row < len(self.window_content)-1: + self.window_content[self.cpos.row] += self.window_content[self.cpos.row+1] + del self.window_content[self.cpos.row+1] self.unsaved_progress = True + return '' + return None - def _key_dl(self) -> None: - if self.cur_col == len(self.window_content[self.cur_row])-1: - self.window_content[self.cur_row] = self.window_content[self.cur_row][:self.cur_col] + def _key_dl(self, _) -> str: + if self.cpos.col == len(self.window_content[self.cpos.row])-1: + deleted = self.window_content[self.cpos.row][-1] + self.window_content[self.cpos.row] = self.window_content[self.cpos.row][:self.cpos.col] self.unsaved_progress = True - elif self.cur_col < len(self.window_content[self.cur_row])-1: - cur_col = self.cur_col+1 - tp = self.window_content[self.cur_row][cur_col].isalnum() - while cur_col < len(self.window_content[self.cur_row]) and tp == self.window_content[self.cur_row][cur_col].isalnum(): + return deleted + if self.cpos.col < len(self.window_content[self.cpos.row])-1: + cur_col = self.cpos.col+1 + t_p = self.window_content[self.cpos.row][cur_col].isalnum() + while cur_col < len(self.window_content[self.cpos.row]) and \ + t_p == self.window_content[self.cpos.row][cur_col].isalnum(): cur_col += 1 - self.window_content[self.cur_row] = self.window_content[self.cur_row][:self.cur_col] + self.window_content[self.cur_row][cur_col:] + deleted = ''.join(self.window_content[self.cpos.row][self.cpos.col:cur_col]) + self.window_content[self.cpos.row] = ( + self.window_content[self.cpos.row][:self.cpos.col] + \ + self.window_content[self.cpos.row][cur_col:]) self.unsaved_progress = True - elif self.cur_row < len(self.window_content)-1: - self.window_content[self.cur_row] += self.window_content[self.cur_row+1] - del self.window_content[self.cur_row+1] + return deleted + if self.cpos.row < len(self.window_content)-1: + self.window_content[self.cpos.row] += self.window_content[self.cpos.row+1] + del self.window_content[self.cpos.row+1] self.unsaved_progress = True - - def _key_backspace(self) -> None: - if self.cur_col: # delete char - self.cur_col -= 1 - del self.window_content[self.cur_row][self.cur_col] + return '' + return None + + def _key_backspace(self, _) -> str: + if self.cpos.col: # delete char + self.cpos.col -= 1 + deleted = self.window_content[self.cpos.row][self.cpos.col] + del self.window_content[self.cpos.row][self.cpos.col] self.unsaved_progress = True - elif self.cur_row: # or delete line - line = self.window_content[self.cur_row] - del self.window_content[self.cur_row] - self.cur_row -= 1 - self.cur_col = len(self.window_content[self.cur_row]) - self.window_content[self.cur_row] += line + return deleted + if self.cpos.row: # or delete line + line = self.window_content[self.cpos.row] + del self.window_content[self.cpos.row] + self.cpos.row -= 1 + self.cpos.col = len(self.window_content[self.cpos.row]) + self.window_content[self.cpos.row] += line self.unsaved_progress = True - - def _key_ctl_backspace(self) -> None: - if self.cur_col == 1: # delete char - self.cur_col = 0 - del self.window_content[self.cur_row][self.cur_col] + return '' + return None + + def _key_ctl_backspace(self, _) -> str: + if self.cpos.col == 1: # delete char + self.cpos.col = 0 + deleted = self.window_content[self.cpos.row][self.cpos.col] + del self.window_content[self.cpos.row][self.cpos.col] self.unsaved_progress = True - elif self.cur_col > 1: - old_col = self.cur_col - self.cur_col -= 2 - tp = self.window_content[self.cur_row][self.cur_col].isalnum() - while self.cur_col > 0 and tp == self.window_content[self.cur_row][self.cur_col].isalnum(): - self.cur_col -= 1 - if self.cur_col: - self.cur_col += 1 - del self.window_content[self.cur_row][self.cur_col:old_col] + return deleted + if self.cpos.col > 1: + old_col = self.cpos.col + self.cpos.col -= 2 + t_p = self.window_content[self.cpos.row][self.cpos.col].isalnum() + while self.cpos.col > 0 and \ + t_p == self.window_content[self.cpos.row][self.cpos.col].isalnum(): + self.cpos.col -= 1 + if self.cpos.col: + self.cpos.col += 1 + deleted = ''.join(self.window_content[self.cpos.row][self.cpos.col:old_col]) + del self.window_content[self.cpos.row][self.cpos.col:old_col] self.unsaved_progress = True - elif self.cur_row: # or delete line - line = self.window_content[self.cur_row] - del self.window_content[self.cur_row] - self.cur_row -= 1 - self.cur_col = len(self.window_content[self.cur_row]) - self.window_content[self.cur_row] += line + return deleted + if self.cpos.row: # or delete line + line = self.window_content[self.cpos.row] + del self.window_content[self.cpos.row] + self.cpos.row -= 1 + self.cpos.col = len(self.window_content[self.cpos.row]) + self.window_content[self.cpos.row] += line self.unsaved_progress = True - - def _key_left(self) -> None: - if self.cur_col: - self.cur_col -= 1 - elif self.cur_row: - self.cur_row -= 1 - self.cur_col = len(self.window_content[self.cur_row]) - - def _key_right(self) -> None: - if self.cur_col < len(self.window_content[self.cur_row]): - self.cur_col += 1 - elif self.cur_row < len(self.window_content)-1: - self.cur_row += 1 - self.cur_col = 0 - - def _key_up(self) -> None: - if self.cur_row: - self.cur_row -= 1 - - def _key_down(self) -> None: - if self.cur_row < len(self.window_content)-1: - self.cur_row += 1 - - def _key_ctl_left(self) -> None: - if self.cur_col == 1: - self.cur_col = 0 - elif self.cur_col > 1: - self.cur_col -= 2 - tp = self.window_content[self.cur_row][self.cur_col].isalnum() - while self.cur_col > 0 and tp == self.window_content[self.cur_row][self.cur_col].isalnum(): - self.cur_col -= 1 - if self.cur_col: - self.cur_col += 1 - elif self.cur_row: - self.cur_row -= 1 - self.cur_col = len(self.window_content[self.cur_row]) - - def _key_ctl_right(self) -> None: - if self.cur_col == len(self.window_content[self.cur_row])-1: - self.cur_col = len(self.window_content[self.cur_row]) - elif self.cur_col < len(self.window_content[self.cur_row])-1: - self.cur_col += 1 - tp = self.window_content[self.cur_row][self.cur_col].isalnum() - while self.cur_col < len(self.window_content[self.cur_row]) and tp == self.window_content[self.cur_row][self.cur_col].isalnum(): - self.cur_col += 1 - elif self.cur_row < len(self.window_content)-1: - self.cur_row += 1 - self.cur_col = 0 - - def _key_ctl_up(self) -> None: - if self.cur_row >= 10: - self.cur_row -= 10 + return '' + return None + + def _key_left(self, _) -> str: + if self.cpos.col: + self.cpos.col -= 1 + elif self.cpos.row: + self.cpos.row -= 1 + self.cpos.col = len(self.window_content[self.cpos.row]) + return None + + def _key_right(self, _) -> str: + if self.cpos.col < len(self.window_content[self.cpos.row]): + self.cpos.col += 1 + elif self.cpos.row < len(self.window_content)-1: + self.cpos.row += 1 + self.cpos.col = 0 + return None + + def _key_up(self, _) -> str: + if self.cpos.row: + self.cpos.row -= 1 + return None + + def _key_down(self, _) -> str: + if self.cpos.row < len(self.window_content)-1: + self.cpos.row += 1 + return None + + def _key_ctl_left(self, _) -> str: + if self.cpos.col == 1: + self.cpos.col = 0 + elif self.cpos.col > 1: + self.cpos.col -= 2 + t_p = self.window_content[self.cpos.row][self.cpos.col].isalnum() + while self.cpos.col > 0 and \ + t_p == self.window_content[self.cpos.row][self.cpos.col].isalnum(): + self.cpos.col -= 1 + if self.cpos.col: + self.cpos.col += 1 + elif self.cpos.row: + self.cpos.row -= 1 + self.cpos.col = len(self.window_content[self.cpos.row]) + return None + + def _key_ctl_right(self, _) -> str: + if self.cpos.col == len(self.window_content[self.cpos.row])-1: + self.cpos.col = len(self.window_content[self.cpos.row]) + elif self.cpos.col < len(self.window_content[self.cpos.row])-1: + self.cpos.col += 1 + t_p = self.window_content[self.cpos.row][self.cpos.col].isalnum() + while self.cpos.col < len(self.window_content[self.cpos.row]) and \ + t_p == self.window_content[self.cpos.row][self.cpos.col].isalnum(): + self.cpos.col += 1 + elif self.cpos.row < len(self.window_content)-1: + self.cpos.row += 1 + self.cpos.col = 0 + return None + + def _key_ctl_up(self, _) -> str: + if self.cpos.row >= 10: + self.cpos.row -= 10 else: - self.cur_row = 0 + self.cpos.row = 0 + self.cpos.col = 0 + return None - def _key_ctl_down(self) -> None: - if self.cur_row < len(self.window_content)-10: - self.cur_row += 10 + def _key_ctl_down(self, _) -> str: + if self.cpos.row < len(self.window_content)-10: + self.cpos.row += 10 else: - self.cur_row = len(self.window_content)-1 + self.cpos.row = len(self.window_content)-1 + self.cpos.col = len(self.window_content[self.cpos.row]) + return None - def _key_page_up(self) -> None: + def _key_page_up(self, _) -> str: max_y, _ = self.getxymax() - self.y = max(self.y-max_y, 0) - self.cur_row = max(self.cur_row-max_y, 0) + self.wpos.row = max(self.wpos.row-max_y, 0) + self.cpos.row = max(self.cpos.row-max_y, 0) + return None - def _key_page_down(self) -> None: + def _key_page_down(self, _) -> str: max_y, _ = self.getxymax() - self.y = max(min(self.y+max_y, len(self.window_content)-1-max_y), 0) - self.cur_row = min(self.cur_row+max_y, len(self.window_content)-1) + self.wpos.row = max(min(self.wpos.row+max_y, len(self.window_content)-1-max_y), 0) + self.cpos.row = min(self.cpos.row+max_y, len(self.window_content)-1) + return None - def _key_end(self) -> None: - self.cur_col = len(self.window_content[self.cur_row]) + def _key_end(self, _) -> str: + self.cpos.col = len(self.window_content[self.cpos.row]) + return None - def _key_ctl_end(self) -> None: + def _key_ctl_end(self, _) -> str: max_y, _ = self.getxymax() - self.y = max(len(self.window_content)-1-max_y, 0) - self.cur_row = len(self.window_content)-1 - self.cur_col = len(self.window_content[-1]) - - def _key_home(self) -> None: - self.cur_col = 0 + self.wpos.row = max(len(self.window_content)-1-max_y, 0) + self.cpos.row = len(self.window_content)-1 + self.cpos.col = len(self.window_content[-1]) + return None + + def _key_home(self, _) -> str: + self.cpos.col = 0 + return None + + def _key_ctl_home(self, _) -> str: + self.cpos.row = 0 + self.cpos.col = 0 + return None + + def _key_string(self, wchars) -> str: + if not (isinstance(wchars, str) and wchars.isprintable() or wchars == '\t'): + return '' + self.unsaved_progress = True + self.window_content[self.cpos.row][self.cpos.col:self.cpos.col] = list(wchars) + self.cpos.col += len(wchars) + return wchars - def _key_ctl_home(self) -> None: - self.cur_row = 0 - self.cur_col = 0 + def _key_undo(self, _) -> str: + self.history.undo(self) + return None - def _key_char(self, wchar: str) -> None: - self.unsaved_progress = True - self.window_content[self.cur_row].insert(self.cur_col, wchar) - self.cur_col += 1 + def _key_redo(self, _) -> str: + self.history.redo(self) + return None def _action_save(self, write_func) -> bool: + """ + handle the save file action. + + Parameters: + write_func (function): + the function to use for saving the file + + Returns + (bool): + indicates if the editor should keep running + """ content = self.line_sep.join([''.join(line) for line in self.window_content]) try: write_func(content, self.file, self.file_encoding) @@ -215,21 +328,35 @@ def _action_save(self, write_func) -> bool: self.unsaved_progress = False self.error_bar = '' self.status_bar_size = 1 - except OSError as e: + except OSError as exc: self.unsaved_progress = True - self.error_bar = str(e) + self.error_bar = str(exc) self.status_bar_size = 2 if self.debug_mode: print(self.error_bar, file=sys.stderr) return True def _action_quit(self, write_func) -> bool: + """ + handles the quit editor action. + + Parameters: + write_func (function): + the function to use for possibly saving the file + + Returns: + (bool): + indicates if the editor should keep running + """ if self.unsaved_progress: max_y, max_x = self.getxymax() save_message = 'Save changes? [y]es, [n]o'[:max_x] - self.curse_window.addstr(max_y + self.status_bar_size - 1, 0, save_message, self._get_color(5)) + self.curse_window.addstr(max_y + self.status_bar_size - 1, 0, save_message, + self._get_color(5)) if max_x > len(save_message): - self.curse_window.addstr(max_y + self.status_bar_size - 1, len(save_message), ' ' * (max_x-len(save_message)-1), self._get_color(5)) + self.curse_window.addstr(max_y + self.status_bar_size - 1, + len(save_message), ' ' * (max_x-len(save_message)-1), + self._get_color(5)) self.curse_window.refresh() wchar = '' @@ -246,11 +373,25 @@ def _action_quit(self, write_func) -> bool: return False def _action_interrupt(self, _) -> bool: + """ + handles the interrupt action. + + Returns: + (bool): + indicates if the editor should keep running + """ if self.debug_mode: print('Interrupting...', file=sys.stderr) - raise KeyboardInterrupt() + raise KeyboardInterrupt def _action_resize(self, _) -> bool: + """ + handles the resizing of the (terminal) window. + + Returns: + (bool): + indicates if the editor should keep running + """ try: curses.resize_term(*self.curse_window.getmaxyx()) except curses.error: @@ -259,7 +400,13 @@ def _action_resize(self, _) -> bool: return True def _get_new_char(self) -> tuple: - # get next char + """ + get next char + + Returns + (tuple): + the char received and the possible action it means. + """ wchar = -1 while wchar == -1: try: # try-except in case of no delay mode @@ -267,24 +414,41 @@ def _get_new_char(self) -> tuple: except curses.error: pass _key = curses.keyname(wchar if isinstance(wchar, int) else ord(wchar)) - key = UNIFY_HOTKEYS.get(_key, _key) + key = UNIFY_HOTKEYS.get(_key, b'_key_string') if self.debug_mode: - print(f"__DEBUG__: Received wchar \t{repr(wchar)} \t{repr(chr(wchar)) if isinstance(wchar, int) else ord(wchar)} \t{str(_key).ljust(15)} \t{key}", file=sys.stderr) + _debug_info = repr(chr(wchar)) if isinstance(wchar, int) else ord(wchar) + print(f"__DEBUG__: Received wchar \t{repr(wchar)} " + \ + f"\t{_debug_info} \t{str(_key).ljust(15)} \t{key}", file=sys.stderr) return (wchar, key) - def _get_color(self, id: int) -> int: + def _get_color(self, c_id: int) -> int: + """ + get curses color by id. + + Parameter: + c_id (int): + the id of the color to grab + + Returns + (int): + the curses.color + """ if not curses.has_colors(): return 0 - return curses.color_pair(id) + return curses.color_pair(c_id) - def _render_scr(self) -> tuple: + def _render_scr(self) -> None: + """ + render the curses window. + """ max_y, max_x = self.getxymax() # fix cursor position (makes movement hotkeys easier) - row = self.window_content[self.cur_row] if self.cur_row < len(self.window_content) else None + row = self.window_content[self.cpos.row] if ( + self.cpos.row < len(self.window_content)) else None rowlen = len(row) if row is not None else 0 - if self.cur_col > rowlen: - self.cur_col = rowlen + if self.cpos.col > rowlen: + self.cpos.col = rowlen # set/enforce the boundaries curses.curs_set(0) @@ -292,26 +456,27 @@ def _render_scr(self) -> tuple: self.curse_window.move(0, 0) except curses.error: pass - if self.cur_row < self.y: - self.y = self.cur_row - elif self.cur_row >= self.y + max_y: - self.y = self.cur_row - max_y + 1 - if self.cur_col < self.x: - self.x = self.cur_col - elif self.cur_col >= self.x + max_x: - self.x = self.cur_col - max_x + 1 + if self.cpos.row < self.wpos.row: + self.wpos.row = self.cpos.row + elif self.cpos.row >= self.wpos.row + max_y: + self.wpos.row = self.cpos.row - max_y + 1 + if self.cpos.col < self.wpos.col: + self.wpos.col = self.cpos.col + elif self.cpos.col >= self.wpos.col + max_x: + self.wpos.col = self.cpos.col - max_x + 1 # display screen for row in range(max_y): - brow = row + self.y + brow = row + self.wpos.row for col in range(max_x): - bcol = col + self.x + bcol = col + self.wpos.col try: if self.window_content[brow][bcol] == '\t': self.curse_window.addch(row, col, '>', self._get_color(4)) elif not self.window_content[brow][bcol].isprintable(): self.curse_window.addch(row, col, '?', self._get_color(5)) elif all(map(lambda c: c.isspace(), self.window_content[brow][bcol:])): - self.curse_window.addch(row, col, self.window_content[brow][bcol], self._get_color(3)) + self.curse_window.addch(row, col, self.window_content[brow][bcol], + self._get_color(3)) else: self.curse_window.addch(row, col, self.window_content[brow][bcol]) except (IndexError, curses.error): @@ -324,28 +489,46 @@ def _render_scr(self) -> tuple: # display status/error_bar try: if self.error_bar: - self.curse_window.addstr(max_y + self.status_bar_size - 2, 0, self.error_bar[:max_x], self._get_color(2)) + self.curse_window.addstr(max_y + self.status_bar_size - 2, 0, + self.error_bar[:max_x], self._get_color(2)) if (max_x - len(self.error_bar) - 1) > 0: - self.curse_window.addstr(max_y + self.status_bar_size - 2, len(self.error_bar), " " * (max_x - len(self.error_bar) - 1), self._get_color(2)) + self.curse_window.addstr(max_y + self.status_bar_size - 2, + len(self.error_bar), + " " * (max_x - len(self.error_bar) - 1), + self._get_color(2)) - status_bar = f"File: {self.file} | Exit: ^q | Save: ^s | Pos: {self.cur_col}, {self.cur_row} | {'NOT ' * self.unsaved_progress}Saved!" + status_bar = f"File: {self.file} | Exit: ^q | Save: ^s | Pos: {self.cpos.col}" + status_bar += f", {self.cpos.row} | {'NOT ' * self.unsaved_progress}Saved!" if len(status_bar) > max_x: necc_space = max(0, max_x - (len(status_bar) - len(self.file) + 3)) - status_bar = f"File: ...{self.file[-necc_space:] * bool(necc_space)} | Exit: ^q | Save: ^s | Pos: {self.cur_col}, {self.cur_row} | {'NOT ' * self.unsaved_progress}Saved!"[:max_x] - self.curse_window.addstr(max_y + self.status_bar_size - 1, 0, status_bar, self._get_color(1)) + status_bar = f"File: ...{self.file[-necc_space:] * bool(necc_space)} " + status_bar += f"| Exit: ^q | Save: ^s | Pos: {self.cpos.col}, {self.cpos.row} " + status_bar += f"| {'NOT ' * self.unsaved_progress}Saved!"[:max_x] + self.curse_window.addstr(max_y + self.status_bar_size - 1, 0, + status_bar, self._get_color(1)) if (max_x - len(status_bar) - 1) > 0: - self.curse_window.addstr(max_y + self.status_bar_size - 1, len(status_bar), " " * (max_x - len(status_bar) - 1), self._get_color(1)) + self.curse_window.addstr(max_y + self.status_bar_size - 1, + len(status_bar), " " * (max_x - len(status_bar) - 1), + self._get_color(1)) except curses.error: pass try: - self.curse_window.move(max(self.cur_row-self.y, 0), max(self.cur_col-self.x, 0)) + self.curse_window.move(max(self.cpos.row-self.wpos.row, 0), + max(self.cpos.col-self.wpos.col, 0)) except curses.error: pass curses.curs_set(1) self.curse_window.refresh() - def _open(self, write_func) -> None: + def _run(self, write_func) -> None: + """ + main loop for the editor. + + Parameters: + write_func (function): + a function to write a file + """ running = True while running: @@ -355,51 +538,43 @@ def _open(self, write_func) -> None: # handle new wchar if key in KEY_HOTKEYS: - getattr(self, key.decode(), lambda: None)() + f_len = len(self.window_content) + pre_pos = self.cpos.get_pos() + action_text = getattr(self, key.decode(), lambda *_: None)(wchar) + self.history.add(key, action_text, f_len, pre_pos, self.cpos.get_pos()) elif key in ACTION_HOTKEYS: running &= getattr(self, key.decode(), lambda *_: False)(write_func) - # insert new key - elif isinstance(wchar, str) and wchar.isprintable() or wchar == '\t': - self._key_char(wchar) - - def _run(self, curse_window, write_func) -> None: - self.curse_window = curse_window - if curses.can_change_color(): - curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) # status_bar - curses.init_pair(2, curses.COLOR_RED , curses.COLOR_WHITE) # error_bar - curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_RED ) # trailing_whitespace - curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_GREEN) # tab-char - curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_RED ) # special char (not printable) & quit-prompt - curses.raw() - self.curse_window.nodelay(False) - self._open(write_func) - - def run(self, write_func) -> bool: - curses.wrapper(self._run, write_func) - return self.changes_made - - -class Editor: - def get_newline(file: str) -> str: + def _open(self, curse_window, write_func) -> None: """ - determines the line ending of a given file. + define curses settings and + run the editor on the initialized data. Parameters: - file (str): - a file (-path) as string representation - - Returns: - (str): - the line ending that the given file is using - (\r or \n or \r\n) + curse_window (curses._Window): + the curses window from initscr() + write_func (function): + a function to write a file """ - with open(file, 'rb') as f: - l = f.readline() - l += b'\n' * bool(not l[-1:] or l[-1:] not in b'\r\n') - return '\r\n' if l[-2:] == b'\r\n' else l[-1:].decode() + self.curse_window = curse_window + if curses.can_change_color(): + # status_bar + curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) + # error_bar + curses.init_pair(2, curses.COLOR_RED , curses.COLOR_WHITE) + # trailing_whitespace + curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_RED ) + # tab-char + curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_GREEN) + # special char (not printable) & quit-prompt + curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_RED ) + curses.raw() + self.curse_window.nodelay(False) + self._run(write_func) - def open(file: str, file_encoding: str, write_func, on_windows_os: bool, debug_mode: bool = False) -> bool: + @classmethod + def open(cls, file: str, file_encoding: str, write_func, on_windows_os: bool, + debug_mode: bool = False) -> bool: """ simple editor to change the contents of any provided file. the first file in the list will be loaded as a basis but all @@ -424,11 +599,13 @@ def open(file: str, file_encoding: str, write_func, on_windows_os: bool, debug_m if CURSES_MODULE_ERROR: print("The Editor could not be loaded. No Module 'curses' was found.", file=sys.stderr) if on_windows_os: - print("If you are on Windows OS, try pip-installing 'windows-curses'.", file=sys.stderr) + print('If you are on Windows OS, try pip-installing ', end='', file=sys.stderr) + print("'windows-curses'.", file=sys.stderr) return False # if not (sys.stdin.isatty() | sys.stdout.isatty()): # print("The Editor could not be loaded.", file=sys.stderr) # return False - editor = _Editor(file, file_encoding, debug_mode) - return editor.run(write_func) + editor = cls(file, file_encoding, debug_mode) + curses.wrapper(editor._open, write_func) + return editor.changes_made diff --git a/cat_win/util/editorhelper.py b/cat_win/util/editorhelper.py index af1abf17..f3b00c1b 100644 --- a/cat_win/util/editorhelper.py +++ b/cat_win/util/editorhelper.py @@ -1,3 +1,8 @@ +""" +editorhelper +""" + + UNIFY_HOTKEYS = { # newline b'^M' : b'_key_enter', # CR @@ -68,12 +73,220 @@ b'CTL_HOME' : b'_key_ctl_home', # windows b'kHOM5' : b'_key_ctl_home', # xterm b'CTL_PAD7' : b'_key_ctl_home', # numpad + # default alnum key + b'_key_string' : b'_key_string', + # history + b'^Z' : b'_key_undo', + b'^Y' : b'_key_redo', # actions b'^S' : b'_action_save', b'^Q' : b'_action_quit', b'^C' : b'_action_interrupt', b'KEY_RESIZE' : b'_action_resize', -} +} # translates key-inputs to pre-defined actions/methods KEY_HOTKEYS = set(v for v in UNIFY_HOTKEYS.values() if v.startswith(b'_key' )) ACTION_HOTKEYS = set(v for v in UNIFY_HOTKEYS.values() if v.startswith(b'_action')) + +REVERSE_ACTION = { + b'_key_dc' : b'_key_string', + b'_key_dl' : b'_key_string', + b'_key_backspace' : b'_key_string', + b'_key_ctl_backspace': b'_key_string', + b'_key_string' : b'_key_backspace', +} # defines the counter action if the file has the same amount of lines + +REVERSE_ACTION_SIZE_CHANGE = { + b'_key_enter' : b'_key_backspace', + b'_key_dc' : b'_key_enter', + b'_key_dl' : b'_key_enter', + b'_key_backspace' : b'_key_enter', + b'_key_ctl_backspace': b'_key_enter', +} # defines the counter action if the file has a different amount of lines + +ACTION_STACKABLE = [ + b'_key_dc', + b'_key_backspace', + b'_key_string', +] +# these actions will be chained +# (e.g. when writing a word, the entire word should be undone/redone) + + +class Position: + """ + define a position in the text + """ + def __init__(self, row: int, column: int) -> None: + self.row = row + self.col = column + + def get_pos(self) -> tuple: + """ + return a tuple that defines the position. + this makes comparison easier. + """ + return (self.row, self.col) + + def set_pos(self, new_pos: tuple) -> None: + """ + setter for row and column of position + """ + self.row, self.col = new_pos + + +class _Action: + def __init__(self, key_action: bytes, action_text: str, file_len: int, + pre_pos: tuple, post_pos: tuple) -> None: + """ + defines an action. + + Parameters: + key_action (bytes): + the action taken as defined by UNIFY_HOTKEYS + action_text (str): + the text added/removed by the action + file_len (int) + the amount of lines the file had before the action + pre_pos (tuple): + the cursor position before the action + post_pos (tuple): + the cursor position after the action + """ + self.key_action: bytes = key_action + self.action_text: str = action_text + self.file_len: int = file_len + self.pre_pos: tuple = pre_pos + self.post_pos: tuple = post_pos + + +class History: + """ + keeps track of editing history and provided + undo/redo functionality. + """ + def __init__(self, stack_size: int = 800) -> None: + self.stack_size = max(stack_size, 1) + + # following stacks/lists will contain _Action objects. + # each object needs ~300Bytes, meaning that both lists + # together will be, at max, of the size ~600*stack_size (Bytes) + # 500KB = 500_000B, 500_000/600 ~= 800 + self._stack_undo: list = [] + self._stack_redo: list = [] + + def _add(self, action: _Action, stack_type: str = 'undo') -> None: + """ + Add an action to the stack. + + Parameters: + action (_Action): + the action to append + stack_type (str): + defines the stack to use + """ + if stack_type == 'undo': + _stack = self._stack_undo + elif stack_type == 'redo': + _stack = self._stack_redo + else: + return + + if len(_stack) == self.stack_size: + del _stack[0] + _stack.append(action) + + def add(self, key_action: bytes, action_text: str, file_len: int, pre_pos: tuple, + post_pos: tuple, stack_type: str = 'undo') -> None: + """ + Add an action to the stack. + + Parameters: + __init__ variables of _Action + + stack_type (str): + defines the stack to use + """ + if key_action not in REVERSE_ACTION and key_action not in REVERSE_ACTION_SIZE_CHANGE: + return + if action_text is None: + # no edit has been made (e.g. invalid edit (backspace in top left)) + return + + if stack_type == 'undo': + self._stack_redo.clear() + + action = _Action(key_action, action_text, file_len, pre_pos, post_pos) + self._add(action, stack_type) + + def _undo(self, editor: object, action: _Action) -> None: + self._add(action, 'redo') + if len(editor.window_content) == action.file_len: + reverse_action = REVERSE_ACTION.get(action.key_action) + else: + reverse_action = REVERSE_ACTION_SIZE_CHANGE.get(action.key_action) + if reverse_action is None: + assert False, 'unreachable.' + reverse_action_method = getattr(editor, reverse_action.decode(), lambda *_: None) + editor.cpos.set_pos(action.post_pos) + reverse_action_method(action.action_text) + editor.cpos.set_pos(action.pre_pos) + + def undo(self, editor: object) -> None: + """ + Undo an action taken. + + Parameters: + editor (Editor): + the editor in use + """ + try: + action: _Action = self._stack_undo.pop() + except IndexError: + return + + self._undo(editor, action) + while self._stack_undo: + n_action: _Action = self._stack_undo.pop() + if action.key_action == n_action.key_action and \ + action.pre_pos == n_action.post_pos and \ + action.key_action in ACTION_STACKABLE and \ + not action.action_text.isspace(): + action = n_action + self._undo(editor, action) + else: + self._stack_undo.append(n_action) + break + + def _redo(self, editor: object, action: _Action) -> None: + self._add(action, 'undo') + reverse_action_method = getattr(editor, action.key_action.decode(), lambda *_: None) + editor.cpos.set_pos(action.pre_pos) + reverse_action_method(action.action_text) + editor.cpos.set_pos(action.post_pos) + + def redo(self, editor: object) -> None: + """ + Redo an action taken. + + Parameters: + editor (Editor): + the editor in use + """ + try: + action: _Action = self._stack_redo.pop() + except IndexError: + return + + self._redo(editor, action) + while self._stack_redo: + n_action: _Action = self._stack_redo.pop() + if action.key_action == n_action.key_action and \ + action.post_pos == n_action.pre_pos and \ + action.key_action in ACTION_STACKABLE and \ + not action.action_text.isspace(): + action = n_action + self._redo(editor, action) + else: + self._stack_redo.append(n_action) + break