properly render tab characters in babi

This commit is contained in:
Anthony Sottile
2020-03-31 09:43:56 -07:00
parent 711cf65266
commit 6206db3ef2
6 changed files with 197 additions and 36 deletions

View File

@@ -1,11 +1,17 @@
import bisect
import contextlib import contextlib
from typing import Callable from typing import Callable
from typing import Generator from typing import Generator
from typing import Iterator from typing import Iterator
from typing import List from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol 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 from babi.margin import Margin
SetCallback = Callable[['Buf', int, str], None] SetCallback = Callable[['Buf', int, str], None]
@@ -13,6 +19,16 @@ DelCallback = Callable[['Buf', int, str], None]
InsCallback = Callable[['Buf', int], 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): class Modification(Protocol):
def __call__(self, buf: 'Buf') -> None: ... def __call__(self, buf: 'Buf') -> None: ...
@@ -45,9 +61,11 @@ class Buf:
self._lines = lines self._lines = lines
self.file_y = self.y = self._x = self._x_hint = 0 self.file_y = self.y = self._x = self._x_hint = 0
self._set_callbacks: List[SetCallback] = [] self._set_callbacks: List[SetCallback] = [self._set_cb]
self._del_callbacks: List[DelCallback] = [] self._del_callbacks: List[DelCallback] = [self._del_cb]
self._ins_callbacks: List[InsCallback] = [] self._ins_callbacks: List[InsCallback] = [self._ins_cb]
self._positions: List[Optional[Tuple[int, ...]]] = []
# read only interface # read only interface
@@ -73,6 +91,8 @@ class Buf:
# mutators # mutators
def __setitem__(self, idx: int, val: str) -> None: def __setitem__(self, idx: int, val: str) -> None:
if idx < 0:
idx %= len(self)
victim = self._lines[idx] victim = self._lines[idx]
self._lines[idx] = val self._lines[idx] = val
@@ -178,7 +198,47 @@ class Buf:
@x.setter @x.setter
def x(self, x: int) -> None: def x(self, x: int) -> None:
self._x = x 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 # movement
@@ -188,7 +248,12 @@ class Buf:
self.file_y = max(self.y - margin.body_lines // 2, 0) self.file_y = max(self.y - margin.body_lines // 2, 0)
def _set_x_after_vertical_movement(self) -> None: 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: def up(self, margin: Margin) -> None:
if self.y > 0: if self.y > 0:

View File

@@ -29,8 +29,6 @@ from babi.hl.interface import HLFactory
from babi.hl.replace import Replace from babi.hl.replace import Replace
from babi.hl.selection import Selection from babi.hl.selection import Selection
from babi.hl.trailing_whitespace import TrailingWhitespace 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.margin import Margin
from babi.prompt import PromptResult from babi.prompt import PromptResult
from babi.status import Status from babi.status import Status
@@ -88,8 +86,8 @@ class Action:
final=True, final=True,
) )
file.buf.x = self.start_x
file.buf.y = self.start_y file.buf.y = self.start_y
file.buf.x = self.start_x
file.modified = self.start_modified file.modified = self.start_modified
return action return action
@@ -511,7 +509,7 @@ class File:
if self.buf[l_y]: if self.buf[l_y]:
self.buf[l_y] = ' ' * 4 + self.buf[l_y] self.buf[l_y] = ' ' * 4 + self.buf[l_y]
if l_y == self.buf.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: if l_y == sel_y and sel_x != 0:
sel_x += 4 sel_x += 4
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x) 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 n = 4 - self.buf.x % 4
line = self.buf[self.buf.y] line = self.buf[self.buf.y]
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:] 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() self.buf.restore_eof_invariant()
def tab(self, margin: Margin) -> None: def tab(self, margin: Margin) -> None:
@@ -696,7 +694,7 @@ class File:
def c(self, wch: str, margin: Margin) -> None: def c(self, wch: str, margin: Margin) -> None:
s = self.buf[self.buf.y] s = self.buf[self.buf.y]
self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:] 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() self.buf.restore_eof_invariant()
def finalize_previous_action(self) -> None: def finalize_previous_action(self) -> None:
@@ -761,18 +759,12 @@ class File:
# positioning # 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( def move_cursor(
self, self,
stdscr: 'curses._CursesWindow', stdscr: 'curses._CursesWindow',
margin: Margin, margin: Margin,
) -> None: ) -> 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: def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(self.buf.displayable_count, margin.body_lines) to_display = min(self.buf.displayable_count, margin.body_lines)
@@ -784,34 +776,41 @@ class File:
for i in range(to_display): for i in range(to_display):
draw_y = i + margin.header draw_y = i + margin.header
l_y = self.buf.file_y + i l_y = self.buf.file_y + i
x = self.buf.x if l_y == self.buf.y else 0 stdscr.insstr(draw_y, 0, self.buf.rendered_line(l_y, margin))
line = scrolled_line(self.buf[l_y], x, margin.cols)
stdscr.insstr(draw_y, 0, line)
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 l_x_max = l_x + margin.cols
for file_hl in self._file_hls: for file_hl in self._file_hls:
for region in file_hl.regions[l_y]: 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 break
elif region.end <= l_x: elif r_end <= l_x:
continue continue
if l_x and region.x <= l_x: if l_x and r_x <= l_x:
if file_hl.include_edge: if file_hl.include_edge:
h_s_x = 0 h_s_x = 0
else: else:
h_s_x = 1 h_s_x = 1
else: 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: if file_hl.include_edge:
h_e_x = margin.cols h_e_x = margin.cols
else: else:
h_e_x = margin.cols - 1 h_e_x = margin.cols - 1
else: 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) stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr)

