Highlight trailing whitespace

This commit is contained in:
Anthony Sottile
2020-03-13 20:49:59 -07:00
parent b52fb15368
commit 1d06a77d44
12 changed files with 300 additions and 35 deletions

View File

@@ -2,5 +2,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Protocol # python3.8+ from typing import Protocol # python3.8+
from typing_extensions import TypedDict # python3.8+
else: else:
Protocol = object Protocol = object
TypedDict = dict

21
babi/color_manager.py Normal file
View File

@@ -0,0 +1,21 @@
import contextlib
import curses
from typing import Dict
from typing import NamedTuple
from typing import Tuple
class ColorManager(NamedTuple):
raw_pairs: Dict[Tuple[int, int], int]
def raw_color_pair(self, fg: int, bg: int) -> int:
with contextlib.suppress(KeyError):
return self.raw_pairs[(fg, bg)]
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg)
return n
@classmethod
def make(cls) -> 'ColorManager':
return cls({})

View File

@@ -21,6 +21,8 @@ from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
from typing import Union from typing import Union
from babi.hl.interface import FileHL
from babi.hl.interface import HLFactory
from babi.horizontal_scrolling import line_x from babi.horizontal_scrolling import line_x
from babi.horizontal_scrolling import scrolled_line from babi.horizontal_scrolling import scrolled_line
from babi.list_spy import ListSpy from babi.list_spy import ListSpy
@@ -96,6 +98,7 @@ class Action:
file.x = self.start_x file.x = self.start_x
file.y = self.start_y file.y = self.start_y
file.modified = self.start_modified file.modified = self.start_modified
file.touch(spy.min_line_touched)
return action return action
@@ -202,7 +205,11 @@ class _SearchIter:
class File: class File:
def __init__(self, filename: Optional[str]) -> None: def __init__(
self,
filename: Optional[str],
hl_factories: Tuple[HLFactory, ...],
) -> None:
self.filename = filename self.filename = filename
self.modified = False self.modified = False
self.lines: MutableSequenceNoSlice = [] self.lines: MutableSequenceNoSlice = []
@@ -212,6 +219,8 @@ class File:
self.undo_stack: List[Action] = [] self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = [] self.redo_stack: List[Action] = []
self.select_start: Optional[Tuple[int, int]] = None self.select_start: Optional[Tuple[int, int]] = None
self._hl_factories = hl_factories
self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded(self, status: Status) -> None: def ensure_loaded(self, status: Status) -> None:
if self.lines: if self.lines:
@@ -234,9 +243,17 @@ class File:
status.update(f'mixed newlines will be converted to {self.nl!r}') status.update(f'mixed newlines will be converted to {self.nl!r}')
self.modified = True self.modified = True
file_hls = []
for factory in self._hl_factories:
if self.filename is not None:
# TODO: this does an extra read
file_hls.append(factory.get_file_highlighter(self.filename))
else:
file_hls.append(factory.get_blank_file_highlighter())
self._file_hls = tuple(file_hls)
def __repr__(self) -> str: def __repr__(self) -> str:
attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items()) return f'<{type(self).__name__} {self.filename!r}>'
return f'{type(self).__name__}(\n {attrs},\n)'
# movement # movement
@@ -757,6 +774,7 @@ class File:
if continue_last: if continue_last:
self.undo_stack[-1].end_x = self.x self.undo_stack[-1].end_x = self.x
self.undo_stack[-1].end_y = self.y self.undo_stack[-1].end_y = self.y
self.touch(spy.min_line_touched)
elif spy.has_modifications: elif spy.has_modifications:
self.modified = True self.modified = True
action = Action( action = Action(
@@ -768,6 +786,7 @@ class File:
final=final, final=final,
) )
self.undo_stack.append(action) self.undo_stack.append(action)
self.touch(spy.min_line_touched)
@contextlib.contextmanager @contextlib.contextmanager
def select(self) -> Generator[None, None, None]: def select(self) -> Generator[None, None, None]:
@@ -803,6 +822,10 @@ class File:
else: else:
return self.select_start, select_end return self.select_start, select_end
def touch(self, lineno: int) -> None:
for file_hl in self._file_hls:
file_hl.touch(lineno)
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(len(self.lines) - self.file_y, margin.body_lines) to_display = min(len(self.lines) - self.file_y, margin.body_lines)
for i in range(to_display): for i in range(to_display):
@@ -815,6 +838,17 @@ class File:
stdscr.move(i + margin.header, 0) stdscr.move(i + margin.header, 0)
stdscr.clrtoeol() stdscr.clrtoeol()
for file_hl in self._file_hls:
file_hl.highlight_until(self.lines, self.file_y + to_display)
for i in range(self.file_y, self.file_y + to_display):
for file_hl in self._file_hls:
for region in file_hl.regions[i]:
self.highlight(
stdscr, margin,
y=i, include_edge=False, **region,
)
if self.select_start is not None: if self.select_start is not None:
(s_y, s_x), (e_y, e_x) = self._get_selection() (s_y, s_x), (e_y, e_x) = self._get_selection()
@@ -861,7 +895,7 @@ class File:
l_x = 0 l_x = 0
h_x = x h_x = x
if not include_edge and len(self.lines[y]) > l_x + curses.COLS: if not include_edge and len(self.lines[y]) > l_x + curses.COLS:
raise NotImplementedError('h_n = min(curses.COLS - h_x - 1, n)') h_n = min(curses.COLS - h_x - 1, n)
else: else:
h_n = n h_n = n
if ( if (

0
babi/hl/__init__.py Normal file
View File

27
babi/hl/interface.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Sequence
from typing import Tuple
from babi._types import Protocol
from babi._types import TypedDict
from babi.list_spy import SequenceNoSlice
class CursesRegion(TypedDict):
x: int
n: int
color: int
CursesRegions = Tuple[CursesRegion, ...]
class FileHL(Protocol):
@property
def regions(self) -> Sequence[CursesRegions]: ...
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ...
def touch(self, lineno: int) -> None: ...
class HLFactory(Protocol):
def get_file_highlighter(self, filename: str) -> FileHL: ...
def get_blank_file_highlighter(self) -> FileHL: ...

View File

@@ -0,0 +1,48 @@
import curses
from typing import List
from typing import NamedTuple
from babi.color_manager import ColorManager
from babi.hl.interface import CursesRegion
from babi.hl.interface import CursesRegions
from babi.list_spy import SequenceNoSlice
class FileTrailingWhitespace:
def __init__(self, color_manager: ColorManager) -> None:
self._color_manager = color_manager
self.regions: List[CursesRegions] = []
def _trailing_ws(self, line: str) -> CursesRegions:
if not line:
return ()
i = len(line)
while i > 0 and line[i - 1].isspace():
i -= 1
if i == len(line):
return ()
else:
pair = self._color_manager.raw_color_pair(-1, curses.COLOR_RED)
attr = curses.color_pair(pair)
return (CursesRegion(x=i, n=len(line) - i, color=attr),)
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
for i in range(len(self.regions), idx):
self.regions.append(self._trailing_ws(lines[i]))
def touch(self, lineno: int) -> None:
del self.regions[lineno:]
class TrailingWhitespace(NamedTuple):
color_manager: ColorManager
def get_file_highlighter(self, filename: str) -> FileTrailingWhitespace:
# no file-specific behaviour
return self.get_blank_file_highlighter()
def get_blank_file_highlighter(self) -> FileTrailingWhitespace:
return FileTrailingWhitespace(self.color_manager)

View File

@@ -1,4 +1,5 @@
import functools import functools
import sys
from typing import Callable from typing import Callable
from typing import Iterator from typing import Iterator
from typing import List from typing import List
@@ -6,17 +7,20 @@ from typing import List
from babi._types import Protocol from babi._types import Protocol
class MutableSequenceNoSlice(Protocol): class SequenceNoSlice(Protocol):
def __len__(self) -> int: ... def __len__(self) -> int: ...
def __getitem__(self, idx: int) -> str: ... def __getitem__(self, idx: int) -> str: ...
def __setitem__(self, idx: int, val: str) -> None: ...
def __delitem__(self, idx: int) -> None: ...
def insert(self, idx: int, val: str) -> None: ...
def __iter__(self) -> Iterator[str]: def __iter__(self) -> Iterator[str]:
for i in range(len(self)): for i in range(len(self)):
yield self[i] yield self[i]
class MutableSequenceNoSlice(SequenceNoSlice, Protocol):
def __setitem__(self, idx: int, val: str) -> None: ...
def __delitem__(self, idx: int) -> None: ...
def insert(self, idx: int, val: str) -> None: ...
def append(self, val: str) -> None: def append(self, val: str) -> None:
self.insert(len(self), val) self.insert(len(self), val)
@@ -42,6 +46,7 @@ class ListSpy(MutableSequenceNoSlice):
def __init__(self, lst: MutableSequenceNoSlice) -> None: def __init__(self, lst: MutableSequenceNoSlice) -> None:
self._lst = lst self._lst = lst
self._undo: List[Callable[[MutableSequenceNoSlice], None]] = [] self._undo: List[Callable[[MutableSequenceNoSlice], None]] = []
self.min_line_touched = sys.maxsize
def __repr__(self) -> str: def __repr__(self) -> str:
return f'{type(self).__name__}({self._lst})' return f'{type(self).__name__}({self._lst})'
@@ -54,18 +59,21 @@ class ListSpy(MutableSequenceNoSlice):
def __setitem__(self, idx: int, val: str) -> None: def __setitem__(self, idx: int, val: str) -> None:
self._undo.append(functools.partial(_set, idx=idx, val=self._lst[idx])) self._undo.append(functools.partial(_set, idx=idx, val=self._lst[idx]))
self.min_line_touched = min(idx, self.min_line_touched)
self._lst[idx] = val self._lst[idx] = val
def __delitem__(self, idx: int) -> None: def __delitem__(self, idx: int) -> None:
if idx < 0: if idx < 0:
idx %= len(self) idx %= len(self)
self._undo.append(functools.partial(_ins, idx=idx, val=self._lst[idx])) self._undo.append(functools.partial(_ins, idx=idx, val=self._lst[idx]))
self.min_line_touched = min(idx, self.min_line_touched)
del self._lst[idx] del self._lst[idx]
def insert(self, idx: int, val: str) -> None: def insert(self, idx: int, val: str) -> None:
if idx < 0: if idx < 0:
idx %= len(self) idx %= len(self)
self._undo.append(functools.partial(_del, idx=idx)) self._undo.append(functools.partial(_del, idx=idx))
self.min_line_touched = min(idx, self.min_line_touched)
self._lst.insert(idx, val) self._lst.insert(idx, val)
def undo(self, lst: MutableSequenceNoSlice) -> None: def undo(self, lst: MutableSequenceNoSlice) -> None:

View File

@@ -15,10 +15,12 @@ from typing import Pattern
from typing import Tuple from typing import Tuple
from typing import Union from typing import Union
from babi.color_manager import ColorManager
from babi.file import Action from babi.file import Action
from babi.file import File from babi.file import File
from babi.file import get_lines from babi.file import get_lines
from babi.history import History from babi.history import History
from babi.hl.trailing_whitespace import TrailingWhitespace
from babi.margin import Margin from babi.margin import Margin
from babi.perf import Perf from babi.perf import Perf
from babi.prompt import Prompt from babi.prompt import Prompt
@@ -70,7 +72,9 @@ class Screen:
perf: Perf, perf: Perf,
) -> None: ) -> None:
self.stdscr = stdscr self.stdscr = stdscr
self.files = [File(f) for f in filenames] color_manager = ColorManager.make()
hl_factories = (TrailingWhitespace(color_manager),)
self.files = [File(f, hl_factories) for f in filenames]
self.i = 0 self.i = 0
self.history = History() self.history = History()
self.perf = perf self.perf = perf
@@ -489,15 +493,13 @@ def _init_screen() -> 'curses._CursesWindow':
with contextlib.suppress(curses.error): with contextlib.suppress(curses.error):
curses.start_color() curses.start_color()
curses.use_default_colors() curses.use_default_colors()
# TODO: colors
return stdscr return stdscr
@contextlib.contextmanager @contextlib.contextmanager
def make_stdscr() -> Generator['curses._CursesWindow', None, None]: def make_stdscr() -> Generator['curses._CursesWindow', None, None]:
"""essentially `curses.wrapper` but split out to implement ^Z""" """essentially `curses.wrapper` but split out to implement ^Z"""
stdscr = _init_screen()
try: try:
yield stdscr yield _init_screen()
finally: finally:
curses.endwin() curses.endwin()

