properly render tab characters in babi
This commit is contained in:
75
babi/buf.py
75
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:
|
||||
|
||||
45
babi/file.py
45
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user