commit bbe73eff215846990d335e97d842f4ebeb0bfab2 Author: Andrew Hamilton Date: Sat Jan 1 17:37:20 2022 +1000 Initial commit. diff --git a/BUGS b/BUGS new file mode 100644 index 0000000..d5059e6 --- /dev/null +++ b/BUGS @@ -0,0 +1,8 @@ +Current: +- Interface freezing often. + + +Fixed: + + +Won't fix: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb393b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + The Artistic License 2.0 + + Copyright (c) 2022 Andrew Hamilton + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +Preamble + +This license establishes the terms under which a given free software +Package may be copied, modified, distributed, and/or redistributed. +The intent is that the Copyright Holder maintains some artistic +control over the development of that Package while still keeping the +Package available as open source and free software. + +You are always permitted to make arrangements wholly outside of this +license directly with the Copyright Holder of a given Package. If the +terms of this license do not permit the full use that you propose to +make of the Package, you should contact the Copyright Holder and seek +a different licensing arrangement. + +Definitions + + "Copyright Holder" means the individual(s) or organization(s) + named in the copyright notice for the entire Package. + + "Contributor" means any party that has contributed code or other + material to the Package, in accordance with the Copyright Holder's + procedures. + + "You" and "your" means any person who would like to copy, + distribute, or modify the Package. + + "Package" means the collection of files distributed by the + Copyright Holder, and derivatives of that collection and/or of + those files. A given Package may consist of either the Standard + Version, or a Modified Version. + + "Distribute" means providing a copy of the Package or making it + accessible to anyone else, or in the case of a company or + organization, to others outside of your company or organization. + + "Distributor Fee" means any fee that you charge for Distributing + this Package or providing support for this Package to another + party. It does not mean licensing fees. + + "Standard Version" refers to the Package if it has not been + modified, or has been modified only in ways explicitly requested + by the Copyright Holder. + + "Modified Version" means the Package, if it has been changed, and + such changes were not explicitly requested by the Copyright + Holder. + + "Original License" means this Artistic License as Distributed with + the Standard Version of the Package, in its current version or as + it may be modified by The Perl Foundation in the future. + + "Source" form means the source code, documentation source, and + configuration files for the Package. + + "Compiled" form means the compiled bytecode, object code, binary, + or any other form resulting from mechanical transformation or + translation of the Source form. + + +Permission for Use and Modification Without Distribution + +(1) You are permitted to use the Standard Version and create and use +Modified Versions for any purpose without restriction, provided that +you do not Distribute the Modified Version. + + +Permissions for Redistribution of the Standard Version + +(2) You may Distribute verbatim copies of the Source form of the +Standard Version of this Package in any medium without restriction, +either gratis or for a Distributor Fee, provided that you duplicate +all of the original copyright notices and associated disclaimers. At +your discretion, such verbatim copies may or may not include a +Compiled form of the Package. + +(3) You may apply any bug fixes, portability changes, and other +modifications made available from the Copyright Holder. The resulting +Package will still be considered the Standard Version, and as such +will be subject to the Original License. + + +Distribution of Modified Versions of the Package as Source + +(4) You may Distribute your Modified Version as Source (either gratis +or for a Distributor Fee, and with or without a Compiled form of the +Modified Version) provided that you clearly document how it differs +from the Standard Version, including, but not limited to, documenting +any non-standard features, executables, or modules, and provided that +you do at least ONE of the following: + + (a) make the Modified Version available to the Copyright Holder + of the Standard Version, under the Original License, so that the + Copyright Holder may include your modifications in the Standard + Version. + + (b) ensure that installation of your Modified Version does not + prevent the user installing or running the Standard Version. In + addition, the Modified Version must bear a name that is different + from the name of the Standard Version. + + (c) allow anyone who receives a copy of the Modified Version to + make the Source form of the Modified Version available to others + under + + (i) the Original License or + + (ii) a license that permits the licensee to freely copy, + modify and redistribute the Modified Version using the same + licensing terms that apply to the copy that the licensee + received, and requires that the Source form of the Modified + Version, and of any works derived from it, be made freely + available in that license fees are prohibited but Distributor + Fees are allowed. + + +Distribution of Compiled Forms of the Standard Version +or Modified Versions without the Source + +(5) You may Distribute Compiled forms of the Standard Version without +the Source, provided that you include complete instructions on how to +get the Source of the Standard Version. Such instructions must be +valid at the time of your distribution. If these instructions, at any +time while you are carrying out such distribution, become invalid, you +must provide new instructions on demand or cease further distribution. +If you provide valid instructions or cease distribution within thirty +days after you become aware that the instructions are invalid, then +you do not forfeit any of your rights under this license. + +(6) You may Distribute a Modified Version in Compiled form without +the Source, provided that you comply with Section 4 with respect to +the Source of the Modified Version. + + +Aggregating or Linking the Package + +(7) You may aggregate the Package (either the Standard Version or +Modified Version) with other packages and Distribute the resulting +aggregation provided that you do not charge a licensing fee for the +Package. Distributor Fees are permitted, and licensing fees for other +components in the aggregation are permitted. The terms of this license +apply to the use and Distribution of the Standard or Modified Versions +as included in the aggregation. + +(8) You are permitted to link Modified and Standard Versions with +other works, to embed the Package in a larger work of your own, or to +build stand-alone binary or bytecode versions of applications that +include the Package, and Distribute the result without restriction, +provided the result does not expose a direct interface to the Package. + + +Items That are Not Considered Part of a Modified Version + +(9) Works (including, but not limited to, modules and scripts) that +merely extend or make use of the Package, do not, by themselves, cause +the Package to be a Modified Version. In addition, such works are not +considered parts of the Package itself, and are not subject to the +terms of this license. + + +General Provisions + +(10) Any use, modification, and distribution of the Standard or +Modified Versions is governed by this Artistic License. By using, +modifying or distributing the Package, you accept this license. Do not +use, modify, or distribute the Package, if you do not accept this +license. + +(11) If your Modified Version has been derived from a Modified +Version made by someone other than you, you are nevertheless required +to ensure that your Modified Version complies with the requirements of +this license. + +(12) This license does not grant you the right to use any trademark, +service mark, tradename, or logo of the Copyright Holder. + +(13) This license includes the non-exclusive, worldwide, +free-of-charge patent license to make, have made, use, offer to sell, +sell, import and otherwise transfer the Package with respect to any +patent claims licensable by the Copyright Holder that are necessarily +infringed by the Package. If you institute patent litigation +(including a cross-claim or counterclaim) against any party alleging +that the Package constitutes direct or contributory patent +infringement, then this Artistic License to you shall terminate on the +date that such litigation is filed. + +(14) Disclaimer of Warranty: +THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS +IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL +LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a07b29 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Diff-edit + +Out of order, do not use. + +## Summary + +Edit two files side by side, showing differences. + +## Installation + +Install diff-edit directly using pip: + + pip install --upgrade pip # A recent version of pip is needed. + pip install git+https://github.com/ahamilton/diff-edit@v2022.01.01 + +Or install from source: + + git clone https://github.com/ahamilton/diff-edit + pip install ./diff-edit + +Then to run: + + diff-edit -h diff --git a/TODO b/TODO new file mode 100644 index 0000000..77bb2a1 --- /dev/null +++ b/TODO @@ -0,0 +1,27 @@ +Todo: +- Fix coast scrolling. +- Right align the left editor. +- Put it on github. + + +Done: +- Try real unicode arrows instead of > <. +- Name it. +- Make a USAGE. +- Syntax check the cmdline. +- Make a setup.py. +- Add key to toggle sub-highlights within modification highlights. +- Fix scroll limit. +- Add key to jump to next change. Ctrl-d +- Optimize highlighting in Editor. +- Let document be saved. +- Show an indicator when the document has unsaved changes. +- Fix spaces at end of lines not being a document change. +- Correctly set cursor position when switching editors. +- Correctly optimize highlight_modification. +- Add key to cycle syntax highlighting themes (including none). +- Make changing highlighting themes work in Editor. +- Follow the cursor when moving off the screen to the left or right. + + +Shelved: diff --git a/diff_edit/__init__.py b/diff_edit/__init__.py new file mode 100755 index 0000000..5416154 --- /dev/null +++ b/diff_edit/__init__.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 + + +"""Diff-edit. + +Edit two files side by side, showing differences. +""" + + +import asyncio +import difflib +import functools +import os +import sys + +import docopt +import fill3 +import fill3.terminal as terminal +import termstr + +import diff_edit.editor as editor + + +__version__ = "v2022.01.01" + + +PROJECT_NAME = "diff-edit" +USAGE = f""" +Usage: + {PROJECT_NAME} + {PROJECT_NAME} -h | --help + {PROJECT_NAME} --version + +Example: + # {PROJECT_NAME} project.py.bak project.py + +Keys: + Alt-s Save file. + Alt-o Switch focus between editors. (toggle) + Alt-up Move to previous difference. + Alt-down Move to next difference. + Alt-c Change syntax highlighting theme. (cycle) + Alt-h Hide sub-highlighting of modifications. (toggle) +""" + + +_LINE_MAP = {"━": 0b0101, "┃": 0b1010, "┏": 0b0110, "┗": 0b1100, "┛": 0b1001, "┓": 0b0011, + "╋": 0b1111, "┣": 0b1110, "┳": 0b0111, "┫": 0b1011, "┻": 0b1101, " ": 0b0000} +_LINE_MAP_INVERTED = {v: k for k, v in _LINE_MAP.items()} + + +@functools.lru_cache() +def union_box_line(a_line, b_line): + return _LINE_MAP_INVERTED[_LINE_MAP[a_line] | _LINE_MAP[b_line]] + + +@functools.lru_cache(maxsize=500) +def highlight_str(line, bg_color, transparency): + 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) + + +@functools.lru_cache(maxsize=500) +def get_diff(a_text, b_text): + return difflib.SequenceMatcher(a=a_text, b=b_text).get_opcodes() + + +def get_lines(text_editor, start, end): + return tuple(text_editor.text_widget[start:end]), tuple(text_editor.text_widget.text[start:end]) + + +def replace_part(a_str, start, end, part): + return a_str[:start] + part + a_str[end:] + + +@functools.lru_cache(maxsize=500) +def highlight_modification(a_lines, b_lines, show_sub_highlights): + blue = termstr.Color.blue + left_line = fill3.join("\n", tuple(colored_line[:len(line)] + for line, colored_line in zip(*a_lines))) + right_line = fill3.join("\n", tuple(colored_line[:len(line)] + for line, colored_line in zip(*b_lines))) + if show_sub_highlights: + diff = get_diff(left_line.data, right_line.data) + for opcode, left_start, left_end, right_start, right_end in diff: + color = termstr.Color.white if opcode == "replace" else termstr.Color.green + if opcode == "delete" or opcode == "replace": + part = highlight_str(left_line[left_start:left_end], color, 0.8) + left_line = replace_part(left_line, left_start, left_end, part) + if opcode == "insert" or opcode == "replace": + part = highlight_str(right_line[right_start:right_end], color, 0.8) + right_line = replace_part(right_line, right_start, right_end, part) + return ([highlight_str(line + a_line[len(line):], blue, 0.6) + for line, a_line in zip(left_line.split("\n"), a_lines[1])], + [highlight_str(line + b_line[len(line):], blue, 0.6) + for line, b_line in zip(right_line.split("\n"), b_lines[1])]) + + +def draw_connector(columns, color, left_y, right_y): + left_arrows, line, right_arrows = columns + height = len(left_arrows) + left_corner, right_corner = ("┓", "┗") if left_y < right_y else ("┛", "┏") + if left_y == right_y: + left_corner, right_corner = "━", "━" + for column, y, arrow, corner in [(left_arrows, left_y, "╺", left_corner), + (right_arrows, right_y, "╸", right_corner)]: + if y <= 0: + pass + elif y >= height - 1: + pass + else: + column[y] = termstr.TermStr(arrow).fg_color(color) + line[y] = union_box_line(corner, line[y]) + if 0 < left_y < height - 1 or 0 < right_y < height - 1: + if left_y != right_y: + start, end = sorted([left_y, right_y]) + start = max(start, -1) + end = min(end, height) + for index in range(start+1, end): + line[index] = union_box_line("┃", line[index]) + + +class DiffEditor: + + def __init__(self, left_path, right_path): + self.left_editor = editor.Editor() + self.left_editor.load(left_path) + self.left_editor.view_widget.is_scrollbar_right = False + self.right_editor = editor.Editor() + self.right_editor.load(right_path) + # self.unify_duplicate_lines() + self.show_sub_highlights = True + self.diff = None + + def highlight_lines(appearance, start, end, opcode, change_opcode): + if opcode == change_opcode: + for index in range(start, end): + appearance[index] = highlight_str(appearance[index], (0, 200, 0), 0.6) + + def left_highlight_lines(appearance): + appearance = appearance.copy() + for op, left_start, left_end, right_start, right_end in self.diff: + if op == "replace": + left_lines = get_lines(self.left_editor, left_start, left_end) + right_lines = get_lines(self.right_editor, right_start, right_end) + left_appearance, right_appearance = highlight_modification( + left_lines, right_lines, self.show_sub_highlights) + appearance[left_start:left_end] = left_appearance + highlight_lines(appearance, left_start, left_end, op, "delete") + return appearance + + def right_highlight_lines(appearance): + appearance = appearance.copy() + for op, left_start, left_end, right_start, right_end in self.diff: + if op == "replace": + left_lines = get_lines(self.left_editor, left_start, left_end) + right_lines = get_lines(self.right_editor, right_start, right_end) + left_appearance, right_appearance = highlight_modification( + left_lines, right_lines, self.show_sub_highlights) + appearance[right_start:right_end] = right_appearance + highlight_lines(appearance, right_start, right_end, op, "insert") + return appearance + + left_decor = editor.Decor(self.left_editor.text_widget, left_highlight_lines) + self.left_editor.decor_widget.widget = left_decor + self.left_view = self.left_editor.view_widget + right_decor = editor.Decor(self.right_editor.text_widget, right_highlight_lines) + self.right_editor.decor_widget.widget = right_decor + self.right_view = self.right_editor.view_widget + self.right_editor.is_editing = False + self.editors = [self.left_editor, self.right_editor] + + # def unify_duplicate_lines(self): + # lines = {line: line for line in self.left_editor.text_widget} + # lines.update({line: line for line in self.right_editor.text_widget}) + # for editor_ in [self.left_editor, self.right_editor]: + # for index, line in enumerate(editor.text_widget): + # try: + # editor_.text_widget[index] = lines[line] + # except KeyError: + # pass + + def _equivalent_line(self, y): + for opcode, left_start, left_end, right_start, right_end in self.diff: + if self.editors[0] == self.right_editor: + left_start, left_end, right_start, right_end = \ + right_start, right_end, left_start, left_end + if left_start <= y < left_end: + fraction = (y - left_start) / (left_end - left_start) + return round(right_start + fraction * (right_end - right_start)) + + def follow_scroll(self): + x, y = self.editors[0].scroll_position + last_width, last_height = self.last_dimensions + middle_y = last_height // 2 + new_y = self._equivalent_line(y + middle_y) + if new_y is None: + new_y = 0 + self.editors[1].scroll_position = max(0, x), new_y - middle_y + + def switch_editor(self): + self.editors[1].cursor_x = self.editors[0].cursor_x + self.editors[1].cursor_y = self._equivalent_line( + self.editors[0].cursor_y) + self.editors[1].follow_cursor() + self.editors.reverse() + self.editors[0].is_editing, self.editors[1].is_editing = True, False + + def on_divider_pressed(self, x, y, left_x, right_x): + left_scroll = self.left_view.position[1] + right_scroll = self.right_view.position[1] + for opcode, left_start, left_end, right_start, right_end in self.diff: + if opcode == "equal": + continue + left_y = left_start - left_scroll + 1 # 1 for header + right_y = right_start - right_scroll + 1 # 1 for header + if x == left_x and left_y == y: + self.left_editor.text_widget[left_start:left_end] = \ + [self.right_editor.text_widget[line_num] + for line_num in range(right_start, right_end)] + self.diff = None + elif x == right_x and right_y == y: + self.right_editor.text_widget[right_start:right_end] = \ + [self.left_editor.text_widget[line_num] + for line_num in range(left_start, left_end)] + self.diff = None + + def on_mouse_press(self, x, y, left_x, right_x): + if x < left_x: + if self.editors[0] == self.right_editor: + self.switch_editor() + self.left_editor.on_mouse_press(x, y) + elif x > right_x: + if self.editors[0] == self.left_editor: + self.switch_editor() + self.right_editor.on_mouse_press(x - right_x - 1, y) + else: # divider pressed + self.on_divider_pressed(x, y, left_x, right_x) + + def on_mouse_event(self, action, x, y): + width, height = self.last_dimensions + divider_width = 3 + left_x = (width - divider_width) // 2 + right_x = left_x + 2 + if action == terminal.MOUSE_PRESS: + self.on_mouse_press(x, y, left_x, right_x) + elif action == terminal.MOUSE_DRAG: + if x < left_x: + self.left_editor.on_mouse_drag(x, y) + elif x > right_x: + self.right_editor.on_mouse_drag(x - right_x - 1, y) + else: # mouse release + pass + # if x < left_x: + # self.left_editor.on_mouse_release(x, y) + # elif x > right_x: + # self.right_editor.on_mouse_release(x - right_x - 1, y) + + def update_diff(self): + self.diff = difflib.SequenceMatcher( + a=self.left_editor.text_widget, + b=self.right_editor.text_widget).get_opcodes() + + def jump_to_next_diff(self): + y = self.editors[0].cursor_y + for op, left_start, left_end, right_start, right_end in self.diff: + if op == "equal": + continue + start = (left_start if self.editors[0] == self.left_editor + else right_start) + if start > y: + try: + self.editors[0].cursor_y = start + except IndexError: + self.editors[0].cursor_y = start - 1 + self.editors[0].center_cursor() + break + + def jump_to_previous_diff(self): + y = self.editors[0].cursor_y + for op, left_start, left_end, right_start, right_end in reversed(self.diff): + if op == "equal": + continue + start = (left_start if self.editors[0] == self.left_editor + else right_start) + if start < y: + try: + self.editors[0].cursor_y = start + except IndexError: + self.editors[0].cursor_y = start - 1 + self.editors[0].center_cursor() + break + + def cycle_syntax_highlighting(self): + for editor_ in self.editors: + editor_.cycle_syntax_highlighting() + + def on_keyboard_input(self, term_code): + if term_code == terminal.ALT_o: + self.switch_editor() + elif term_code == terminal.ALT_h: + self.show_sub_highlights = not self.show_sub_highlights + elif term_code == terminal.ALT_DOWN: + self.jump_to_next_diff() + elif term_code == terminal.ALT_UP: + self.jump_to_previous_diff() + elif term_code == terminal.ALT_c: + self.cycle_syntax_highlighting() + else: + self.editors[0].on_keyboard_input(term_code) + self.diff = None + fill3.APPEARANCE_CHANGED_EVENT.set() + + def on_mouse_input(self, term_code): + action, flag, x, y = terminal.decode_mouse_input(term_code) + if action in [terminal.MOUSE_PRESS, terminal.MOUSE_DRAG, terminal.MOUSE_RELEASE]: + self.on_mouse_event(action, x, y) + fill3.APPEARANCE_CHANGED_EVENT.set() + + _ARROW_COLORS = [termstr.Color.yellow, termstr.Color.green, termstr.Color.red, + termstr.Color.light_blue, termstr.Color.purple, termstr.Color.orange, + termstr.Color.brown] + + def divider_appearance(self, height): + left_scroll = self.left_view.position[1] + right_scroll = self.right_view.position[1] + left_arrows = [" "] * height + line = [" "] * height + right_arrows = [" "] * height + columns = [left_arrows, line, right_arrows] + color_index = 0 + colors = self._ARROW_COLORS + has_top_mark, has_bottom_mark = False, False + for opcode, left_start, left_end, right_start, right_end in self.diff: + if opcode == "equal": + continue + color = colors[color_index % len(colors)] + left_y = left_start - left_scroll + 1 # 1 for header + right_y = right_start - right_scroll + 1 # 1 for header + draw_connector(columns, color, left_y, right_y) + for y in [left_y, right_y]: + if y <= 0: + has_top_mark = True + elif y >= height - 1: + has_bottom_mark = True + color_index += 1 + if has_top_mark: + line[0] = "↑" + if has_bottom_mark: + line[-1] = "↓" + return columns + + def appearance(self, dimensions): + width, height = self.last_dimensions = dimensions + if self.diff is None: + self.update_diff() + self.follow_scroll() + divider_width = 3 + left_width = (width - divider_width) // 2 + right_width = width - divider_width - left_width + left_appearance = self.left_editor.appearance((left_width, height)) + right_appearance = self.right_editor.appearance((right_width, height)) + inactive_appearance = (right_appearance if self.left_editor is self.editors[0] + else left_appearance) + inactive_appearance[0] = highlight_str(inactive_appearance[0], termstr.Color.black, 0.5) + return fill3.join_horizontal( + [left_appearance] + self.divider_appearance(height) + [right_appearance]) + + +def check_arguments(): + arguments = docopt.docopt(USAGE) + if arguments["--version"]: + print(__version__) + sys.exit(0) + for path in [arguments[""], arguments[""]]: + if not os.path.isfile(path): + print("File does not exist:", path) + sys.exit(1) + return arguments[""], arguments[""] + + +def main(): + path_a, path_b = check_arguments() + editor = DiffEditor(path_a, path_b) + asyncio.run(fill3.tui(PROJECT_NAME, editor)) + + +if __name__ == "__main__": + main() diff --git a/diff_edit/editor.py b/diff_edit/editor.py new file mode 100755 index 0000000..0a5fc12 --- /dev/null +++ b/diff_edit/editor.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import asyncio +import contextlib +import functools +import string +import sys + +import fill3 +import fill3.terminal as terminal +import pygments +import pygments.lexers +import pygments.styles +import termstr + + +@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("monokai") +# NATIVE_STYLE = pygments.styles.get_style_by_name("native") +NATIVE_STYLE = pygments.styles.get_style_by_name("paraiso-dark") +# NATIVE_STYLE = pygments.styles.get_style_by_name("fruity") +# NATIVE_STYLE = pygments.styles.get_style_by_name("solarizedlight") + + +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 = 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) + + +class Text: + + def __init__(self, text, pad_char=" "): + self.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 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): + new_lengths = [len(line) for line in new_lines] + try: + max_new_lengths = max(new_lengths) + except ValueError: + max_new_lengths = 0 + 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(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_min(self): + return self.text + + def appearance(self, dimensions): + return fill3.appearance_resize(self.appearance_min(), dimensions) + + +class Code(Text): + + def __init__(self, text, lexer=PYTHON_LEXER, theme=NATIVE_STYLE): + self.lexer = lexer + self.theme = theme + self.padding_char = _syntax_highlight(" ", lexer, theme) + Text.__init__(self, text) + + def _convert_line(self, line, max_line_length): + if self.theme is None: + return termstr.TermStr(line.ljust(max_line_length)) + else: + return _syntax_highlight(line.ljust(max_line_length), self.lexer, self.theme) + + 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(self, dimensions): + return self.decorator(self.widget.appearance(dimensions)) + + def appearance_min(self): + return self.decorator(self.widget.appearance_min()) + + +def highlight_part(line, start, end): + return (line[:start] + highlight_str(line[start:end], termstr.Color.white, + transparency=0.7) + line[end:]) + + +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() + if start_y == end_y: + result[start_y] = highlight_part(result[start_y], start_x, end_x) + else: + result[start_y] = highlight_part(result[start_y], 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, 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] + result[self.cursor_y] = ( + cursor_line[:self.cursor_x] + + termstr.TermStr(cursor_line[self.cursor_x]).invert() + + cursor_line[self.cursor_x+1:]) + return result + + +class Editor: + + THEMES = [pygments.styles.get_style_by_name(style) + for style in ["monokai", "fruity", "native"]] + [None] + + def __init__(self, text="", path="Untitled"): + self.set_text(text) + self.path = path + self.mark = None + self.clipboard = None + self.last_width = 100 + self.last_height = 40 + self.is_editing = True + self.theme_index = 0 + + @property + def cursor_x(self): + line_length = len(self.text_widget.actual_text[self.cursor_y]) + return min(self._cursor_x, line_length) + + @cursor_x.setter + def cursor_x(self, x): + 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): + x, y = position + # text_width = self.text_widget.max_line_length + # if x < 0: + # new_x = 0 + # elif x > text_width - self.last_width + 2: + # new_x = max(text_width - self.last_width + 2, 0) + # else: + # new_x = x + # if y < 0: + # new_y = 0 + # elif y > len(self.text_widget) - self.last_height + 2: + # new_y = max(len(self.text_widget) - self.last_height + 2, 0) + # else: + # new_y = y + new_x, new_y = max(x, 0), y + self.view_widget.position = new_x, new_y + view_x, view_y = self.view_widget.position + new_cursor_y = self.cursor_y + y - view_y + self.cursor_y = max( + 0, min(new_cursor_y, len(self.text_widget) - 1)) + + 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 set_text(self, text): + self.text_widget = Code(text) + # self.text_widget = Text(text) + self.decor_widget = Decor(self.text_widget, lambda appearance: + add_highlights(self, appearance)) + self.view_widget = fill3.View.from_widget(self.decor_widget) + self.cursor_x, self.cursor_y = 0, 0 + self.original_text = self.text_widget.actual_text.copy() + + def load(self, path): + self.set_text(open(path).read()) + self.path = path + + def save(self): + pass + # with open(self.path, "w") as f: + # f.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): + try: + current_line = self.text_widget[self.cursor_y] + new_line = (current_line[:self.cursor_x] + text + + current_line[self.cursor_x:]) + self.text_widget[self.cursor_y] = new_line + 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 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 comment_highlighted(self): + pass + + 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 get_text(self): + return self.text_widget.get_text() + + def follow_cursor(self): + height = self.last_height + height -= 1 # header + 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 + if self.cursor_x >= view_x + width or self.cursor_x < view_x: + new_x = self.cursor_x - width // 2 + else: + new_x = view_x + self.view_widget.position = max(0, new_x), max(0, new_y) + + _PRINTABLE = string.printable[:-5] + + def on_keyboard_input(self, term_code): + if term_code in Editor.KEY_MAP: + with contextlib.suppress(IndexError): + Editor.KEY_MAP[term_code](self) + elif term_code in self._PRINTABLE: + self.insert_text(term_code) + else: + self.insert_text(repr(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 = view_x + dx, view_y + dy + + def on_mouse_press(self, x, y): + view_x, view_y = self.view_widget.position + self.cursor_x = x + view_x + self.cursor_y = min(y + view_y - 1, len(self.text_widget) - 1) + 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_min(self): + return self.decor_widget.appearance_min() + + _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 = "Line %s Column %s" % (cursor_y + 1, cursor_x + 1) + 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(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((width, height - 1)) + return result + + KEY_MAP = { + terminal.ALT_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.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_highlighted, + terminal.ALT_c: cycle_syntax_highlighting} + + +def main(): + editor = Editor() + editor.load(sys.argv[1]) + asyncio.run(fill3.tui("Editor", editor), debug=True) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..2539657 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + + +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + + +setup(name="diff-edit", + version="v2022.01.01", + description="Edit two files side by side, showing differences.", + url="https://github.com/ahamilton/diff-edit", + author="Andrew Hamilton", + author_email="and_hamilton@yahoo.com", + license="Artistic 2.0", + packages=["diff_edit"], + entry_points={"console_scripts": ["diff-edit=diff_edit:main"]}, + install_requires=[ + "pygments==2.10.0", "docopt==0.6.2", + "fill3 @ git+https://github.com/ahamilton/eris@v2021.12.24#subdirectory=fill3"]) diff --git a/tests/editor_test.py b/tests/editor_test.py new file mode 100755 index 0000000..0f37e4a --- /dev/null +++ b/tests/editor_test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + + +import contextlib +import unittest + +import diff_edit.editor as editor + + +class TextWidgetTestCase(unittest.TestCase): + + def test_get_text(self): + text = editor.Text("a") + self.assertEqual(text.get_text(), "a") + + def test_padding(self): + text = editor.Text("a\nbb") + self.assertEqual(text.appearance_min(), ["a ", "bb"]) + + def test_get_line(self): + text = editor.Text("") + self.assertEqual(text[0], "") + text = editor.Text("a\nbb") + self.assertEqual(text[0], "a") + self.assertEqual(text[1], "bb") + + def test_change_line(self): + text = editor.Text("a\nbb") + text[0] = "aaa" + self.assertEqual(text.appearance_min(), ["aaa", "bb "]) + + def test_insert_line(self): + text = editor.Text("a\nbb") + text.insert(1, "ccc") + self.assertEqual(text.appearance_min(), ["a ", "ccc", "bb "]) + + def test_append_line(self): + text = editor.Text("a") + text.append("bb") + self.assertEqual(text.appearance_min(), ["a ", "bb"]) + + def test_replace_lines(self): + text = editor.Text("a\nbb\nc\nd") + text[1:3] = ["e", "f", "g"] + self.assertEqual(text.appearance_min(), ["a", "e", "f", "g", "d"]) + + def test_len(self): + text = editor.Text("a\nbb\nc\nd") + self.assertEqual(len(text), 4) + + +class EditorTestCase(unittest.TestCase): + + def setUp(self): + self.editor = editor.Editor() + + def _assert_editor(self, expected_text, expected_cursor_position): + cursor_x, cursor_y = expected_cursor_position + self.assertEqual(self.editor.get_text(), expected_text) + self.assertEqual(self.editor.cursor_x, cursor_x) + self.assertEqual(self.editor.cursor_y, cursor_y) + + def _set_editor(self, text, cursor_position): + self.editor.set_text(text) + self.editor.cursor_x, self.editor.cursor_y = cursor_position + + def _assert_changes(self, changes): + for index, change in enumerate(changes): + with self.subTest(index=index, change=change): + method, expected_text, expected_cursor_position = change + with contextlib.suppress(IndexError): + method() + self._assert_editor(expected_text, expected_cursor_position) + + def test_empty_editor(self): + self._assert_editor("", (0, 0)) + + def test_set_text(self): + self.editor.set_text("foo") + self.assertEqual(self.editor.get_text(), "foo") + + def test_insert_text(self): + self.editor.insert_text("a") + self._assert_editor("a", (1, 0)) + self.editor.insert_text("bc") + self._assert_editor("abc", (3, 0)) + + def test_enter(self): + self._set_editor("ab", (1, 0)) + self.editor.enter() + self._assert_editor("a\nb", (0, 1)) + + def test_delete_character(self): + self._set_editor("ab\nc", (1, 0)) + self._assert_changes([(self.editor.delete_character, "a\nc", (1, 0)), + (self.editor.delete_character, "ac", (1, 0)), + (self.editor.delete_character, "a", (1, 0)), + (self.editor.delete_character, "a", (1, 0))]) + + def test_backspace(self): + self._set_editor("a\n" + "bcd", (2, 1)) + self._assert_changes([(self.editor.backspace, "a\n" + "bd", (1, 1)), + (self.editor.backspace, "a\nd", (0, 1)), + (self.editor.backspace, "ad", (1, 0)), + (self.editor.backspace, "d", (0, 0)), + (self.editor.backspace, "d", (0, 0))]) + + def test_cursor_movement(self): + text = ("a\n" + "bc") + self._set_editor(text, (0, 0)) + up, down = self.editor.cursor_up, self.editor.cursor_down + left, right = self.editor.cursor_left, self.editor.cursor_right + self._assert_changes([ + (up, text, (0, 0)), (left, text, (0, 0)), (right, text, (1, 0)), + (right, text, (0, 1)), (left, text, (1, 0)), (down, text, (1, 1)), + (right, text, (2, 1)), (right, text, (2, 1)), (up, text, (1, 0)), + (down, text, (2, 1)), + (self.editor.jump_to_start_of_line, text, (0, 1)), + (self.editor.jump_to_end_of_line, text, (2, 1))]) + + def test_jumping_words(self): + text = ("ab .dj\n" + " bc*d") + self._set_editor(text, (0, 0)) + next, previous = self.editor.next_word, self.editor.previous_word + self._assert_changes([ + (next, text, (2, 0)), (next, text, (6, 0)), (next, text, (3, 1)), + (next, text, (5, 1)), (next, text, (5, 1)), + (previous, text, (4, 1)), (previous, text, (1, 1)), + (previous, text, (4, 0)), (previous, text, (0, 0)), + (previous, text, (0, 0))]) + + def test_jumping_blocks(self): + text = "a\nb\n\nc\nd" + self._set_editor(text, (0, 0)) + self._assert_changes([(self.editor.jump_to_block_start, text, (0, 0)), + (self.editor.jump_to_block_end, text, (0, 2)), + (self.editor.jump_to_block_end, text, (0, 4)), + (self.editor.jump_to_block_end, text, (0, 4))]) + + def test_page_up_and_down(self): + text = "a\nbb\nc\nd" + self._set_editor(text, (1, 1)) + self._assert_changes([(self.editor.page_up, text, (0, 0)), + (self.editor.page_up, text, (0, 0)), + (self.editor.page_down, text, (0, 3)), + (self.editor.page_down, text, (0, 3))]) + + def test_join_lines(self): + text = " \nab- \n -cd " + self._set_editor(text, (4, 2)) + self._assert_changes([(self.editor.join_lines, " \nab- -cd ", (3, 1)), + (self.editor.join_lines, "ab- -cd ", (0, 0)), + (self.editor.join_lines, "ab- -cd ", (0, 0))]) + + +if __name__ == "__main__": + unittest.main()