Highlight trailing whitespace
This commit is contained in:
@@ -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
21
babi/color_manager.py
Normal 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({})
|
||||||
42
babi/file.py
42
babi/file.py
@@ -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
0
babi/hl/__init__.py
Normal file
27
babi/hl/interface.py
Normal file
27
babi/hl/interface.py
Normal 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: ...
|
||||||
48
babi/hl/trailing_whitespace.py
Normal file
48
babi/hl/trailing_whitespace.py
Normal 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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
23
tests/features/trailing_whitespace_test.py
Normal file
23
tests/features/trailing_whitespace_test.py
Normal 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)])
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user