View File

@@ -1,3 +1,8 @@
import curses
from babi.cached_property import cached_property
def line_x(x: int, width: int) -> int: def line_x(x: int, width: int) -> int:
if x + 1 < width: if x + 1 < width:
return 0 return 0
@@ -25,3 +30,17 @@ def scrolled_line(s: str, x: int, width: int) -> str:
return f'{s[:width - 1]}»' return f'{s[:width - 1]}»'
else: else:
return s.ljust(width) 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

View File

@@ -63,6 +63,16 @@ class Screen:
self._prev_screenshot = ret self._prev_screenshot = ret
return 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): 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]
@@ -173,7 +183,8 @@ class CursesError(NamedTuple):
class CursesScreen: class CursesScreen:
def __init__(self, runner): def __init__(self, screen, runner):
self._screen = screen
self._runner = runner self._runner = runner
self._bkgd_attr = (-1, -1, 0) self._bkgd_attr = (-1, -1, 0)
@@ -197,20 +208,26 @@ class CursesScreen:
pass pass
def nodelay(self, val): 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): 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): def clrtoeol(self):
s = self._runner.screen.width * ' ' s = self._screen.width * ' '
self.insstr(self._runner.screen.y, self._runner.screen.x, s) self.insstr(self._screen.y, self._screen.x, s)
def chgat(self, y, x, n, attr): 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): 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): def get_wch(self):
return self._runner._get_wch() return self._runner._get_wch()
@@ -399,7 +416,10 @@ class DeferredRunner:
def _curses_initscr(self): def _curses_initscr(self):
self._curses_update_lines_cols() 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 _curses_not_implemented(self, fn):
def fn_inner(*args, **kwargs): def fn_inner(*args, **kwargs):

View File

@@ -411,3 +411,30 @@ def test_sequence_handling(run_only_fake):
h.press(' test7') h.press(' test7')
h.await_text('test1 test2 test3 test4 test5 test6 test7') h.await_text('test1 test2 test3 test4 test5 test6 test7')
h.await_text(r'\x1b[1;') 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)

View File

@@ -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): with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
h.await_text('# xxx') h.await_text('# xxx')
h.assert_screen_attr_equals(1, [(243, 40, 0)] * 20) 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')