#!/usr/bin/env python3 # -*- coding: utf-8 -*- import asyncio import contextlib import functools import os import string import sys import fill3 import fill3.terminal as terminal import pygments import pygments.lexers import pygments.styles import termstr import cwcwidth @functools.lru_cache(maxsize=100) def highlight_str(line, bg_color, transparency=0.6): def blend_style(style): return termstr.CharStyle(termstr.blend_color(style.fg_color, bg_color, transparency), termstr.blend_color(style.bg_color, bg_color, transparency), is_bold=style.is_bold, is_italic=style.is_italic, is_underlined=style.is_underlined) return termstr.TermStr(line).transform_style(blend_style) PYTHON_LEXER = pygments.lexers.get_lexer_by_name("python") NATIVE_STYLE = pygments.styles.get_style_by_name("paraiso-dark") def _syntax_highlight(text, lexer, style): @functools.lru_cache(maxsize=500) def _parse_rgb(hex_rgb): if hex_rgb.startswith("#"): hex_rgb = hex_rgb[1:] return tuple(int("0x" + hex_rgb[index:index+2], base=16) for index in [0, 2, 4]) @functools.lru_cache(maxsize=500) def _char_style_for_token_type(token_type, default_bg_color, default_style): try: token_style = style.style_for_token(token_type) except KeyError: return default_style fg_color = (termstr.Color.black if token_style["color"] is None else _parse_rgb(token_style["color"])) bg_color = (default_bg_color if token_style["bgcolor"] is None else _parse_rgb(token_style["bgcolor"])) return termstr.CharStyle(fg_color, bg_color, token_style["bold"], token_style["italic"], token_style["underline"]) default_bg_color = _parse_rgb(style.background_color) default_style = termstr.CharStyle(bg_color=default_bg_color) text = expandtabs(text) text = fill3.join("", [termstr.TermStr( text, _char_style_for_token_type(token_type, default_bg_color, default_style)) for token_type, text in pygments.lex(text, lexer)]) text_widget = fill3.Text(text, pad_char=termstr.TermStr(" ").bg_color(default_bg_color)) return fill3.join("\n", text_widget.text) @functools.lru_cache(maxsize=500) def expand_str(str_): expanded_str = termstr.TermStr(str_) return str_ if expanded_str.data == str_ else expanded_str class Text: def __init__(self, text, padding_char=" "): self.padding_char = padding_char self.text, self.actual_text, self.max_line_length = [], [], 0 lines = [""] if text == "" else text.splitlines() if text.endswith("\n"): lines.append("") self[:] = lines def __len__(self): return len(self.text) def __getitem__(self, line_index): return self.actual_text[line_index] def _convert_line(self, line, max_line_length): return expand_str(line).ljust(max_line_length) def __setitem__(self, key, value): if type(key) == int: self._replace_lines(slice(key, key + 1), [value]) else: # slice self._replace_lines(key, value) def _replace_lines(self, slice_, new_lines): fixed_lines = [expand_str(line) for line in new_lines] max_new_lengths = max(len(line) for line in fixed_lines) if max_new_lengths > self.max_line_length: padding = self.padding_char * (max_new_lengths - self.max_line_length) self.text = [line + padding for line in self.text] self.max_line_length = max_new_lengths converted_lines = [self._convert_line(line, self.max_line_length) for line in new_lines] self.text[slice_], self.actual_text[slice_] = converted_lines, new_lines new_max_line_length = max(len(expand_str(line)) for line in self.actual_text) if new_max_line_length < self.max_line_length: clip_width = self.max_line_length - new_max_line_length self.text = [line[:-clip_width] for line in self.text] self.max_line_length = new_max_line_length def insert(self, index, line): self._replace_lines(slice(index, index), [line]) def append(self, line): self.insert(len(self.text), line) def get_text(self): return "\n".join(self) def appearance(self): return self.text def appearance_for(self, dimensions): return fill3.appearance_resize(self.appearance(), dimensions) class Code(Text): def __init__(self, text, path, theme=NATIVE_STYLE): self.lexer = pygments.lexers.get_lexer_for_filename(path, text, stripnl=False) self.theme = theme padding_char = _syntax_highlight(" ", self.lexer, theme) Text.__init__(self, text, padding_char) def _convert_line(self, line, max_line_length): highlighted_line = (termstr.TermStr(line) if self.theme is None else _syntax_highlight(line, self.lexer, self.theme)) return highlighted_line.ljust(max_line_length, fillchar=self.padding_char) def syntax_highlight_all(self): if self.theme is None: self.text = [termstr.TermStr(line.ljust(self.max_line_length)) for line in self.get_text().splitlines()] else: self.padding_char = _syntax_highlight(" ", self.lexer, self.theme) highlighted = _syntax_highlight(self.get_text(), self.lexer, self.theme) self.text = [line.ljust(self.max_line_length) for line in highlighted.splitlines()] class Decor: def __init__(self, widget, decorator): self.widget = widget self.decorator = decorator def appearance_for(self, dimensions): return self.decorator(self.widget.appearance_for(dimensions)) def appearance(self): return self.decorator(self.widget.appearance()) def highlight_part(line, start, end): return (line[:start] + highlight_str(line[start:end], termstr.Color.white, transparency=0.7) + line[end:]) @functools.lru_cache(maxsize=500) def expandtabs(text): result = [] for line in text.splitlines(keepends=True): parts = line.split("\t") if len(parts) == 1: result.append(line) continue result.append(parts[0]) line_length = cwcwidth.wcswidth(parts[0]) for part in parts[1:]: spacing = 8 - line_length % 8 result.extend([" " * spacing, part]) line_length += spacing + cwcwidth.wcswidth(part) return "".join(result) @functools.lru_cache(maxsize=500) def expand_str_inverse(str_): result = [] for index, char in enumerate(str_): run_length = 8 - len(result) % 8 if char == "\t" else cwcwidth.wcwidth(char) result.extend([index] * run_length) return result class Editor: TAB_SIZE = 4 THEMES = [pygments.styles.get_style_by_name(style) for style in ["monokai", "fruity", "native"]] + [None] def __init__(self, text="", path="Untitled", is_left_aligned=True): self.path = os.path.normpath(path) self.is_left_aligned = is_left_aligned self.set_text(text) self.mark = None self.clipboard = None self.last_width = 100 self.last_height = 40 self.is_editing = True self.theme_index = 0 self.is_overwriting = False self.previous_term_code = None self.history = [] @property def cursor_x(self): try: return expand_str_inverse(self.text_widget[self.cursor_y])[self._cursor_x] except IndexError: return len(self.text_widget.actual_text[self.cursor_y]) @cursor_x.setter def cursor_x(self, x): try: self._cursor_x = len(expand_str(self.text_widget[self.cursor_y][:x])) except IndexError: self._cursor_x = x @property def cursor_y(self): return self._cursor_y @cursor_y.setter def cursor_y(self, y): if y < 0 or y >= len(self.text_widget): raise IndexError self._cursor_y = y @property def scroll_position(self): return self.view_widget.position @scroll_position.setter def scroll_position(self, position): self.view_widget.position = position def get_selection_interval(self): mark_x, mark_y = self.mark (start_y, start_x), (end_y, end_x) = sorted( [(mark_y, mark_x), (self.cursor_y, self.cursor_x)]) return (start_x, start_y), (end_x, end_y) def add_highlights(self, appearance): result = appearance.copy() if not self.is_editing: return result if self.mark is None: result[self.cursor_y] = highlight_str(result[self.cursor_y], termstr.Color.white, 0.8) else: (start_x, start_y), (end_x, end_y) = self.get_selection_interval() screen_start_x = len(expand_str(self.text_widget[start_y][:start_x])) screen_end_x = len(expand_str(self.text_widget[end_y][:end_x])) if start_y == end_y: result[start_y] = highlight_part(result[start_y], screen_start_x, screen_end_x) else: result[start_y] = highlight_part(result[start_y], screen_start_x, len(result[start_y])) view_x, view_y = self.view_widget.position for line_num in range(max(start_y+1, view_y), min(end_y, view_y + self.last_height)): result[line_num] = highlight_part(result[line_num], 0, len(result[line_num])) result[end_y] = highlight_part(result[end_y], 0, screen_end_x) if self.cursor_x >= len(result[0]): result = fill3.appearance_resize(result, (self.cursor_x+1, len(result))) cursor_line = result[self.cursor_y] screen_x = len(expand_str(self.text_widget[self.cursor_y][:self.cursor_x])) screen_x_after = (screen_x + 1 if self._current_character() in ["\t", "\n"] else len(expand_str(self.text_widget[self.cursor_y][:self.cursor_x+1]))) result[self.cursor_y] = (cursor_line[:screen_x] + termstr.TermStr(cursor_line[screen_x:screen_x_after]).invert() + cursor_line[screen_x_after:]) return result def set_text(self, text): try: self.text_widget = Code(text, self.path) except pygments.util.ClassNotFound: # No lexer for path self.text_widget = Text(text) self.decor_widget = Decor(self.text_widget, lambda appearance: self.add_highlights(appearance)) self.view_widget = fill3.View.from_widget(self.decor_widget) self.view_widget.portal.is_scroll_limited = True if not self.is_left_aligned: self.view_widget.portal.is_left_aligned = False self._cursor_x, self._cursor_y = 0, 0 self.original_text = self.text_widget.actual_text.copy() def load(self, path): self.path = os.path.normpath(path) with open(path) as file_: self.set_text(file_.read()) def save(self): with open(self.path, "w") as file_: file_.write(self.text_widget.get_text()) self.original_text = self.text_widget.actual_text.copy() def backspace(self): if self.cursor_x == 0: if self.cursor_y != 0: self.set_mark() self.cursor_left() self.delete_selection() else: line = self.text_widget[self.cursor_y] new_line = line[:self.cursor_x-1] + line[self.cursor_x:] self.cursor_x -= 1 self.text_widget[self.cursor_y] = new_line def cursor_left(self): if self.cursor_x == 0: self.cursor_up() self.jump_to_end_of_line() else: self.cursor_x -= 1 def cursor_right(self): if self.cursor_x == len(self.text_widget.actual_text[self.cursor_y]): self.cursor_down() self.jump_to_start_of_line() else: self.cursor_x += 1 def cursor_up(self): self.cursor_y -= 1 def cursor_down(self): self.cursor_y += 1 def page_up(self): new_y = self.cursor_y - self.last_height // 2 self.cursor_x, self.cursor_y = 0, max(0, new_y) def page_down(self): new_y = self.cursor_y + self.last_height // 2 self.cursor_x, self.cursor_y = 0, min(len(self.text_widget.text) - 1, new_y) def jump_to_start_of_line(self): self.cursor_x = 0 def jump_to_end_of_line(self): self.cursor_x = len(self.text_widget.actual_text[self.cursor_y]) def open_line(self): line = self.text_widget[self.cursor_y] self.text_widget[self.cursor_y:self.cursor_y+1] = \ [line[:self.cursor_x], line[self.cursor_x:]] def enter(self): self.open_line() self.cursor_x, self.cursor_y = 0, self.cursor_y + 1 def set_mark(self): self.mark = self.cursor_x, self.cursor_y def drop_highlight(self): self.mark = None def copy_selection(self): if self.mark is not None: (start_x, start_y), (end_x, end_y) = self.get_selection_interval() selection = [self.text_widget[line_num] for line_num in range(start_y, end_y+1)] selection[-1] = selection[-1][:end_x] selection[0] = selection[0][start_x:] self.clipboard = selection self.mark = None def delete_selection(self): if self.mark is not None: (start_x, start_y), (end_x, end_y) = self.get_selection_interval() self.copy_selection() start_line = self.text_widget[start_y] end_line = self.text_widget[end_y] new_line = start_line[:start_x] + end_line[end_x:] self.text_widget[start_y:end_y+1] = [new_line] self.cursor_x, self.cursor_y = start_x, start_y def insert_text(self, text, is_overwriting=False): try: current_line = self.text_widget[self.cursor_y] replace_count = len(text) if is_overwriting else 0 self.text_widget[self.cursor_y] = (current_line[:self.cursor_x] + text + current_line[self.cursor_x+replace_count:]) except IndexError: self.text_widget.append(text) self.cursor_x += len(text) def delete_character(self): self.cursor_right() self.backspace() def delete_right(self): self.set_mark() self.next_word() self.delete_selection() def paste_from_clipboard(self): if self.clipboard is not None: for line in self.clipboard[:-1]: self.insert_text(line) self.enter() self.insert_text(self.clipboard[-1]) def _is_on_empty_line(self): return self.text_widget[self.cursor_y].strip() == "" def _jump_to_block_edge(self, direction_func): self.jump_to_start_of_line() while self._is_on_empty_line(): direction_func() while not self._is_on_empty_line(): direction_func() def jump_to_block_start(self): return self._jump_to_block_edge(self.cursor_up) def jump_to_block_end(self): return self._jump_to_block_edge(self.cursor_down) WORD_CHARS = string.ascii_letters + string.digits def _current_character(self): try: return self.text_widget[self.cursor_y][self.cursor_x] except IndexError: return "\n" def next_word(self): while self._current_character() not in Editor.WORD_CHARS: self.cursor_right() while self._current_character() in Editor.WORD_CHARS: self.cursor_right() def previous_word(self): self.cursor_left() while self._current_character() not in Editor.WORD_CHARS: self.cursor_left() while self._current_character() in Editor.WORD_CHARS: self.cursor_left() self.cursor_right() def delete_backward(self): self.set_mark() with contextlib.suppress(IndexError): self.previous_word() self.delete_selection() def delete_line(self): empty_selection = self.text_widget[self.cursor_y][self.cursor_x:].strip() == "" self.set_mark() self.jump_to_end_of_line() self.delete_selection() if empty_selection: self.delete_character() def _indent_level(self): if self.cursor_y == 0: return 0 self.jump_to_start_of_line() self.cursor_up() while self._current_character() in [" ", "\t"]: self.cursor_right() return self.cursor_x def tab_align(self): if self.cursor_y == 0: return indent = self._indent_level() self.cursor_down() self.jump_to_start_of_line() self.set_mark() while self._current_character() in [" ", "\t"]: self.cursor_right() self.delete_selection() self.insert_text(" " * indent) def insert_tab(self): self.insert_text("\t") def _line_indent(self, y): line = self.text_widget[y] for index, char in enumerate(line): if char != " ": return index return 0 def comment_lines(self): if self.mark is None: if self.text_widget[self.cursor_y].strip() == "": self.text_widget[self.cursor_y] = "# " self.cursor_x = 2 else: try: index = self.text_widget[self.cursor_y].index("#") self.cursor_x = index + 1 except ValueError: # '#' not in line self.jump_to_end_of_line() self.insert_text(" # ") else: (start_x, start_y), (end_x, end_y) = self.get_selection_interval() if end_x != 0 and not self.cursor_x == len(self.text_widget[end_y]): self.enter() self.cursor_left() if start_x != 0: new_line = (self.text_widget[start_y][:start_x] + "# " + self.text_widget[start_y][start_x:]) self.text_widget[start_y] = new_line self._cursor_x = len(new_line) start_y += 1 if end_x != 0: end_y += 1 mid_lines = range(start_y, end_y) try: min_indent = min(self._line_indent(y) for y in mid_lines if self.text_widget[y].strip() != "") except ValueError: pass else: if all(self.text_widget[y][min_indent:min_indent+2] == "# " or self.text_widget[y].strip() == "" for y in mid_lines): for y in mid_lines: line = self.text_widget[y] if line.strip() != "": self.text_widget[y] = line[:min_indent] + line[min_indent + 2:] else: for y in mid_lines: line = self.text_widget[y] if line.strip() != "": self.text_widget[y] = line[:min_indent] + "# " + line[min_indent:] self.mark = None def join_lines(self): if self.cursor_y == 0: self.jump_to_start_of_line() else: left_part = self.text_widget[self.cursor_y-1].rstrip() right_part = self.text_widget[self.cursor_y].lstrip() new_line = right_part if left_part == "" else (left_part + " " + right_part) self.text_widget[self.cursor_y-1:self.cursor_y+1] = [new_line] self.cursor_x, self.cursor_y = len(left_part), self.cursor_y - 1 def highlight_block(self): self.jump_to_block_end() self.set_mark() self.jump_to_block_start() def syntax_highlight_all(self): self.text_widget.syntax_highlight_all() def center_cursor(self): view_x, view_y = self.view_widget.position new_y = max(0, self.cursor_y - self.last_height // 2) self.view_widget.position = view_x, new_y def cycle_syntax_highlighting(self): self.theme_index += 1 if self.theme_index == len(Editor.THEMES): self.theme_index = 0 theme = self.THEMES[self.theme_index] self.text_widget.theme = theme self.text_widget.syntax_highlight_all() def quit(self): fill3.SHUTDOWN_EVENT.set() def ring_bell(self): if "unittest" not in sys.modules: print("\a", end="") def undo(self): self.text_widget[:], self._cursor_x, self._cursor_y = self.history.pop() def toggle_overwrite(self): self.is_overwriting = not self.is_overwriting def _work_lines(self): if self.mark is None: return [self.cursor_y] else: (start_x, start_y), (end_x, end_y) = self.get_selection_interval() return range(start_y + (start_x > 0), end_y + 1 - (end_x == 0)) def indent(self): indent_ = " " * Editor.TAB_SIZE for line_num in self._work_lines(): if self.text_widget[line_num].strip() == "": self.text_widget[line_num] = "" continue self.text_widget[line_num] = indent_ + self.text_widget[line_num] if self.cursor_y == line_num: self.cursor_x += Editor.TAB_SIZE def dedent(self): indent_ = " " * Editor.TAB_SIZE line_nums = self._work_lines() if not all(self.text_widget[line_num].startswith(indent_) or self.text_widget[line_num].strip() == "" for line_num in line_nums): self.ring_bell() return for line_num in line_nums: if self.cursor_y == line_num: self.cursor_x = max(self.cursor_x - Editor.TAB_SIZE, 0) if self.text_widget[line_num].strip() == "": self.text_widget[line_num] = "" continue self.text_widget[line_num] = self.text_widget[line_num][Editor.TAB_SIZE:] def abort_command(self): self.mark = None self.ring_bell() def get_text(self): return self.text_widget.get_text() def follow_cursor(self): height = self.last_height height -= 2 # header + scrollbar width = self.last_width width -= 1 # scrollbar view_x, view_y = self.view_widget.position if self.cursor_y >= view_y + height or self.cursor_y < view_y: new_y = self.cursor_y - height // 2 else: new_y = view_y screen_x = len(expand_str(self.text_widget[self.cursor_y][:self.cursor_x])) if screen_x >= view_x + width or screen_x < view_x: new_x = screen_x - width // 2 else: new_x = view_x self.view_widget.position = max(0, new_x), max(0, new_y) def add_to_history(self): self.history.append((self.text_widget.actual_text.copy(), self._cursor_x, self._cursor_y)) def on_keyboard_input(self, term_code): if term_code not in [terminal.CTRL_UNDERSCORE, terminal.CTRL_Z]: self.add_to_history() if action := (Editor.KEY_MAP.get((self.previous_term_code, term_code)) or Editor.KEY_MAP.get(term_code)): try: action(self) except IndexError: self.ring_bell() elif len(term_code) == 1 and ord(term_code) < 32: pass else: self.insert_text(term_code, is_overwriting=self.is_overwriting) self.previous_term_code = term_code self.follow_cursor() fill3.APPEARANCE_CHANGED_EVENT.set() def scroll(self, dx, dy): view_x, view_y = self.scroll_position self.scroll_position = max(0, view_x + dx), max(0, view_y + dy) def on_mouse_press(self, x, y): view_x, view_y = self.view_widget.position self.cursor_y = min(y + view_y - 1, len(self.text_widget) - 1) self._cursor_x = x + view_x self.last_mouse_position = (x, y) def on_mouse_drag(self, x, y): last_x, last_y = self.last_mouse_position self.scroll(last_x - x, last_y - y) self.last_mouse_position = (x, y) def on_mouse_input(self, term_code): action, flag, x, y = terminal.decode_mouse_input(term_code) if action == terminal.MOUSE_PRESS: self.on_mouse_press(x, y) elif action == terminal.MOUSE_DRAG: self.on_mouse_drag(x, y) self.follow_cursor() fill3.APPEARANCE_CHANGED_EVENT.set() def appearance(self): return self.decor_widget.appearance() _HEADER_STYLE = termstr.CharStyle(fg_color=termstr.Color.white, bg_color=termstr.Color.green) @functools.lru_cache(maxsize=100) def get_header(self, path, width, cursor_x, cursor_y, is_changed): change_marker = "*" if is_changed else "" cursor_position = f"Line {cursor_y+1} Column {cursor_x+1:<3}" path_part = (path + change_marker).ljust(width - len(cursor_position) - 2) return (termstr.TermStr(" " + path_part, self._HEADER_STYLE).bold() + termstr.TermStr(cursor_position + " ", self._HEADER_STYLE)) def appearance_for(self, dimensions): width, height = dimensions is_changed = self.text_widget.actual_text != self.original_text header = self.get_header(self.path, width, self.cursor_x, self.cursor_y, is_changed) self.last_width = width self.last_height = height result = [header] + self.view_widget.appearance_for((width, height - 1)) return result KEY_MAP = { (terminal.CTRL_X, terminal.CTRL_S): save, terminal.BACKSPACE: backspace, terminal.LEFT: cursor_left, terminal.CTRL_B: cursor_left, terminal.RIGHT: cursor_right, terminal.CTRL_F: cursor_right, terminal.UP: cursor_up, terminal.CTRL_P: cursor_up, terminal.DOWN: cursor_down, terminal.CTRL_N: cursor_down, terminal.CTRL_A: jump_to_start_of_line, terminal.CTRL_E: jump_to_end_of_line, terminal.CTRL_O: open_line, terminal.ENTER: enter, terminal.CTRL_SPACE: set_mark, terminal.CTRL_G: drop_highlight, terminal.PAGE_DOWN: page_down, terminal.CTRL_V: page_down, terminal.PAGE_UP: page_up, terminal.ALT_v: page_up, terminal.ALT_w: copy_selection, terminal.CTRL_W: delete_selection, terminal.CTRL_D: delete_character, terminal.DELETE: delete_character, terminal.ALT_d: delete_right, terminal.CTRL_Y: paste_from_clipboard, terminal.CTRL_UP: jump_to_block_start, terminal.CTRL_DOWN: jump_to_block_end, terminal.ALT_f: next_word, terminal.CTRL_RIGHT: next_word, terminal.ALT_RIGHT: next_word, terminal.ALT_b: previous_word, terminal.CTRL_LEFT: previous_word, terminal.ALT_LEFT: previous_word, terminal.ALT_BACKSPACE: delete_backward, terminal.ALT_CARROT: join_lines, terminal.ALT_h: highlight_block, terminal.ALT_H: highlight_block, terminal.CTRL_R: syntax_highlight_all, terminal.CTRL_L: center_cursor, terminal.ALT_SEMICOLON: comment_lines, terminal.ALT_c: cycle_syntax_highlighting, (terminal.CTRL_X, terminal.CTRL_C): quit, terminal.ESC: quit, terminal.CTRL_K: delete_line, terminal.TAB: tab_align, (terminal.CTRL_Q, terminal.TAB): insert_tab, terminal.CTRL_UNDERSCORE: undo, terminal.CTRL_Z: undo, terminal.CTRL_G: abort_command, terminal.INSERT: toggle_overwrite, (terminal.CTRL_C, ">"): indent, (terminal.CTRL_C, "<"): dedent} def main(): editor = Editor() editor.load(sys.argv[1]) asyncio.run(fill3.tui("Editor", editor)) if __name__ == "__main__": main()