View File

@@ -1,8 +1,65 @@
import contextlib import contextlib
import curses
import enum
import re
from typing import List
from typing import Tuple
from hecate import Runner from hecate import Runner
class Token(enum.Enum):
RESET = re.compile(r'\x1b\[0?m')
ESC = re.compile(r'\x1b\[(\d+)m')
NL = re.compile(r'\n')
CHAR = re.compile('.')
def tokenize_colors(s):
i = 0
while i < len(s):
for tp in Token:
match = tp.value.match(s, i)
if match is not None:
yield tp, match
i = match.end()
break
else:
raise AssertionError(f'unreachable: not matched at {i}?')
def to_attrs(screen, width):
fg = bg = -1
attr = 0
idx = 0
ret: List[List[Tuple[int, int, int]]]
ret = [[] for _ in range(len(screen.splitlines()))]
for tp, match in tokenize_colors(screen):
if tp is Token.RESET:
fg = bg = attr = 0
elif tp is Token.ESC:
if match[1] == '7':
attr |= curses.A_REVERSE
elif match[1] == '39':
fg = -1
elif match[1] == '49':
bg = -1
elif 40 <= int(match[1]) <= 47:
bg = int(match[1]) - 40
else:
raise AssertionError(f'unknown escape {match[1]}')
elif tp is Token.NL:
ret[idx].extend([(fg, bg, attr)] * (width - len(ret[idx])))
idx += 1
elif tp is Token.CHAR:
ret[idx].append((fg, bg, attr))
else:
raise AssertionError(f'unreachable {tp} {match}')
return ret
class PrintsErrorRunner(Runner): class PrintsErrorRunner(Runner):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self._prev_screenshot = None self._prev_screenshot = None
@@ -17,6 +74,19 @@ class PrintsErrorRunner(Runner):
self._prev_screenshot = ret self._prev_screenshot = ret
return ret return ret
def color_screenshot(self):
ret = self.tmux.execute_command('capture-pane', '-ept0')
if ret != self._prev_screenshot:
print('=' * 79, flush=True)
print(ret, end='\x1b[m', flush=True)
print('=' * 79, flush=True)
self._prev_screenshot = ret
return ret
def get_attrs(self):
width, _ = self.get_pane_size()
return to_attrs(self.color_screenshot(), width)
def await_text(self, text, timeout=None): def await_text(self, text, timeout=None):
"""copied from the base implementation but doesn't munge newlines""" """copied from the base implementation but doesn't munge newlines"""
for _ in self.poll_until_timeout(timeout): for _ in self.poll_until_timeout(timeout):
@@ -46,6 +116,10 @@ class PrintsErrorRunner(Runner):
screen_line = self._get_screen_line(n) screen_line = self._get_screen_line(n)
assert screen_line == s, (screen_line, s) assert screen_line == s, (screen_line, s)
def assert_screen_attr_equals(self, n, attr):
attr_line = self.get_attrs()[n]
assert attr_line == attr, (n, attr_line, attr)
def assert_full_contents(self, s): def assert_full_contents(self, s):
contents = self.screenshot() contents = self.screenshot()
assert contents == s assert contents == s

