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
|
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:
|
||||||
|
|||||||
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.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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user