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
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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

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):
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')