View File

@@ -4,6 +4,7 @@ import os
import sys import sys
from typing import List from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Tuple
from typing import Union from typing import Union
from unittest import mock from unittest import mock
@@ -36,6 +37,7 @@ class Screen:
self.width = width self.width = width
self.height = height self.height = height
self.lines = [' ' * self.width for _ in range(self.height)] self.lines = [' ' * self.width for _ in range(self.height)]
self.attrs = [[(0, 0, 0)] * self.width for _ in range(self.height)]
self.x = self.y = 0 self.x = self.y = 0
self._prev_screenshot = None self._prev_screenshot = None
@@ -48,10 +50,17 @@ class Screen:
self._prev_screenshot = ret self._prev_screenshot = ret
return ret return ret
def insstr(self, y, x, s): def insstr(self, y, x, s, attr):
line = self.lines[y] line = self.lines[y]
self.lines[y] = (line[:x] + s + line[x:])[:self.width] self.lines[y] = (line[:x] + s + line[x:])[:self.width]
line_attr = self.attrs[y]
new = [attr] * len(s)
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
def chgat(self, y, x, n, attr):
self.attrs[y][x:x + n] = [attr] * n
def move(self, y, x): def move(self, y, x):
assert 0 <= y < self.height assert 0 <= y < self.height
assert 0 <= x < self.width assert 0 <= x < self.width
@@ -113,6 +122,14 @@ class AssertScreenLineEquals(NamedTuple):
assert screen.lines[self.n].rstrip() == self.line assert screen.lines[self.n].rstrip() == self.line
class AssertScreenAttrEquals(NamedTuple):
n: int
attr: List[Tuple[int, int, int]]
def __call__(self, screen: Screen) -> None:
assert screen.attrs[self.n] == self.attr
class AssertFullContents(NamedTuple): class AssertFullContents(NamedTuple):
contents: str contents: str
@@ -144,6 +161,19 @@ class CursesError(NamedTuple):
class CursesScreen: class CursesScreen:
def __init__(self, runner): def __init__(self, runner):
self._runner = runner self._runner = runner
self._bkgd_attr = (-1, -1, 0)
def _to_attr(self, attr):
if attr == 0:
return self._bkgd_attr
else:
pair = (attr & (0xff << 8)) >> 8
if pair == 0:
fg, bg, _ = self._bkgd_attr
else:
fg, bg = self._runner.color_pairs[pair]
attr = attr & ~(0xff << 8)
return (fg, bg, attr)
def keypad(self, val): def keypad(self, val):
pass pass
@@ -152,14 +182,14 @@ class CursesScreen:
self._runner.screen.nodelay = val self._runner.screen.nodelay = val
def insstr(self, y, x, s, attr=0): def insstr(self, y, x, s, attr=0):
self._runner.screen.insstr(y, x, s) self._runner.screen.insstr(y, x, s, self._to_attr(attr))
def clrtoeol(self): def clrtoeol(self):
s = self._runner.screen.width * ' ' s = self._runner.screen.width * ' '
self.insstr(self._runner.screen.y, self._runner.screen.x, s) self.insstr(self._runner.screen.y, self._runner.screen.x, s)
def chgat(self, y, x, n, color): def chgat(self, y, x, n, attr):
pass self._runner.screen.chgat(y, x, n, self._to_attr(attr))
def move(self, y, x): def move(self, y, x):
self._runner.screen.move(y, x) self._runner.screen.move(y, x)
@@ -234,6 +264,7 @@ class DeferredRunner:
self.command = command self.command = command
self._i = 0 self._i = 0
self._ops: List[Op] = [] self._ops: List[Op] = []
self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height) self.screen = Screen(width, height)
self._n_colors, self._can_change_color = { self._n_colors, self._can_change_color = {
'screen': (8, False), 'screen': (8, False),
@@ -270,6 +301,9 @@ class DeferredRunner:
def assert_screen_line_equals(self, n, line): def assert_screen_line_equals(self, n, line):
self._ops.append(AssertScreenLineEquals(n, line)) self._ops.append(AssertScreenLineEquals(n, line))
def assert_screen_attr_equals(self, n, attr):
self._ops.append(AssertScreenAttrEquals(n, attr))
def assert_full_contents(self, contents): def assert_full_contents(self, contents):
self._ops.append(AssertFullContents(contents)) self._ops.append(AssertFullContents(contents))
@@ -319,8 +353,8 @@ class DeferredRunner:
def _curses__noop(self, *_, **__): def _curses__noop(self, *_, **__):
pass pass
_curses_cbreak = _curses_init_pair = _curses_noecho = _curses__noop _curses_cbreak = _curses_noecho = _curses_nonl = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop _curses_raw = _curses_use_default_colors = _curses__noop
_curses_error = curses.error # so we don't mock the exception _curses_error = curses.error # so we don't mock the exception
@@ -334,6 +368,13 @@ class DeferredRunner:
def _curses_start_color(self): def _curses_start_color(self):
curses.COLORS = self._n_colors curses.COLORS = self._n_colors
def _curses_init_pair(self, pair, fg, bg):
self.color_pairs[pair] = (fg, bg)
def _curses_color_pair(self, pair):
assert pair in self.color_pairs
return pair << 8
def _curses_initscr(self): def _curses_initscr(self):
self._curses_update_lines_cols() self._curses_update_lines_cols()
self.screen.disabled = False self.screen.disabled = False

View File

@@ -0,0 +1,23 @@
import curses
from testing.runner import and_exit
def test_trailing_whitespace_highlighting(run, tmpdir):
f = tmpdir.join('f')
f.write('0123456789 \n')
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
h.await_text('123456789')
h.assert_screen_attr_equals(0, [(-1, -1, curses.A_REVERSE)] * 20)
attrs = [(-1, -1, 0)] * 10 + [(-1, 1, 0)] * 5 + [(-1, -1, 0)] * 5
h.assert_screen_attr_equals(1, attrs)
def test_trailing_whitespace_does_not_highlight_line_continuation(run, tmpdir):
f = tmpdir.join('f')
f.write(f'{" " * 30}\nhello\n')
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
h.await_text('hello')
h.assert_screen_attr_equals(1, [(-1, 1, 0)] * 19 + [(-1, -1, 0)])

View File

@@ -7,23 +7,8 @@ from babi.file import get_lines
def test_position_repr(): def test_position_repr():
ret = repr(File('f.txt')) ret = repr(File('f.txt', ()))
assert ret == ( assert ret == "<File 'f.txt'>"
'File(\n'
" filename='f.txt',\n"
' modified=False,\n'
' lines=[],\n'
" nl='\\n',\n"
' file_y=0,\n'
' y=0,\n'
' x=0,\n'
' x_hint=0,\n'
' sha256=None,\n'
' undo_stack=[],\n'
' redo_stack=[],\n'
' select_start=None,\n'
')'
)
@pytest.mark.parametrize( @pytest.mark.parametrize(