diff --git a/diff_edit/__init__.py b/diff_edit/__init__.py index b43cde2..0f01316 100755 --- a/diff_edit/__init__.py +++ b/diff_edit/__init__.py @@ -71,7 +71,7 @@ def get_diff(a_text, b_text): def get_lines(text_editor, start, end): - return tuple(text_editor.text_widget[start:end]), tuple(text_editor.text_widget.text[start:end]) + return tuple(text_editor.text_widget[start:end]), tuple(text_editor.text_widget.appearance_interval((start, end))) def replace_part(a_str, start, end, part): @@ -125,6 +125,20 @@ def draw_connector(columns, color, left_y, right_y): line[index] = union_box_line("┃", line[index]) +def ranges_overlap(a, b): + return a[1] > b[0] and a[0] < b[1] + + +def overlay_list(bg_list, fg_list, index): + if index < 0: + bg_len = len(bg_list) + bg_list[:len(fg_list) + index] = fg_list[abs(index):] + bg_list[bg_len:] = [] + else: + bg_list[index:index + len(fg_list)] = fg_list[:len(bg_list) - index] + return bg_list + + class DiffEditor: def __init__(self, left_path, right_path): @@ -142,27 +156,31 @@ class DiffEditor: appearance[index] = highlight_str(appearance[index], (0, 200, 0), 0.6) def left_highlight_lines(appearance): - appearance = appearance.copy() + view_x, view_y = self.left_view.position for op, left_start, left_end, right_start, right_end in self.diff: - if op == "replace": + if (op == "replace" + and ranges_overlap((left_start, left_end), (view_y, view_y + len(appearance)))): 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") + overlay_list(appearance, left_appearance, left_start - view_y) + highlight_lines(appearance, max(left_start, view_y) - view_y, + min(left_end, view_y + len(appearance)) - view_y, op, "delete") return appearance def right_highlight_lines(appearance): - appearance = appearance.copy() + view_x, view_y = self.right_view.position for op, left_start, left_end, right_start, right_end in self.diff: - if op == "replace": + if (op == "replace" + and ranges_overlap((right_start, right_end), (view_y, view_y + len(appearance)))): 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") + overlay_list(appearance, right_appearance, right_start - view_y) + highlight_lines(appearance, max(right_start, view_y) - view_y, + min(right_end, view_y + len(appearance)) - view_y, op, "insert") return appearance left_decor = editor.Decor(self.left_editor.text_widget, left_highlight_lines) diff --git a/diff_edit/editor.py b/diff_edit/editor.py index 05eba3e..0f2810e 100755 --- a/diff_edit/editor.py +++ b/diff_edit/editor.py @@ -62,7 +62,7 @@ def _syntax_highlight(text, lexer, style): return fill3.join("\n", text_widget.text) -@functools.lru_cache(maxsize=500) +@functools.lru_cache(maxsize=5000) def expand_str(str_): expanded_str = termstr.TermStr(str_) return str_ if expanded_str.data == str_ else expanded_str @@ -72,19 +72,20 @@ class Text: def __init__(self, text, padding_char=" "): self.padding_char = padding_char - self.text, self.actual_text, self.max_line_length = [], [], 0 + self.actual_text = [] + self.max_line_length = None lines = [""] if text == "" else text.splitlines() if text.endswith("\n"): lines.append("") self[:] = lines def __len__(self): - return len(self.text) + return len(self.actual_text) def __getitem__(self, line_index): return self.actual_text[line_index] - @functools.lru_cache(maxsize=1000) + @functools.lru_cache(maxsize=5000) def _convert_line(self, line, max_line_length): return expand_str(line).ljust(max_line_length) @@ -94,35 +95,34 @@ class Text: else: # slice self._replace_lines(key, value) + @functools.cached_property + def max_line_length(self): + return max(len(expand_str(line)) for line in self.actual_text) + 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 + self.actual_text[slice_] = new_lines + with contextlib.suppress(AttributeError): + del self.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) + self.insert(len(self.actual_text), line) def get_text(self): return "\n".join(self) def appearance(self): - return self.text + return [self._convert_line(line, self.max_line_length) for line in self.actual_text] - def appearance_for(self, dimensions): - return fill3.appearance_resize(self.appearance(), dimensions) + def appearance_dimensions(self): + return (self.max_line_length, len(self.actual_text)) + + def appearance_interval(self, interval): + start_y, end_y = interval + return [self._convert_line(line, self.max_line_length) + for line in self.actual_text[start_y:end_y]] class Code(Text): @@ -130,23 +130,23 @@ 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) + padding_char = None Text.__init__(self, text, padding_char) - @functools.lru_cache(maxsize=1000) - 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)) + @functools.lru_cache(maxsize=5000) + def _convert_line_themed(self, line, max_line_length, theme): + if self.padding_char is None: + self.padding_char = (" " if self.theme is None + else _syntax_highlight(" ", self.lexer, self.theme)) + highlighted_line = (termstr.TermStr(line) if theme is None + else _syntax_highlight(line, self.lexer, theme)) return highlighted_line.ljust(max_line_length, fillchar=self.padding_char) + def _convert_line(self, line, max_line_length): + return self._convert_line_themed(line, max_line_length, 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()] + self.padding_char = None class Decor: @@ -161,13 +161,19 @@ class Decor: def appearance(self): return self.decorator(self.widget.appearance()) + def appearance_interval(self, interval): + return self.decorator(self.widget.appearance_interval(interval)) + + def appearance_dimensions(self): + return self.widget.appearance_dimensions() + 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) +@functools.lru_cache(maxsize=5000) def expandtabs(text): result = [] for line in text.splitlines(keepends=True): @@ -184,7 +190,7 @@ def expandtabs(text): return "".join(result) -@functools.lru_cache(maxsize=500) +@functools.lru_cache(maxsize=5000) def expand_str_inverse(str_): result = [] for index, char in enumerate(str_): @@ -252,32 +258,39 @@ class Editor: return (start_x, start_y), (end_x, end_y) def add_highlights(self, appearance): - result = appearance.copy() + view_x, view_y = self.view_widget.position + result = appearance 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) + result[self.cursor_y - view_y] = highlight_str(result[self.cursor_y - view_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])) + start_y -= view_y + end_y -= view_y 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])) + if 0 <= start_y < len(result): + 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) + for line_num in range(max(start_y+1, 0), min(end_y, self.last_height)): + if 0 <= line_num < len(result): + result[line_num] = highlight_part(result[line_num], 0, len(result[line_num])) + if 0 <= end_y < len(result): + 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] + cursor_line = result[self.cursor_y - view_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:]) + result[self.cursor_y - view_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): @@ -342,7 +355,7 @@ class Editor: 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) + self.cursor_x, self.cursor_y = 0, min(len(self.text_widget.actual_text) - 1, new_y) def jump_to_start_of_line(self): self.cursor_x = 0 diff --git a/tests/diff_editor_test.py b/tests/diff_editor_test.py new file mode 100755 index 0000000..be3667d --- /dev/null +++ b/tests/diff_editor_test.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + + +import unittest + +import diff_edit + + +class OverlayListTestCase(unittest.TestCase): + + def test_overlay_list(self): + self.assertEqual(diff_edit.overlay_list([1, 2, 3, 4], [5, 6], 0), [5, 6, 3, 4]) + self.assertEqual(diff_edit.overlay_list([1, 2, 3, 4], [5, 6], 3), [1, 2, 3, 5]) + self.assertEqual(diff_edit.overlay_list([1, 2, 3, 4], [5, 6], -1), [6, 2, 3, 4]) + self.assertEqual(diff_edit.overlay_list([5, 6], [1, 2, 3, 4], -1), [2, 3]) + + +if __name__ == "__main__": + unittest.main()