Initial commit.
This commit is contained in:
commit
bbe73eff21
8 changed files with 1417 additions and 0 deletions
8
BUGS
Normal file
8
BUGS
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
Current:
|
||||||
|
- Interface freezing often.
|
||||||
|
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
|
||||||
|
Won't fix:
|
||||||
202
LICENSE
Normal file
202
LICENSE
Normal file
|
|
@ -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.
|
||||||
|
|
||||||
23
README.md
Normal file
23
README.md
Normal file
|
|
@ -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
|
||||||
27
TODO
Normal file
27
TODO
Normal file
|
|
@ -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:
|
||||||
393
diff_edit/__init__.py
Executable file
393
diff_edit/__init__.py
Executable file
|
|
@ -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} <file-a> <file-b>
|
||||||
|
{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["<file-a>"], arguments["<file-b>"]]:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print("File does not exist:", path)
|
||||||
|
sys.exit(1)
|
||||||
|
return arguments["<file-a>"], arguments["<file-b>"]
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
582
diff_edit/editor.py
Executable file
582
diff_edit/editor.py
Executable file
|
|
@ -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()
|
||||||
21
setup.py
Executable file
21
setup.py
Executable file
|
|
@ -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"])
|
||||||
161
tests/editor_test.py
Executable file
161
tests/editor_test.py
Executable file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue