editor: Color the parts list with the theme

- Can change theme while parts list is showing.
- Added more themes.
- Added a botom dividing line to the parts list.
- Parts aren't always classes or functions.
- Also tidied up theme related code
  - Always using Code class and the TextLexer if theres no lexer
    for the file.
  - Not nesting parse_rgb and charstyle_for_token_type.
This commit is contained in:
Andrew Hamilton 2022-06-28 10:11:38 +10:00
parent 04aeacac14
commit 7138a3c08c
2 changed files with 82 additions and 93 deletions

View file

@ -15,6 +15,7 @@ import fill3.terminal as terminal
import lscolors import lscolors
import pygments import pygments
import pygments.lexers import pygments.lexers
import pygments.lexers.special
import pygments.styles import pygments.styles
import termstr import termstr
@ -38,32 +39,35 @@ def highlight_line(line):
NATIVE_STYLE = pygments.styles.get_style_by_name("paraiso-dark") NATIVE_STYLE = pygments.styles.get_style_by_name("paraiso-dark")
def _syntax_highlight(text, lexer, style): @functools.lru_cache(maxsize=500)
@functools.lru_cache(maxsize=500) def parse_rgb(hex_rgb):
def _parse_rgb(hex_rgb):
if hex_rgb.startswith("#"): if hex_rgb.startswith("#"):
hex_rgb = hex_rgb[1:] hex_rgb = hex_rgb[1:]
return tuple(int("0x" + hex_rgb[index:index+2], base=16) for index in [0, 2, 4]) 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): @functools.lru_cache(maxsize=500)
def char_style_for_token_type(token_type, style):
default_bg_color = parse_rgb(style.background_color)
default_style = termstr.CharStyle(bg_color=default_bg_color)
try: try:
token_style = style.style_for_token(token_type) token_style = style.style_for_token(token_type)
except KeyError: except KeyError:
return default_style return default_style
fg_color = (termstr.Color.black if token_style["color"] is None fg_color = (termstr.Color.black if token_style["color"] is None
else _parse_rgb(token_style["color"])) else parse_rgb(token_style["color"]))
bg_color = (default_bg_color if token_style["bgcolor"] is None bg_color = (default_bg_color if token_style["bgcolor"] is None
else _parse_rgb(token_style["bgcolor"])) else parse_rgb(token_style["bgcolor"]))
return termstr.CharStyle(fg_color, bg_color, token_style["bold"], token_style["italic"], return termstr.CharStyle(fg_color, bg_color, token_style["bold"], token_style["italic"],
token_style["underline"]) token_style["underline"])
default_bg_color = _parse_rgb(style.background_color)
default_style = termstr.CharStyle(bg_color=default_bg_color)
def syntax_highlight(text, lexer, style):
text = expandtabs(text) text = expandtabs(text)
text = termstr.join("", [termstr.TermStr( text = termstr.join("", [termstr.TermStr(text, char_style_for_token_type(token_type, style))
text, _char_style_for_token_type(token_type, default_bg_color, default_style))
for token_type, text in pygments.lex(text, lexer)]) for token_type, text in pygments.lex(text, lexer)])
text_widget = fill3.Text(text, pad_char=termstr.TermStr(" ").bg_color(default_bg_color)) bg_color = parse_rgb(style.background_color)
text_widget = fill3.Text(text, pad_char=termstr.TermStr(" ").bg_color(bg_color))
return termstr.join("\n", text_widget.text) return termstr.join("\n", text_widget.text)
@ -75,15 +79,12 @@ def expand_str(str_):
class Text: class Text:
def __init__(self, text, padding_char=" "): def __init__(self, text):
self.padding_char = padding_char
self.lines = []
self.max_line_length = None
lines = [""] if text == "" else text.splitlines() lines = [""] if text == "" else text.splitlines()
if text.endswith("\n"): if text.endswith("\n"):
lines.append("") lines.append("")
self.version = 0 self.version = 0
self[:] = lines self.lines = lines
def __len__(self): def __len__(self):
return len(self.lines) return len(self.lines)
@ -141,26 +142,22 @@ class Text:
class Code(Text): class Code(Text):
def __init__(self, text, path, theme=NATIVE_STYLE): def __init__(self, text, path, theme=NATIVE_STYLE):
try:
self.lexer = pygments.lexers.get_lexer_for_filename(path, text) self.lexer = pygments.lexers.get_lexer_for_filename(path, text)
except pygments.util.ClassNotFound:
self.lexer = pygments.lexers.special.TextLexer()
self.theme = theme self.theme = theme
padding_char = None Text.__init__(self, text)
Text.__init__(self, text, padding_char)
@functools.lru_cache(maxsize=5000) @functools.lru_cache(maxsize=5000)
def _convert_line_themed(self, line, max_line_length, theme): def _convert_line_themed(self, line, max_line_length, theme):
if self.padding_char is None: padding_char = syntax_highlight(" ", self.lexer, self.theme)
self.padding_char = (" " if self.theme is None highlighted_line = syntax_highlight(line, self.lexer, theme)
else _syntax_highlight(" ", self.lexer, self.theme)) return highlighted_line.ljust(max_line_length, fillchar=padding_char)
highlighted_line = (termstr.TermStr(line) if theme is None
else _syntax_highlight(line, self.lexer, theme))
return highlighted_line.ljust(max_line_length, fillchar=self.padding_char)
def _convert_line(self, line, max_line_length): def _convert_line(self, line, max_line_length):
return self._convert_line_themed(line, max_line_length, self.theme) return self._convert_line_themed(line, max_line_length, self.theme)
def syntax_highlight_all(self):
self.padding_char = None
class Decor: class Decor:
@ -225,13 +222,13 @@ def _wrap_text_lines(words, width):
yield words[first_word:] yield words[first_word:]
def wrap_text(words, width): def wrap_text(words, width, pad_char=" "):
appearance = [] appearance = []
coords = [] coords = []
for index, line in enumerate(_wrap_text_lines(words, width)): for index, line in enumerate(_wrap_text_lines(words, width)):
line = list(line) line = list(line)
content = termstr.join(" ", line) content = termstr.join(pad_char, line)
appearance.append(content.center(width)) appearance.append(content.center(width, pad_char))
cursor = index * width + round((width - len(content)) / 2) cursor = index * width + round((width - len(content)) / 2)
for word in line: for word in line:
coords.append((cursor, cursor + len(word))) coords.append((cursor, cursor + len(word)))
@ -239,18 +236,15 @@ def wrap_text(words, width):
return appearance, coords return appearance, coords
class Line(enum.Enum):
class_ = enum.auto()
function = enum.auto()
endpoint = enum.auto()
@functools.lru_cache(100) @functools.lru_cache(100)
def parts_lines(source, lexer): def parts_lines(source, lexer, theme):
cursor = 0 cursor = 0
line_num = 0 line_num = 0
line_lengths = [len(line) for line in source.splitlines(keepends=True)] line_lengths = [len(line) for line in source.splitlines(keepends=True)]
result = [(Line.endpoint, "top", 0)] white_style = termstr.CharStyle(fg_color=termstr.Color.white)
result = [(termstr.TermStr("top", white_style), 0)]
token_types = {pygments.token.Name.Class, pygments.token.Name.Function,
pygments.token.Name.Function.Magic}
if lexer is None: if lexer is None:
line_num = len(source.splitlines()) line_num = len(source.splitlines())
else: else:
@ -258,31 +252,26 @@ def parts_lines(source, lexer):
while position >= cursor: while position >= cursor:
cursor += line_lengths[line_num] cursor += line_lengths[line_num]
line_num += 1 line_num += 1
if token_type == pygments.token.Name.Class: if token_type in token_types:
result.append((Line.class_, text, line_num - 1)) char_style = char_style_for_token_type(token_type, theme)
elif token_type in [pygments.token.Name.Function, pygments.token.Name.Function.Magic]: result.append((termstr.TermStr(text, char_style), line_num - 1))
result.append((Line.function, text, line_num - 1)) result.append((termstr.TermStr("bottom", white_style), line_num - 1))
result.append((Line.endpoint, "bottom", line_num - 1))
return result return result
COLOR_MAP = {Line.class_: termstr.Color.red, Line.function: termstr.Color.green,
Line.endpoint: termstr.Color.white}
class Parts: class Parts:
def __init__(self, editor, source, lexer): def __init__(self, editor, source, lexer):
self.editor = editor self.editor = editor
self.lines = parts_lines(source, lexer) self.source = source
self.parts = [termstr.TermStr(text).fg_color(COLOR_MAP[line_type]) self.lexer = lexer
for line_type, text, line_num in self.lines] self.lines = parts_lines(source, lexer, editor.text_widget.theme)
self.width, self.height = None, None self.width, self.height = None, None
self.is_focused = True self.is_focused = True
self.set_cursor() self.set_cursor()
def set_cursor(self): def set_cursor(self):
for index, (line_type, text, line_num) in enumerate(self.lines): for index, (text, line_num) in enumerate(self.lines):
if line_num > self.editor.cursor_y: if line_num > self.editor.cursor_y:
self.cursor = index - 1 self.cursor = index - 1
break break
@ -290,8 +279,8 @@ class Parts:
self.cursor = len(self.lines) - 1 self.cursor = len(self.lines) - 1
def _move_cursor(self, delta): def _move_cursor(self, delta):
self.cursor = (self.cursor + delta) % len(self.parts) self.cursor = (self.cursor + delta) % len(self.lines)
self.editor.cursor_x, self.editor.cursor_y = 0, self.lines[self.cursor][2] self.editor.cursor_x, self.editor.cursor_y = 0, self.lines[self.cursor][1]
x, y = self.editor.view_widget.portal.position x, y = self.editor.view_widget.portal.position
self.editor.view_widget.portal.position = x, self.editor.cursor_y - 1 self.editor.view_widget.portal.position = x, self.editor.cursor_y - 1
@ -320,11 +309,14 @@ class Parts:
def appearance(self): def appearance(self):
width, height = self.dimensions width, height = self.dimensions
parts = self.parts.copy() lines = parts_lines(self.source, self.lexer, self.editor.text_widget.theme)
parts = [text for text, line_num in lines]
parts[self.cursor] = parts[self.cursor].invert() parts[self.cursor] = parts[self.cursor].invert()
result, coords = wrap_text(parts, width) pad_char = syntax_highlight(" ", self.editor.text_widget.lexer,
self.editor.text_widget.theme)
result, coords = wrap_text(parts, width, pad_char)
if len(result) > height: if len(result) > height:
appearance, coords = wrap_text(parts, width - 1) appearance, coords = wrap_text(parts, width - 1, pad_char)
line_num = coords[self.cursor][0] // (width - 1) line_num = coords[self.cursor][0] // (width - 1)
if self.is_focused: if self.is_focused:
appearance[line_num] = highlight_line(appearance[line_num]) appearance[line_num] = highlight_line(appearance[line_num])
@ -338,6 +330,9 @@ class Parts:
if self.is_focused: if self.is_focused:
line_num = coords[self.cursor][0] // width line_num = coords[self.cursor][0] // width
result[line_num] = highlight_line(result[line_num]) result[line_num] = highlight_line(result[line_num])
fg_color = termstr.Color.grey_100
bg_color = parse_rgb(self.editor.text_widget.theme.background_color)
result.append(termstr.TermStr("").bg_color(bg_color).fg_color(fg_color) * width)
return result return result
@ -345,7 +340,8 @@ class TextEditor:
TAB_SIZE = 4 TAB_SIZE = 4
THEMES = [pygments.styles.get_style_by_name(style) THEMES = [pygments.styles.get_style_by_name(style)
for style in ["monokai", "fruity", "native"]] + [None] for style in ["material", "monokai", "fruity", "native", "inkpot", "solarized-light",
"manni", "gruvbox-light", "perldoc", "zenburn", "friendly",]]
def __init__(self, text="", path="Untitled", is_left_aligned=True): def __init__(self, text="", path="Untitled", is_left_aligned=True):
self.path = os.path.normpath(path) self.path = os.path.normpath(path)
@ -452,10 +448,7 @@ class TextEditor:
return appearance return appearance
def set_text(self, text): def set_text(self, text):
try:
self.text_widget = Code(text, self.path) self.text_widget = Code(text, self.path)
except pygments.util.ClassNotFound: # No lexer for path
self.text_widget = Text(text)
self.decor_widget = Decor(self.text_widget, self.decor_widget = Decor(self.text_widget,
lambda appearance: self._add_highlights(appearance)) lambda appearance: self._add_highlights(appearance))
self.view_widget = fill3.View.from_widget(self.decor_widget) self.view_widget = fill3.View.from_widget(self.decor_widget)
@ -727,21 +720,14 @@ class TextEditor:
self.set_mark() self.set_mark()
self.jump_to_block_start() self.jump_to_block_start()
def syntax_highlight_all(self):
self.text_widget.syntax_highlight_all()
def center_cursor(self): def center_cursor(self):
view_x, view_y = self.view_widget.position view_x, view_y = self.view_widget.position
new_y = max(0, self.cursor_y - self.last_height // 2) new_y = max(0, self.cursor_y - self.last_height // 2)
self.view_widget.position = view_x, new_y self.view_widget.position = view_x, new_y
def cycle_syntax_highlighting(self): def cycle_syntax_highlighting(self):
self.theme_index += 1 self.theme_index = (self.theme_index + 1) % len(TextEditor.THEMES)
if self.theme_index == len(TextEditor.THEMES): self.text_widget.theme = self.THEMES[self.theme_index]
self.theme_index = 0
theme = self.THEMES[self.theme_index]
self.text_widget.theme = theme
self.text_widget.syntax_highlight_all()
def quit(self): def quit(self):
fill3.SHUTDOWN_EVENT.set() fill3.SHUTDOWN_EVENT.set()
@ -929,10 +915,10 @@ class TextEditor:
terminal.ALT_b: previous_word, terminal.CTRL_LEFT: previous_word, terminal.ALT_b: previous_word, terminal.CTRL_LEFT: previous_word,
terminal.ALT_LEFT: previous_word, terminal.ALT_BACKSPACE: delete_backward, terminal.ALT_LEFT: previous_word, terminal.ALT_BACKSPACE: delete_backward,
terminal.ALT_CARROT: join_lines, terminal.ALT_h: highlight_block, terminal.ALT_CARROT: join_lines, terminal.ALT_h: highlight_block,
terminal.ALT_H: highlight_block, terminal.CTRL_R: syntax_highlight_all, terminal.ALT_H: highlight_block, terminal.CTRL_L: center_cursor,
terminal.CTRL_L: center_cursor, terminal.ALT_SEMICOLON: comment_lines, terminal.ALT_SEMICOLON: comment_lines, terminal.ALT_c: cycle_syntax_highlighting,
terminal.ALT_c: cycle_syntax_highlighting, (terminal.CTRL_X, terminal.CTRL_C): quit, (terminal.CTRL_X, terminal.CTRL_C): quit, terminal.ESC: show_parts_list,
terminal.ESC: show_parts_list, terminal.CTRL_K: delete_line, terminal.TAB: tab_align, terminal.CTRL_K: delete_line, terminal.TAB: tab_align,
(terminal.CTRL_Q, terminal.TAB): insert_tab, terminal.CTRL_UNDERSCORE: undo, (terminal.CTRL_Q, terminal.TAB): insert_tab, terminal.CTRL_UNDERSCORE: undo,
terminal.CTRL_Z: undo, terminal.CTRL_G: abort_command, terminal.INSERT: toggle_overwrite, terminal.CTRL_Z: undo, terminal.CTRL_G: abort_command, terminal.INSERT: toggle_overwrite,
(terminal.CTRL_C, ">"): indent, (terminal.CTRL_C, "<"): dedent} (terminal.CTRL_C, ">"): indent, (terminal.CTRL_C, "<"): dedent}

