Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b55ebfd0e | ||
|
|
7d1e61f734 | ||
|
|
3e7ca8e922 | ||
|
|
843f1b6ff1 | ||
|
|
f704505ee2 | ||
|
|
b595333fc6 | ||
|
|
486af96c12 | ||
|
|
8b71d289a3 | ||
|
|
759cadd868 | ||
|
|
b9a12537b1 | ||
|
|
936fd7e3a0 | ||
|
|
2d0f3a3077 | ||
|
|
2a9eccefb2 | ||
|
|
c449f96bf0 | ||
|
|
47e008afa4 | ||
|
|
1919c2d4fe | ||
|
|
18057542bf | ||
|
|
49f95a5a2c | ||
|
|
612f09eb3a | ||
|
|
6206db3ef2 | ||
|
|
711cf65266 | ||
|
|
2b66c465a6 | ||
|
|
9f36fe2f1b | ||
|
|
3844dcf329 | ||
|
|
04aaf9530e | ||
|
|
7850481565 | ||
|
|
b536291989 | ||
|
|
f8737557d3 | ||
|
|
d597b4087d | ||
|
|
41aa025d3d | ||
|
|
de956b7bab | ||
|
|
1d3d413b93 | ||
|
|
50ad1e06f9 | ||
|
|
032c3d78fc | ||
|
|
a197645087 | ||
|
|
9f8e400d32 | ||
|
|
2123e6ee84 | ||
|
|
b529dde91a | ||
|
|
c4e2f8e9cf | ||
|
|
e7d4fa1a07 | ||
|
|
c186adcc6c | ||
|
|
bdf07b8cb3 | ||
|
|
bf1c3d1ee1 | ||
|
|
f1772ec829 | ||
|
|
84b489bb9b | ||
|
|
175fd61119 | ||
|
|
01bb6d91b9 | ||
|
|
ffd5c87118 | ||
|
|
87f3e32f36 | ||
|
|
d20be693d2 | ||
|
|
d826b8b472 | ||
|
|
25173c5dca | ||
|
|
b2ebfa7b48 | ||
|
|
efa6561200 | ||
|
|
b683657f23 | ||
|
|
b59d03858c | ||
|
|
6ec1da061b | ||
|
|
c08557b6ca | ||
|
|
006c2bc8e4 | ||
|
|
080f6e1d54 | ||
|
|
e77a660029 | ||
|
|
e32e5b8c05 | ||
|
|
08638f990c | ||
|
|
414adffa9b | ||
|
|
8d77d5792a | ||
|
|
c85c50c207 | ||
|
|
d5376ca6f2 | ||
|
|
31e7c9345b | ||
|
|
41543f8d6c | ||
|
|
1be4e80edd |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,4 +5,6 @@
|
||||
/.mypy_cache
|
||||
/.pytest_cache
|
||||
/.tox
|
||||
/build
|
||||
/dist
|
||||
/venv*
|
||||
|
||||
@@ -20,12 +20,12 @@ repos:
|
||||
hooks:
|
||||
- id: autopep8
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v1.9.0
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: [--py3-plus]
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v1.5.0
|
||||
rev: v2.0.1
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
args: [--py36-plus]
|
||||
@@ -34,6 +34,10 @@ repos:
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.7.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.770
|
||||
hooks:
|
||||
|
||||
18
README.md
18
README.md
@@ -6,6 +6,10 @@ babi
|
||||
|
||||
a text editor, eventually...
|
||||
|
||||
### installation
|
||||
|
||||
`pip install babi`
|
||||
|
||||
### why is it called babi?
|
||||
|
||||
I usually use the text editor `nano`, frequently I typo this. on a qwerty
|
||||
@@ -23,6 +27,7 @@ these are all of the current key bindings in babi
|
||||
- <kbd>^S</kbd>: save
|
||||
- <kbd>^O</kbd>: save as
|
||||
- <kbd>^X</kbd>: quit
|
||||
- <kbd>^P</kbd>: open file
|
||||
- arrow keys: movement
|
||||
- <kbd>^A</kbd> / <kbd>home</kbd>: move to beginning of line
|
||||
- <kbd>^E</kbd> / <kbd>end</kbd>: move to end of line
|
||||
@@ -62,10 +67,15 @@ in prompts (search, search replace, command):
|
||||
|
||||
the syntax highlighting setup is a bit manual right now
|
||||
|
||||
1. from a clone of babi, run `./bin/download-syntax` -- you will likely need
|
||||
to install some additional packages to download them (`pip install cson`)
|
||||
2. find a visual studio code theme, convert it to json (if it is not already
|
||||
json) and put it at `~/.config/babi/theme.json`
|
||||
1. find a visual studio code theme, convert it to json (if it is not already
|
||||
json) and put it at `~/.config/babi/theme.json`. a helper script is
|
||||
provided to make this easier: `./bin/download-theme NAME URL`
|
||||
|
||||
here's a modified vs dark plus theme that works:
|
||||
|
||||
```bash
|
||||
./bin/download-theme vs-dark-asottile https://gist.github.com/asottile/b465856c82b1aaa4ba8c7c6314a72e13/raw/22d602fb355fb12b04f176a733941ba5713bc36c/vs_dark_asottile.json
|
||||
```
|
||||
|
||||
## demos
|
||||
|
||||
|
||||
@@ -2,7 +2,5 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Protocol # python3.8+
|
||||
from typing_extensions import TypedDict # python3.8+
|
||||
else:
|
||||
Protocol = object
|
||||
TypedDict = dict
|
||||
|
||||
314
babi/buf.py
Normal file
314
babi/buf.py
Normal file
@@ -0,0 +1,314 @@
|
||||
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]
|
||||
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: ...
|
||||
|
||||
|
||||
class SetModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf[self.idx] = self.s
|
||||
|
||||
|
||||
class InsModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf.insert(self.idx, self.s)
|
||||
|
||||
|
||||
class DelModification(NamedTuple):
|
||||
idx: int
|
||||
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
del buf[self.idx]
|
||||
|
||||
|
||||
class Buf:
|
||||
def __init__(self, lines: List[str]) -> None:
|
||||
self._lines = lines
|
||||
self.file_y = self.y = self._x = self._x_hint = 0
|
||||
|
||||
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
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f'{type(self).__name__}('
|
||||
f'{self._lines!r}, x={self.x}, y={self.y}, file_y={self.file_y}'
|
||||
f')'
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._lines)
|
||||
|
||||
def __getitem__(self, idx: int) -> str:
|
||||
return self._lines[idx]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
yield from self._lines
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._lines)
|
||||
|
||||
# mutators
|
||||
|
||||
def __setitem__(self, idx: int, val: str) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
victim = self._lines[idx]
|
||||
|
||||
self._lines[idx] = val
|
||||
|
||||
for set_callback in self._set_callbacks:
|
||||
set_callback(self, idx, victim)
|
||||
|
||||
def __delitem__(self, idx: int) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
victim = self._lines[idx]
|
||||
|
||||
del self._lines[idx]
|
||||
|
||||
for del_callback in self._del_callbacks:
|
||||
del_callback(self, idx, victim)
|
||||
|
||||
def insert(self, idx: int, val: str) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
|
||||
self._lines.insert(idx, val)
|
||||
|
||||
for ins_callback in self._ins_callbacks:
|
||||
ins_callback(self, idx)
|
||||
|
||||
# also mutators, but implemented using above functions
|
||||
|
||||
def append(self, val: str) -> None:
|
||||
self.insert(len(self), val)
|
||||
|
||||
def pop(self, idx: int = -1) -> str:
|
||||
victim = self[idx]
|
||||
del self[idx]
|
||||
return victim
|
||||
|
||||
def restore_eof_invariant(self) -> None:
|
||||
"""the file lines will always contain a blank empty string at the end
|
||||
to simplify rendering. call this whenever the last line may change
|
||||
"""
|
||||
if self[-1] != '':
|
||||
self.append('')
|
||||
|
||||
# event handling
|
||||
|
||||
def add_set_callback(self, cb: SetCallback) -> None:
|
||||
self._set_callbacks.append(cb)
|
||||
|
||||
def remove_set_callback(self, cb: SetCallback) -> None:
|
||||
self._set_callbacks.remove(cb)
|
||||
|
||||
def add_del_callback(self, cb: DelCallback) -> None:
|
||||
self._del_callbacks.append(cb)
|
||||
|
||||
def remove_del_callback(self, cb: DelCallback) -> None:
|
||||
self._del_callbacks.remove(cb)
|
||||
|
||||
def add_ins_callback(self, cb: InsCallback) -> None:
|
||||
self._ins_callbacks.append(cb)
|
||||
|
||||
def remove_ins_callback(self, cb: InsCallback) -> None:
|
||||
self._ins_callbacks.remove(cb)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def record(self) -> Generator[List[Modification], None, None]:
|
||||
modifications: List[Modification] = []
|
||||
|
||||
def set_cb(buf: 'Buf', idx: int, victim: str) -> None:
|
||||
modifications.append(SetModification(idx, victim))
|
||||
|
||||
def del_cb(buf: 'Buf', idx: int, victim: str) -> None:
|
||||
modifications.append(InsModification(idx, victim))
|
||||
|
||||
def ins_cb(buf: 'Buf', idx: int) -> None:
|
||||
modifications.append(DelModification(idx))
|
||||
|
||||
self.add_set_callback(set_cb)
|
||||
self.add_del_callback(del_cb)
|
||||
self.add_ins_callback(ins_cb)
|
||||
try:
|
||||
yield modifications
|
||||
finally:
|
||||
self.remove_ins_callback(ins_cb)
|
||||
self.remove_del_callback(del_cb)
|
||||
self.remove_set_callback(set_cb)
|
||||
|
||||
def apply(self, modifications: List[Modification]) -> List[Modification]:
|
||||
with self.record() as ret_modifications:
|
||||
for modification in reversed(modifications):
|
||||
modification(self)
|
||||
return ret_modifications
|
||||
|
||||
# position properties
|
||||
|
||||
@property
|
||||
def displayable_count(self) -> int:
|
||||
return len(self._lines) - self.file_y
|
||||
|
||||
@property
|
||||
def x(self) -> int:
|
||||
return self._x
|
||||
|
||||
@x.setter
|
||||
def x(self, x: int) -> None:
|
||||
self._x = 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:
|
||||
line = self._lines[idx]
|
||||
positions = self.line_positions(idx)
|
||||
cursor_x = self._cursor_x if idx == self.y else 0
|
||||
return scrolled_line(line, positions, cursor_x, margin.cols)
|
||||
|
||||
# movement
|
||||
|
||||
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||
# if the `y` is not on screen, make it so
|
||||
if not (self.file_y <= self.y < self.file_y + margin.body_lines):
|
||||
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||
|
||||
def _set_x_after_vertical_movement(self) -> None:
|
||||
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:
|
||||
self.y -= 1
|
||||
if self.y < self.file_y:
|
||||
self.file_y = max(self.file_y - margin.scroll_amount, 0)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
def down(self, margin: Margin) -> None:
|
||||
if self.y < len(self._lines) - 1:
|
||||
self.y += 1
|
||||
if self.y >= self.file_y + margin.body_lines:
|
||||
self.file_y += margin.scroll_amount
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
def right(self, margin: Margin) -> None:
|
||||
if self.x >= len(self._lines[self.y]):
|
||||
if self.y < len(self._lines) - 1:
|
||||
self.down(margin)
|
||||
self.home()
|
||||
else:
|
||||
self.x += 1
|
||||
|
||||
def left(self, margin: Margin) -> None:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self.up(margin)
|
||||
self.end()
|
||||
else:
|
||||
self.x -= 1
|
||||
|
||||
def home(self) -> None:
|
||||
self.x = 0
|
||||
|
||||
def end(self) -> None:
|
||||
self.x = len(self._lines[self.y])
|
||||
|
||||
# screen movement
|
||||
|
||||
def file_up(self, margin: Margin) -> None:
|
||||
if self.file_y > 0:
|
||||
self.file_y -= 1
|
||||
if self.y > self.file_y + margin.body_lines - 1:
|
||||
self.up(margin)
|
||||
|
||||
def file_down(self, margin: Margin) -> None:
|
||||
if self.file_y < len(self._lines) - 1:
|
||||
self.file_y += 1
|
||||
if self.y < self.file_y:
|
||||
self.down(margin)
|
||||
|
||||
# key input
|
||||
|
||||
def c(self, s: str) -> None:
|
||||
self[self.y] = self[self.y][:self.x] + s + self[self.y][self.x:]
|
||||
self.x += len(s)
|
||||
@@ -1,5 +1,9 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
# TODO: find a standard which defines these
|
||||
# limited number of "named" colors
|
||||
NAMED_COLORS = {'white': '#ffffff', 'black': '#000000'}
|
||||
|
||||
|
||||
class Color(NamedTuple):
|
||||
r: int
|
||||
@@ -8,4 +12,9 @@ class Color(NamedTuple):
|
||||
|
||||
@classmethod
|
||||
def parse(cls, s: str) -> 'Color':
|
||||
if s.startswith('#') and len(s) >= 7:
|
||||
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
||||
elif s.startswith('#'):
|
||||
return cls.parse(f'#{s[1] * 2}{s[2] * 2}{s[3] * 2}')
|
||||
else:
|
||||
return cls.parse(NAMED_COLORS[s])
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import contextlib
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
@@ -19,26 +18,25 @@ class ColorManager(NamedTuple):
|
||||
raw_pairs: Dict[Tuple[int, int], int]
|
||||
|
||||
def init_color(self, color: Color) -> None:
|
||||
if curses.COLORS < 256:
|
||||
return
|
||||
elif curses.can_change_color():
|
||||
if curses.can_change_color():
|
||||
n = min(self.colors.values(), default=256) - 1
|
||||
self.colors[color] = n
|
||||
curses.init_color(n, *_color_to_curses(color))
|
||||
else:
|
||||
elif curses.COLORS >= 256:
|
||||
self.colors[color] = color_kd.nearest(color, color_kd.make_256())
|
||||
else:
|
||||
self.colors[color] = -1
|
||||
|
||||
def color_pair(self, fg: Optional[Color], bg: Optional[Color]) -> int:
|
||||
if curses.COLORS < 256:
|
||||
return 0
|
||||
|
||||
fg_i = self.colors[fg] if fg is not None else -1
|
||||
bg_i = self.colors[bg] if bg is not None else -1
|
||||
return self.raw_color_pair(fg_i, bg_i)
|
||||
|
||||
def raw_color_pair(self, fg: int, bg: int) -> int:
|
||||
with contextlib.suppress(KeyError):
|
||||
try:
|
||||
return self.raw_pairs[(fg, bg)]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
|
||||
curses.init_pair(n, fg, bg)
|
||||
|
||||
@@ -3,8 +3,10 @@ from typing import Iterable
|
||||
from typing import Mapping
|
||||
from typing import TypeVar
|
||||
|
||||
TKey = TypeVar('TKey')
|
||||
TValue = TypeVar('TValue')
|
||||
from babi._types import Protocol
|
||||
|
||||
TKey = TypeVar('TKey', contravariant=True)
|
||||
TValue = TypeVar('TValue', covariant=True)
|
||||
|
||||
|
||||
class FDict(Generic[TKey, TValue]):
|
||||
@@ -22,3 +24,21 @@ class FDict(Generic[TKey, TValue]):
|
||||
|
||||
def values(self) -> Iterable[TValue]:
|
||||
return self._dct.values()
|
||||
|
||||
|
||||
class Indexable(Generic[TKey, TValue], Protocol):
|
||||
def __getitem__(self, key: TKey) -> TValue: ...
|
||||
|
||||
|
||||
class FChainMap(Generic[TKey, TValue]):
|
||||
def __init__(self, *mappings: Indexable[TKey, TValue]) -> None:
|
||||
self._mappings = mappings
|
||||
|
||||
def __getitem__(self, key: TKey) -> TValue:
|
||||
for mapping in reversed(self._mappings):
|
||||
try:
|
||||
return mapping[key]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
667
babi/file.py
667
babi/file.py
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import os.path
|
||||
@@ -10,20 +9,31 @@ from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import TypeVar
|
||||
|
||||
from identify.identify import tags_from_filename
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.fdict import FDict
|
||||
from babi.fdict import FChainMap
|
||||
from babi.reg import _Reg
|
||||
from babi.reg import _RegSet
|
||||
from babi.reg import ERR_REG
|
||||
from babi.reg import expand_escaped
|
||||
from babi.reg import make_reg
|
||||
from babi.reg import make_regset
|
||||
|
||||
T = TypeVar('T')
|
||||
Scope = Tuple[str, ...]
|
||||
Regions = Tuple['Region', ...]
|
||||
Captures = Tuple[Tuple[int, '_Rule'], ...]
|
||||
|
||||
|
||||
def uniquely_constructed(t: T) -> T:
|
||||
"""avoid tuple.__hash__ for "singleton" constructed objects"""
|
||||
t.__hash__ = object.__hash__ # type: ignore
|
||||
return t
|
||||
|
||||
|
||||
def _split_name(s: Optional[str]) -> Tuple[str, ...]:
|
||||
if s is None:
|
||||
return ()
|
||||
@@ -57,8 +67,11 @@ class _Rule(Protocol):
|
||||
def include(self) -> Optional[str]: ...
|
||||
@property
|
||||
def patterns(self) -> 'Tuple[_Rule, ...]': ...
|
||||
@property
|
||||
def repository(self) -> 'FChainMap[str, _Rule]': ...
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class Rule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
match: Optional[str]
|
||||
@@ -72,9 +85,24 @@ class Rule(NamedTuple):
|
||||
while_captures: Captures
|
||||
include: Optional[str]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
repository: FChainMap[str, _Rule]
|
||||
|
||||
@classmethod
|
||||
def from_dct(cls, dct: Dict[str, Any]) -> _Rule:
|
||||
def make(
|
||||
cls,
|
||||
dct: Dict[str, Any],
|
||||
parent_repository: FChainMap[str, _Rule],
|
||||
) -> _Rule:
|
||||
if 'repository' in dct:
|
||||
# this looks odd, but it's so we can have a self-referential
|
||||
# immutable-after-construction chain map
|
||||
repository_dct: Dict[str, _Rule] = {}
|
||||
repository = FChainMap(parent_repository, repository_dct)
|
||||
for k, sub_dct in dct['repository'].items():
|
||||
repository_dct[k] = Rule.make(sub_dct, repository)
|
||||
else:
|
||||
repository = parent_repository
|
||||
|
||||
name = _split_name(dct.get('name'))
|
||||
match = dct.get('match')
|
||||
begin = dct.get('begin')
|
||||
@@ -84,7 +112,7 @@ class Rule(NamedTuple):
|
||||
|
||||
if 'captures' in dct:
|
||||
captures = tuple(
|
||||
(int(k), Rule.from_dct(v))
|
||||
(int(k), Rule.make(v, repository))
|
||||
for k, v in dct['captures'].items()
|
||||
)
|
||||
else:
|
||||
@@ -92,7 +120,7 @@ class Rule(NamedTuple):
|
||||
|
||||
if 'beginCaptures' in dct:
|
||||
begin_captures = tuple(
|
||||
(int(k), Rule.from_dct(v))
|
||||
(int(k), Rule.make(v, repository))
|
||||
for k, v in dct['beginCaptures'].items()
|
||||
)
|
||||
else:
|
||||
@@ -100,7 +128,7 @@ class Rule(NamedTuple):
|
||||
|
||||
if 'endCaptures' in dct:
|
||||
end_captures = tuple(
|
||||
(int(k), Rule.from_dct(v))
|
||||
(int(k), Rule.make(v, repository))
|
||||
for k, v in dct['endCaptures'].items()
|
||||
)
|
||||
else:
|
||||
@@ -108,12 +136,16 @@ class Rule(NamedTuple):
|
||||
|
||||
if 'whileCaptures' in dct:
|
||||
while_captures = tuple(
|
||||
(int(k), Rule.from_dct(v))
|
||||
(int(k), Rule.make(v, repository))
|
||||
for k, v in dct['whileCaptures'].items()
|
||||
)
|
||||
else:
|
||||
while_captures = ()
|
||||
|
||||
# some grammars (at least xml) have begin rules with no end
|
||||
if begin is not None and end is None and while_ is None:
|
||||
end = '$impossible^'
|
||||
|
||||
# Using the captures key for a begin/end/while rule is short-hand for
|
||||
# giving both beginCaptures and endCaptures with same values
|
||||
if begin and end and captures:
|
||||
@@ -126,7 +158,7 @@ class Rule(NamedTuple):
|
||||
include = dct.get('include')
|
||||
|
||||
if 'patterns' in dct:
|
||||
patterns = tuple(Rule.from_dct(d) for d in dct['patterns'])
|
||||
patterns = tuple(Rule.make(d, repository) for d in dct['patterns'])
|
||||
else:
|
||||
patterns = ()
|
||||
|
||||
@@ -143,69 +175,34 @@ class Rule(NamedTuple):
|
||||
while_captures=while_captures,
|
||||
include=include,
|
||||
patterns=patterns,
|
||||
)
|
||||
|
||||
|
||||
class Grammar(NamedTuple):
|
||||
scope_name: str
|
||||
first_line_match: Optional[_Reg]
|
||||
file_types: FrozenSet[str]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
repository: FDict[str, _Rule]
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'Grammar':
|
||||
scope_name = data['scopeName']
|
||||
if 'firstLineMatch' in data:
|
||||
first_line_match: Optional[_Reg] = make_reg(data['firstLineMatch'])
|
||||
else:
|
||||
first_line_match = None
|
||||
if 'fileTypes' in data:
|
||||
file_types = frozenset(data['fileTypes'])
|
||||
else:
|
||||
file_types = frozenset()
|
||||
patterns = tuple(Rule.from_dct(dct) for dct in data['patterns'])
|
||||
if 'repository' in data:
|
||||
repository = FDict({
|
||||
k: Rule.from_dct(dct) for k, dct in data['repository'].items()
|
||||
})
|
||||
else:
|
||||
repository = FDict({})
|
||||
return cls(
|
||||
scope_name=scope_name,
|
||||
first_line_match=first_line_match,
|
||||
file_types=file_types,
|
||||
patterns=patterns,
|
||||
repository=repository,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, filename: str) -> 'Grammar':
|
||||
with open(filename) as f:
|
||||
return cls.from_data(json.load(f))
|
||||
|
||||
@uniquely_constructed
|
||||
class Grammar(NamedTuple):
|
||||
scope_name: str
|
||||
repository: FChainMap[str, _Rule]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
|
||||
@classmethod
|
||||
def blank(cls) -> 'Grammar':
|
||||
return cls(
|
||||
scope_name='source.unknown',
|
||||
first_line_match=None,
|
||||
file_types=frozenset(),
|
||||
patterns=(),
|
||||
repository=FDict({}),
|
||||
)
|
||||
|
||||
def matches_file(self, filename: str, first_line: str) -> bool:
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lstrip('.') in self.file_types:
|
||||
return True
|
||||
elif self.first_line_match is not None:
|
||||
return bool(
|
||||
self.first_line_match.match(
|
||||
first_line, 0, first_line=True, boundary=True,
|
||||
),
|
||||
)
|
||||
def make(cls, data: Dict[str, Any]) -> 'Grammar':
|
||||
scope_name = data['scopeName']
|
||||
if 'repository' in data:
|
||||
# this looks odd, but it's so we can have a self-referential
|
||||
# immutable-after-construction chain map
|
||||
repository_dct: Dict[str, _Rule] = {}
|
||||
repository = FChainMap(repository_dct)
|
||||
for k, dct in data['repository'].items():
|
||||
repository_dct[k] = Rule.make(dct, repository)
|
||||
else:
|
||||
return False
|
||||
repository = FChainMap()
|
||||
patterns = tuple(Rule.make(d, repository) for d in data['patterns'])
|
||||
return cls(
|
||||
scope_name=scope_name,
|
||||
repository=repository,
|
||||
patterns=patterns,
|
||||
)
|
||||
|
||||
|
||||
class Region(NamedTuple):
|
||||
@@ -369,6 +366,7 @@ def _do_regset(
|
||||
return state, match.end(), boundary, tuple(ret)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class PatternRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
regset: _RegSet
|
||||
@@ -395,6 +393,7 @@ class PatternRule(NamedTuple):
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class MatchRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
captures: Captures
|
||||
@@ -420,6 +419,7 @@ class MatchRule(NamedTuple):
|
||||
raise AssertionError(f'unreachable {self}')
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class EndRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
@@ -439,7 +439,7 @@ class EndRule(NamedTuple):
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(match.expand(self.end))
|
||||
reg = make_reg(expand_escaped(match, self.end))
|
||||
state = state.push(Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
@@ -480,6 +480,7 @@ class EndRule(NamedTuple):
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class WhileRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
@@ -499,7 +500,7 @@ class WhileRule(NamedTuple):
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(match.expand(self.while_))
|
||||
reg = make_reg(expand_escaped(match, self.while_))
|
||||
state = state.push_while(self, Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
@@ -534,7 +535,7 @@ class WhileRule(NamedTuple):
|
||||
|
||||
|
||||
class Compiler:
|
||||
def __init__(self, grammar: Grammar, grammars: Dict[str, Grammar]) -> None:
|
||||
def __init__(self, grammar: Grammar, grammars: 'Grammars') -> None:
|
||||
self._root_scope = grammar.scope_name
|
||||
self._grammars = grammars
|
||||
self._rule_to_grammar: Dict[_Rule, Grammar] = {}
|
||||
@@ -550,19 +551,23 @@ class Compiler:
|
||||
def _include(
|
||||
self,
|
||||
grammar: Grammar,
|
||||
repository: FChainMap[str, _Rule],
|
||||
s: str,
|
||||
) -> Tuple[List[str], Tuple[_Rule, ...]]:
|
||||
if s == '$self':
|
||||
return self._patterns(grammar, grammar.patterns)
|
||||
elif s == '$base':
|
||||
return self._include(self._grammars[self._root_scope], '$self')
|
||||
grammar = self._grammars.grammar_for_scope(self._root_scope)
|
||||
return self._include(grammar, grammar.repository, '$self')
|
||||
elif s.startswith('#'):
|
||||
return self._patterns(grammar, (grammar.repository[s[1:]],))
|
||||
return self._patterns(grammar, (repository[s[1:]],))
|
||||
elif '#' not in s:
|
||||
return self._include(self._grammars[s], '$self')
|
||||
grammar = self._grammars.grammar_for_scope(s)
|
||||
return self._include(grammar, grammar.repository, '$self')
|
||||
else:
|
||||
scope, _, s = s.partition('#')
|
||||
return self._include(self._grammars[scope], f'#{s}')
|
||||
grammar = self._grammars.grammar_for_scope(scope)
|
||||
return self._include(grammar, grammar.repository, f'#{s}')
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _patterns(
|
||||
@@ -574,7 +579,9 @@ class Compiler:
|
||||
ret_rules: List[_Rule] = []
|
||||
for rule in rules:
|
||||
if rule.include is not None:
|
||||
tmp_regs, tmp_rules = self._include(grammar, rule.include)
|
||||
tmp_regs, tmp_rules = self._include(
|
||||
grammar, rule.repository, rule.include,
|
||||
)
|
||||
ret_regs.extend(tmp_regs)
|
||||
ret_rules.extend(tmp_rules)
|
||||
elif rule.match is None and rule.begin is None and rule.patterns:
|
||||
@@ -634,8 +641,10 @@ class Compiler:
|
||||
return PatternRule(rule.name, make_regset(*regs), rules)
|
||||
|
||||
def compile_rule(self, rule: _Rule) -> CompiledRule:
|
||||
with contextlib.suppress(KeyError):
|
||||
try:
|
||||
return self._c_rules[rule]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
grammar = self._rule_to_grammar[rule]
|
||||
ret = self._c_rules[rule] = self._compile_rule(grammar, rule)
|
||||
@@ -643,49 +652,87 @@ class Compiler:
|
||||
|
||||
|
||||
class Grammars:
|
||||
def __init__(self, grammars: List[Grammar]) -> None:
|
||||
self.grammars = {grammar.scope_name: grammar for grammar in grammars}
|
||||
self._compilers: Dict[Grammar, Compiler] = {}
|
||||
def __init__(self, *directories: str) -> None:
|
||||
self._scope_to_files = {
|
||||
os.path.splitext(filename)[0]: os.path.join(directory, filename)
|
||||
for directory in directories
|
||||
if os.path.exists(directory)
|
||||
for filename in sorted(os.listdir(directory))
|
||||
if filename.endswith('.json')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_syntax_dir(cls, syntax_dir: str) -> 'Grammars':
|
||||
grammars = [Grammar.blank()]
|
||||
if os.path.exists(syntax_dir):
|
||||
grammars.extend(
|
||||
Grammar.parse(os.path.join(syntax_dir, filename))
|
||||
for filename in os.listdir(syntax_dir)
|
||||
)
|
||||
return cls(grammars)
|
||||
unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
|
||||
self._raw = {'source.unknown': unknown_grammar}
|
||||
self._file_types: List[Tuple[FrozenSet[str], str]] = []
|
||||
self._first_line: List[Tuple[_Reg, str]] = []
|
||||
self._parsed: Dict[str, Grammar] = {}
|
||||
self._compiled: Dict[str, Compiler] = {}
|
||||
|
||||
def _compiler_for_grammar(self, grammar: Grammar) -> Compiler:
|
||||
with contextlib.suppress(KeyError):
|
||||
return self._compilers[grammar]
|
||||
def _raw_for_scope(self, scope: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return self._raw[scope]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
ret = self._compilers[grammar] = Compiler(grammar, self.grammars)
|
||||
grammar_path = self._scope_to_files.pop(scope)
|
||||
with open(grammar_path) as f:
|
||||
ret = self._raw[scope] = json.load(f)
|
||||
|
||||
file_types = frozenset(ret.get('fileTypes', ()))
|
||||
first_line = make_reg(ret.get('firstLineMatch', '$impossible^'))
|
||||
|
||||
self._file_types.append((file_types, scope))
|
||||
self._first_line.append((first_line, scope))
|
||||
|
||||
return ret
|
||||
|
||||
def grammar_for_scope(self, scope: str) -> Grammar:
|
||||
try:
|
||||
return self._parsed[scope]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
raw = self._raw_for_scope(scope)
|
||||
ret = self._parsed[scope] = Grammar.make(raw)
|
||||
return ret
|
||||
|
||||
def compiler_for_scope(self, scope: str) -> Compiler:
|
||||
return self._compiler_for_grammar(self.grammars[scope])
|
||||
try:
|
||||
return self._compiled[scope]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
grammar = self.grammar_for_scope(scope)
|
||||
ret = self._compiled[scope] = Compiler(grammar, self)
|
||||
return ret
|
||||
|
||||
def blank_compiler(self) -> Compiler:
|
||||
return self.compiler_for_scope('source.unknown')
|
||||
|
||||
def compiler_for_file(self, filename: str) -> Compiler:
|
||||
if os.path.exists(filename):
|
||||
with open(filename) as f:
|
||||
first_line = next(f, '')
|
||||
else:
|
||||
first_line = ''
|
||||
for grammar in self.grammars.values():
|
||||
if grammar.matches_file(filename, first_line):
|
||||
break
|
||||
else:
|
||||
grammar = self.grammars['source.unknown']
|
||||
def compiler_for_file(self, filename: str, first_line: str) -> Compiler:
|
||||
for tag in tags_from_filename(filename) - {'text'}:
|
||||
try:
|
||||
# TODO: this doesn't always match even if we detect it
|
||||
return self.compiler_for_scope(f'source.{tag}')
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return self._compiler_for_grammar(grammar)
|
||||
# didn't find it in the fast path, need to read all the json
|
||||
for k in tuple(self._scope_to_files):
|
||||
self._raw_for_scope(k)
|
||||
|
||||
_, _, ext = os.path.basename(filename).rpartition('.')
|
||||
for extensions, scope in self._file_types:
|
||||
if ext in extensions:
|
||||
return self.compiler_for_scope(scope)
|
||||
|
||||
for reg, scope in self._first_line:
|
||||
if reg.match(first_line, 0, first_line=True, boundary=True):
|
||||
return self.compiler_for_scope(scope)
|
||||
|
||||
return self.compiler_for_scope('source.unknown')
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def highlight_line(
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
from typing import Sequence
|
||||
from typing import NamedTuple
|
||||
from typing import Tuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi._types import TypedDict
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.buf import Buf
|
||||
|
||||
|
||||
class CursesRegion(TypedDict):
|
||||
class HL(NamedTuple):
|
||||
x: int
|
||||
n: int
|
||||
color: int
|
||||
end: int
|
||||
attr: int
|
||||
|
||||
|
||||
CursesRegions = Tuple[CursesRegion, ...]
|
||||
HLs = Tuple[HL, ...]
|
||||
|
||||
|
||||
class RegionsMapping(Protocol):
|
||||
def __getitem__(self, idx: int) -> HLs: ...
|
||||
|
||||
|
||||
class FileHL(Protocol):
|
||||
@property
|
||||
def regions(self) -> Sequence[CursesRegions]: ...
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ...
|
||||
def touch(self, lineno: int) -> None: ...
|
||||
def include_edge(self) -> bool: ...
|
||||
@property
|
||||
def regions(self) -> RegionsMapping: ...
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None: ...
|
||||
def register_callbacks(self, buf: Buf) -> None: ...
|
||||
|
||||
|
||||
class HLFactory(Protocol):
|
||||
def get_file_highlighter(self, filename: str) -> FileHL: ...
|
||||
def get_blank_file_highlighter(self) -> FileHL: ...
|
||||
def file_highlighter(self, filename: str, first_line: str) -> FileHL: ...
|
||||
def blank_file_highlighter(self) -> FileHL: ...
|
||||
|
||||
32
babi/hl/replace.py
Normal file
32
babi/hl/replace.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
|
||||
|
||||
class Replace:
|
||||
include_edge = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def region(self, y: int, x: int, end: int) -> Generator[None, None, None]:
|
||||
# XXX: this assumes pair 1 is the background
|
||||
attr = curses.A_REVERSE | curses.A_DIM | curses.color_pair(1)
|
||||
self.regions[y] = (HL(x=x, end=end, attr=attr),)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
del self.regions[y]
|
||||
58
babi/hl/selection.py
Normal file
58
babi/hl/selection.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import collections
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
|
||||
|
||||
class Selection:
|
||||
include_edge = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
self.start: Optional[Tuple[int, int]] = None
|
||||
self.end: Optional[Tuple[int, int]] = None
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
if self.start is None or self.end is None:
|
||||
return
|
||||
|
||||
# XXX: this assumes pair 1 is the background
|
||||
attr = curses.A_REVERSE | curses.A_DIM | curses.color_pair(1)
|
||||
(s_y, s_x), (e_y, e_x) = self.get()
|
||||
if s_y == e_y:
|
||||
self.regions[s_y] = (HL(x=s_x, end=e_x, attr=attr),)
|
||||
else:
|
||||
self.regions[s_y] = (
|
||||
HL(x=s_x, end=len(lines[s_y]) + 1, attr=attr),
|
||||
)
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
self.regions[l_y] = (
|
||||
HL(x=0, end=len(lines[l_y]) + 1, attr=attr),
|
||||
)
|
||||
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
||||
|
||||
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
||||
assert self.start is not None and self.end is not None
|
||||
if self.start < self.end:
|
||||
return self.start, self.end
|
||||
else:
|
||||
return self.end, self.start
|
||||
|
||||
def clear(self) -> None:
|
||||
if self.start is not None and self.end is not None:
|
||||
(s_y, _), (e_y, _) = self.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
del self.regions[l_y]
|
||||
self.start = self.end = None
|
||||
|
||||
def set(self, s_y: int, s_x: int, e_y: int, e_x: int) -> None:
|
||||
self.clear()
|
||||
self.start, self.end = (s_y, s_x), (e_y, e_x)
|
||||
@@ -1,19 +1,23 @@
|
||||
import curses
|
||||
from typing import Dict
|
||||
import functools
|
||||
import math
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.highlight import Compiler
|
||||
from babi.highlight import Grammars
|
||||
from babi.highlight import highlight_line
|
||||
from babi.highlight import State
|
||||
from babi.hl.interface import CursesRegion
|
||||
from babi.hl.interface import CursesRegions
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.theme import Style
|
||||
from babi.theme import Theme
|
||||
from babi.user_data import prefix_data
|
||||
from babi.user_data import xdg_config
|
||||
from babi.user_data import xdg_data
|
||||
|
||||
@@ -21,6 +25,8 @@ A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # new in py37
|
||||
|
||||
|
||||
class FileSyntax:
|
||||
include_edge = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
@@ -31,11 +37,13 @@ class FileSyntax:
|
||||
self._theme = theme
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: List[CursesRegions] = []
|
||||
self.regions: List[HLs] = []
|
||||
self._states: List[State] = []
|
||||
|
||||
self._hl_cache: Dict[str, Dict[State, Tuple[State, CursesRegions]]]
|
||||
self._hl_cache = {}
|
||||
# this will be assigned a functools.lru_cache per instance for
|
||||
# better hit rate and memory usage
|
||||
self._hl: Optional[Callable[[State, str, bool], Tuple[State, HLs]]]
|
||||
self._hl = None
|
||||
|
||||
def attr(self, style: Style) -> int:
|
||||
pair = self._color_manager.color_pair(style.fg, style.bg)
|
||||
@@ -46,72 +54,83 @@ class FileSyntax:
|
||||
curses.A_UNDERLINE * style.u
|
||||
)
|
||||
|
||||
def _hl(
|
||||
def _hl_uncached(
|
||||
self,
|
||||
state: State,
|
||||
line: str,
|
||||
i: int,
|
||||
) -> Tuple[State, CursesRegions]:
|
||||
try:
|
||||
return self._hl_cache[line][state]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
first_line: bool,
|
||||
) -> Tuple[State, HLs]:
|
||||
new_state, regions = highlight_line(
|
||||
self._compiler, state, f'{line}\n', first_line=i == 0,
|
||||
self._compiler, state, f'{line}\n', first_line=first_line,
|
||||
)
|
||||
|
||||
# remove the trailing newline
|
||||
new_end = regions[-1]._replace(end=regions[-1].end - 1)
|
||||
regions = regions[:-1] + (new_end,)
|
||||
|
||||
regs: List[CursesRegion] = []
|
||||
regs: List[HL] = []
|
||||
for r in regions:
|
||||
style = self._theme.select(r.scope)
|
||||
if style == self._theme.default:
|
||||
continue
|
||||
|
||||
n = r.end - r.start
|
||||
attr = self.attr(style)
|
||||
if (
|
||||
regs and
|
||||
regs[-1]['color'] == attr and
|
||||
regs[-1]['x'] + regs[-1]['n'] == r.start
|
||||
regs[-1].attr == attr and
|
||||
regs[-1].end == r.start
|
||||
):
|
||||
regs[-1]['n'] += n
|
||||
regs[-1] = regs[-1]._replace(end=r.end)
|
||||
else:
|
||||
regs.append(CursesRegion(x=r.start, n=n, color=attr))
|
||||
regs.append(HL(x=r.start, end=r.end, attr=attr))
|
||||
|
||||
dct = self._hl_cache.setdefault(line, {})
|
||||
ret = dct[state] = (new_state, tuple(regs))
|
||||
return ret
|
||||
return new_state, tuple(regs)
|
||||
|
||||
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||
del self.regions[idx:]
|
||||
del self._states[idx:]
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
buf.add_set_callback(self._set_cb)
|
||||
buf.add_del_callback(self._del_cb)
|
||||
buf.add_ins_callback(self._ins_cb)
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
if self._hl is None:
|
||||
# the docs claim better performance with power of two sizing
|
||||
size = max(4096, 2 ** (int(math.log(len(lines), 2)) + 2))
|
||||
self._hl = functools.lru_cache(maxsize=size)(self._hl_uncached)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
if not self._states:
|
||||
state = self._compiler.root_state
|
||||
else:
|
||||
state = self._states[-1]
|
||||
|
||||
for i in range(len(self._states), idx):
|
||||
state, regions = self._hl(state, lines[i], i)
|
||||
# https://github.com/python/mypy/issues/8579
|
||||
state, regions = self._hl(state, lines[i], i == 0) # type: ignore
|
||||
self._states.append(state)
|
||||
self.regions.append(regions)
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
del self._states[lineno:]
|
||||
del self.regions[lineno:]
|
||||
|
||||
|
||||
class Syntax(NamedTuple):
|
||||
grammars: Grammars
|
||||
theme: Theme
|
||||
color_manager: ColorManager
|
||||
|
||||
def get_file_highlighter(self, filename: str) -> FileSyntax:
|
||||
compiler = self.grammars.compiler_for_file(filename)
|
||||
def file_highlighter(self, filename: str, first_line: str) -> FileSyntax:
|
||||
compiler = self.grammars.compiler_for_file(filename, first_line)
|
||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||
|
||||
def get_blank_file_highlighter(self) -> FileSyntax:
|
||||
def blank_file_highlighter(self) -> FileSyntax:
|
||||
compiler = self.grammars.blank_compiler()
|
||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||
|
||||
@@ -139,7 +158,7 @@ class Syntax(NamedTuple):
|
||||
stdscr: 'curses._CursesWindow',
|
||||
color_manager: ColorManager,
|
||||
) -> 'Syntax':
|
||||
grammars = Grammars.from_syntax_dir(xdg_data('textmate_syntax'))
|
||||
grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1'))
|
||||
theme = Theme.from_filename(xdg_config('theme.json'))
|
||||
ret = cls(grammars, theme, color_manager)
|
||||
ret._init_screen(stdscr)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import curses
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import CursesRegion
|
||||
from babi.hl.interface import CursesRegions
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
|
||||
|
||||
class FileTrailingWhitespace:
|
||||
class TrailingWhitespace:
|
||||
include_edge = False
|
||||
|
||||
def __init__(self, color_manager: ColorManager) -> None:
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: List[CursesRegions] = []
|
||||
self.regions: List[HLs] = []
|
||||
|
||||
def _trailing_ws(self, line: str) -> CursesRegions:
|
||||
def _trailing_ws(self, line: str) -> HLs:
|
||||
if not line:
|
||||
return ()
|
||||
|
||||
@@ -27,22 +28,25 @@ class FileTrailingWhitespace:
|
||||
else:
|
||||
pair = self._color_manager.raw_color_pair(-1, curses.COLOR_RED)
|
||||
attr = curses.color_pair(pair)
|
||||
return (CursesRegion(x=i, n=len(line) - i, color=attr),)
|
||||
return (HL(x=i, end=len(line), attr=attr),)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
def _set_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
if idx < len(self.regions):
|
||||
self.regions[idx] = self._trailing_ws(lines[idx])
|
||||
|
||||
def _del_cb(self, lines: Buf, idx: int, victim: str) -> None:
|
||||
if idx < len(self.regions):
|
||||
del self.regions[idx]
|
||||
|
||||
def _ins_cb(self, lines: Buf, idx: int) -> None:
|
||||
if idx < len(self.regions):
|
||||
self.regions.insert(idx, self._trailing_ws(lines[idx]))
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
buf.add_set_callback(self._set_cb)
|
||||
buf.add_del_callback(self._del_cb)
|
||||
buf.add_ins_callback(self._ins_cb)
|
||||
|
||||
def highlight_until(self, lines: Buf, idx: int) -> None:
|
||||
for i in range(len(self.regions), idx):
|
||||
self.regions.append(self._trailing_ws(lines[i]))
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
del self.regions[lineno:]
|
||||
|
||||
|
||||
class TrailingWhitespace(NamedTuple):
|
||||
color_manager: ColorManager
|
||||
|
||||
def get_file_highlighter(self, filename: str) -> FileTrailingWhitespace:
|
||||
# no file-specific behaviour
|
||||
return self.get_blank_file_highlighter()
|
||||
|
||||
def get_blank_file_highlighter(self) -> FileTrailingWhitespace:
|
||||
return FileTrailingWhitespace(self.color_manager)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import bisect
|
||||
import curses
|
||||
from typing import Tuple
|
||||
|
||||
from babi.cached_property import cached_property
|
||||
|
||||
|
||||
def line_x(x: int, width: int) -> int:
|
||||
margin = min(width - 3, 6)
|
||||
if x + 1 < width:
|
||||
return 0
|
||||
elif width == 1:
|
||||
return x
|
||||
else:
|
||||
margin = min(width - 3, 6)
|
||||
return (
|
||||
width - margin - 2 +
|
||||
(x + 1 - width) //
|
||||
@@ -13,15 +20,48 @@ def line_x(x: int, width: int) -> int:
|
||||
)
|
||||
|
||||
|
||||
def scrolled_line(s: str, x: int, width: int) -> str:
|
||||
l_x = line_x(x, width)
|
||||
def scrolled_line(
|
||||
s: str,
|
||||
positions: Tuple[int, ...],
|
||||
cursor_x: int,
|
||||
width: int,
|
||||
) -> str:
|
||||
l_x = line_x(cursor_x, width)
|
||||
if l_x:
|
||||
s = f'«{s[l_x + 1:]}'
|
||||
if l_x and len(s) > width:
|
||||
return f'{s[:width - 1]}»'
|
||||
l_x_min = l_x + 1
|
||||
start = bisect.bisect_left(positions, l_x_min)
|
||||
pad_left = '«' * (positions[start] - l_x)
|
||||
|
||||
l_x_max = l_x + width
|
||||
if positions[-1] > l_x_max:
|
||||
end_max = l_x_max - 1
|
||||
end = bisect.bisect_left(positions, end_max)
|
||||
if positions[end] > end_max:
|
||||
end -= 1
|
||||
pad_right = '»' * (l_x_max - positions[end])
|
||||
return f'{pad_left}{s[start:end].expandtabs(4)}{pad_right}'
|
||||
else:
|
||||
return s.ljust(width)
|
||||
elif len(s) > width:
|
||||
return f'{s[:width - 1]}»'
|
||||
return f'{pad_left}{s[start:]}'.ljust(width)
|
||||
elif positions[-1] > width:
|
||||
end_max = width - 1
|
||||
end = bisect.bisect_left(positions, end_max)
|
||||
if positions[end] > end_max:
|
||||
end -= 1
|
||||
pad_right = '»' * (width - positions[end])
|
||||
return f'{s[:end].expandtabs(4)}{pad_right}'
|
||||
else:
|
||||
return s.ljust(width)
|
||||
return s.expandtabs(4).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
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import functools
|
||||
import sys
|
||||
from typing import Callable
|
||||
from typing import Iterator
|
||||
from typing import List
|
||||
|
||||
from babi._types import Protocol
|
||||
|
||||
|
||||
class SequenceNoSlice(Protocol):
|
||||
def __len__(self) -> int: ...
|
||||
def __getitem__(self, idx: int) -> str: ...
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
for i in range(len(self)):
|
||||
yield self[i]
|
||||
|
||||
|
||||
class MutableSequenceNoSlice(SequenceNoSlice, Protocol):
|
||||
def __setitem__(self, idx: int, val: str) -> None: ...
|
||||
def __delitem__(self, idx: int) -> None: ...
|
||||
def insert(self, idx: int, val: str) -> None: ...
|
||||
|
||||
def append(self, val: str) -> None:
|
||||
self.insert(len(self), val)
|
||||
|
||||
def pop(self, idx: int = -1) -> str:
|
||||
victim = self[idx]
|
||||
del self[idx]
|
||||
return victim
|
||||
|
||||
|
||||
def _del(lst: MutableSequenceNoSlice, *, idx: int) -> None:
|
||||
del lst[idx]
|
||||
|
||||
|
||||
def _set(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None:
|
||||
lst[idx] = val
|
||||
|
||||
|
||||
def _ins(lst: MutableSequenceNoSlice, *, idx: int, val: str) -> None:
|
||||
lst.insert(idx, val)
|
||||
|
||||
|
||||
class ListSpy(MutableSequenceNoSlice):
|
||||
def __init__(self, lst: MutableSequenceNoSlice) -> None:
|
||||
self._lst = lst
|
||||
self._undo: List[Callable[[MutableSequenceNoSlice], None]] = []
|
||||
self.min_line_touched = sys.maxsize
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'{type(self).__name__}({self._lst})'
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._lst)
|
||||
|
||||
def __getitem__(self, idx: int) -> str:
|
||||
return self._lst[idx]
|
||||
|
||||
def __setitem__(self, idx: int, val: str) -> None:
|
||||
self._undo.append(functools.partial(_set, idx=idx, val=self._lst[idx]))
|
||||
self.min_line_touched = min(idx, self.min_line_touched)
|
||||
self._lst[idx] = val
|
||||
|
||||
def __delitem__(self, idx: int) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
self._undo.append(functools.partial(_ins, idx=idx, val=self._lst[idx]))
|
||||
self.min_line_touched = min(idx, self.min_line_touched)
|
||||
del self._lst[idx]
|
||||
|
||||
def insert(self, idx: int, val: str) -> None:
|
||||
if idx < 0:
|
||||
idx %= len(self)
|
||||
self._undo.append(functools.partial(_del, idx=idx))
|
||||
self.min_line_touched = min(idx, self.min_line_touched)
|
||||
self._lst.insert(idx, val)
|
||||
|
||||
def undo(self, lst: MutableSequenceNoSlice) -> None:
|
||||
for fn in reversed(self._undo):
|
||||
fn(lst)
|
||||
|
||||
@property
|
||||
def has_modifications(self) -> bool:
|
||||
return bool(self._undo)
|
||||
58
babi/main.py
58
babi/main.py
@@ -1,17 +1,23 @@
|
||||
import argparse
|
||||
import curses
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.file import File
|
||||
from babi.perf import Perf
|
||||
from babi.perf import perf_log
|
||||
from babi.screen import EditResult
|
||||
from babi.screen import make_stdscr
|
||||
from babi.screen import Screen
|
||||
|
||||
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
|
||||
|
||||
def _edit(screen: Screen) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status)
|
||||
|
||||
def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status, stdin)
|
||||
|
||||
while True:
|
||||
screen.status.tick(screen.margin)
|
||||
@@ -27,20 +33,26 @@ def _edit(screen: Screen) -> EditResult:
|
||||
return ret
|
||||
elif key.keyname == b'STRING':
|
||||
assert isinstance(key.wch, str), key.wch
|
||||
screen.file.c(key.wch, screen.margin)
|
||||
screen.file.c(key.wch)
|
||||
else:
|
||||
screen.status.update(f'unknown key: {key}')
|
||||
|
||||
|
||||
def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> int:
|
||||
def c_main(
|
||||
stdscr: 'curses._CursesWindow',
|
||||
args: argparse.Namespace,
|
||||
stdin: str,
|
||||
) -> int:
|
||||
with perf_log(args.perf_log) as perf:
|
||||
screen = Screen(stdscr, args.filenames or [None], perf)
|
||||
with screen.history.save():
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
res = _edit(screen)
|
||||
res = _edit(screen, stdin)
|
||||
if res == EditResult.EXIT:
|
||||
del screen.files[screen.i]
|
||||
# always go to the next file except at the end
|
||||
screen.i = min(screen.i, len(screen.files) - 1)
|
||||
screen.status.clear()
|
||||
elif res == EditResult.NEXT:
|
||||
screen.i += 1
|
||||
@@ -48,19 +60,53 @@ def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> int:
|
||||
elif res == EditResult.PREV:
|
||||
screen.i -= 1
|
||||
screen.status.clear()
|
||||
elif res == EditResult.OPEN:
|
||||
screen.i = len(screen.files) - 1
|
||||
else:
|
||||
raise AssertionError(f'unreachable {res}')
|
||||
return 0
|
||||
|
||||
|
||||
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], Perf())
|
||||
screen.file.buf = Buf([''])
|
||||
|
||||
while True:
|
||||
screen.status.update('press q to quit')
|
||||
screen.draw()
|
||||
screen.file.move_cursor(screen.stdscr, screen.margin)
|
||||
|
||||
key = screen.get_char()
|
||||
screen.file.buf.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
||||
screen.file.down(screen.margin)
|
||||
if key.wch == curses.KEY_RESIZE:
|
||||
screen.resize()
|
||||
if key.wch == 'q':
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||
parser.add_argument('--perf-log')
|
||||
parser.add_argument(
|
||||
'--key-debug', action='store_true', help=argparse.SUPPRESS,
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if '-' in args.filenames:
|
||||
print('reading stdin...', file=sys.stderr)
|
||||
stdin = sys.stdin.read()
|
||||
tty = os.open(CONSOLE, os.O_RDONLY)
|
||||
os.dup2(tty, sys.stdin.fileno())
|
||||
else:
|
||||
stdin = ''
|
||||
|
||||
with make_stdscr() as stdscr:
|
||||
return c_main(stdscr, args)
|
||||
if args.key_debug:
|
||||
return _key_debug(stdscr)
|
||||
else:
|
||||
return c_main(stdscr, args, stdin)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -3,12 +3,20 @@ from typing import NamedTuple
|
||||
|
||||
|
||||
class Margin(NamedTuple):
|
||||
header: bool
|
||||
footer: bool
|
||||
lines: int
|
||||
cols: int
|
||||
|
||||
@property
|
||||
def header(self) -> bool:
|
||||
return self.lines > 2
|
||||
|
||||
@property
|
||||
def footer(self) -> bool:
|
||||
return self.lines > 1
|
||||
|
||||
@property
|
||||
def body_lines(self) -> int:
|
||||
return curses.LINES - self.header - self.footer
|
||||
return self.lines - self.header - self.footer
|
||||
|
||||
@property
|
||||
def page_size(self) -> int:
|
||||
@@ -17,11 +25,11 @@ class Margin(NamedTuple):
|
||||
else:
|
||||
return self.body_lines - 2
|
||||
|
||||
@property
|
||||
def scroll_amount(self) -> int:
|
||||
# integer round up without banker's rounding (so 1/2 => 1 instead of 0)
|
||||
return int(self.lines / 2 + .5)
|
||||
|
||||
@classmethod
|
||||
def from_current_screen(cls) -> 'Margin':
|
||||
if curses.LINES == 1:
|
||||
return cls(header=False, footer=False)
|
||||
elif curses.LINES == 2:
|
||||
return cls(header=False, footer=True)
|
||||
else:
|
||||
return cls(header=True, footer=True)
|
||||
return cls(curses.LINES, curses.COLS)
|
||||
|
||||
@@ -6,8 +6,7 @@ from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.buf import Buf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babi.main import Screen # XXX: circular
|
||||
@@ -19,43 +18,54 @@ class Prompt:
|
||||
def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None:
|
||||
self._screen = screen
|
||||
self._prompt = prompt
|
||||
self._lst = lst
|
||||
self._y = len(lst) - 1
|
||||
self._buf = Buf(lst)
|
||||
self._buf.y = self._buf.file_y = len(lst) - 1
|
||||
self._x = len(self._s)
|
||||
|
||||
@property
|
||||
def _x(self) -> int:
|
||||
return self._buf.x
|
||||
|
||||
@_x.setter
|
||||
def _x(self, x: int) -> None:
|
||||
self._buf.x = x
|
||||
|
||||
@property
|
||||
def _s(self) -> str:
|
||||
return self._lst[self._y]
|
||||
return self._buf[self._buf.y]
|
||||
|
||||
@_s.setter
|
||||
def _s(self, s: str) -> None:
|
||||
self._lst[self._y] = s
|
||||
self._buf[self._buf.y] = s
|
||||
|
||||
def _render_prompt(self, *, base: Optional[str] = None) -> None:
|
||||
base = base or self._prompt
|
||||
if not base or curses.COLS < 7:
|
||||
if not base or self._screen.margin.cols < 7:
|
||||
prompt_s = ''
|
||||
elif len(base) > curses.COLS - 6:
|
||||
prompt_s = f'{base[:curses.COLS - 7]}…: '
|
||||
elif len(base) > self._screen.margin.cols - 6:
|
||||
prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
|
||||
else:
|
||||
prompt_s = f'{base}: '
|
||||
width = curses.COLS - len(prompt_s)
|
||||
line = scrolled_line(self._s, self._x, width)
|
||||
cmd = f'{prompt_s}{line}'
|
||||
self._screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE)
|
||||
x = len(prompt_s) + self._x - line_x(self._x, width)
|
||||
self._screen.stdscr.move(curses.LINES - 1, x)
|
||||
|
||||
width = self._screen.margin.cols - len(prompt_s)
|
||||
margin = self._screen.margin._replace(cols=width)
|
||||
cmd = f'{prompt_s}{self._buf.rendered_line(self._buf.y, margin)}'
|
||||
prompt_line = self._screen.margin.lines - 1
|
||||
self._screen.stdscr.insstr(prompt_line, 0, cmd, curses.A_REVERSE)
|
||||
|
||||
_, x_off = self._buf.cursor_position(margin)
|
||||
self._screen.stdscr.move(prompt_line, len(prompt_s) + x_off)
|
||||
|
||||
def _up(self) -> None:
|
||||
self._y = max(0, self._y - 1)
|
||||
self._x = len(self._s)
|
||||
self._buf.up(self._screen.margin)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _down(self) -> None:
|
||||
self._y = min(len(self._lst) - 1, self._y + 1)
|
||||
self._x = len(self._s)
|
||||
self._buf.down(self._screen.margin)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _right(self) -> None:
|
||||
self._x = min(len(self._s), self._x + 1)
|
||||
self._x = min(len(self._buf[self._buf.y]), self._x + 1)
|
||||
|
||||
def _left(self) -> None:
|
||||
self._x = max(0, self._x - 1)
|
||||
@@ -64,11 +74,11 @@ class Prompt:
|
||||
self._x = 0
|
||||
|
||||
def _end(self) -> None:
|
||||
self._x = len(self._s)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _ctrl_left(self) -> None:
|
||||
if self._x <= 1:
|
||||
self._x = 0
|
||||
self._buf.home()
|
||||
else:
|
||||
self._x -= 1
|
||||
tp = self._s[self._x - 1].isalnum()
|
||||
@@ -77,7 +87,7 @@ class Prompt:
|
||||
|
||||
def _ctrl_right(self) -> None:
|
||||
if self._x >= len(self._s) - 1:
|
||||
self._x = len(self._s)
|
||||
self._buf.end()
|
||||
else:
|
||||
self._x += 1
|
||||
tp = self._s[self._x].isalnum()
|
||||
@@ -102,9 +112,9 @@ class Prompt:
|
||||
def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]:
|
||||
failed = False
|
||||
for search_idx in range(idx, -1, -1):
|
||||
if s in self._lst[search_idx]:
|
||||
idx = self._y = search_idx
|
||||
self._x = self._lst[search_idx].index(s)
|
||||
if s in self._buf[search_idx]:
|
||||
idx = self._buf.y = search_idx
|
||||
self._x = self._buf[search_idx].index(s)
|
||||
break
|
||||
else:
|
||||
failed = True
|
||||
@@ -112,7 +122,7 @@ class Prompt:
|
||||
|
||||
def _reverse_search(self) -> Union[None, str, PromptResult]:
|
||||
reverse_s = ''
|
||||
idx = self._y
|
||||
idx = self._buf.y
|
||||
while True:
|
||||
fail, idx = self._check_failed(idx, reverse_s)
|
||||
|
||||
@@ -126,7 +136,7 @@ class Prompt:
|
||||
key = self._screen.get_char()
|
||||
if key.keyname == b'KEY_RESIZE':
|
||||
self._screen.resize()
|
||||
elif key.keyname == b'KEY_BACKSPACE' or key.keyname == b'^H':
|
||||
elif key.keyname == b'KEY_BACKSPACE':
|
||||
reverse_s = reverse_s[:-1]
|
||||
elif key.keyname == b'^R':
|
||||
idx = max(0, idx - 1)
|
||||
@@ -163,7 +173,6 @@ class Prompt:
|
||||
b'kLFT5': _ctrl_left,
|
||||
# editing
|
||||
b'KEY_BACKSPACE': _backspace,
|
||||
b'^H': _backspace, # ^Backspace
|
||||
b'KEY_DC': _delete,
|
||||
b'^K': _cut_to_end,
|
||||
# misc
|
||||
@@ -174,8 +183,7 @@ class Prompt:
|
||||
}
|
||||
|
||||
def _c(self, c: str) -> None:
|
||||
self._s = self._s[:self._x] + c + self._s[self._x:]
|
||||
self._x += len(c)
|
||||
self._buf.c(c)
|
||||
|
||||
def run(self) -> Union[PromptResult, str]:
|
||||
while True:
|
||||
|
||||
15
babi/reg.py
15
babi/reg.py
@@ -1,4 +1,5 @@
|
||||
import functools
|
||||
import re
|
||||
from typing import Match
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
@@ -7,6 +8,8 @@ import onigurumacffi
|
||||
|
||||
from babi.cached_property import cached_property
|
||||
|
||||
_BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)')
|
||||
|
||||
|
||||
def _replace_esc(s: str, chars: str) -> str:
|
||||
"""replace the given escape sequences of `chars` with \\uffff"""
|
||||
@@ -47,19 +50,19 @@ class _Reg:
|
||||
|
||||
@cached_property
|
||||
def _reg(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(self._pattern)
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'z'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'A'))
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Az'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'G'))
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Gz'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'AG'))
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'AGz'))
|
||||
|
||||
def _get_reg(
|
||||
self,
|
||||
@@ -142,6 +145,10 @@ class _RegSet:
|
||||
return self._set_no_A_no_G.search(line, pos)
|
||||
|
||||
|
||||
def expand_escaped(match: Match[str], s: str) -> str:
|
||||
return _BACKREF_RE.sub(lambda m: f'{m[1]}{re.escape(match[int(m[2])])}', s)
|
||||
|
||||
|
||||
make_reg = functools.lru_cache(maxsize=None)(_Reg)
|
||||
make_regset = functools.lru_cache(maxsize=None)(_RegSet)
|
||||
ERR_REG = make_reg(')this pattern always triggers an error when used(')
|
||||
|
||||
150
babi/screen.py
150
babi/screen.py
@@ -6,7 +6,6 @@ import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from typing import Callable
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
@@ -21,15 +20,19 @@ from babi.file import File
|
||||
from babi.file import get_lines
|
||||
from babi.history import History
|
||||
from babi.hl.syntax import Syntax
|
||||
from babi.hl.trailing_whitespace import TrailingWhitespace
|
||||
from babi.margin import Margin
|
||||
from babi.perf import Perf
|
||||
from babi.prompt import Prompt
|
||||
from babi.prompt import PromptResult
|
||||
from babi.status import Status
|
||||
|
||||
VERSION_STR = 'babi v0'
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV')
|
||||
if sys.version_info >= (3, 8): # pragma: no cover (py38+)
|
||||
import importlib.metadata as importlib_metadata
|
||||
else: # pragma: no cover (<py38)
|
||||
import importlib_metadata
|
||||
|
||||
VERSION_STR = f'babi v{importlib_metadata.version("babi")}'
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
|
||||
|
||||
# TODO: find a place to populate these, surely there's a database somewhere
|
||||
SEQUENCE_KEYNAME = {
|
||||
@@ -58,6 +61,36 @@ SEQUENCE_KEYNAME = {
|
||||
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
|
||||
'\x1b[1;6F': b'kEND6', # Shift + ^End
|
||||
}
|
||||
KEYNAME_REWRITE = {
|
||||
# windows-curses: numeric pad arrow keys
|
||||
# - some overlay keyboards pick these as well
|
||||
# - in xterm it seems these are mapped automatically
|
||||
b'KEY_A2': b'KEY_UP',
|
||||
b'KEY_C2': b'KEY_DOWN',
|
||||
b'KEY_B3': b'KEY_RIGHT',
|
||||
b'KEY_B1': b'KEY_LEFT',
|
||||
b'PADSTOP': b'KEY_DC',
|
||||
b'KEY_A3': b'KEY_PPAGE',
|
||||
b'KEY_C3': b'KEY_NPAGE',
|
||||
b'KEY_A1': b'KEY_HOME',
|
||||
b'KEY_C1': b'KEY_END',
|
||||
# windows-curses: map to our M- names
|
||||
b'ALT_U': b'M-u',
|
||||
# windows-curses: arguably these names are better than the xterm names
|
||||
b'CTL_UP': b'kUP5',
|
||||
b'CTL_DOWN': b'kDN5',
|
||||
b'CTL_RIGHT': b'kRIT5',
|
||||
b'CTL_LEFT': b'kLFT5',
|
||||
b'ALT_RIGHT': b'kRIT3',
|
||||
b'ALT_LEFT': b'kLFT3',
|
||||
# windows-curses: idk why these are different
|
||||
b'KEY_SUP': b'KEY_SR',
|
||||
b'KEY_SDOWN': b'KEY_SF',
|
||||
# macos: (sends this for backspace key, others interpret this as well)
|
||||
b'^?': b'KEY_BACKSPACE',
|
||||
# linux, perhaps others
|
||||
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
|
||||
}
|
||||
|
||||
|
||||
class Key(NamedTuple):
|
||||
@@ -73,12 +106,12 @@ class Screen:
|
||||
perf: Perf,
|
||||
) -> None:
|
||||
self.stdscr = stdscr
|
||||
color_manager = ColorManager.make()
|
||||
hl_factories = (
|
||||
Syntax.from_screen(stdscr, color_manager),
|
||||
TrailingWhitespace(color_manager),
|
||||
)
|
||||
self.files = [File(f, hl_factories) for f in filenames]
|
||||
self.color_manager = ColorManager.make()
|
||||
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
|
||||
self.files = [
|
||||
File(filename, self.color_manager, self.hl_factories)
|
||||
for filename in filenames
|
||||
]
|
||||
self.i = 0
|
||||
self.history = History()
|
||||
self.perf = perf
|
||||
@@ -86,7 +119,6 @@ class Screen:
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.cut_buffer: Tuple[str, ...] = ()
|
||||
self.cut_selection = False
|
||||
self._resize_cb: Optional[Callable[[], None]] = None
|
||||
self._buffered_input: Union[int, str, None] = None
|
||||
|
||||
@property
|
||||
@@ -103,7 +135,7 @@ class Screen:
|
||||
else:
|
||||
files = ''
|
||||
version_width = len(VERSION_STR) + 2
|
||||
centered = filename.center(curses.COLS)[version_width:]
|
||||
centered = filename.center(self.margin.cols)[version_width:]
|
||||
s = f' {VERSION_STR} {files}{centered}{files}'
|
||||
self.stdscr.insstr(0, 0, s, curses.A_REVERSE)
|
||||
|
||||
@@ -200,12 +232,10 @@ class Screen:
|
||||
elif isinstance(wch, str) and wch.isprintable():
|
||||
wch = self._get_string(wch)
|
||||
return Key(wch, b'STRING')
|
||||
elif wch == '\x7f': # pragma: no cover (macos)
|
||||
keyname = curses.keyname(curses.KEY_BACKSPACE)
|
||||
return Key(wch, keyname)
|
||||
|
||||
key = wch if isinstance(wch, int) else ord(wch)
|
||||
keyname = curses.keyname(key)
|
||||
keyname = KEYNAME_REWRITE.get(keyname, keyname)
|
||||
return Key(wch, keyname)
|
||||
|
||||
def get_char(self) -> Key:
|
||||
@@ -220,31 +250,48 @@ class Screen:
|
||||
self.file.draw(self.stdscr, self.margin)
|
||||
self.status.draw(self.stdscr, self.margin)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def resize_cb(self, f: Callable[[], None]) -> Generator[None, None, None]:
|
||||
assert self._resize_cb is None, self._resize_cb
|
||||
self._resize_cb = f
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._resize_cb = None
|
||||
|
||||
def resize(self) -> None:
|
||||
curses.update_lines_cols()
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||
self.draw()
|
||||
if self._resize_cb is not None:
|
||||
self._resize_cb()
|
||||
|
||||
def quick_prompt(self, prompt: str, opts: str) -> Union[str, PromptResult]:
|
||||
def quick_prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
opt_strs: Tuple[str, ...],
|
||||
) -> Union[str, PromptResult]:
|
||||
opts = [opt[0] for opt in opt_strs]
|
||||
while True:
|
||||
s = prompt.ljust(curses.COLS)
|
||||
if len(s) > curses.COLS:
|
||||
s = f'{s[:curses.COLS - 1]}…'
|
||||
self.stdscr.insstr(curses.LINES - 1, 0, s, curses.A_REVERSE)
|
||||
x = min(curses.COLS - 1, len(prompt) + 1)
|
||||
self.stdscr.move(curses.LINES - 1, x)
|
||||
x = 0
|
||||
prompt_line = self.margin.lines - 1
|
||||
|
||||
def _write(s: str, *, attr: int = curses.A_REVERSE) -> None:
|
||||
nonlocal x
|
||||
|
||||
if x >= self.margin.cols:
|
||||
return
|
||||
self.stdscr.insstr(prompt_line, x, s, attr)
|
||||
x += len(s)
|
||||
|
||||
_write(prompt)
|
||||
_write(' [')
|
||||
for i, opt_str in enumerate(opt_strs):
|
||||
_write(opt_str[0], attr=curses.A_REVERSE | curses.A_BOLD)
|
||||
_write(opt_str[1:])
|
||||
if i != len(opt_strs) - 1:
|
||||
_write(', ')
|
||||
_write(']?')
|
||||
|
||||
if x < self.margin.cols - 1:
|
||||
s = ' ' * (self.margin.cols - x)
|
||||
self.stdscr.insstr(prompt_line, x, s, curses.A_REVERSE)
|
||||
x += 1
|
||||
else:
|
||||
x = self.margin.cols - 1
|
||||
self.stdscr.insstr(prompt_line, x, '…', curses.A_REVERSE)
|
||||
|
||||
self.stdscr.move(prompt_line, x)
|
||||
|
||||
key = self.get_char()
|
||||
if key.keyname == b'KEY_RESIZE':
|
||||
@@ -299,14 +346,14 @@ class Screen:
|
||||
self.file.go_to_line(lineno, self.margin)
|
||||
|
||||
def current_position(self) -> None:
|
||||
line = f'line {self.file.y + 1}'
|
||||
col = f'col {self.file.x + 1}'
|
||||
line_count = max(len(self.file.lines) - 1, 1)
|
||||
line = f'line {self.file.buf.y + 1}'
|
||||
col = f'col {self.file.buf.x + 1}'
|
||||
line_count = max(len(self.file.buf) - 1, 1)
|
||||
lines_word = 'line' if line_count == 1 else 'lines'
|
||||
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
|
||||
|
||||
def cut(self) -> None:
|
||||
if self.file.select_start:
|
||||
if self.file.selection.start:
|
||||
self.cut_buffer = self.file.cut_selection(self.margin)
|
||||
self.cut_selection = True
|
||||
else:
|
||||
@@ -340,8 +387,9 @@ class Screen:
|
||||
else:
|
||||
action = from_stack.pop()
|
||||
to_stack.append(action.apply(self.file))
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.file.buf.scroll_screen_if_needed(self.margin)
|
||||
self.status.update(f'{op}: {action.name}')
|
||||
self.file.selection.clear()
|
||||
|
||||
def undo(self) -> None:
|
||||
self._undo_redo('undo', self.file.undo_stack, self.file.redo_stack)
|
||||
@@ -366,6 +414,8 @@ class Screen:
|
||||
def command(self) -> Optional[EditResult]:
|
||||
response = self.prompt('', history='command')
|
||||
if response == ':q':
|
||||
return self.quit_save_modified()
|
||||
elif response == ':q!':
|
||||
return EditResult.EXIT
|
||||
elif response == ':w':
|
||||
self.save()
|
||||
@@ -373,7 +423,7 @@ class Screen:
|
||||
self.save()
|
||||
return EditResult.EXIT
|
||||
elif response == ':sort':
|
||||
if self.file.select_start:
|
||||
if self.file.selection.start:
|
||||
self.file.sort_selection(self.margin)
|
||||
else:
|
||||
self.file.sort(self.margin)
|
||||
@@ -397,12 +447,12 @@ class Screen:
|
||||
self.file.filename = filename
|
||||
|
||||
if os.path.isfile(self.file.filename):
|
||||
with open(self.file.filename) as f:
|
||||
with open(self.file.filename, newline='') as f:
|
||||
*_, sha256 = get_lines(f)
|
||||
else:
|
||||
sha256 = hashlib.sha256(b'').hexdigest()
|
||||
|
||||
contents = self.file.nl.join(self.file.lines)
|
||||
contents = self.file.nl.join(self.file.buf)
|
||||
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
|
||||
|
||||
# the file on disk is the same as when we opened it
|
||||
@@ -410,12 +460,12 @@ class Screen:
|
||||
self.status.update('(file changed on disk, not implemented)')
|
||||
return PromptResult.CANCELLED
|
||||
|
||||
with open(self.file.filename, 'w') as f:
|
||||
with open(self.file.filename, 'w', newline='') as f:
|
||||
f.write(contents)
|
||||
|
||||
self.file.modified = False
|
||||
self.file.sha256 = sha256_to_save
|
||||
num_lines = len(self.file.lines) - 1
|
||||
num_lines = len(self.file.buf) - 1
|
||||
lines = 'lines' if num_lines != 1 else 'line'
|
||||
self.status.update(f'saved! ({num_lines} {lines} written)')
|
||||
|
||||
@@ -436,10 +486,19 @@ class Screen:
|
||||
self.file.filename = response
|
||||
return self.save()
|
||||
|
||||
def open_file(self) -> Optional[EditResult]:
|
||||
response = self.prompt('enter filename', history='open')
|
||||
if response is not PromptResult.CANCELLED:
|
||||
opened = File(response, self.color_manager, self.hl_factories)
|
||||
self.files.append(opened)
|
||||
return EditResult.OPEN
|
||||
else:
|
||||
return None
|
||||
|
||||
def quit_save_modified(self) -> Optional[EditResult]:
|
||||
if self.file.modified:
|
||||
response = self.quick_prompt(
|
||||
'file is modified - save [y(es), n(o)]?', 'yn',
|
||||
'file is modified - save', ('yes', 'no'),
|
||||
)
|
||||
if response == 'y':
|
||||
if self.save_filename() is not PromptResult.CANCELLED:
|
||||
@@ -473,6 +532,7 @@ class Screen:
|
||||
b'^S': save,
|
||||
b'^O': save_filename,
|
||||
b'^X': quit_save_modified,
|
||||
b'^P': open_file,
|
||||
b'kLFT3': lambda screen: EditResult.PREV,
|
||||
b'kRIT3': lambda screen: EditResult.NEXT,
|
||||
b'^Z': background,
|
||||
|
||||
@@ -18,14 +18,14 @@ class Status:
|
||||
|
||||
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
|
||||
if margin.footer or self._status:
|
||||
stdscr.insstr(curses.LINES - 1, 0, ' ' * curses.COLS)
|
||||
stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols)
|
||||
if self._status:
|
||||
status = f' {self._status} '
|
||||
x = (curses.COLS - len(status)) // 2
|
||||
x = (margin.cols - len(status)) // 2
|
||||
if x < 0:
|
||||
x = 0
|
||||
status = status.strip()
|
||||
stdscr.insstr(curses.LINES - 1, x, status, curses.A_REVERSE)
|
||||
stdscr.insstr(margin.lines - 1, x, status, curses.A_REVERSE)
|
||||
|
||||
def tick(self, margin: Margin) -> None:
|
||||
# when the window is only 1-tall, hide the status quicker
|
||||
|
||||
69
babi/textmate_demo.py
Normal file
69
babi/textmate_demo.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import argparse
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.highlight import Compiler
|
||||
from babi.highlight import Grammars
|
||||
from babi.highlight import highlight_line
|
||||
from babi.theme import Style
|
||||
from babi.theme import Theme
|
||||
from babi.user_data import prefix_data
|
||||
from babi.user_data import xdg_config
|
||||
|
||||
|
||||
def print_styled(s: str, style: Style) -> None:
|
||||
color_s = ''
|
||||
undo_s = ''
|
||||
if style.fg is not None:
|
||||
color_s += '\x1b[38;2;{r};{g};{b}m'.format(**style.fg._asdict())
|
||||
undo_s += '\x1b[39m'
|
||||
if style.bg is not None:
|
||||
color_s += '\x1b[48;2;{r};{g};{b}m'.format(**style.bg._asdict())
|
||||
undo_s += '\x1b[49m'
|
||||
if style.b:
|
||||
color_s += '\x1b[1m'
|
||||
undo_s += '\x1b[22m'
|
||||
if style.i:
|
||||
color_s += '\x1b[3m'
|
||||
undo_s += '\x1b[23m'
|
||||
if style.u:
|
||||
color_s += '\x1b[4m'
|
||||
undo_s += '\x1b[24m'
|
||||
print(f'{color_s}{s}{undo_s}', end='', flush=True)
|
||||
|
||||
|
||||
def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
|
||||
state = compiler.root_state
|
||||
|
||||
if theme.default.bg is not None:
|
||||
print('\x1b[48;2;{r};{g};{b}m'.format(**theme.default.bg._asdict()))
|
||||
with open(filename) as f:
|
||||
for line_idx, line in enumerate(f):
|
||||
first_line = line_idx == 0
|
||||
state, regions = highlight_line(compiler, state, line, first_line)
|
||||
for start, end, scope in regions:
|
||||
print_styled(line[start:end], theme.select(scope))
|
||||
print('\x1b[m', end='')
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--theme', default=xdg_config('theme.json'))
|
||||
parser.add_argument('--grammar-dir', default=prefix_data('grammar_v1'))
|
||||
parser.add_argument('filename')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.filename) as f:
|
||||
first_line = next(f, '')
|
||||
|
||||
theme = Theme.from_filename(args.theme)
|
||||
|
||||
grammars = Grammars(args.grammar_dir)
|
||||
compiler = grammars.compiler_for_file(args.filename, first_line)
|
||||
|
||||
return _highlight_output(theme, compiler, args.filename)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -1,7 +1,6 @@
|
||||
import functools
|
||||
import json
|
||||
import os.path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
@@ -12,9 +11,6 @@ from babi._types import Protocol
|
||||
from babi.color import Color
|
||||
from babi.fdict import FDict
|
||||
|
||||
# yes I know this is wrong, but it's good enough for now
|
||||
UN_COMMENT = re.compile(r'^\s*//.*$', re.MULTILINE)
|
||||
|
||||
|
||||
class Style(NamedTuple):
|
||||
fg: Optional[Color]
|
||||
@@ -114,10 +110,14 @@ class Theme(NamedTuple):
|
||||
for rule in rules:
|
||||
if 'scope' not in rule:
|
||||
scopes = ['']
|
||||
elif rule['scope'] == '':
|
||||
scopes = ['']
|
||||
elif isinstance(rule['scope'], str):
|
||||
scopes = [
|
||||
# some themes have a buggy trailing comma
|
||||
s.strip() for s in rule['scope'].strip(',').split(',')
|
||||
s.strip()
|
||||
# some themes have a buggy trailing/leading comma
|
||||
for s in rule['scope'].strip().strip(',').split(',')
|
||||
if s.strip()
|
||||
]
|
||||
else:
|
||||
scopes = rule['scope']
|
||||
@@ -148,5 +148,4 @@ class Theme(NamedTuple):
|
||||
return cls.blank()
|
||||
else:
|
||||
with open(filename) as f:
|
||||
contents = UN_COMMENT.sub('', f.read())
|
||||
return cls.from_dct(json.loads(contents))
|
||||
return cls.from_dct(json.load(f))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
|
||||
def _xdg(*path: str, env: str, default: str) -> str:
|
||||
@@ -14,3 +15,7 @@ def xdg_data(*path: str) -> str:
|
||||
|
||||
def xdg_config(*path: str) -> str:
|
||||
return _xdg(*path, env='XDG_CONFIG_HOME', default='~/.config')
|
||||
|
||||
|
||||
def prefix_data(*path: str) -> str:
|
||||
return os.path.join(sys.prefix, 'share/babi', *path)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import enum
|
||||
import json
|
||||
import os.path
|
||||
import plistlib
|
||||
import urllib.request
|
||||
from typing import NamedTuple
|
||||
|
||||
import cson # pip install cson
|
||||
|
||||
|
||||
DEFAULT_DIR = os.path.join(
|
||||
os.environ.get('XDG_DATA_HOME') or
|
||||
os.path.expanduser('~/.local/share'),
|
||||
'babi/textmate_syntax',
|
||||
)
|
||||
Ext = enum.Enum('Ext', 'CSON PLIST JSON')
|
||||
|
||||
|
||||
def _convert_cson(src: bytes) -> str:
|
||||
return json.dumps(cson.loads(src))
|
||||
|
||||
|
||||
def _convert_json(src: bytes) -> str:
|
||||
return json.dumps(json.loads(src))
|
||||
|
||||
|
||||
def _convert_plist(src: bytes) -> str:
|
||||
return json.dumps(plistlib.loads(src))
|
||||
|
||||
|
||||
EXT_CONVERT = {
|
||||
Ext.CSON: _convert_cson,
|
||||
Ext.JSON: _convert_json,
|
||||
Ext.PLIST: _convert_plist,
|
||||
}
|
||||
|
||||
|
||||
class Syntax(NamedTuple):
|
||||
name: str
|
||||
ext: Ext
|
||||
url: str
|
||||
|
||||
|
||||
SYNTAXES = (
|
||||
Syntax('c', Ext.JSON, 'https://raw.githubusercontent.com/jeff-hykin/cpp-textmate-grammar/53e39b1c/syntaxes/c.tmLanguage.json'), # noqa: E501
|
||||
Syntax('css', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-css/9feb69c081308b63f78bb0d6a2af2ff5eb7d869b/grammars/css.cson'), # noqa: E501
|
||||
Syntax('diff', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/diff.tmbundle/0593bb77/Syntaxes/Diff.plist'), # noqa: E501
|
||||
Syntax('html', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee5/Syntaxes/HTML.plist'), # noqa: E501
|
||||
Syntax('html-derivative', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee54de3a993f747f54186b73a4d2d3c44a2/Syntaxes/HTML%20(Derivative).tmLanguage'), # noqa: E501
|
||||
Syntax('ini', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/ini.tmbundle/7d8c7b55/Syntaxes/Ini.plist'), # noqa: E501
|
||||
Syntax('json', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-JSON.tmLanguage/d113e90937ed3ecc31ac54750aac2e8efa08d784/JSON.tmLanguage'), # noqa: E501
|
||||
Syntax('markdown', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-markdown-tm-grammar/59a5962/syntaxes/markdown.tmLanguage'), # noqa: E501
|
||||
Syntax('powershell', Ext.PLIST, 'https://raw.githubusercontent.com/PowerShell/EditorSyntax/4a0a0766/PowerShellSyntax.tmLanguage'), # noqa: E501
|
||||
Syntax('python', Ext.PLIST, 'https://raw.githubusercontent.com/MagicStack/MagicPython/c9b3409d/grammars/MagicPython.tmLanguage'), # noqa: E501
|
||||
# TODO: https://github.com/zargony/atom-language-rust/pull/149
|
||||
Syntax('rust', Ext.CSON, 'https://raw.githubusercontent.com/asottile/atom-language-rust/e113ca67/grammars/rust.cson'), # noqa: E501
|
||||
Syntax('shell', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-shellscript/7008ea926867d8a231003e78094091471c4fccf8/grammars/shell-unix-bash.cson'), # noqa: E501
|
||||
Syntax('yaml', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/yaml.tmbundle/e54ceae3/Syntaxes/YAML.tmLanguage'), # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--dest', default=DEFAULT_DIR)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.dest, exist_ok=True)
|
||||
for syntax in SYNTAXES:
|
||||
print(f'downloading {syntax.name}...')
|
||||
resp = urllib.request.urlopen(syntax.url).read()
|
||||
converted = EXT_CONVERT[syntax.ext](resp)
|
||||
with open(os.path.join(args.dest, f'{syntax.name}.json'), 'w') as f:
|
||||
f.write(converted)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
88
bin/download-theme
Executable file
88
bin/download-theme
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os.path
|
||||
import plistlib
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import cson # pip install cson
|
||||
|
||||
TOKEN = re.compile(br'(\\\\|\\"|"|//|\n)')
|
||||
|
||||
|
||||
def json_with_comments(s: bytes) -> Any:
|
||||
bio = io.BytesIO()
|
||||
|
||||
idx = 0
|
||||
in_string = False
|
||||
in_comment = False
|
||||
|
||||
match = TOKEN.search(s, idx)
|
||||
while match:
|
||||
if not in_comment:
|
||||
bio.write(s[idx:match.start()])
|
||||
|
||||
tok = match[0]
|
||||
if not in_comment and tok == b'"':
|
||||
in_string = not in_string
|
||||
elif in_comment and tok == b'\n':
|
||||
in_comment = False
|
||||
elif not in_string and tok == b'//':
|
||||
in_comment = True
|
||||
|
||||
if not in_comment:
|
||||
bio.write(tok)
|
||||
|
||||
idx = match.end()
|
||||
match = TOKEN.search(s, idx)
|
||||
|
||||
bio.seek(0)
|
||||
return json.load(bio)
|
||||
|
||||
|
||||
STRATEGIES = (json.loads, plistlib.loads, cson.loads, json_with_comments)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('name')
|
||||
parser.add_argument('url')
|
||||
args = parser.parse_args()
|
||||
|
||||
if '/blob/' in args.url:
|
||||
url = args.url.replace('/blob/', '/raw/')
|
||||
else:
|
||||
url = args.url
|
||||
|
||||
contents = urllib.request.urlopen(url).read()
|
||||
|
||||
errors = []
|
||||
for strategy in STRATEGIES:
|
||||
try:
|
||||
loaded = strategy(contents)
|
||||
except Exception as e:
|
||||
errors.append((f'{strategy.__module__}.{strategy.__name__}', e))
|
||||
else:
|
||||
break
|
||||
else:
|
||||
errors_s = '\n'.join(f'\t{name}: {error}' for name, error in errors)
|
||||
raise AssertionError(f'could not load as json/plist/cson:\n{errors_s}')
|
||||
|
||||
config_dir = os.path.expanduser('~/.config/babi')
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
dest = os.path.join(config_dir, f'{args.name}.json')
|
||||
with open(dest, 'w') as f:
|
||||
json.dump(loaded, f)
|
||||
|
||||
theme_json = os.path.join(config_dir, 'theme.json')
|
||||
if os.path.lexists(theme_json):
|
||||
os.remove(theme_json)
|
||||
os.symlink(dest, theme_json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -1,5 +1,6 @@
|
||||
covdefaults
|
||||
coverage
|
||||
git+https://github.com/asottile/hecate@ebe6dfb
|
||||
git+https://github.com/asottile/hecate@875567f
|
||||
pytest
|
||||
remote-pdb
|
||||
wcwidth
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = babi
|
||||
version = 0.0.1
|
||||
version = 0.0.7
|
||||
description = a text editor
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
@@ -22,12 +22,17 @@ classifiers =
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
babi-grammars
|
||||
identify
|
||||
onigurumacffi>=0.0.10
|
||||
importlib_metadata>=1;python_version<"3.8"
|
||||
windows-curses;sys_platform=="win32"
|
||||
python_requires = >=3.6.1
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
babi = babi.main:main
|
||||
babi-textmate-demo = babi.textmate_demo:main
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
|
||||
2
testing/vsc_test/.gitignore
vendored
Normal file
2
testing/vsc_test/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/package-lock.json
|
||||
5
testing/vsc_test/package.json
Normal file
5
testing/vsc_test/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"vscode-textmate"
|
||||
]
|
||||
}
|
||||
51
testing/vsc_test/vsc.js
Normal file
51
testing/vsc_test/vsc.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const fs = require('fs');
|
||||
const vsctm = require('vscode-textmate');
|
||||
|
||||
if (process.argv.length < 4) {
|
||||
console.log('usage: t.js GRAMMAR FILE');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const grammar = process.argv[2];
|
||||
const file = process.argv[3];
|
||||
|
||||
const scope = JSON.parse(fs.readFileSync(grammar, {encoding: 'UTF-8'})).scopeName;
|
||||
|
||||
/**
|
||||
* Utility to read a file as a promise
|
||||
*/
|
||||
function readFile(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(path, (error, data) => error ? reject(error) : resolve(data));
|
||||
})
|
||||
}
|
||||
|
||||
// Create a registry that can create a grammar from a scope name.
|
||||
const registry = new vsctm.Registry({
|
||||
loadGrammar: (scopeName) => {
|
||||
if (scopeName === scope) {
|
||||
return readFile(grammar).then(data => vsctm.parseRawGrammar(data.toString(), grammar))
|
||||
}
|
||||
console.log(`Unknown scope name: ${scopeName}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Load the JavaScript grammar and any other grammars included by it async.
|
||||
registry.loadGrammar(scope).then(grammar => {
|
||||
const text = fs.readFileSync(file, {encoding: 'UTF-8'}).trimEnd('\n').split(/\n/);
|
||||
let ruleStack = vsctm.INITIAL;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const line = text[i];
|
||||
const lineTokens = grammar.tokenizeLine(line, ruleStack);
|
||||
console.log(`\nTokenizing line: ${line}`);
|
||||
for (let j = 0; j < lineTokens.tokens.length; j++) {
|
||||
const token = lineTokens.tokens[j];
|
||||
console.log(` - token from ${token.startIndex} to ${token.endIndex} ` +
|
||||
`(${line.substring(token.startIndex, token.endIndex)}) ` +
|
||||
`with scopes ${token.scopes.join(', ')}`
|
||||
);
|
||||
}
|
||||
ruleStack = lineTokens.ruleStack;
|
||||
}
|
||||
});
|
||||
178
tests/buf_test.py
Normal file
178
tests/buf_test.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import pytest
|
||||
|
||||
from babi.buf import Buf
|
||||
|
||||
|
||||
def test_buf_repr():
|
||||
ret = repr(Buf(['a', 'b', 'c']))
|
||||
assert ret == "Buf(['a', 'b', 'c'], x=0, y=0, file_y=0)"
|
||||
|
||||
|
||||
def test_buf_item_retrieval():
|
||||
buf = Buf(['a', 'b', 'c'])
|
||||
assert buf[1] == 'b'
|
||||
assert buf[-1] == 'c'
|
||||
with pytest.raises(IndexError):
|
||||
buf[3]
|
||||
|
||||
|
||||
def test_buf_del():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
del buf[1]
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_del_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
del buf[-1]
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_insert():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.insert(1, 'q')
|
||||
|
||||
assert lst == ['a', 'q', 'b', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_insert_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.insert(-1, 'q')
|
||||
|
||||
assert lst == ['a', 'b', 'q', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_set_value():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[1] = 'hello'
|
||||
|
||||
assert lst == ['a', 'hello', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_set_value_idx_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[-1] = 'hello'
|
||||
|
||||
assert lst == ['a', 'b', 'hello']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_multiple_modifications():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf[1] = 'hello'
|
||||
buf.insert(1, 'ohai')
|
||||
del buf[0]
|
||||
|
||||
assert lst == ['ohai', 'hello', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_iter():
|
||||
buf = Buf(['a', 'b', 'c'])
|
||||
buf_iter = iter(buf)
|
||||
assert next(buf_iter) == 'a'
|
||||
assert next(buf_iter) == 'b'
|
||||
assert next(buf_iter) == 'c'
|
||||
with pytest.raises(StopIteration):
|
||||
next(buf_iter)
|
||||
|
||||
|
||||
def test_buf_append():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.append('q')
|
||||
|
||||
assert lst == ['a', 'b', 'c', 'q']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_pop_default():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.pop()
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_buf_pop_idx():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
buf = Buf(lst)
|
||||
|
||||
with buf.record() as modifications:
|
||||
buf.pop(1)
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
buf.apply(modifications)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
16
tests/color_test.py
Normal file
16
tests/color_test.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
from babi.color import Color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('s', 'expected'),
|
||||
(
|
||||
('#1e77d3', Color(0x1e, 0x77, 0xd3)),
|
||||
('white', Color(0xff, 0xff, 0xff)),
|
||||
('black', Color(0x00, 0x00, 0x00)),
|
||||
('#ccc', Color(0xcc, 0xcc, 0xcc)),
|
||||
),
|
||||
)
|
||||
def test_color_parse(s, expected):
|
||||
assert Color.parse(s) == expected
|
||||
17
tests/conftest.py
Normal file
17
tests/conftest.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.highlight import Grammars
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_grammars(tmpdir):
|
||||
grammar_dir = tmpdir.join('grammars').ensure_dir()
|
||||
|
||||
def make_grammars(*grammar_dcts):
|
||||
for grammar in grammar_dcts:
|
||||
filename = f'{grammar["scopeName"]}.json'
|
||||
grammar_dir.join(filename).write(json.dumps(grammar))
|
||||
return Grammars(grammar_dir)
|
||||
return make_grammars
|
||||
@@ -1,3 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from babi.fdict import FChainMap
|
||||
from babi.fdict import FDict
|
||||
|
||||
|
||||
@@ -5,3 +8,21 @@ def test_fdict_repr():
|
||||
# mostly because this shouldn't get hit elsewhere but is uesful for
|
||||
# debugging purposes
|
||||
assert repr(FDict({1: 2, 3: 4})) == 'FDict({1: 2, 3: 4})'
|
||||
|
||||
|
||||
def test_f_chain_map():
|
||||
chain_map = FChainMap({1: 2}, {3: 4}, FDict({1: 5}))
|
||||
assert chain_map[1] == 5
|
||||
assert chain_map[3] == 4
|
||||
|
||||
with pytest.raises(KeyError) as excinfo:
|
||||
chain_map[2]
|
||||
k, = excinfo.value.args
|
||||
assert k == 2
|
||||
|
||||
|
||||
def test_f_chain_map_extend():
|
||||
chain_map = FChainMap({1: 2})
|
||||
assert chain_map[1] == 2
|
||||
chain_map = FChainMap(chain_map, {1: 5})
|
||||
assert chain_map[1] == 5
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Union
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import wcwidth
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.main import main
|
||||
@@ -16,6 +17,13 @@ from babi.screen import VERSION_STR
|
||||
from testing.runner import PrintsErrorRunner
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def prefix_home(tmpdir):
|
||||
prefix_home = tmpdir.join('prefix_home')
|
||||
with mock.patch.object(sys, 'prefix', str(prefix_home)):
|
||||
yield prefix_home
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def xdg_data_home(tmpdir):
|
||||
data_home = tmpdir.join('data_home')
|
||||
@@ -39,7 +47,6 @@ def ten_lines(tmpdir):
|
||||
|
||||
class Screen:
|
||||
def __init__(self, width, height):
|
||||
self.disabled = True
|
||||
self.nodelay = False
|
||||
self.width = width
|
||||
self.height = height
|
||||
@@ -57,6 +64,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 + wcwidth.wcswidth(s)
|
||||
|
||||
def insstr(self, y, x, s, attr):
|
||||
line = self.lines[y]
|
||||
self.lines[y] = (line[:x] + s + line[x:])[:self.width]
|
||||
@@ -66,6 +83,7 @@ class Screen:
|
||||
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
|
||||
|
||||
def chgat(self, y, x, n, attr):
|
||||
assert n >= 0 # TODO: switch to > 0, we should never do 0-length
|
||||
self.attrs[y][x:x + n] = [attr] * n
|
||||
|
||||
def move(self, y, x):
|
||||
@@ -166,7 +184,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)
|
||||
|
||||
@@ -190,20 +209,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()
|
||||
@@ -254,6 +279,7 @@ KEYS = [
|
||||
Key('^E', b'^E', '\x05'),
|
||||
Key('^J', b'^J', '\n'),
|
||||
Key('^O', b'^O', '\x0f'),
|
||||
Key('^P', b'^P', '\x10'),
|
||||
Key('^R', b'^R', '\x12'),
|
||||
Key('^S', b'^S', '\x13'),
|
||||
Key('^U', b'^U', '\x15'),
|
||||
@@ -266,7 +292,7 @@ KEYS = [
|
||||
Key('^\\', b'^\\', '\x1c'),
|
||||
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
|
||||
]
|
||||
KEYS_TMUX = {k.tmux: k.value for k in KEYS}
|
||||
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
|
||||
KEYS_CURSES = {k.value: k.curses for k in KEYS}
|
||||
|
||||
|
||||
@@ -297,7 +323,7 @@ class DeferredRunner:
|
||||
print(f'KEY: {keypress_event.wch!r}')
|
||||
return keypress_event.wch
|
||||
|
||||
def await_text(self, text):
|
||||
def await_text(self, text, timeout=1):
|
||||
self._ops.append(AwaitText(text))
|
||||
|
||||
def await_text_missing(self, text):
|
||||
@@ -364,8 +390,8 @@ class DeferredRunner:
|
||||
def _curses__noop(self, *_, **__):
|
||||
pass
|
||||
|
||||
_curses_cbreak = _curses_noecho = _curses_nonl = _curses__noop
|
||||
_curses_raw = _curses_use_default_colors = _curses__noop
|
||||
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
|
||||
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
|
||||
|
||||
_curses_error = curses.error # so we don't mock the exception
|
||||
|
||||
@@ -391,11 +417,10 @@ class DeferredRunner:
|
||||
|
||||
def _curses_initscr(self):
|
||||
self._curses_update_lines_cols()
|
||||
self.screen.disabled = False
|
||||
return CursesScreen(self)
|
||||
return CursesScreen(self.screen, self)
|
||||
|
||||
def _curses_endwin(self):
|
||||
self.screen.disabled = True
|
||||
def _curses_newwin(self, height, width):
|
||||
return CursesScreen(Screen(width, height), self)
|
||||
|
||||
def _curses_not_implemented(self, fn):
|
||||
def fn_inner(*args, **kwargs):
|
||||
|
||||
@@ -132,3 +132,21 @@ def test_selection_cut_uncut_selection_offscreen_x(run):
|
||||
h.await_text_missing('hello')
|
||||
h.press('^K')
|
||||
h.await_text('hello\n')
|
||||
|
||||
|
||||
def test_selection_cut_uncut_at_end_of_file(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
h.press('S-Right')
|
||||
h.press('^K')
|
||||
h.await_text_missing('line_0')
|
||||
h.await_text_missing('line_1')
|
||||
h.await_text('ine_1')
|
||||
|
||||
h.press('^End')
|
||||
h.press('^U')
|
||||
h.await_text('line_0\nl\n')
|
||||
h.await_cursor_position(x=1, y=11)
|
||||
|
||||
h.press('Down')
|
||||
h.await_cursor_position(x=0, y=12)
|
||||
|
||||
22
tests/features/key_debug_test.py
Normal file
22
tests/features/key_debug_test.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import curses
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
|
||||
|
||||
def test_key_debug(run):
|
||||
with run('--key-debug') as h:
|
||||
h.await_text(VERSION_STR, timeout=2)
|
||||
|
||||
h.await_text('press q to quit')
|
||||
|
||||
h.press('a')
|
||||
h.await_text("'a' 'STRING'")
|
||||
|
||||
h.press('^X')
|
||||
h.await_text(r"'\x18' '^X'")
|
||||
|
||||
with h.resize(width=20, height=20):
|
||||
h.await_text(f"{curses.KEY_RESIZE} 'KEY_RESIZE'")
|
||||
|
||||
h.press('q')
|
||||
h.await_exit()
|
||||
@@ -411,3 +411,78 @@ 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'
|
||||
f'\t12\t{"x" * 20}\n'
|
||||
f'\tnot long\n',
|
||||
)
|
||||
|
||||
with run(str(f), width=20) as h, and_exit(h):
|
||||
h.await_text(
|
||||
'123456789\n'
|
||||
' 12 xxxxxxxxxxx»\n'
|
||||
' not long\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)
|
||||
|
||||
|
||||
def test_movement_with_wide_characters(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write(
|
||||
f'{"🙃" * 20}\n'
|
||||
f'a{"🙃" * 20}\n',
|
||||
)
|
||||
|
||||
with run(str(f), width=20) as h, and_exit(h):
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
h.press('Right')
|
||||
h.await_text(
|
||||
'««🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
for _ in range(6):
|
||||
h.press('Right')
|
||||
h.await_text(
|
||||
'««🙃🙃🙃🙃🙃🙃🙃\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
h.press('Down')
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'«🙃🙃🙃🙃🙃🙃🙃🙃\n',
|
||||
)
|
||||
|
||||
h.press('Left')
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'«🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
def test_multiple_files(run, tmpdir):
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def abc(tmpdir):
|
||||
a = tmpdir.join('file_a')
|
||||
a.write('a text')
|
||||
b = tmpdir.join('file_b')
|
||||
b.write('b text')
|
||||
c = tmpdir.join('file_c')
|
||||
c.write('c text')
|
||||
yield a, b, c
|
||||
|
||||
|
||||
def test_multiple_files(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.await_text('file_a')
|
||||
@@ -44,9 +53,36 @@ def test_multiple_files(run, tmpdir):
|
||||
h.press('^J')
|
||||
h.await_text('unknown key')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.await_text('file_b')
|
||||
h.await_text_missing('unknown key')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_multiple_files_close_from_beginning(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.press('^X')
|
||||
h.await_text('file_b')
|
||||
h.press('^X')
|
||||
h.await_text('file_c')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_multiple_files_close_from_end(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.press('M-Right')
|
||||
h.await_text('file_b')
|
||||
|
||||
h.press('^X')
|
||||
h.await_text('file_c')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
38
tests/features/open_test.py
Normal file
38
tests/features/open_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
def test_open_cancelled(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^P')
|
||||
h.await_text('enter filename:')
|
||||
h.press('^C')
|
||||
|
||||
h.await_text('cancelled')
|
||||
h.await_text('hello world')
|
||||
|
||||
|
||||
def test_open(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
g = tmpdir.join('g')
|
||||
g.write('goodbye world')
|
||||
|
||||
with run(str(f)) as h:
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^P')
|
||||
h.press_and_enter(str(g))
|
||||
|
||||
h.await_text('[2/2]')
|
||||
h.await_text('goodbye world')
|
||||
|
||||
h.press('^X')
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
@@ -37,7 +37,7 @@ def test_replace_actual_contents(run, ten_lines):
|
||||
h.press_and_enter('line_0')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_0')
|
||||
h.await_text('ohai')
|
||||
@@ -59,7 +59,7 @@ match me!
|
||||
h.press_and_enter('me!')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('youuuu')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_cursor_position(x=6, y=3)
|
||||
h.press('Up')
|
||||
@@ -74,7 +74,7 @@ def test_replace_cancel_at_individual_replace(run, ten_lines):
|
||||
h.press_and_enter(r'line_\d')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_replace_unknown_characters_at_individual_replace(run, ten_lines):
|
||||
h.press_and_enter(r'line_\d')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
@@ -99,7 +99,7 @@ def test_replace_say_no_to_individual_replace(run, ten_lines):
|
||||
h.press_and_enter('line_[135]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_1')
|
||||
h.press('n')
|
||||
@@ -116,7 +116,7 @@ def test_replace_all(run, ten_lines):
|
||||
h.press_and_enter(r'line_(\d)')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter(r'ohai+\1')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
h.await_text_missing('line')
|
||||
h.await_text('ohai+1')
|
||||
@@ -130,7 +130,7 @@ def test_replace_with_empty_string(run, ten_lines):
|
||||
h.press_and_enter('line_1')
|
||||
h.await_text('replace with:')
|
||||
h.press('Enter')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_1')
|
||||
|
||||
@@ -153,7 +153,7 @@ def test_replace_small_window_size(run, ten_lines):
|
||||
h.press_and_enter('line')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('wat')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
|
||||
with h.resize(width=8, height=24):
|
||||
h.await_text('replace…')
|
||||
@@ -170,7 +170,7 @@ def test_replace_height_1_highlight(run, tmpdir):
|
||||
h.press_and_enter('^x+$')
|
||||
h.await_text('replace with:')
|
||||
h.press('Enter')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
|
||||
with h.resize(width=80, height=1):
|
||||
h.await_text_missing('xxxxx')
|
||||
@@ -189,7 +189,7 @@ def test_replace_line_goes_off_screen(run):
|
||||
h.press_and_enter('b+')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('wat')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.await_text(f'{"a" * 20}{"b" * 59}»')
|
||||
h.press('y')
|
||||
h.await_text(f'{"a" * 20}wat')
|
||||
@@ -221,7 +221,7 @@ def test_replace_multiple_occurrences_in_line(run):
|
||||
h.press_and_enter('a+')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('q')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
h.await_text('bqbq')
|
||||
|
||||
@@ -234,7 +234,7 @@ def test_replace_after_wrapping(run, ten_lines):
|
||||
h.press_and_enter('line_[02]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_2')
|
||||
h.press('y')
|
||||
@@ -251,7 +251,7 @@ def test_replace_after_cursor_after_wrapping(run):
|
||||
h.press_and_enter('b')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('q')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('n')
|
||||
h.press('y')
|
||||
h.await_text('replaced 1 occurrence')
|
||||
@@ -267,8 +267,36 @@ def test_replace_separate_line_after_wrapping(run, ten_lines):
|
||||
h.press_and_enter('line_[01]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('_')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_0')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_1')
|
||||
|
||||
|
||||
def test_replace_with_newline_characters(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('^\\')
|
||||
h.await_text('search (to replace):')
|
||||
h.press_and_enter('(line)_([01])')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter(r'\1\n\2')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
h.await_text_missing('line_0')
|
||||
h.await_text_missing('line_1')
|
||||
h.await_text('line\n0\nline\n1\n')
|
||||
|
||||
|
||||
def test_replace_with_multiple_newline_characters(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('^\\')
|
||||
h.await_text('search (to replace):')
|
||||
h.press_and_enter('(li)(ne)_(1)')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter(r'\1\n\2\n\3\n')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
|
||||
h.await_text_missing('line_1')
|
||||
h.await_text('li\nne\n1\n\nline_2')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
def test_mixed_newlines(run, tmpdir):
|
||||
@@ -12,6 +13,31 @@ def test_mixed_newlines(run, tmpdir):
|
||||
h.await_text(r"mixed newlines will be converted to '\n'")
|
||||
|
||||
|
||||
def test_modify_file_with_windows_newlines(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write_binary(b'foo\r\nbar\r\n')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
# should not start modified
|
||||
h.await_text_missing('*')
|
||||
h.press('Enter')
|
||||
h.await_text('*')
|
||||
h.press('^S')
|
||||
h.await_text('saved!')
|
||||
assert f.read_binary() == b'\r\nfoo\r\nbar\r\n'
|
||||
|
||||
|
||||
def test_saving_file_with_multiple_lines_at_end_maintains_those(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('foo\n\n')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.await_text('*')
|
||||
h.press('^S')
|
||||
h.await_text('saved!')
|
||||
|
||||
assert f.read() == 'afoo\n\n'
|
||||
|
||||
|
||||
def test_new_file(run):
|
||||
with run('this_is_a_new_file') as h, and_exit(h):
|
||||
h.await_text('this_is_a_new_file')
|
||||
@@ -148,7 +174,7 @@ def test_save_on_exit_cancel_yn(run):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
@@ -158,7 +184,7 @@ def test_save_on_exit_cancel_filename(run):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text('enter filename:')
|
||||
h.press('^C')
|
||||
@@ -171,7 +197,7 @@ def test_save_on_exit(run, tmpdir):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text(f'enter filename: {f}')
|
||||
h.press('Enter')
|
||||
@@ -183,9 +209,44 @@ def test_save_on_exit_resize(run, tmpdir):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
with h.resize(width=10, height=24):
|
||||
h.await_text('file is m…')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
|
||||
def test_vim_save_on_exit_cancel_yn(run):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':q')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
|
||||
def test_vim_save_on_exit(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
with run(str(f)) as h:
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':q')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text(f'enter filename: ')
|
||||
h.press('Enter')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_vim_force_exit(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
with run(str(f)) as h:
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':q!')
|
||||
h.await_exit()
|
||||
|
||||
22
tests/features/stdin_test.py
Normal file
22
tests/features/stdin_test.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
from testing.runner import PrintsErrorRunner
|
||||
|
||||
|
||||
def test_open_from_stdin():
|
||||
with PrintsErrorRunner('env', 'TERM=screen', 'bash', '--norc') as h:
|
||||
cmd = (sys.executable, '-mcoverage', 'run', '-m', 'babi', '-')
|
||||
babi_cmd = ' '.join(shlex.quote(part) for part in cmd)
|
||||
h.press_and_enter(fr"echo $'hello\nworld' | {babi_cmd}")
|
||||
|
||||
h.await_text(VERSION_STR, timeout=2)
|
||||
h.await_text('<<new file>> *')
|
||||
h.await_text('hello\nworld')
|
||||
|
||||
h.press('^X')
|
||||
h.press('n')
|
||||
h.await_text_missing('<<new file>>')
|
||||
h.press_and_enter('exit')
|
||||
h.await_exit()
|
||||
@@ -15,6 +15,7 @@ THEME = json.dumps({
|
||||
'settings': {'foreground': '#5f0000', 'background': '#ff5f5f'},
|
||||
},
|
||||
{'scope': 'tqs', 'settings': {'foreground': '#00005f'}},
|
||||
{'scope': 'qmark', 'settings': {'foreground': '#5f0000'}},
|
||||
{'scope': 'b', 'settings': {'fontStyle': 'bold'}},
|
||||
{'scope': 'i', 'settings': {'fontStyle': 'italic'}},
|
||||
{'scope': 'u', 'settings': {'fontStyle': 'underline'}},
|
||||
@@ -28,6 +29,7 @@ SYNTAX = json.dumps({
|
||||
{'match': r'#.*$\n?', 'name': 'comment'},
|
||||
{'match': r'^-.*$\n?', 'name': 'diffremove'},
|
||||
{'begin': '"""', 'end': '"""', 'name': 'tqs'},
|
||||
{'match': r'\?', 'name': 'qmark'},
|
||||
],
|
||||
})
|
||||
DEMO_S = '''\
|
||||
@@ -43,7 +45,7 @@ still more
|
||||
@pytest.fixture(autouse=True)
|
||||
def theme_and_grammar(xdg_data_home, xdg_config_home):
|
||||
xdg_config_home.join('babi/theme.json').ensure().write(THEME)
|
||||
xdg_data_home.join('babi/textmate_syntax/demo.json').ensure().write(SYNTAX)
|
||||
xdg_data_home.join('babi/grammar_v1/demo.json').ensure().write(SYNTAX)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -78,3 +80,76 @@ def test_syntax_highlighting_does_not_highlight_arrows(run, tmpdir):
|
||||
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||
h.await_text('loooo')
|
||||
h.assert_screen_attr_equals(2, [(243, 40, 0)] * 19 + [(236, 40, 0)])
|
||||
|
||||
h.press('Down')
|
||||
h.press('^E')
|
||||
h.await_text_missing('loooo')
|
||||
expected = [(236, 40, 0)] + [(243, 40, 0)] * 15 + [(236, 40, 0)] * 4
|
||||
h.assert_screen_attr_equals(2, expected)
|
||||
|
||||
|
||||
def test_syntax_highlighting_off_screen_does_not_crash(run, tmpdir):
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write(f'"""a"""{"x" * 40}"""b"""')
|
||||
|
||||
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||
h.await_text('"""a"""')
|
||||
h.assert_screen_attr_equals(1, [(17, 40, 0)] * 7 + [(236, 40, 0)] * 13)
|
||||
h.press('^E')
|
||||
h.await_text('"""b"""')
|
||||
expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2
|
||||
h.assert_screen_attr_equals(1, expected)
|
||||
|
||||
|
||||
def test_syntax_highlighting_one_off_left_of_screen(run, tmpdir):
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write(f'{"x" * 11}?123456789')
|
||||
|
||||
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||
h.await_text('xxx?123')
|
||||
expected = [(236, 40, 0)] * 11 + [(52, 40, 0)] + [(236, 40, 0)] * 8
|
||||
h.assert_screen_attr_equals(1, expected)
|
||||
|
||||
h.press('End')
|
||||
h.await_text_missing('?')
|
||||
h.assert_screen_attr_equals(1, [(236, 40, 0)] * 20)
|
||||
|
||||
|
||||
def test_syntax_highlighting_to_edge_of_screen(run, tmpdir):
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write(f'# {"x" * 18}')
|
||||
|
||||
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')
|
||||
|
||||
@@ -97,6 +97,24 @@ def test_delete_at_end_of_line(run, tmpdir):
|
||||
h.await_text('f *')
|
||||
|
||||
|
||||
def test_delete_at_end_of_last_line(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello\n')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.await_text('hello')
|
||||
h.press('End')
|
||||
h.press('DC')
|
||||
# should not make the file modified
|
||||
h.await_text_missing('*')
|
||||
|
||||
# delete should still be functional
|
||||
h.press('Left')
|
||||
h.press('Left')
|
||||
h.press('DC')
|
||||
h.await_text('helo')
|
||||
|
||||
|
||||
def test_press_enter_beginning_of_file(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
|
||||
@@ -127,3 +127,16 @@ def test_undo_redo_causes_scroll(run):
|
||||
h.await_cursor_position(x=0, y=1)
|
||||
h.press('M-U')
|
||||
h.await_cursor_position(x=0, y=4)
|
||||
|
||||
|
||||
def test_undo_redo_clears_selection(run, ten_lines):
|
||||
# maintaining the selection across undo/redo is both difficult and not all
|
||||
# that useful. prior to this it was buggy anyway (a negative selection
|
||||
# indented and then undone would highlight out of bounds)
|
||||
with run(str(ten_lines), width=20) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
h.press('Tab')
|
||||
h.await_cursor_position(x=4, y=2)
|
||||
h.press('M-u')
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
h.assert_screen_attr_equals(1, [(-1, -1, 0)] * 20)
|
||||
|
||||
@@ -2,12 +2,13 @@ import io
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.file import File
|
||||
from babi.file import get_lines
|
||||
|
||||
|
||||
def test_position_repr():
|
||||
ret = repr(File('f.txt', ()))
|
||||
ret = repr(File('f.txt', ColorManager.make(), ()))
|
||||
assert ret == "<File 'f.txt'>"
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
from babi.highlight import Grammar
|
||||
from babi.highlight import Grammars
|
||||
import pytest
|
||||
|
||||
from babi.highlight import highlight_line
|
||||
from babi.highlight import Region
|
||||
|
||||
|
||||
def _compiler_state(grammar_dct, *others):
|
||||
grammar = Grammar.from_data(grammar_dct)
|
||||
grammars = [grammar, *(Grammar.from_data(dct) for dct in others)]
|
||||
compiler = Grammars(grammars).compiler_for_scope(grammar.scope_name)
|
||||
def test_grammar_matches_extension_only_name(make_grammars):
|
||||
data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']}
|
||||
grammars = make_grammars(data)
|
||||
compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi')
|
||||
assert compiler.root_state.entries[0].scope[0] == 'shell'
|
||||
|
||||
|
||||
def test_grammar_matches_via_identify_tag(make_grammars):
|
||||
grammars = make_grammars({'scopeName': 'source.ini', 'patterns': []})
|
||||
compiler = grammars.compiler_for_file('setup.cfg', '')
|
||||
assert compiler.root_state.entries[0].scope[0] == 'source.ini'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compiler_state(make_grammars):
|
||||
def _compiler_state(*grammar_dcts):
|
||||
grammars = make_grammars(*grammar_dcts)
|
||||
compiler = grammars.compiler_for_scope(grammar_dcts[0]['scopeName'])
|
||||
return compiler, compiler.root_state
|
||||
return _compiler_state
|
||||
|
||||
|
||||
def test_backslash_a():
|
||||
def test_backslash_a(compiler_state):
|
||||
grammar = {
|
||||
'scopeName': 'test',
|
||||
'patterns': [{'name': 'aaa', 'match': r'\Aa+'}],
|
||||
}
|
||||
compiler, state = _compiler_state(grammar)
|
||||
compiler, state = compiler_state(grammar)
|
||||
|
||||
state, (region_0,) = highlight_line(compiler, state, 'aaa', True)
|
||||
state, (region_1,) = highlight_line(compiler, state, 'aaa', False)
|
||||
@@ -39,8 +54,8 @@ BEGIN_END_NO_NL = {
|
||||
}
|
||||
|
||||
|
||||
def test_backslash_g_inline():
|
||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
||||
def test_backslash_g_inline(compiler_state):
|
||||
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||
|
||||
_, regions = highlight_line(compiler, state, 'xaax', True)
|
||||
assert regions == (
|
||||
@@ -51,8 +66,8 @@ def test_backslash_g_inline():
|
||||
)
|
||||
|
||||
|
||||
def test_backslash_g_next_line():
|
||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
||||
def test_backslash_g_next_line(compiler_state):
|
||||
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||
|
||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
||||
@@ -69,8 +84,8 @@ def test_backslash_g_next_line():
|
||||
)
|
||||
|
||||
|
||||
def test_end_before_other_match():
|
||||
compiler, state = _compiler_state(BEGIN_END_NO_NL)
|
||||
def test_end_before_other_match(compiler_state):
|
||||
compiler, state = compiler_state(BEGIN_END_NO_NL)
|
||||
|
||||
state, regions = highlight_line(compiler, state, 'xazzx', True)
|
||||
|
||||
@@ -95,8 +110,8 @@ BEGIN_END_NL = {
|
||||
}
|
||||
|
||||
|
||||
def test_backslash_g_captures_nl():
|
||||
compiler, state = _compiler_state(BEGIN_END_NL)
|
||||
def test_backslash_g_captures_nl(compiler_state):
|
||||
compiler, state = compiler_state(BEGIN_END_NL)
|
||||
|
||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||
state, regions2 = highlight_line(compiler, state, 'aax\n', False)
|
||||
@@ -112,8 +127,8 @@ def test_backslash_g_captures_nl():
|
||||
)
|
||||
|
||||
|
||||
def test_backslash_g_captures_nl_next_line():
|
||||
compiler, state = _compiler_state(BEGIN_END_NL)
|
||||
def test_backslash_g_captures_nl_next_line(compiler_state):
|
||||
compiler, state = compiler_state(BEGIN_END_NL)
|
||||
|
||||
state, regions1 = highlight_line(compiler, state, 'x\n', True)
|
||||
state, regions2 = highlight_line(compiler, state, 'aa\n', False)
|
||||
@@ -135,8 +150,8 @@ def test_backslash_g_captures_nl_next_line():
|
||||
)
|
||||
|
||||
|
||||
def test_while_no_nl():
|
||||
compiler, state = _compiler_state({
|
||||
def test_while_no_nl(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{
|
||||
'begin': '> ',
|
||||
@@ -170,8 +185,8 @@ def test_while_no_nl():
|
||||
)
|
||||
|
||||
|
||||
def test_complex_captures():
|
||||
compiler, state = _compiler_state({
|
||||
def test_complex_captures(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -201,8 +216,8 @@ def test_complex_captures():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_multiple_applied_to_same_capture():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_multiple_applied_to_same_capture(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -244,8 +259,8 @@ def test_captures_multiple_applied_to_same_capture():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_ignores_empty():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_ignores_empty(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{
|
||||
'match': '(.*) hi',
|
||||
@@ -267,8 +282,8 @@ def test_captures_ignores_empty():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_ignores_invalid_out_of_bounds():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_ignores_invalid_out_of_bounds(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{'match': '.', 'captures': {'1': {'name': 'oob'}}}],
|
||||
})
|
||||
@@ -280,8 +295,8 @@ def test_captures_ignores_invalid_out_of_bounds():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_begin_end():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_begin_end(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -302,8 +317,8 @@ def test_captures_begin_end():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_while_captures():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_while_captures(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -331,8 +346,8 @@ def test_captures_while_captures():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_implies_begin_end_captures():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_implies_begin_end_captures(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -352,8 +367,8 @@ def test_captures_implies_begin_end_captures():
|
||||
)
|
||||
|
||||
|
||||
def test_captures_implies_begin_while_captures():
|
||||
compiler, state = _compiler_state({
|
||||
def test_captures_implies_begin_while_captures(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -380,8 +395,8 @@ def test_captures_implies_begin_while_captures():
|
||||
)
|
||||
|
||||
|
||||
def test_include_self():
|
||||
compiler, state = _compiler_state({
|
||||
def test_include_self(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
@@ -404,8 +419,8 @@ def test_include_self():
|
||||
)
|
||||
|
||||
|
||||
def test_include_repository_rule():
|
||||
compiler, state = _compiler_state({
|
||||
def test_include_repository_rule(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{'include': '#impl'}],
|
||||
'repository': {
|
||||
@@ -426,8 +441,40 @@ def test_include_repository_rule():
|
||||
)
|
||||
|
||||
|
||||
def test_include_other_grammar():
|
||||
compiler, state = _compiler_state(
|
||||
def test_include_with_nested_repositories(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{
|
||||
'begin': '<', 'end': '>', 'name': 'b',
|
||||
'patterns': [
|
||||
{'include': '#rule1'},
|
||||
{'include': '#rule2'},
|
||||
{'include': '#rule3'},
|
||||
],
|
||||
'repository': {
|
||||
'rule2': {'match': '2', 'name': 'inner2'},
|
||||
'rule3': {'match': '3', 'name': 'inner3'},
|
||||
},
|
||||
}],
|
||||
'repository': {
|
||||
'rule1': {'match': '1', 'name': 'root1'},
|
||||
'rule2': {'match': '2', 'name': 'root2'},
|
||||
},
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, '<123>', first_line=True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 1, ('test', 'b')),
|
||||
Region(1, 2, ('test', 'b', 'root1')),
|
||||
Region(2, 3, ('test', 'b', 'inner2')),
|
||||
Region(3, 4, ('test', 'b', 'inner3')),
|
||||
Region(4, 5, ('test', 'b')),
|
||||
)
|
||||
|
||||
|
||||
def test_include_other_grammar(compiler_state):
|
||||
compiler, state = compiler_state(
|
||||
{
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
@@ -482,8 +529,8 @@ def test_include_other_grammar():
|
||||
)
|
||||
|
||||
|
||||
def test_include_base():
|
||||
compiler, state = _compiler_state(
|
||||
def test_include_base(compiler_state):
|
||||
compiler, state = compiler_state(
|
||||
{
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
@@ -528,3 +575,65 @@ def test_include_base():
|
||||
Region(2, 3, ('test', 'tick')),
|
||||
Region(3, 4, ('test',)),
|
||||
)
|
||||
|
||||
|
||||
def test_rule_with_begin_and_no_end(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
'begin': '!', 'end': '!', 'name': 'bang',
|
||||
'patterns': [{'begin': '--', 'name': 'invalid'}],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, '!x! !--!', True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 1, ('test', 'bang')),
|
||||
Region(1, 2, ('test', 'bang')),
|
||||
Region(2, 3, ('test', 'bang')),
|
||||
Region(3, 4, ('test',)),
|
||||
Region(4, 5, ('test', 'bang')),
|
||||
Region(5, 7, ('test', 'bang', 'invalid')),
|
||||
Region(7, 8, ('test', 'bang', 'invalid')),
|
||||
)
|
||||
|
||||
|
||||
def test_begin_end_substitute_special_chars(compiler_state):
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}],
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, '*italic*', True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 1, ('test', 'italic')),
|
||||
Region(1, 7, ('test', 'italic')),
|
||||
Region(7, 8, ('test', 'italic')),
|
||||
)
|
||||
|
||||
|
||||
def test_backslash_z(compiler_state):
|
||||
# similar to text.git-commit grammar, \z matches nothing!
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{'begin': '#', 'end': r'\z', 'name': 'comment'},
|
||||
{'name': 'other', 'match': '.'},
|
||||
],
|
||||
})
|
||||
|
||||
state, regions1 = highlight_line(compiler, state, '# comment', True)
|
||||
state, regions2 = highlight_line(compiler, state, 'other?', False)
|
||||
|
||||
assert regions1 == (
|
||||
Region(0, 1, ('test', 'comment')),
|
||||
Region(1, 9, ('test', 'comment')),
|
||||
)
|
||||
|
||||
assert regions2 == (
|
||||
Region(0, 6, ('test', 'comment')),
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.highlight import Grammar
|
||||
from babi.highlight import Grammars
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.syntax import Syntax
|
||||
from babi.theme import Color
|
||||
from babi.theme import Theme
|
||||
@@ -72,18 +72,23 @@ THEME = Theme.from_dct({
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def syntax():
|
||||
return Syntax(Grammars([Grammar.blank()]), THEME, ColorManager.make())
|
||||
def syntax(make_grammars):
|
||||
return Syntax(make_grammars(), THEME, ColorManager.make())
|
||||
|
||||
|
||||
def test_init_screen_low_color(stdscr, syntax):
|
||||
with FakeCurses.patch(n_colors=16, can_change_color=False) as fake_curses:
|
||||
syntax._init_screen(stdscr)
|
||||
assert syntax.color_manager.colors == {}
|
||||
assert syntax.color_manager.raw_pairs == {}
|
||||
assert syntax.color_manager.colors == {
|
||||
Color.parse('#cccccc'): -1,
|
||||
Color.parse('#333333'): -1,
|
||||
Color.parse('#000000'): -1,
|
||||
Color.parse('#009900'): -1,
|
||||
}
|
||||
assert syntax.color_manager.raw_pairs == {(-1, -1): 1}
|
||||
assert fake_curses.colors == {}
|
||||
assert fake_curses.pairs == {}
|
||||
assert stdscr.attr == 0
|
||||
assert fake_curses.pairs == {1: (-1, -1)}
|
||||
assert stdscr.attr == 1 << 8
|
||||
|
||||
|
||||
def test_init_screen_256_color(stdscr, syntax):
|
||||
@@ -131,7 +136,7 @@ def test_lazily_instantiated_pairs(stdscr, syntax):
|
||||
assert len(fake_curses.pairs) == 1
|
||||
|
||||
style = THEME.select(('string.python',))
|
||||
attr = syntax.get_blank_file_highlighter().attr(style)
|
||||
attr = syntax.blank_file_highlighter().attr(style)
|
||||
assert attr == 2 << 8
|
||||
|
||||
assert len(syntax.color_manager.raw_pairs) == 2
|
||||
@@ -143,5 +148,22 @@ def test_style_attributes_applied(stdscr, syntax):
|
||||
syntax._init_screen(stdscr)
|
||||
|
||||
style = THEME.select(('keyword.python',))
|
||||
attr = syntax.get_blank_file_highlighter().attr(style)
|
||||
attr = syntax.blank_file_highlighter().attr(style)
|
||||
assert attr == 2 << 8 | curses.A_BOLD
|
||||
|
||||
|
||||
def test_syntax_highlight_cache_first_line(stdscr, make_grammars):
|
||||
with FakeCurses.patch(n_colors=256, can_change_color=False):
|
||||
grammars = make_grammars({
|
||||
'scopeName': 'source.demo',
|
||||
'fileTypes': ['demo'],
|
||||
'patterns': [{'match': r'\Aint', 'name': 'keyword'}],
|
||||
})
|
||||
syntax = Syntax(grammars, THEME, ColorManager.make())
|
||||
syntax._init_screen(stdscr)
|
||||
file_hl = syntax.file_highlighter('foo.demo', '')
|
||||
file_hl.highlight_until(Buf(['int', 'int']), 2)
|
||||
assert file_hl.regions == [
|
||||
(HL(0, 3, curses.A_BOLD | 2 << 8),),
|
||||
(),
|
||||
]
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from babi.list_spy import ListSpy
|
||||
|
||||
|
||||
def test_list_spy_repr():
|
||||
assert repr(ListSpy(['a', 'b', 'c'])) == "ListSpy(['a', 'b', 'c'])"
|
||||
|
||||
|
||||
def test_list_spy_item_retrieval():
|
||||
spy = ListSpy(['a', 'b', 'c'])
|
||||
assert spy[1] == 'b'
|
||||
assert spy[-1] == 'c'
|
||||
with pytest.raises(IndexError):
|
||||
spy[3]
|
||||
|
||||
|
||||
def test_list_spy_del():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
del spy[1]
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_del_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
del spy[-1]
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_insert():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy.insert(1, 'q')
|
||||
|
||||
assert lst == ['a', 'q', 'b', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_insert_with_negative():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy.insert(-1, 'q')
|
||||
|
||||
assert lst == ['a', 'b', 'q', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_set_value():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy[1] = 'hello'
|
||||
|
||||
assert lst == ['a', 'hello', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_multiple_modifications():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy[1] = 'hello'
|
||||
spy.insert(1, 'ohai')
|
||||
del spy[0]
|
||||
|
||||
assert lst == ['ohai', 'hello', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_iter():
|
||||
spy = ListSpy(['a', 'b', 'c'])
|
||||
spy_iter = iter(spy)
|
||||
assert next(spy_iter) == 'a'
|
||||
assert next(spy_iter) == 'b'
|
||||
assert next(spy_iter) == 'c'
|
||||
with pytest.raises(StopIteration):
|
||||
next(spy_iter)
|
||||
|
||||
|
||||
def test_list_spy_append():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy.append('q')
|
||||
|
||||
assert lst == ['a', 'b', 'c', 'q']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_pop_default():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy.pop()
|
||||
|
||||
assert lst == ['a', 'b']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
|
||||
|
||||
def test_list_spy_pop_idx():
|
||||
lst = ['a', 'b', 'c']
|
||||
|
||||
spy = ListSpy(lst)
|
||||
spy.pop(1)
|
||||
|
||||
assert lst == ['a', 'c']
|
||||
|
||||
spy.undo(lst)
|
||||
|
||||
assert lst == ['a', 'b', 'c']
|
||||
84
tests/textmate_demo_test.py
Normal file
84
tests/textmate_demo_test.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.textmate_demo import main
|
||||
|
||||
THEME = {
|
||||
'colors': {'foreground': '#ffffff', 'background': '#000000'},
|
||||
'tokenColors': [
|
||||
{'scope': 'bold', 'settings': {'fontStyle': 'bold'}},
|
||||
{'scope': 'italic', 'settings': {'fontStyle': 'italic'}},
|
||||
{'scope': 'underline', 'settings': {'fontStyle': 'underline'}},
|
||||
{'scope': 'comment', 'settings': {'foreground': '#1e77d3'}},
|
||||
],
|
||||
}
|
||||
|
||||
GRAMMAR = {
|
||||
'scopeName': 'source.demo',
|
||||
'fileTypes': ['demo'],
|
||||
'patterns': [
|
||||
{'match': r'\*[^*]*\*', 'name': 'bold'},
|
||||
{'match': '/[^/]*/', 'name': 'italic'},
|
||||
{'match': '_[^_]*_', 'name': 'underline'},
|
||||
{'match': '#.*', 'name': 'comment'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def theme_grammars(tmpdir):
|
||||
theme = tmpdir.join('config/theme.json').ensure()
|
||||
theme.write(json.dumps(THEME))
|
||||
grammars = tmpdir.join('grammar_v1').ensure_dir()
|
||||
grammars.join('source.demo.json').write(json.dumps(GRAMMAR))
|
||||
return theme, grammars
|
||||
|
||||
|
||||
def test_basic(theme_grammars, tmpdir, capsys):
|
||||
theme, grammars = theme_grammars
|
||||
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write('*bold*/italic/_underline_# comment\n')
|
||||
|
||||
assert not main((
|
||||
'--theme', str(theme), '--grammar-dir', str(grammars),
|
||||
str(f),
|
||||
))
|
||||
|
||||
out, _ = capsys.readouterr()
|
||||
|
||||
assert out == (
|
||||
'\x1b[48;2;0;0;0m\n'
|
||||
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[1m'
|
||||
'*bold*'
|
||||
'\x1b[39m\x1b[49m\x1b[22m'
|
||||
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[3m'
|
||||
'/italic/'
|
||||
'\x1b[39m\x1b[49m\x1b[23m'
|
||||
'\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[4m'
|
||||
'_underline_'
|
||||
'\x1b[39m\x1b[49m\x1b[24m'
|
||||
'\x1b[38;2;30;119;211m\x1b[48;2;0;0;0m'
|
||||
'# comment'
|
||||
'\x1b[39m\x1b[49m\x1b'
|
||||
'[38;2;255;255;255m\x1b[48;2;0;0;0m\n\x1b[39m\x1b[49m'
|
||||
'\x1b[m'
|
||||
)
|
||||
|
||||
|
||||
def test_basic_with_blank_theme(theme_grammars, tmpdir, capsys):
|
||||
theme, grammars = theme_grammars
|
||||
theme.write('{}')
|
||||
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write('*bold*/italic/_underline_# comment\n')
|
||||
|
||||
assert not main((
|
||||
'--theme', str(theme), '--grammar-dir', str(grammars),
|
||||
str(f),
|
||||
))
|
||||
|
||||
out, _ = capsys.readouterr()
|
||||
|
||||
assert out == '*bold*/italic/_underline_# comment\n\x1b[m'
|
||||
@@ -72,6 +72,31 @@ def test_theme_scope_split_by_commas():
|
||||
assert theme.select(('c',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_comma_at_beginning_and_end():
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
'tokenColors': [
|
||||
{'scope': '\n,a,b,\n', 'settings': {'fontStyle': 'italic'}},
|
||||
],
|
||||
})
|
||||
assert theme.select(('d',)).i is False
|
||||
assert theme.select(('a',)).i is True
|
||||
assert theme.select(('b',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_internal_newline_commas():
|
||||
# this is arguably malformed, but `cobalt2` in the wild has this issue
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
'tokenColors': [
|
||||
{'scope': '\n,a,\n,b,\n', 'settings': {'fontStyle': 'italic'}},
|
||||
],
|
||||
})
|
||||
assert theme.select(('d',)).i is False
|
||||
assert theme.select(('a',)).i is True
|
||||
assert theme.select(('b',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_as_A_list():
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
|
||||
Reference in New Issue
Block a user