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:
from typing import Protocol # python3.8+
from typing_extensions import TypedDict # python3.8+
else:
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 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 scrolled_line
from babi.list_spy import ListSpy
@@ -96,6 +98,7 @@ class Action:
file.x = self.start_x
file.y = self.start_y
file.modified = self.start_modified
file.touch(spy.min_line_touched)
return action
@@ -202,7 +205,11 @@ class _SearchIter:
class File:
def __init__(self, filename: Optional[str]) -> None:
def __init__(
self,
filename: Optional[str],
hl_factories: Tuple[HLFactory, ...],
) -> None:
self.filename = filename
self.modified = False
self.lines: MutableSequenceNoSlice = []
@@ -212,6 +219,8 @@ class File:
self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = []
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:
if self.lines:
@@ -234,9 +243,17 @@ class File:
status.update(f'mixed newlines will be converted to {self.nl!r}')
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:
attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
return f'{type(self).__name__}(\n {attrs},\n)'
return f'<{type(self).__name__} {self.filename!r}>'
# movement
@@ -757,6 +774,7 @@ class File:
if continue_last:
self.undo_stack[-1].end_x = self.x
self.undo_stack[-1].end_y = self.y
self.touch(spy.min_line_touched)
elif spy.has_modifications:
self.modified = True
action = Action(
@@ -768,6 +786,7 @@ class File:
final=final,
)
self.undo_stack.append(action)
self.touch(spy.min_line_touched)
@contextlib.contextmanager
def select(self) -> Generator[None, None, None]:
@@ -803,6 +822,10 @@ class File:
else:
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:
to_display = min(len(self.lines) - self.file_y, margin.body_lines)
for i in range(to_display):
@@ -815,6 +838,17 @@ class File:
stdscr.move(i + margin.header, 0)
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:
(s_y, s_x), (e_y, e_x) = self._get_selection()
@@ -861,7 +895,7 @@ class File:
l_x = 0
h_x = x
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:
h_n = n
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 sys
from typing import Callable
from typing import Iterator
from typing import List
@@ -6,17 +7,20 @@ from typing import List
from babi._types import Protocol
class MutableSequenceNoSlice(Protocol):
class SequenceNoSlice(Protocol):
def __len__(self) -> int: ...
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]:
for i in range(len(self)):
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:
self.insert(len(self), val)
@@ -42,6 +46,7 @@ class ListSpy(MutableSequenceNoSlice):
def __init__(self, lst: MutableSequenceNoSlice) -> None:
self._lst = lst
self._undo: List[Callable[[MutableSequenceNoSlice], None]] = []
self.min_line_touched = sys.maxsize
def __repr__(self) -> str:
return f'{type(self).__name__}({self._lst})'
@@ -54,18 +59,21 @@ class ListSpy(MutableSequenceNoSlice):
def __setitem__(self, idx: int, val: str) -> None:
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
def __delitem__(self, idx: int) -> None:
if idx < 0:
idx %= len(self)
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]
def insert(self, idx: int, val: str) -> None:
if idx < 0:
idx %= len(self)
self._undo.append(functools.partial(_del, idx=idx))
self.min_line_touched = min(idx, self.min_line_touched)
self._lst.insert(idx, val)
def undo(self, lst: MutableSequenceNoSlice) -> None:

View File

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

View File

@@ -1,8 +1,65 @@
import contextlib
import curses
import enum
import re
from typing import List
from typing import Tuple
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):
def __init__(self, *args, **kwargs):
self._prev_screenshot = None
@@ -17,6 +74,19 @@ class PrintsErrorRunner(Runner):
self._prev_screenshot = 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):
"""copied from the base implementation but doesn't munge newlines"""
for _ in self.poll_until_timeout(timeout):
@@ -46,6 +116,10 @@ class PrintsErrorRunner(Runner):
screen_line = self._get_screen_line(n)
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):
contents = self.screenshot()
assert contents == s

View File

@@ -4,6 +4,7 @@ import os
import sys
from typing import List
from typing import NamedTuple
from typing import Tuple
from typing import Union
from unittest import mock
@@ -36,6 +37,7 @@ class Screen:
self.width = width
self.height = 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._prev_screenshot = None
@@ -48,10 +50,17 @@ class Screen:
self._prev_screenshot = ret
return ret
def insstr(self, y, x, s):
def insstr(self, y, x, s, attr):
line = self.lines[y]
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):
assert 0 <= y < self.height
assert 0 <= x < self.width
@@ -113,6 +122,14 @@ class AssertScreenLineEquals(NamedTuple):
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):
contents: str
@@ -144,6 +161,19 @@ class CursesError(NamedTuple):
class CursesScreen:
def __init__(self, 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):
pass
@@ -152,14 +182,14 @@ class CursesScreen:
self._runner.screen.nodelay = val
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):
s = self._runner.screen.width * ' '
self.insstr(self._runner.screen.y, self._runner.screen.x, s)
def chgat(self, y, x, n, color):
pass
def chgat(self, y, x, n, attr):
self._runner.screen.chgat(y, x, n, self._to_attr(attr))
def move(self, y, x):
self._runner.screen.move(y, x)
@@ -234,6 +264,7 @@ class DeferredRunner:
self.command = command
self._i = 0
self._ops: List[Op] = []
self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height)
self._n_colors, self._can_change_color = {
'screen': (8, False),
@@ -270,6 +301,9 @@ class DeferredRunner:
def assert_screen_line_equals(self, 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):
self._ops.append(AssertFullContents(contents))
@@ -319,8 +353,8 @@ class DeferredRunner:
def _curses__noop(self, *_, **__):
pass
_curses_cbreak = _curses_init_pair = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
_curses_cbreak = _curses_noecho = _curses_nonl = _curses__noop
_curses_raw = _curses_use_default_colors = _curses__noop
_curses_error = curses.error # so we don't mock the exception
@@ -334,6 +368,13 @@ class DeferredRunner:
def _curses_start_color(self):
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):
self._curses_update_lines_cols()
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():
ret = repr(File('f.txt'))
assert ret == (
'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'
')'
)
ret = repr(File('f.txt', ()))
assert ret == "<File 'f.txt'>"
@pytest.mark.parametrize(