View file

@ -72,12 +72,15 @@ class PartsListTestCase(unittest.TestCase):
def test_parts_lines(self): def test_parts_lines(self):
python_lexer = pygments.lexers.python.PythonLexer() python_lexer = pygments.lexers.python.PythonLexer()
self.assertEqual(editor.parts_lines("class A:\n pass", python_lexer), theme = pygments.styles.get_style_by_name("paraiso-dark")
[(editor.Line.endpoint, "top", 0), (editor.Line.class_, "A", 0), class_charstyle = editor.char_style_for_token_type(pygments.token.Name.Class, theme)
(editor.Line.endpoint, "bottom", 1)]) self.assertEqual(editor.parts_lines("class A:\n pass", python_lexer, theme),
self.assertEqual(editor.parts_lines("\ndef B:", python_lexer), [(termstr.TermStr("top"), 0), (termstr.TermStr("A", class_charstyle), 0),
[(editor.Line.endpoint, "top", 0), (editor.Line.function, "B", 1), (termstr.TermStr("bottom"), 1)])
(editor.Line.endpoint, "bottom", 1)]) func_charstyle = editor.char_style_for_token_type(pygments.token.Name.Function, theme)
self.assertEqual(editor.parts_lines("\ndef B:", python_lexer, theme),
[(termstr.TermStr("top"), 0), (termstr.TermStr("B", func_charstyle), 1),
(termstr.TermStr("bottom"), 1)])
class ExpandTabsTestCase(unittest.TestCase): class ExpandTabsTestCase(unittest.TestCase):