From 6206db3ef27a3951a619823041ce6a372cb51477 Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Tue, 31 Mar 2020 09:43:56 -0700 Subject: [PATCH] properly render tab characters in babi --- babi/buf.py | 75 +++++++++++++++++++++++-- babi/file.py | 45 ++++++++------- babi/horizontal_scrolling.py | 19 +++++++ tests/features/conftest.py | 36 +++++++++--- tests/features/movement_test.py | 27 +++++++++ tests/features/syntax_highlight_test.py | 31 ++++++++++ 6 files changed, 197 insertions(+), 36 deletions(-) diff --git a/babi/buf.py b/babi/buf.py index 41bbf35..de0cba3 100644 --- a/babi/buf.py +++ b/babi/buf.py @@ -1,11 +1,17 @@ +import bisect import contextlib from typing import Callable from typing import Generator from typing import Iterator from typing import List from typing import NamedTuple +from typing import Optional +from typing import Tuple from babi._types import Protocol +from babi.horizontal_scrolling import line_x +from babi.horizontal_scrolling import scrolled_line +from babi.horizontal_scrolling import wcwidth from babi.margin import Margin SetCallback = Callable[['Buf', int, str], None] @@ -13,6 +19,16 @@ DelCallback = Callable[['Buf', int, str], None] InsCallback = Callable[['Buf', int], None] +def _offsets(s: str) -> Tuple[int, ...]: + ret = [0] + for c in s: + if c == '\t': + ret.append(ret[-1] + (4 - ret[-1] % 4)) + else: + ret.append(ret[-1] + wcwidth(c)) + return tuple(ret) + + class Modification(Protocol): def __call__(self, buf: 'Buf') -> None: ... @@ -45,9 +61,11 @@ class Buf: self._lines = lines self.file_y = self.y = self._x = self._x_hint = 0 - self._set_callbacks: List[SetCallback] = [] - self._del_callbacks: List[DelCallback] = [] - self._ins_callbacks: List[InsCallback] = [] + self._set_callbacks: List[SetCallback] = [self._set_cb] + self._del_callbacks: List[DelCallback] = [self._del_cb] + self._ins_callbacks: List[InsCallback] = [self._ins_cb] + + self._positions: List[Optional[Tuple[int, ...]]] = [] # read only interface @@ -73,6 +91,8 @@ class Buf: # mutators def __setitem__(self, idx: int, val: str) -> None: + if idx < 0: + idx %= len(self) victim = self._lines[idx] self._lines[idx] = val @@ -178,7 +198,47 @@ class Buf: @x.setter def x(self, x: int) -> None: self._x = x - self._x_hint = x + self._x_hint = self._cursor_x + + def _extend_positions(self, idx: int) -> None: + self._positions.extend([None] * (1 + idx - len(self._positions))) + + def _set_cb(self, buf: 'Buf', idx: int, victim: str) -> None: + self._extend_positions(idx) + self._positions[idx] = None + + def _del_cb(self, buf: 'Buf', idx: int, victim: str) -> None: + self._extend_positions(idx) + del self._positions[idx] + + def _ins_cb(self, buf: 'Buf', idx: int) -> None: + self._extend_positions(idx) + self._positions.insert(idx, None) + + def line_positions(self, idx: int) -> Tuple[int, ...]: + self._extend_positions(idx) + value = self._positions[idx] + if value is None: + value = self._positions[idx] = _offsets(self._lines[idx]) + return value + + def line_x(self, margin: Margin) -> int: + return line_x(self._cursor_x, margin.cols) + + @property + def _cursor_x(self) -> int: + return self.line_positions(self.y)[self.x] + + def cursor_position(self, margin: Margin) -> Tuple[int, int]: + y = self.y - self.file_y + margin.header + x = self._cursor_x - self.line_x(margin) + return y, x + + # rendered lines + + def rendered_line(self, idx: int, margin: Margin) -> str: + x = self._cursor_x if idx == self.y else 0 + return scrolled_line(self._lines[idx].expandtabs(4), x, margin.cols) # movement @@ -188,7 +248,12 @@ class Buf: self.file_y = max(self.y - margin.body_lines // 2, 0) def _set_x_after_vertical_movement(self) -> None: - self._x = min(len(self._lines[self.y]), self._x_hint) + positions = self.line_positions(self.y) + x = bisect.bisect_left(positions, self._x_hint) + x = min(len(self._lines[self.y]), x) + if positions[x] > self._x_hint: + x -= 1 + self._x = x def up(self, margin: Margin) -> None: if self.y > 0: diff --git a/babi/file.py b/babi/file.py index fac729b..e9eb893 100644 --- a/babi/file.py +++ b/babi/file.py @@ -29,8 +29,6 @@ from babi.hl.interface import HLFactory from babi.hl.replace import Replace from babi.hl.selection import Selection from babi.hl.trailing_whitespace import TrailingWhitespace -from babi.horizontal_scrolling import line_x -from babi.horizontal_scrolling import scrolled_line from babi.margin import Margin from babi.prompt import PromptResult from babi.status import Status @@ -88,8 +86,8 @@ class Action: final=True, ) - file.buf.x = self.start_x file.buf.y = self.start_y + file.buf.x = self.start_x file.modified = self.start_modified return action @@ -511,7 +509,7 @@ class File: if self.buf[l_y]: self.buf[l_y] = ' ' * 4 + self.buf[l_y] if l_y == self.buf.y: - self.buf.x = self.buf.x + 4 + self.buf.x += 4 if l_y == sel_y and sel_x != 0: sel_x += 4 self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x) @@ -521,7 +519,7 @@ class File: n = 4 - self.buf.x % 4 line = self.buf[self.buf.y] self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:] - self.buf.x = self.buf.x + n + self.buf.x += n self.buf.restore_eof_invariant() def tab(self, margin: Margin) -> None: @@ -696,7 +694,7 @@ class File: def c(self, wch: str, margin: Margin) -> None: s = self.buf[self.buf.y] self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:] - self.buf.x = self.buf.x + len(wch) + self.buf.x += len(wch) self.buf.restore_eof_invariant() def finalize_previous_action(self) -> None: @@ -761,18 +759,12 @@ class File: # positioning - def rendered_y(self, margin: Margin) -> int: - return self.buf.y - self.buf.file_y + margin.header - - def rendered_x(self, margin: Margin) -> int: - return self.buf.x - line_x(self.buf.x, margin.cols) - def move_cursor( self, stdscr: 'curses._CursesWindow', margin: Margin, ) -> None: - stdscr.move(self.rendered_y(margin), self.rendered_x(margin)) + stdscr.move(*self.buf.cursor_position(margin)) def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: to_display = min(self.buf.displayable_count, margin.body_lines) @@ -784,34 +776,41 @@ class File: for i in range(to_display): draw_y = i + margin.header l_y = self.buf.file_y + i - x = self.buf.x if l_y == self.buf.y else 0 - line = scrolled_line(self.buf[l_y], x, margin.cols) - stdscr.insstr(draw_y, 0, line) + stdscr.insstr(draw_y, 0, self.buf.rendered_line(l_y, margin)) - l_x = line_x(x, margin.cols) + l_x = self.buf.line_x(margin) if l_y == self.buf.y else 0 l_x_max = l_x + margin.cols for file_hl in self._file_hls: for region in file_hl.regions[l_y]: - if region.x >= l_x_max: + l_positions = self.buf.line_positions(l_y) + r_x = l_positions[region.x] + # the selection highlight intentionally extends one past + # the end of the line, which won't have a position + if region.end == len(l_positions): + r_end = l_positions[-1] + 1 + else: + r_end = l_positions[region.end] + + if r_x >= l_x_max: break - elif region.end <= l_x: + elif r_end <= l_x: continue - if l_x and region.x <= l_x: + if l_x and r_x <= l_x: if file_hl.include_edge: h_s_x = 0 else: h_s_x = 1 else: - h_s_x = region.x - l_x + h_s_x = r_x - l_x - if region.end >= l_x_max and l_x_max < len(self.buf[l_y]): + if r_end >= l_x_max and l_x_max < l_positions[-1]: if file_hl.include_edge: h_e_x = margin.cols else: h_e_x = margin.cols - 1 else: - h_e_x = region.end - l_x + h_e_x = r_end - l_x stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr) diff --git a/babi/horizontal_scrolling.py b/babi/horizontal_scrolling.py index 3de2fac..ad6c7f4 100644 --- a/babi/horizontal_scrolling.py +++ b/babi/horizontal_scrolling.py @@ -1,3 +1,8 @@ +import curses + +from babi.cached_property import cached_property + + def line_x(x: int, width: int) -> int: if x + 1 < width: return 0 @@ -25,3 +30,17 @@ def scrolled_line(s: str, x: int, width: int) -> str: return f'{s[:width - 1]}»' else: return s.ljust(width) + + +class _CalcWidth: + @cached_property + def _window(self) -> 'curses._CursesWindow': + return curses.newwin(1, 10) + + def wcwidth(self, c: str) -> int: + self._window.addstr(0, 0, c) + return self._window.getyx()[1] + + +wcwidth = _CalcWidth().wcwidth +del _CalcWidth diff --git a/tests/features/conftest.py b/tests/features/conftest.py index 51a9732..73c4d71 100644 --- a/tests/features/conftest.py +++ b/tests/features/conftest.py @@ -63,6 +63,16 @@ class Screen: self._prev_screenshot = ret return ret + def addstr(self, y, x, s, attr): + self.lines[y] = self.lines[y][:x] + s + self.lines[y][x + len(s):] + + line_attr = self.attrs[y] + new = [attr] * len(s) + self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):] + + self.y = y + self.x = x + len(s) + def insstr(self, y, x, s, attr): line = self.lines[y] self.lines[y] = (line[:x] + s + line[x:])[:self.width] @@ -173,7 +183,8 @@ class CursesError(NamedTuple): class CursesScreen: - def __init__(self, runner): + def __init__(self, screen, runner): + self._screen = screen self._runner = runner self._bkgd_attr = (-1, -1, 0) @@ -197,20 +208,26 @@ class CursesScreen: pass def nodelay(self, val): - self._runner.screen.nodelay = val + self._screen.nodelay = val + + def addstr(self, y, x, s, attr=0): + self._screen.addstr(y, x, s, self._to_attr(attr)) def insstr(self, y, x, s, attr=0): - self._runner.screen.insstr(y, x, s, self._to_attr(attr)) + self._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) + s = self._screen.width * ' ' + self.insstr(self._screen.y, self._screen.x, s) def chgat(self, y, x, n, attr): - self._runner.screen.chgat(y, x, n, self._to_attr(attr)) + self._screen.chgat(y, x, n, self._to_attr(attr)) def move(self, y, x): - self._runner.screen.move(y, x) + self._screen.move(y, x) + + def getyx(self): + return self._screen.y, self._screen.x def get_wch(self): return self._runner._get_wch() @@ -399,7 +416,10 @@ class DeferredRunner: def _curses_initscr(self): self._curses_update_lines_cols() - return CursesScreen(self) + return CursesScreen(self.screen, self) + + def _curses_newwin(self, height, width): + return CursesScreen(Screen(width, height), self) def _curses_not_implemented(self, fn): def fn_inner(*args, **kwargs): diff --git a/tests/features/movement_test.py b/tests/features/movement_test.py index 098b5bd..f207e73 100644 --- a/tests/features/movement_test.py +++ b/tests/features/movement_test.py @@ -411,3 +411,30 @@ def test_sequence_handling(run_only_fake): h.press(' test7') h.await_text('test1 test2 test3 test4 test5 test6 test7') h.await_text(r'\x1b[1;') + + +def test_indentation_using_tabs(run, tmpdir): + f = tmpdir.join('f') + f.write(f'123456789\n\t12\t{"x" * 20}\n') + + with run(str(f), width=20) as h, and_exit(h): + h.await_text('123456789\n 12 xxxxxxxxxxx»\n') + + h.press('Down') + h.await_cursor_position(x=0, y=2) + h.press('Up') + h.await_cursor_position(x=0, y=1) + + h.press('Right') + h.await_cursor_position(x=1, y=1) + h.press('Down') + h.await_cursor_position(x=0, y=2) + h.press('Up') + h.await_cursor_position(x=1, y=1) + + h.press('Down') + h.await_cursor_position(x=0, y=2) + h.press('Right') + h.await_cursor_position(x=4, y=2) + h.press('Up') + h.await_cursor_position(x=4, y=1) diff --git a/tests/features/syntax_highlight_test.py b/tests/features/syntax_highlight_test.py index 7208674..bf9e361 100644 --- a/tests/features/syntax_highlight_test.py +++ b/tests/features/syntax_highlight_test.py @@ -122,3 +122,34 @@ def test_syntax_highlighting_to_edge_of_screen(run, tmpdir): with run(str(f), term='screen-256color', width=20) as h, and_exit(h): h.await_text('# xxx') h.assert_screen_attr_equals(1, [(243, 40, 0)] * 20) + + +def test_syntax_highlighting_with_tabs(run, tmpdir): + f = tmpdir.join('f.demo') + f.write('\t# 12345678901234567890\n') + + with run(str(f), term='screen-256color', width=20) as h, and_exit(h): + h.await_text('1234567890') + expected = 4 * [(236, 40, 0)] + 15 * [(243, 40, 0)] + [(236, 40, 0)] + h.assert_screen_attr_equals(1, expected) + + +def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir): + f = tmpdir.join('f') + # trailing whitespace is used to trigger highlighting + f.write('foo\n\txx \ny \n') + + with run(str(f), term='screen-256color') as h, and_exit(h): + # this looks weird, but it populates the width cache + h.press('Down') + h.press('Down') + h.press('Down') + + # press enter after the tab + h.press('Up') + h.press('Up') + h.press('Right') + h.press('Right') + h.press('Enter') + + h.await_text('foo\n x\nx\ny\n')