2022-01-01 17:37:20 +10:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import contextlib
|
2022-04-19 11:23:32 +10:00
|
|
|
import enum
|
2022-01-01 17:37:20 +10:00
|
|
|
import functools
|
2022-01-18 18:42:04 +10:00
|
|
|
import os
|
2022-01-01 17:37:20 +10:00
|
|
|
import string
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import fill3
|
|
|
|
|
import fill3.terminal as terminal
|
2022-04-30 14:17:35 +10:00
|
|
|
import lscolors
|
2022-01-01 17:37:20 +10:00
|
|
|
import pygments
|
|
|
|
|
import pygments.lexers
|
|
|
|
|
import pygments.styles
|
|
|
|
|
import termstr
|
|
|
|
|
|
2022-02-23 23:00:21 +10:00
|
|
|
import cwcwidth
|
|
|
|
|
|
2022-01-29 15:37:56 +10:00
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
@functools.lru_cache(maxsize=100)
|
|
|
|
|
def highlight_str(line, bg_color, transparency=0.6):
|
|
|
|
|
def blend_style(style):
|
2022-06-12 17:30:14 +10:00
|
|
|
return termstr.CharStyle(termstr.blend_color(style.fg_rgb_color, bg_color, transparency),
|
|
|
|
|
termstr.blend_color(style.bg_rgb_color, bg_color, transparency),
|
2022-01-01 21:38:40 +10:00
|
|
|
is_bold=style.is_bold, is_italic=style.is_italic,
|
|
|
|
|
is_underlined=style.is_underlined)
|
2022-01-01 17:37:20 +10:00
|
|
|
return termstr.TermStr(line).transform_style(blend_style)
|
|
|
|
|
|
|
|
|
|
|
2022-04-19 11:23:32 +10:00
|
|
|
def highlight_line(line):
|
|
|
|
|
return highlight_str(line, termstr.Color.white, 0.8)
|
|
|
|
|
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
NATIVE_STYLE = pygments.styles.get_style_by_name("paraiso-dark")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _syntax_highlight(text, lexer, style):
|
|
|
|
|
@functools.lru_cache(maxsize=500)
|
|
|
|
|
def _parse_rgb(hex_rgb):
|
|
|
|
|
if hex_rgb.startswith("#"):
|
|
|
|
|
hex_rgb = hex_rgb[1:]
|
|
|
|
|
return tuple(int("0x" + hex_rgb[index:index+2], base=16) for index in [0, 2, 4])
|
|
|
|
|
|
|
|
|
|
@functools.lru_cache(maxsize=500)
|
|
|
|
|
def _char_style_for_token_type(token_type, default_bg_color, default_style):
|
|
|
|
|
try:
|
|
|
|
|
token_style = style.style_for_token(token_type)
|
|
|
|
|
except KeyError:
|
|
|
|
|
return default_style
|
|
|
|
|
fg_color = (termstr.Color.black if token_style["color"] is None
|
|
|
|
|
else _parse_rgb(token_style["color"]))
|
|
|
|
|
bg_color = (default_bg_color if token_style["bgcolor"] is None
|
|
|
|
|
else _parse_rgb(token_style["bgcolor"]))
|
|
|
|
|
return termstr.CharStyle(fg_color, bg_color, token_style["bold"], token_style["italic"],
|
|
|
|
|
token_style["underline"])
|
|
|
|
|
default_bg_color = _parse_rgb(style.background_color)
|
|
|
|
|
default_style = termstr.CharStyle(bg_color=default_bg_color)
|
2022-03-06 22:43:21 +10:00
|
|
|
text = expandtabs(text)
|
2022-05-11 23:04:41 +10:00
|
|
|
text = termstr.join("", [termstr.TermStr(
|
2022-01-01 21:38:40 +10:00
|
|
|
text, _char_style_for_token_type(token_type, default_bg_color, default_style))
|
2022-01-01 17:37:20 +10:00
|
|
|
for token_type, text in pygments.lex(text, lexer)])
|
|
|
|
|
text_widget = fill3.Text(text, pad_char=termstr.TermStr(" ").bg_color(default_bg_color))
|
2022-05-11 23:04:41 +10:00
|
|
|
return termstr.join("\n", text_widget.text)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
@functools.lru_cache(maxsize=5000)
|
2022-02-23 23:00:21 +10:00
|
|
|
def expand_str(str_):
|
|
|
|
|
expanded_str = termstr.TermStr(str_)
|
|
|
|
|
return str_ if expanded_str.data == str_ else expanded_str
|
|
|
|
|
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
class Text:
|
|
|
|
|
|
2022-01-17 22:22:16 +10:00
|
|
|
def __init__(self, text, padding_char=" "):
|
|
|
|
|
self.padding_char = padding_char
|
2022-03-12 17:30:06 +10:00
|
|
|
self.lines = []
|
2022-03-11 19:20:51 +10:00
|
|
|
self.max_line_length = None
|
2022-01-01 17:37:20 +10:00
|
|
|
lines = [""] if text == "" else text.splitlines()
|
|
|
|
|
if text.endswith("\n"):
|
|
|
|
|
lines.append("")
|
2022-06-23 22:07:21 +10:00
|
|
|
self.version = 0
|
2022-01-01 17:37:20 +10:00
|
|
|
self[:] = lines
|
|
|
|
|
|
|
|
|
|
def __len__(self):
|
2022-03-12 17:30:06 +10:00
|
|
|
return len(self.lines)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-05-13 10:16:30 +10:00
|
|
|
@functools.cached_property
|
|
|
|
|
def max_line_length(self):
|
|
|
|
|
return max(len(expand_str(line)) for line in self.lines)
|
|
|
|
|
|
|
|
|
|
def _new_line(self, line):
|
|
|
|
|
self.max_line_length = max(self.max_line_length, len(expand_str(line)))
|
2022-06-23 22:07:21 +10:00
|
|
|
self.version += 1
|
2022-05-13 10:16:30 +10:00
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
def __getitem__(self, line_index):
|
2022-03-12 17:30:06 +10:00
|
|
|
return self.lines[line_index]
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def __setitem__(self, key, value):
|
2022-05-13 14:29:26 +10:00
|
|
|
if type(key) == int and \
|
|
|
|
|
len(expand_str(self.lines[key])) != self.max_line_length:
|
|
|
|
|
self.lines[key] = value
|
|
|
|
|
self._new_line(value)
|
|
|
|
|
else:
|
|
|
|
|
self.lines[key] = value
|
|
|
|
|
with contextlib.suppress(AttributeError):
|
|
|
|
|
del self.max_line_length
|
2022-06-23 22:07:21 +10:00
|
|
|
self.version += 1
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def insert(self, index, line):
|
2022-05-13 10:16:30 +10:00
|
|
|
self.lines.insert(index, line)
|
|
|
|
|
self._new_line(line)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def append(self, line):
|
2022-05-13 10:16:30 +10:00
|
|
|
self.lines.append(line)
|
|
|
|
|
self._new_line(line)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def get_text(self):
|
|
|
|
|
return "\n".join(self)
|
|
|
|
|
|
2022-05-28 22:37:57 +10:00
|
|
|
@staticmethod
|
2022-05-13 10:16:30 +10:00
|
|
|
@functools.lru_cache(maxsize=5000)
|
2022-05-28 22:37:57 +10:00
|
|
|
def _convert_line(line, max_line_length):
|
2022-05-13 10:16:30 +10:00
|
|
|
return expand_str(line).ljust(max_line_length)
|
|
|
|
|
|
2022-01-18 16:37:17 +10:00
|
|
|
def appearance(self):
|
2022-03-12 17:30:06 +10:00
|
|
|
return [self._convert_line(line, self.max_line_length) for line in self.lines]
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
def appearance_dimensions(self):
|
2022-03-12 17:30:06 +10:00
|
|
|
return (self.max_line_length, len(self.lines))
|
2022-03-11 19:20:51 +10:00
|
|
|
|
|
|
|
|
def appearance_interval(self, interval):
|
|
|
|
|
start_y, end_y = interval
|
|
|
|
|
return [self._convert_line(line, self.max_line_length)
|
2022-03-12 17:30:06 +10:00
|
|
|
for line in self.lines[start_y:end_y]]
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Code(Text):
|
|
|
|
|
|
2022-01-14 11:01:11 +10:00
|
|
|
def __init__(self, text, path, theme=NATIVE_STYLE):
|
2022-04-19 11:23:32 +10:00
|
|
|
self.lexer = pygments.lexers.get_lexer_for_filename(path, text)
|
2022-01-01 17:37:20 +10:00
|
|
|
self.theme = theme
|
2022-03-11 19:20:51 +10:00
|
|
|
padding_char = None
|
2022-01-17 22:22:16 +10:00
|
|
|
Text.__init__(self, text, padding_char)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
@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))
|
2022-03-06 22:43:21 +10:00
|
|
|
return highlighted_line.ljust(max_line_length, fillchar=self.padding_char)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
def _convert_line(self, line, max_line_length):
|
|
|
|
|
return self._convert_line_themed(line, max_line_length, self.theme)
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
def syntax_highlight_all(self):
|
2022-03-11 19:20:51 +10:00
|
|
|
self.padding_char = None
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class Decor:
|
|
|
|
|
|
|
|
|
|
def __init__(self, widget, decorator):
|
|
|
|
|
self.widget = widget
|
|
|
|
|
self.decorator = decorator
|
|
|
|
|
|
2022-01-18 16:32:58 +10:00
|
|
|
def appearance_for(self, dimensions):
|
|
|
|
|
return self.decorator(self.widget.appearance_for(dimensions))
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-01-18 16:37:17 +10:00
|
|
|
def appearance(self):
|
|
|
|
|
return self.decorator(self.widget.appearance())
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
def appearance_interval(self, interval):
|
|
|
|
|
return self.decorator(self.widget.appearance_interval(interval))
|
|
|
|
|
|
|
|
|
|
def appearance_dimensions(self):
|
|
|
|
|
return self.widget.appearance_dimensions()
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def highlight_part(line, start, end):
|
2022-01-01 21:38:40 +10:00
|
|
|
return (line[:start] + highlight_str(line[start:end], termstr.Color.white, transparency=0.7) +
|
|
|
|
|
line[end:])
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
@functools.lru_cache(maxsize=5000)
|
2022-03-06 22:43:21 +10:00
|
|
|
def expandtabs(text):
|
|
|
|
|
result = []
|
|
|
|
|
for line in text.splitlines(keepends=True):
|
|
|
|
|
parts = line.split("\t")
|
|
|
|
|
if len(parts) == 1:
|
|
|
|
|
result.append(line)
|
|
|
|
|
continue
|
|
|
|
|
result.append(parts[0])
|
|
|
|
|
line_length = cwcwidth.wcswidth(parts[0])
|
|
|
|
|
for part in parts[1:]:
|
|
|
|
|
spacing = 8 - line_length % 8
|
|
|
|
|
result.extend([" " * spacing, part])
|
|
|
|
|
line_length += spacing + cwcwidth.wcswidth(part)
|
|
|
|
|
return "".join(result)
|
|
|
|
|
|
|
|
|
|
|
2022-03-11 19:20:51 +10:00
|
|
|
@functools.lru_cache(maxsize=5000)
|
2022-02-23 23:00:21 +10:00
|
|
|
def expand_str_inverse(str_):
|
|
|
|
|
result = []
|
|
|
|
|
for index, char in enumerate(str_):
|
|
|
|
|
run_length = 8 - len(result) % 8 if char == "\t" else cwcwidth.wcwidth(char)
|
|
|
|
|
result.extend([index] * run_length)
|
2022-02-16 19:40:39 +10:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2022-04-19 11:23:32 +10:00
|
|
|
def _wrap_text_lines(words, width):
|
|
|
|
|
cursor = len(words[0])
|
|
|
|
|
first_word = 0
|
|
|
|
|
for index, word in enumerate(words[1:]):
|
|
|
|
|
if cursor + 1 + len(word) <= width:
|
|
|
|
|
cursor += (1 + len(word))
|
|
|
|
|
else:
|
|
|
|
|
yield words[first_word:index+1]
|
|
|
|
|
first_word = index + 1
|
|
|
|
|
cursor = len(word)
|
|
|
|
|
yield words[first_word:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wrap_text(words, width):
|
|
|
|
|
appearance = []
|
|
|
|
|
coords = []
|
|
|
|
|
for index, line in enumerate(_wrap_text_lines(words, width)):
|
|
|
|
|
line = list(line)
|
2022-05-11 23:04:41 +10:00
|
|
|
content = termstr.join(" ", line)
|
2022-04-19 11:23:32 +10:00
|
|
|
appearance.append(content.center(width))
|
|
|
|
|
cursor = index * width + round((width - len(content)) / 2)
|
|
|
|
|
for word in line:
|
|
|
|
|
coords.append((cursor, cursor + len(word)))
|
|
|
|
|
cursor += (len(word) + 1)
|
|
|
|
|
return appearance, coords
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Line(enum.Enum):
|
|
|
|
|
class_ = enum.auto()
|
|
|
|
|
function = enum.auto()
|
|
|
|
|
endpoint = enum.auto()
|
|
|
|
|
|
|
|
|
|
|
2022-06-12 17:27:29 +10:00
|
|
|
@functools.lru_cache(100)
|
2022-04-19 11:23:32 +10:00
|
|
|
def parts_lines(source, lexer):
|
|
|
|
|
cursor = 0
|
|
|
|
|
line_num = 0
|
|
|
|
|
line_lengths = [len(line) for line in source.splitlines(keepends=True)]
|
|
|
|
|
result = [(Line.endpoint, "top", 0)]
|
2022-06-12 17:27:29 +10:00
|
|
|
if lexer is None:
|
|
|
|
|
line_num = len(source.splitlines())
|
|
|
|
|
else:
|
|
|
|
|
for position, token_type, text in lexer.get_tokens_unprocessed(source):
|
|
|
|
|
while position >= cursor:
|
|
|
|
|
cursor += line_lengths[line_num]
|
|
|
|
|
line_num += 1
|
|
|
|
|
if token_type == pygments.token.Name.Class:
|
|
|
|
|
result.append((Line.class_, text, line_num - 1))
|
|
|
|
|
elif token_type in [pygments.token.Name.Function, pygments.token.Name.Function.Magic]:
|
|
|
|
|
result.append((Line.function, text, line_num - 1))
|
2022-04-19 11:23:32 +10:00
|
|
|
result.append((Line.endpoint, "bottom", line_num - 1))
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2022-06-12 17:27:29 +10:00
|
|
|
COLOR_MAP = {Line.class_: termstr.Color.red, Line.function: termstr.Color.green,
|
2022-04-19 11:23:32 +10:00
|
|
|
Line.endpoint: termstr.Color.white}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Parts:
|
|
|
|
|
|
|
|
|
|
def __init__(self, editor, source, lexer):
|
|
|
|
|
self.editor = editor
|
|
|
|
|
self.lines = parts_lines(source, lexer)
|
|
|
|
|
self.parts = [termstr.TermStr(text).fg_color(COLOR_MAP[line_type])
|
|
|
|
|
for line_type, text, line_num in self.lines]
|
|
|
|
|
self.width, self.height = None, None
|
2022-06-12 17:30:14 +10:00
|
|
|
self.is_focused = True
|
2022-04-19 11:23:32 +10:00
|
|
|
self.set_cursor()
|
|
|
|
|
|
|
|
|
|
def set_cursor(self):
|
|
|
|
|
for index, (line_type, text, line_num) in enumerate(self.lines):
|
|
|
|
|
if line_num > self.editor.cursor_y:
|
|
|
|
|
self.cursor = index - 1
|
|
|
|
|
break
|
|
|
|
|
else:
|
|
|
|
|
self.cursor = len(self.lines) - 1
|
|
|
|
|
|
|
|
|
|
def _move_cursor(self, delta):
|
|
|
|
|
self.cursor = (self.cursor + delta) % len(self.parts)
|
|
|
|
|
self.editor.cursor_x, self.editor.cursor_y = 0, self.lines[self.cursor][2]
|
|
|
|
|
x, y = self.editor.view_widget.portal.position
|
|
|
|
|
self.editor.view_widget.portal.position = x, self.editor.cursor_y - 1
|
|
|
|
|
|
2022-06-12 17:30:14 +10:00
|
|
|
def escape_parts_browser(self):
|
|
|
|
|
self.editor.parts_widget = None
|
|
|
|
|
self.editor.is_editing = True
|
|
|
|
|
self.editor.center_cursor()
|
|
|
|
|
|
2022-04-19 11:23:32 +10:00
|
|
|
def cursor_left(self):
|
|
|
|
|
self._move_cursor(-1)
|
|
|
|
|
|
|
|
|
|
def cursor_right(self):
|
|
|
|
|
self._move_cursor(1)
|
|
|
|
|
|
|
|
|
|
def on_keyboard_input(self, term_code):
|
2022-06-12 17:30:14 +10:00
|
|
|
match term_code:
|
|
|
|
|
case terminal.ESC:
|
|
|
|
|
self.escape_parts_browser()
|
|
|
|
|
case terminal.DOWN:
|
|
|
|
|
self.escape_parts_browser()
|
|
|
|
|
case terminal.LEFT:
|
|
|
|
|
self.cursor_left()
|
|
|
|
|
case terminal.RIGHT:
|
|
|
|
|
self.cursor_right()
|
2022-04-19 11:23:32 +10:00
|
|
|
fill3.APPEARANCE_CHANGED_EVENT.set()
|
|
|
|
|
|
|
|
|
|
def appearance(self):
|
|
|
|
|
width, height = self.dimensions
|
|
|
|
|
parts = self.parts.copy()
|
|
|
|
|
parts[self.cursor] = parts[self.cursor].invert()
|
|
|
|
|
result, coords = wrap_text(parts, width)
|
|
|
|
|
if len(result) > height:
|
|
|
|
|
appearance, coords = wrap_text(parts, width - 1)
|
|
|
|
|
line_num = coords[self.cursor][0] // (width - 1)
|
2022-06-12 17:30:14 +10:00
|
|
|
if self.is_focused:
|
|
|
|
|
appearance[line_num] = highlight_line(appearance[line_num])
|
2022-04-19 11:23:32 +10:00
|
|
|
view_widget = fill3.View.from_widget(fill3.Fixed(appearance))
|
|
|
|
|
if line_num >= height:
|
|
|
|
|
x, y = view_widget.portal.position
|
|
|
|
|
view_widget.portal.position = x, line_num // height * height
|
|
|
|
|
view_widget.portal.limit_scroll(self.dimensions, (width, len(appearance)))
|
|
|
|
|
result = view_widget.appearance_for(self.dimensions)
|
|
|
|
|
else:
|
2022-06-12 17:30:14 +10:00
|
|
|
if self.is_focused:
|
|
|
|
|
line_num = coords[self.cursor][0] // width
|
|
|
|
|
result[line_num] = highlight_line(result[line_num])
|
2022-04-19 11:23:32 +10:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2022-06-12 21:26:58 +10:00
|
|
|
class TextEditor:
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-01-29 15:37:56 +10:00
|
|
|
TAB_SIZE = 4
|
2022-01-01 17:37:20 +10:00
|
|
|
THEMES = [pygments.styles.get_style_by_name(style)
|
|
|
|
|
for style in ["monokai", "fruity", "native"]] + [None]
|
|
|
|
|
|
2022-01-24 23:10:44 +10:00
|
|
|
def __init__(self, text="", path="Untitled", is_left_aligned=True):
|
2022-01-18 18:42:04 +10:00
|
|
|
self.path = os.path.normpath(path)
|
2022-01-24 23:10:44 +10:00
|
|
|
self.is_left_aligned = is_left_aligned
|
2022-01-14 11:01:11 +10:00
|
|
|
self.set_text(text)
|
2022-01-01 17:37:20 +10:00
|
|
|
self.mark = None
|
|
|
|
|
self.clipboard = None
|
|
|
|
|
self.last_width = 100
|
|
|
|
|
self.last_height = 40
|
|
|
|
|
self.is_editing = True
|
|
|
|
|
self.theme_index = 0
|
2022-01-28 19:42:55 +10:00
|
|
|
self.is_overwriting = False
|
2022-01-04 10:52:38 +10:00
|
|
|
self.previous_term_code = None
|
2022-03-15 18:19:19 +10:00
|
|
|
self.last_mouse_position = 0, 0
|
2022-04-19 11:23:32 +10:00
|
|
|
self.parts_widget = None
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-04-27 16:16:49 +10:00
|
|
|
def screen_x(self, x, y):
|
|
|
|
|
return len(expand_str(self.text_widget[y][:x]))
|
|
|
|
|
|
|
|
|
|
def model_x(self, x, y):
|
|
|
|
|
return expand_str_inverse(self.text_widget[y])[x]
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
@property
|
|
|
|
|
def cursor_x(self):
|
2022-02-23 23:00:21 +10:00
|
|
|
try:
|
2022-04-27 16:16:49 +10:00
|
|
|
return self.model_x(self._cursor_x, self.cursor_y)
|
2022-02-23 23:00:21 +10:00
|
|
|
except IndexError:
|
2022-03-12 17:30:06 +10:00
|
|
|
return len(self.text_widget.lines[self.cursor_y])
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
@cursor_x.setter
|
|
|
|
|
def cursor_x(self, x):
|
2022-02-23 23:00:21 +10:00
|
|
|
try:
|
2022-04-27 16:16:49 +10:00
|
|
|
self._cursor_x = self.screen_x(x, self.cursor_y)
|
2022-02-23 23:00:21 +10:00
|
|
|
except IndexError:
|
|
|
|
|
self._cursor_x = x
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
@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):
|
2022-01-27 16:57:16 +10:00
|
|
|
self.view_widget.position = position
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2022-05-15 13:55:33 +10:00
|
|
|
def _highlight_selection(self, appearance):
|
|
|
|
|
(start_x, start_y), (end_x, end_y) = self.get_selection_interval()
|
|
|
|
|
screen_start_x = self.screen_x(start_x, start_y)
|
|
|
|
|
screen_end_x = self.screen_x(end_x, end_y)
|
|
|
|
|
view_x, view_y = self.view_widget.position
|
|
|
|
|
start_y -= view_y
|
|
|
|
|
end_y -= view_y
|
|
|
|
|
if start_y == end_y:
|
|
|
|
|
appearance[start_y] = highlight_part(appearance[start_y], screen_start_x, screen_end_x)
|
|
|
|
|
else:
|
|
|
|
|
if 0 <= start_y < len(appearance):
|
|
|
|
|
appearance[start_y] = highlight_part(appearance[start_y], screen_start_x,
|
|
|
|
|
len(appearance[start_y]))
|
|
|
|
|
for line_num in range(max(start_y+1, 0), min(end_y, self.last_height)):
|
|
|
|
|
if 0 <= line_num < len(appearance):
|
|
|
|
|
appearance[line_num] = highlight_part(appearance[line_num], 0,
|
|
|
|
|
len(appearance[line_num]))
|
|
|
|
|
if 0 <= end_y < len(appearance):
|
|
|
|
|
appearance[end_y] = highlight_part(appearance[end_y], 0, screen_end_x)
|
|
|
|
|
|
2022-06-13 14:15:43 +10:00
|
|
|
def _highlight_cursor(self, appearance, cursor_y):
|
|
|
|
|
cursor_line = appearance[cursor_y]
|
|
|
|
|
screen_x = self.screen_x(self.cursor_x, self.cursor_y)
|
|
|
|
|
screen_x_after = (screen_x + 1 if self._current_character() in ["\t", "\n"] else
|
|
|
|
|
self.screen_x(self.cursor_x + 1, self.cursor_y))
|
|
|
|
|
appearance[cursor_y] = (cursor_line[:screen_x] +
|
|
|
|
|
termstr.TermStr(cursor_line[screen_x:screen_x_after]).invert() +
|
|
|
|
|
cursor_line[screen_x_after:])
|
|
|
|
|
|
2022-05-15 13:55:33 +10:00
|
|
|
def _add_highlights(self, appearance):
|
2022-03-11 19:20:51 +10:00
|
|
|
view_x, view_y = self.view_widget.position
|
2022-06-13 14:15:43 +10:00
|
|
|
cursor_y = self.cursor_y - view_y
|
|
|
|
|
if 0 <= cursor_y < len(appearance):
|
|
|
|
|
self._highlight_cursor(appearance, cursor_y)
|
2022-02-03 22:58:16 +10:00
|
|
|
if not self.is_editing:
|
2022-05-15 13:55:33 +10:00
|
|
|
return appearance
|
2022-02-03 22:58:16 +10:00
|
|
|
if self.mark is None:
|
2022-05-15 13:55:33 +10:00
|
|
|
if 0 <= cursor_y < len(appearance):
|
|
|
|
|
appearance[cursor_y] = highlight_line(appearance[cursor_y])
|
2022-02-03 22:58:16 +10:00
|
|
|
else:
|
2022-05-15 13:55:33 +10:00
|
|
|
self._highlight_selection(appearance)
|
|
|
|
|
if self.cursor_x >= len(appearance[0]):
|
|
|
|
|
appearance = fill3.appearance_resize(appearance, (self.cursor_x+1, len(appearance)))
|
|
|
|
|
return appearance
|
2022-02-03 22:58:16 +10:00
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
def set_text(self, text):
|
2022-01-14 11:01:11 +10:00
|
|
|
try:
|
|
|
|
|
self.text_widget = Code(text, self.path)
|
|
|
|
|
except pygments.util.ClassNotFound: # No lexer for path
|
|
|
|
|
self.text_widget = Text(text)
|
2022-01-01 21:38:40 +10:00
|
|
|
self.decor_widget = Decor(self.text_widget,
|
2022-05-15 13:55:33 +10:00
|
|
|
lambda appearance: self._add_highlights(appearance))
|
2022-01-01 17:37:20 +10:00
|
|
|
self.view_widget = fill3.View.from_widget(self.decor_widget)
|
2022-01-27 16:57:16 +10:00
|
|
|
self.view_widget.portal.is_scroll_limited = True
|
2022-01-24 23:10:44 +10:00
|
|
|
if not self.is_left_aligned:
|
2022-05-26 14:46:11 +10:00
|
|
|
self.view_widget.portal.x_alignment = fill3.Alignment.right
|
2022-02-23 23:00:21 +10:00
|
|
|
self._cursor_x, self._cursor_y = 0, 0
|
2022-03-12 17:30:06 +10:00
|
|
|
self.original_text = self.text_widget.lines.copy()
|
2022-04-28 22:04:52 +10:00
|
|
|
self.history = []
|
2022-04-29 11:14:11 +10:00
|
|
|
self.history_position = 0
|
2022-04-28 22:04:52 +10:00
|
|
|
self.add_to_history()
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def load(self, path):
|
2022-01-18 18:42:04 +10:00
|
|
|
self.path = os.path.normpath(path)
|
2022-01-04 01:10:22 +10:00
|
|
|
with open(path) as file_:
|
|
|
|
|
self.set_text(file_.read())
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def save(self):
|
2022-02-07 21:11:18 +10:00
|
|
|
with open(self.path, "w") as file_:
|
|
|
|
|
file_.write(self.text_widget.get_text())
|
2022-03-12 17:30:06 +10:00
|
|
|
self.original_text = self.text_widget.lines.copy()
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
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):
|
2022-03-12 17:30:06 +10:00
|
|
|
if self.cursor_x == len(self.text_widget.lines[self.cursor_y]):
|
2022-01-01 17:37:20 +10:00
|
|
|
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
|
2022-03-12 17:30:06 +10:00
|
|
|
self.cursor_x, self.cursor_y = 0, min(len(self.text_widget.lines) - 1, new_y)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def jump_to_start_of_line(self):
|
|
|
|
|
self.cursor_x = 0
|
|
|
|
|
|
|
|
|
|
def jump_to_end_of_line(self):
|
2022-03-12 17:30:06 +10:00
|
|
|
self.cursor_x = len(self.text_widget.lines[self.cursor_y])
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
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()
|
2022-01-01 21:38:40 +10:00
|
|
|
selection = [self.text_widget[line_num] for line_num in range(start_y, end_y+1)]
|
2022-01-01 17:37:20 +10:00
|
|
|
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
|
|
|
|
|
|
2022-01-29 10:44:25 +10:00
|
|
|
def insert_text(self, text, is_overwriting=False):
|
2022-01-01 17:37:20 +10:00
|
|
|
try:
|
|
|
|
|
current_line = self.text_widget[self.cursor_y]
|
2022-01-29 10:44:25 +10:00
|
|
|
replace_count = len(text) if is_overwriting else 0
|
2022-01-28 19:42:55 +10:00
|
|
|
self.text_widget[self.cursor_y] = (current_line[:self.cursor_x] + text
|
|
|
|
|
+ current_line[self.cursor_x+replace_count:])
|
2022-01-01 17:37:20 +10:00
|
|
|
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):
|
2022-06-12 21:26:58 +10:00
|
|
|
while self._current_character() not in TextEditor.WORD_CHARS:
|
2022-01-01 17:37:20 +10:00
|
|
|
self.cursor_right()
|
2022-06-12 21:26:58 +10:00
|
|
|
while self._current_character() in TextEditor.WORD_CHARS:
|
2022-01-01 17:37:20 +10:00
|
|
|
self.cursor_right()
|
|
|
|
|
|
|
|
|
|
def previous_word(self):
|
|
|
|
|
self.cursor_left()
|
2022-06-12 21:26:58 +10:00
|
|
|
while self._current_character() not in TextEditor.WORD_CHARS:
|
2022-01-01 17:37:20 +10:00
|
|
|
self.cursor_left()
|
2022-06-12 21:26:58 +10:00
|
|
|
while self._current_character() in TextEditor.WORD_CHARS:
|
2022-01-01 17:37:20 +10:00
|
|
|
self.cursor_left()
|
|
|
|
|
self.cursor_right()
|
|
|
|
|
|
|
|
|
|
def delete_backward(self):
|
|
|
|
|
self.set_mark()
|
|
|
|
|
with contextlib.suppress(IndexError):
|
|
|
|
|
self.previous_word()
|
|
|
|
|
self.delete_selection()
|
|
|
|
|
|
2022-01-07 11:15:30 +10:00
|
|
|
def delete_line(self):
|
|
|
|
|
empty_selection = self.text_widget[self.cursor_y][self.cursor_x:].strip() == ""
|
|
|
|
|
self.set_mark()
|
|
|
|
|
self.jump_to_end_of_line()
|
|
|
|
|
self.delete_selection()
|
|
|
|
|
if empty_selection:
|
|
|
|
|
self.delete_character()
|
|
|
|
|
|
2022-01-08 15:05:40 +10:00
|
|
|
def _indent_level(self):
|
2022-01-08 10:01:57 +10:00
|
|
|
if self.cursor_y == 0:
|
2022-01-08 15:05:40 +10:00
|
|
|
return 0
|
2022-01-08 10:01:57 +10:00
|
|
|
self.jump_to_start_of_line()
|
|
|
|
|
self.cursor_up()
|
2022-02-16 19:40:39 +10:00
|
|
|
while self._current_character() in [" ", "\t"]:
|
2022-01-08 10:01:57 +10:00
|
|
|
self.cursor_right()
|
2022-01-08 15:05:40 +10:00
|
|
|
return self.cursor_x
|
|
|
|
|
|
|
|
|
|
def tab_align(self):
|
|
|
|
|
if self.cursor_y == 0:
|
|
|
|
|
return
|
|
|
|
|
indent = self._indent_level()
|
2022-01-08 10:01:57 +10:00
|
|
|
self.cursor_down()
|
|
|
|
|
self.jump_to_start_of_line()
|
|
|
|
|
self.set_mark()
|
2022-02-16 19:40:39 +10:00
|
|
|
while self._current_character() in [" ", "\t"]:
|
2022-01-08 10:01:57 +10:00
|
|
|
self.cursor_right()
|
|
|
|
|
self.delete_selection()
|
|
|
|
|
self.insert_text(" " * indent)
|
|
|
|
|
|
2022-02-16 19:40:39 +10:00
|
|
|
def insert_tab(self):
|
|
|
|
|
self.insert_text("\t")
|
|
|
|
|
|
2022-01-08 15:05:40 +10:00
|
|
|
def _line_indent(self, y):
|
|
|
|
|
line = self.text_widget[y]
|
|
|
|
|
for index, char in enumerate(line):
|
|
|
|
|
if char != " ":
|
|
|
|
|
return index
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
def comment_lines(self):
|
|
|
|
|
if self.mark is None:
|
|
|
|
|
if self.text_widget[self.cursor_y].strip() == "":
|
|
|
|
|
self.text_widget[self.cursor_y] = "# "
|
|
|
|
|
self.cursor_x = 2
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
index = self.text_widget[self.cursor_y].index("#")
|
|
|
|
|
self.cursor_x = index + 1
|
2022-01-10 10:58:20 +10:00
|
|
|
except ValueError: # '#' not in line
|
2022-01-08 15:05:40 +10:00
|
|
|
self.jump_to_end_of_line()
|
|
|
|
|
self.insert_text(" # ")
|
|
|
|
|
else:
|
|
|
|
|
(start_x, start_y), (end_x, end_y) = self.get_selection_interval()
|
2022-01-10 10:58:20 +10:00
|
|
|
if end_x != 0 and not self.cursor_x == len(self.text_widget[end_y]):
|
|
|
|
|
self.enter()
|
|
|
|
|
self.cursor_left()
|
|
|
|
|
if start_x != 0:
|
|
|
|
|
new_line = (self.text_widget[start_y][:start_x] + "# " +
|
|
|
|
|
self.text_widget[start_y][start_x:])
|
|
|
|
|
self.text_widget[start_y] = new_line
|
2022-02-23 23:00:21 +10:00
|
|
|
self._cursor_x = len(new_line)
|
2022-01-10 10:58:20 +10:00
|
|
|
start_y += 1
|
|
|
|
|
if end_x != 0:
|
|
|
|
|
end_y += 1
|
|
|
|
|
mid_lines = range(start_y, end_y)
|
|
|
|
|
try:
|
|
|
|
|
min_indent = min(self._line_indent(y) for y in mid_lines
|
|
|
|
|
if self.text_widget[y].strip() != "")
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
2022-01-08 15:05:40 +10:00
|
|
|
else:
|
2022-01-10 10:58:20 +10:00
|
|
|
if all(self.text_widget[y][min_indent:min_indent+2] == "# "
|
|
|
|
|
or self.text_widget[y].strip() == "" for y in mid_lines):
|
|
|
|
|
for y in mid_lines:
|
|
|
|
|
line = self.text_widget[y]
|
|
|
|
|
if line.strip() != "":
|
|
|
|
|
self.text_widget[y] = line[:min_indent] + line[min_indent + 2:]
|
|
|
|
|
else:
|
|
|
|
|
for y in mid_lines:
|
|
|
|
|
line = self.text_widget[y]
|
|
|
|
|
if line.strip() != "":
|
|
|
|
|
self.text_widget[y] = line[:min_indent] + "# " + line[min_indent:]
|
2022-01-08 15:05:40 +10:00
|
|
|
self.mark = None
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
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()
|
2022-01-01 21:38:40 +10:00
|
|
|
new_line = right_part if left_part == "" else (left_part + " " + right_part)
|
2022-01-01 17:37:20 +10:00
|
|
|
self.text_widget[self.cursor_y-1:self.cursor_y+1] = [new_line]
|
|
|
|
|
self.cursor_x, self.cursor_y = len(left_part), self.cursor_y - 1
|
|
|
|
|
|
|
|
|
|
def highlight_block(self):
|
|
|
|
|
self.jump_to_block_end()
|
|
|
|
|
self.set_mark()
|
|
|
|
|
self.jump_to_block_start()
|
|
|
|
|
|
|
|
|
|
def syntax_highlight_all(self):
|
|
|
|
|
self.text_widget.syntax_highlight_all()
|
|
|
|
|
|
|
|
|
|
def center_cursor(self):
|
|
|
|
|
view_x, view_y = self.view_widget.position
|
|
|
|
|
new_y = max(0, self.cursor_y - self.last_height // 2)
|
|
|
|
|
self.view_widget.position = view_x, new_y
|
|
|
|
|
|
|
|
|
|
def cycle_syntax_highlighting(self):
|
|
|
|
|
self.theme_index += 1
|
2022-06-12 21:26:58 +10:00
|
|
|
if self.theme_index == len(TextEditor.THEMES):
|
2022-01-01 17:37:20 +10:00
|
|
|
self.theme_index = 0
|
|
|
|
|
theme = self.THEMES[self.theme_index]
|
|
|
|
|
self.text_widget.theme = theme
|
|
|
|
|
self.text_widget.syntax_highlight_all()
|
|
|
|
|
|
2022-01-04 09:18:17 +10:00
|
|
|
def quit(self):
|
|
|
|
|
fill3.SHUTDOWN_EVENT.set()
|
|
|
|
|
|
2022-04-19 11:23:32 +10:00
|
|
|
def show_parts_list(self):
|
2022-06-12 17:27:29 +10:00
|
|
|
lexer = getattr(self.text_widget, "lexer", None)
|
|
|
|
|
self.parts_widget = Parts(self, self.get_text(), lexer)
|
2022-04-19 11:23:32 +10:00
|
|
|
self.is_editing = False
|
|
|
|
|
self.mark = None
|
|
|
|
|
|
2022-01-13 14:09:55 +10:00
|
|
|
def ring_bell(self):
|
2022-01-13 19:29:27 +10:00
|
|
|
if "unittest" not in sys.modules:
|
|
|
|
|
print("\a", end="")
|
2022-01-13 14:09:55 +10:00
|
|
|
|
2022-06-23 22:07:21 +10:00
|
|
|
def add_to_history(self, state=None):
|
|
|
|
|
if state is None:
|
|
|
|
|
lines = self.text_widget.lines.copy()
|
|
|
|
|
cursor_x, cursor_y = self._cursor_x, self._cursor_y
|
|
|
|
|
else:
|
|
|
|
|
lines, cursor_x, cursor_y = state
|
2022-04-29 11:14:11 +10:00
|
|
|
if self.history_position < len(self.history):
|
2022-04-29 19:32:05 +10:00
|
|
|
self.history.extend(reversed(self.history[self.history_position:-1]))
|
2022-06-23 22:07:21 +10:00
|
|
|
self.history.append((lines, cursor_x, cursor_y))
|
2022-04-29 11:14:11 +10:00
|
|
|
self.history_position = len(self.history)
|
|
|
|
|
|
2022-01-13 00:51:04 +10:00
|
|
|
def undo(self):
|
2022-04-29 11:14:11 +10:00
|
|
|
if self.history_position == 0:
|
|
|
|
|
self.ring_bell()
|
|
|
|
|
return
|
|
|
|
|
if self.history_position == len(self.history):
|
2022-04-28 22:04:52 +10:00
|
|
|
self.add_to_history()
|
2022-04-29 11:14:11 +10:00
|
|
|
self.history_position -= 1
|
|
|
|
|
self.history_position -= 1
|
|
|
|
|
self.text_widget[:], self._cursor_x, self._cursor_y = self.history[self.history_position]
|
2022-04-29 19:51:57 +10:00
|
|
|
self.mark = None
|
2022-04-29 11:14:11 +10:00
|
|
|
|
2022-01-28 19:42:55 +10:00
|
|
|
def toggle_overwrite(self):
|
|
|
|
|
self.is_overwriting = not self.is_overwriting
|
|
|
|
|
|
2022-01-29 15:37:56 +10:00
|
|
|
def _work_lines(self):
|
|
|
|
|
if self.mark is None:
|
|
|
|
|
return [self.cursor_y]
|
|
|
|
|
else:
|
|
|
|
|
(start_x, start_y), (end_x, end_y) = self.get_selection_interval()
|
|
|
|
|
return range(start_y + (start_x > 0), end_y + 1 - (end_x == 0))
|
|
|
|
|
|
|
|
|
|
def indent(self):
|
2022-06-12 21:26:58 +10:00
|
|
|
indent_ = " " * TextEditor.TAB_SIZE
|
2022-01-29 15:37:56 +10:00
|
|
|
for line_num in self._work_lines():
|
|
|
|
|
if self.text_widget[line_num].strip() == "":
|
|
|
|
|
self.text_widget[line_num] = ""
|
|
|
|
|
continue
|
|
|
|
|
self.text_widget[line_num] = indent_ + self.text_widget[line_num]
|
|
|
|
|
if self.cursor_y == line_num:
|
2022-06-12 21:26:58 +10:00
|
|
|
self.cursor_x += TextEditor.TAB_SIZE
|
2022-01-29 15:37:56 +10:00
|
|
|
|
|
|
|
|
def dedent(self):
|
2022-06-12 21:26:58 +10:00
|
|
|
indent_ = " " * TextEditor.TAB_SIZE
|
2022-01-29 15:37:56 +10:00
|
|
|
line_nums = self._work_lines()
|
|
|
|
|
if not all(self.text_widget[line_num].startswith(indent_)
|
|
|
|
|
or self.text_widget[line_num].strip() == "" for line_num in line_nums):
|
|
|
|
|
self.ring_bell()
|
|
|
|
|
return
|
|
|
|
|
for line_num in line_nums:
|
|
|
|
|
if self.cursor_y == line_num:
|
2022-06-12 21:26:58 +10:00
|
|
|
self.cursor_x = max(self.cursor_x - TextEditor.TAB_SIZE, 0)
|
2022-01-29 15:37:56 +10:00
|
|
|
if self.text_widget[line_num].strip() == "":
|
|
|
|
|
self.text_widget[line_num] = ""
|
|
|
|
|
continue
|
2022-06-12 21:26:58 +10:00
|
|
|
self.text_widget[line_num] = self.text_widget[line_num][TextEditor.TAB_SIZE:]
|
2022-01-29 15:37:56 +10:00
|
|
|
|
2022-01-13 13:25:00 +10:00
|
|
|
def abort_command(self):
|
|
|
|
|
self.mark = None
|
2022-01-13 14:09:55 +10:00
|
|
|
self.ring_bell()
|
2022-01-13 13:25:00 +10:00
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
def get_text(self):
|
|
|
|
|
return self.text_widget.get_text()
|
|
|
|
|
|
|
|
|
|
def follow_cursor(self):
|
|
|
|
|
height = self.last_height
|
2022-01-02 23:01:44 +10:00
|
|
|
height -= 2 # header + scrollbar
|
2022-01-01 17:37:20 +10:00
|
|
|
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
|
2022-04-27 16:16:49 +10:00
|
|
|
screen_x = self.screen_x(self.cursor_x, self.cursor_y)
|
2022-02-16 19:40:39 +10:00
|
|
|
if screen_x >= view_x + width or screen_x < view_x:
|
|
|
|
|
new_x = screen_x - width // 2
|
2022-01-01 17:37:20 +10:00
|
|
|
else:
|
|
|
|
|
new_x = view_x
|
|
|
|
|
self.view_widget.position = max(0, new_x), max(0, new_y)
|
|
|
|
|
|
|
|
|
|
def on_keyboard_input(self, term_code):
|
2022-04-19 11:23:32 +10:00
|
|
|
if self.parts_widget is not None:
|
|
|
|
|
self.parts_widget.on_keyboard_input(term_code)
|
|
|
|
|
return
|
2022-06-23 22:07:21 +10:00
|
|
|
old_version = self.text_widget.version
|
|
|
|
|
lines_before = self.text_widget.lines.copy()
|
|
|
|
|
cursor_x_before, cursor_y_before = self._cursor_x, self._cursor_y
|
2022-06-12 21:26:58 +10:00
|
|
|
if action := (TextEditor.KEY_MAP.get((self.previous_term_code, term_code))
|
|
|
|
|
or TextEditor.KEY_MAP.get(term_code)):
|
2022-01-13 14:34:47 +10:00
|
|
|
try:
|
2022-02-07 21:11:18 +10:00
|
|
|
action(self)
|
2022-01-13 14:34:47 +10:00
|
|
|
except IndexError:
|
|
|
|
|
self.ring_bell()
|
2022-04-28 22:20:18 +10:00
|
|
|
elif not (len(term_code) == 1 and ord(term_code) < 32):
|
2022-02-16 19:40:39 +10:00
|
|
|
self.insert_text(term_code, is_overwriting=self.is_overwriting)
|
2022-06-23 22:07:21 +10:00
|
|
|
if self.text_widget.version != old_version and action != TextEditor.undo:
|
|
|
|
|
self.add_to_history((lines_before, cursor_x_before, cursor_y_before))
|
|
|
|
|
self.mark = None
|
2022-01-04 00:10:32 +10:00
|
|
|
self.previous_term_code = term_code
|
2022-01-01 17:37:20 +10:00
|
|
|
self.follow_cursor()
|
|
|
|
|
fill3.APPEARANCE_CHANGED_EVENT.set()
|
|
|
|
|
|
|
|
|
|
def scroll(self, dx, dy):
|
|
|
|
|
view_x, view_y = self.scroll_position
|
2022-03-08 10:06:47 +10:00
|
|
|
self.scroll_position = max(0, view_x + dx), max(0, view_y + dy)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
def on_mouse_press(self, x, y):
|
|
|
|
|
view_x, view_y = self.view_widget.position
|
|
|
|
|
self.cursor_y = min(y + view_y - 1, len(self.text_widget) - 1)
|
2022-02-23 23:00:21 +10:00
|
|
|
self._cursor_x = x + view_x
|
2022-01-01 17:37:20 +10:00
|
|
|
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)
|
2022-06-12 17:30:14 +10:00
|
|
|
match action:
|
|
|
|
|
case terminal.MOUSE_PRESS:
|
|
|
|
|
self.on_mouse_press(x, y)
|
|
|
|
|
case terminal.MOUSE_DRAG:
|
|
|
|
|
self.on_mouse_drag(x, y)
|
2022-01-01 17:37:20 +10:00
|
|
|
self.follow_cursor()
|
|
|
|
|
fill3.APPEARANCE_CHANGED_EVENT.set()
|
|
|
|
|
|
2022-01-18 16:37:17 +10:00
|
|
|
def appearance(self):
|
|
|
|
|
return self.decor_widget.appearance()
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
@functools.lru_cache(maxsize=100)
|
|
|
|
|
def get_header(self, path, width, cursor_x, cursor_y, is_changed):
|
|
|
|
|
change_marker = "*" if is_changed else ""
|
2022-04-30 14:17:35 +10:00
|
|
|
cursor_position = termstr.TermStr(
|
|
|
|
|
f"Line {cursor_y+1} Column {cursor_x+1:<3}").fg_color(termstr.Color.grey_100)
|
2022-05-13 14:29:26 +10:00
|
|
|
path_colored = lscolors.path_colored(path) + change_marker
|
|
|
|
|
path_part = path_colored.ljust(width - len(cursor_position) - 2)
|
2022-04-30 14:17:35 +10:00
|
|
|
header = " " + path_part + cursor_position + " "
|
2022-06-12 17:30:14 +10:00
|
|
|
return termstr.TermStr(header).bg_color(termstr.Color.grey_30)
|
2022-01-01 17:37:20 +10:00
|
|
|
|
2022-01-18 16:32:58 +10:00
|
|
|
def appearance_for(self, dimensions):
|
2022-01-01 17:37:20 +10:00
|
|
|
width, height = dimensions
|
2022-04-19 11:23:32 +10:00
|
|
|
if self.parts_widget is None:
|
|
|
|
|
parts_appearance = []
|
|
|
|
|
else:
|
2022-06-12 17:30:14 +10:00
|
|
|
self.parts_widget.dimensions = width, height // 5
|
2022-04-19 11:23:32 +10:00
|
|
|
parts_appearance = self.parts_widget.appearance()
|
|
|
|
|
self.parts_height = len(parts_appearance)
|
2022-03-12 17:30:06 +10:00
|
|
|
is_changed = self.text_widget.lines != self.original_text
|
2022-01-01 21:38:40 +10:00
|
|
|
header = self.get_header(self.path, width, self.cursor_x, self.cursor_y, is_changed)
|
2022-01-01 17:37:20 +10:00
|
|
|
self.last_width = width
|
|
|
|
|
self.last_height = height
|
2022-04-19 11:23:32 +10:00
|
|
|
body_appearance = self.view_widget.appearance_for((width, height-len(parts_appearance)-1))
|
|
|
|
|
return [header] + parts_appearance + body_appearance
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
KEY_MAP = {
|
2022-02-07 21:11:18 +10:00
|
|
|
(terminal.CTRL_X, terminal.CTRL_S): save, terminal.BACKSPACE: backspace,
|
|
|
|
|
terminal.LEFT: cursor_left, terminal.CTRL_B: cursor_left, terminal.RIGHT: cursor_right,
|
|
|
|
|
terminal.CTRL_F: cursor_right, terminal.UP: cursor_up, terminal.CTRL_P: cursor_up,
|
|
|
|
|
terminal.DOWN: cursor_down, terminal.CTRL_N: cursor_down,
|
|
|
|
|
terminal.CTRL_A: jump_to_start_of_line, terminal.CTRL_E: jump_to_end_of_line,
|
|
|
|
|
terminal.CTRL_O: open_line, terminal.ENTER: enter, terminal.CTRL_SPACE: set_mark,
|
|
|
|
|
terminal.CTRL_G: drop_highlight, terminal.PAGE_DOWN: page_down, terminal.CTRL_V: page_down,
|
|
|
|
|
terminal.PAGE_UP: page_up, terminal.ALT_v: page_up, terminal.ALT_w: copy_selection,
|
|
|
|
|
terminal.CTRL_W: delete_selection, terminal.CTRL_D: delete_character,
|
|
|
|
|
terminal.DELETE: delete_character, terminal.ALT_d: delete_right,
|
|
|
|
|
terminal.CTRL_Y: paste_from_clipboard, terminal.CTRL_UP: jump_to_block_start,
|
|
|
|
|
terminal.CTRL_DOWN: jump_to_block_end, terminal.ALT_f: next_word,
|
|
|
|
|
terminal.CTRL_RIGHT: next_word, terminal.ALT_RIGHT: next_word,
|
2022-01-01 17:37:20 +10:00
|
|
|
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,
|
2022-01-08 15:05:40 +10:00
|
|
|
terminal.CTRL_L: center_cursor, terminal.ALT_SEMICOLON: comment_lines,
|
2022-02-07 21:11:18 +10:00
|
|
|
terminal.ALT_c: cycle_syntax_highlighting, (terminal.CTRL_X, terminal.CTRL_C): quit,
|
2022-04-19 11:23:32 +10:00
|
|
|
terminal.ESC: show_parts_list, terminal.CTRL_K: delete_line, terminal.TAB: tab_align,
|
2022-04-29 19:32:05 +10:00
|
|
|
(terminal.CTRL_Q, terminal.TAB): insert_tab, terminal.CTRL_UNDERSCORE: undo,
|
2022-02-16 19:40:39 +10:00
|
|
|
terminal.CTRL_Z: undo, terminal.CTRL_G: abort_command, terminal.INSERT: toggle_overwrite,
|
|
|
|
|
(terminal.CTRL_C, ">"): indent, (terminal.CTRL_C, "<"): dedent}
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
2022-06-12 17:30:14 +10:00
|
|
|
class FileBrowser:
|
|
|
|
|
|
|
|
|
|
def __init__(self, paths):
|
|
|
|
|
self.parts = [self._path_colored(path) for path in paths]
|
|
|
|
|
self.cursor = 0
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _path_colored(path):
|
|
|
|
|
return termstr.TermStr(os.path.basename(path), lscolors._charstyle_of_path(path))
|
|
|
|
|
|
|
|
|
|
def cursor_left(self):
|
|
|
|
|
self.cursor = (self.cursor - 1) % len(self.parts)
|
|
|
|
|
|
|
|
|
|
def cursor_right(self):
|
|
|
|
|
self.cursor = (self.cursor + 1) % len(self.parts)
|
|
|
|
|
|
|
|
|
|
def appearance(self):
|
|
|
|
|
width, height = self.dimensions
|
|
|
|
|
parts = self.parts.copy()
|
|
|
|
|
parts[self.cursor] = parts[self.cursor].invert()
|
|
|
|
|
result, coords = wrap_text(parts, width)
|
|
|
|
|
if len(result) > height:
|
|
|
|
|
appearance, coords = wrap_text(parts, width - 1)
|
|
|
|
|
line_num = coords[self.cursor][0] // (width - 1)
|
|
|
|
|
appearance[line_num] = highlight_line(appearance[line_num])
|
|
|
|
|
view_widget = fill3.View.from_widget(fill3.Fixed(appearance))
|
|
|
|
|
if line_num >= height:
|
|
|
|
|
x, y = view_widget.portal.position
|
|
|
|
|
view_widget.portal.position = x, line_num // height * height
|
|
|
|
|
view_widget.portal.limit_scroll(self.dimensions, (width, len(appearance)))
|
|
|
|
|
result = view_widget.appearance_for(self.dimensions)
|
|
|
|
|
else:
|
|
|
|
|
line_num = coords[self.cursor][0] // width
|
|
|
|
|
result[line_num] = highlight_line(result[line_num])
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2022-06-12 21:26:58 +10:00
|
|
|
class TextFilesEditor:
|
2022-06-12 17:30:14 +10:00
|
|
|
|
|
|
|
|
def __init__(self, paths):
|
|
|
|
|
self.paths = paths
|
|
|
|
|
self.file_browser = FileBrowser(paths)
|
|
|
|
|
self.is_browsing = False
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@functools.cache
|
|
|
|
|
def get_editor(path):
|
2022-06-12 21:26:58 +10:00
|
|
|
editor = TextEditor()
|
2022-06-12 17:30:14 +10:00
|
|
|
editor.load(path)
|
|
|
|
|
return editor
|
|
|
|
|
|
|
|
|
|
def current_editor(self):
|
|
|
|
|
return self.get_editor(self.paths[self.file_browser.cursor])
|
|
|
|
|
|
|
|
|
|
def open_parts_browser(self):
|
|
|
|
|
editor = self.current_editor()
|
|
|
|
|
if editor.parts_widget is None:
|
|
|
|
|
editor.show_parts_list()
|
|
|
|
|
editor.parts_widget.is_focused = False
|
|
|
|
|
|
|
|
|
|
def on_keyboard_input(self, term_code):
|
|
|
|
|
if self.is_browsing:
|
|
|
|
|
match term_code:
|
|
|
|
|
case terminal.DOWN:
|
|
|
|
|
self.is_browsing = False
|
|
|
|
|
self.current_editor().parts_widget.is_focused = True
|
|
|
|
|
case terminal.LEFT:
|
|
|
|
|
self.file_browser.cursor_left()
|
|
|
|
|
self.open_parts_browser()
|
|
|
|
|
case terminal.RIGHT:
|
|
|
|
|
self.file_browser.cursor_right()
|
|
|
|
|
self.open_parts_browser()
|
|
|
|
|
case terminal.ESC:
|
|
|
|
|
self.is_browsing = False
|
|
|
|
|
self.current_editor().parts_widget.escape_parts_browser()
|
|
|
|
|
elif term_code == terminal.UP and self.current_editor().parts_widget is not None:
|
|
|
|
|
self.is_browsing = True
|
|
|
|
|
self.current_editor().parts_widget.is_focused = False
|
|
|
|
|
else:
|
|
|
|
|
self.current_editor().on_keyboard_input(term_code)
|
|
|
|
|
fill3.APPEARANCE_CHANGED_EVENT.set()
|
|
|
|
|
|
|
|
|
|
def on_mouse_input(self, term_code):
|
|
|
|
|
self.current_editor().on_mouse_input(term_code)
|
|
|
|
|
|
|
|
|
|
def appearance_for(self, dimensions):
|
|
|
|
|
width, height = dimensions
|
|
|
|
|
if self.is_browsing:
|
|
|
|
|
self.file_browser.dimensions = width, height // 5
|
|
|
|
|
file_browser_appearance = self.file_browser.appearance()
|
|
|
|
|
else:
|
|
|
|
|
file_browser_appearance = []
|
2022-06-13 14:15:43 +10:00
|
|
|
editor_dimensions = width, height - len(file_browser_appearance)
|
|
|
|
|
return file_browser_appearance + self.current_editor().appearance_for(editor_dimensions)
|
2022-06-12 17:30:14 +10:00
|
|
|
|
|
|
|
|
|
2022-01-01 17:37:20 +10:00
|
|
|
def main():
|
2022-06-12 21:26:58 +10:00
|
|
|
editor = TextFilesEditor(sys.argv[1:])
|
|
|
|
|
asyncio.run(fill3.tui("Text Editor", editor))
|
2022-01-01 17:37:20 +10:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|