39 Commits
v0.0.2 ... wc

Author SHA1 Message Date
Anthony Sottile
9b55ebfd0e wip 2020-04-17 17:09:16 -07:00
Anthony Sottile
7d1e61f734 v0.0.7 2020-04-04 17:44:40 -07:00
Anthony Sottile
3e7ca8e922 make grammar loading more deterministic 2020-04-04 17:44:27 -07:00
Anthony Sottile
843f1b6ff1 remove stray print 2020-04-04 13:13:53 -07:00
Anthony Sottile
f704505ee2 v0.0.6 2020-04-04 13:04:34 -07:00
Anthony Sottile
b595333fc6 Fix grammars where rules have local repositorys
for example: ruby
2020-04-04 13:03:33 -07:00
Anthony Sottile
486af96c12 Merge pull request #53 from brynphillips/PS-key-fix
Ps key fix
2020-04-03 10:36:12 -07:00
Bryn Phillips
8b71d289a3 Fixed PgDn 2020-04-03 10:28:40 -07:00
Bryn Phillips
759cadd868 Fixes for Win PS keys 2020-04-03 10:26:17 -07:00
Anthony Sottile
b9a12537b1 v0.0.5 2020-04-02 22:52:01 -07:00
Anthony Sottile
936fd7e3a0 Fix delete at end of last line
Resolves #52
2020-04-02 22:51:02 -07:00
Anthony Sottile
2d0f3a3077 simplify platform differences with KEYNAME_REWRITE 2020-04-02 10:15:34 -07:00
Anthony Sottile
2a9eccefb2 Merge pull request #49 from brynphillips/fixed-windows-keys
Fixed windows keys
2020-04-02 09:15:29 -07:00
Bryn Phillips
c449f96bf0 Added up, down, left, right wch codes for win 2020-04-02 09:03:27 -07:00
Anthony Sottile
47e008afa4 Fix writing of crlf on windows when saving
Resolves #51
2020-04-01 22:42:18 -07:00
Anthony Sottile
1919c2d4fe Merge pull request #48 from YouTwitFace/master
Fix exiting using `:q` when the file is modified
2020-04-01 20:59:40 -07:00
YouTwitFace
18057542bf Fix exiting using :q when the file is modified 2020-04-01 20:55:50 -07:00
Anthony Sottile
49f95a5a2c Fix uncut selection at end of file
thanks @YouTwitFace for the report!
2020-04-01 19:36:07 -07:00
Anthony Sottile
612f09eb3a Add install instructions to the readme 2020-04-01 17:48:54 -07:00
Anthony Sottile
6206db3ef2 properly render tab characters in babi 2020-04-01 17:42:19 -07:00
Anthony Sottile
711cf65266 Remove .disabled, it wasn't doing anything 2020-03-31 14:15:28 -07:00
Anthony Sottile
2b66c465a6 move lines and cols into margin 2020-03-30 17:56:50 -07:00
Anthony Sottile
9f36fe2f1b Fix highlighting right at the edge of a non-scrolled line 2020-03-28 16:56:48 -07:00
Anthony Sottile
3844dcf329 Refactor file internals to separate class 2020-03-28 16:28:26 -07:00
Anthony Sottile
04aaf9530e simpler fix for \z 2020-03-28 11:27:53 -07:00
Anthony Sottile
7850481565 v0.0.4 2020-03-28 08:01:02 -07:00
Anthony Sottile
b536291989 Fix replacing with embedded newline characters
Resolves #39
2020-03-27 20:32:43 -07:00
Anthony Sottile
f8737557d3 Add a sample theme to the README 2020-03-27 19:29:52 -07:00
Anthony Sottile
d597b4087d add dist and build to gitignore 2020-03-27 19:10:11 -07:00
Anthony Sottile
41aa025d3d Fix edge highlighting for 1-lenght highlights 2020-03-27 19:06:50 -07:00
Anthony Sottile
de956b7bab fix saving files with windows newlines 2020-03-27 18:42:37 -07:00
Anthony Sottile
1d3d413b93 Fix grammars which include \z 2020-03-27 18:18:16 -07:00
Anthony Sottile
50ad1e06f9 Add demo for showing vs code's tokenization 2020-03-27 17:59:35 -07:00
Anthony Sottile
032c3d78fc v0.0.3 2020-03-26 20:38:52 -07:00
Anthony Sottile
a197645087 merge the textmate demo into babi 2020-03-26 20:26:57 -07:00
Anthony Sottile
9f8e400d32 switch to babi-grammars for syntax 2020-03-26 19:43:01 -07:00
Anthony Sottile
2123e6ee84 improve performance by ~.8%
apparently contextlib.suppress is enough to show up in profiles
2020-03-23 20:57:53 -07:00
Anthony Sottile
b529dde91a Fix incorrect caching in syntax highlighter
the concrete broken case was for markdown with yaml

```md
---
x: y
---

(this one shouldn't be yaml highlighted)
---
x: y
---
```
2020-03-23 20:05:47 -07:00
Anthony Sottile
c4e2f8e9cf this is unused 2020-03-22 20:12:04 -07:00
43 changed files with 1794 additions and 854 deletions

2
.gitignore vendored
View File

@@ -5,4 +5,6 @@
/.mypy_cache /.mypy_cache
/.pytest_cache /.pytest_cache
/.tox /.tox
/build
/dist
/venv* /venv*

View File

@@ -6,6 +6,10 @@ babi
a text editor, eventually... a text editor, eventually...
### installation
`pip install babi`
### why is it called babi? ### why is it called babi?
I usually use the text editor `nano`, frequently I typo this. on a qwerty I usually use the text editor `nano`, frequently I typo this. on a qwerty
@@ -63,12 +67,16 @@ in prompts (search, search replace, command):
the syntax highlighting setup is a bit manual right now the syntax highlighting setup is a bit manual right now
1. from a clone of babi, run `./bin/download-syntax` -- you will likely need 1. find a visual studio code theme, convert it to json (if it is not already
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`. a helper script is json) and put it at `~/.config/babi/theme.json`. a helper script is
provided to make this easier: `./bin/download-theme NAME URL` 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 ## demos
most things work! here's a few screenshots most things work! here's a few screenshots

314
babi/buf.py Normal file
View 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)

View File

@@ -1,4 +1,3 @@
import contextlib
import curses import curses
from typing import Dict from typing import Dict
from typing import NamedTuple from typing import NamedTuple
@@ -34,8 +33,10 @@ class ColorManager(NamedTuple):
return self.raw_color_pair(fg_i, bg_i) return self.raw_color_pair(fg_i, bg_i)
def raw_color_pair(self, fg: int, bg: int) -> int: def raw_color_pair(self, fg: int, bg: int) -> int:
with contextlib.suppress(KeyError): try:
return self.raw_pairs[(fg, bg)] return self.raw_pairs[(fg, bg)]
except KeyError:
pass
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1 n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg) curses.init_pair(n, fg, bg)

View File

@@ -3,8 +3,10 @@ from typing import Iterable
from typing import Mapping from typing import Mapping
from typing import TypeVar from typing import TypeVar
TKey = TypeVar('TKey') from babi._types import Protocol
TValue = TypeVar('TValue')
TKey = TypeVar('TKey', contravariant=True)
TValue = TypeVar('TValue', covariant=True)
class FDict(Generic[TKey, TValue]): class FDict(Generic[TKey, TValue]):
@@ -22,3 +24,21 @@ class FDict(Generic[TKey, TValue]):
def values(self) -> Iterable[TValue]: def values(self) -> Iterable[TValue]:
return self._dct.values() 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)

View File

@@ -21,16 +21,14 @@ from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
from typing import Union from typing import Union
from babi.buf import Buf
from babi.buf import Modification
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.hl.interface import FileHL from babi.hl.interface import FileHL
from babi.hl.interface import HLFactory from babi.hl.interface import HLFactory
from babi.hl.replace import Replace from babi.hl.replace import Replace
from babi.hl.selection import Selection from babi.hl.selection import Selection
from babi.hl.trailing_whitespace import TrailingWhitespace from babi.hl.trailing_whitespace import TrailingWhitespace
from babi.horizontal_scrolling import line_x
from babi.horizontal_scrolling import scrolled_line
from babi.list_spy import ListSpy
from babi.list_spy import MutableSequenceNoSlice
from babi.margin import Margin from babi.margin import Margin
from babi.prompt import PromptResult from babi.prompt import PromptResult
from babi.status import Status from babi.status import Status
@@ -40,17 +38,6 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any]) TCallable = TypeVar('TCallable', bound=Callable[..., Any])
HIGHLIGHT = curses.A_REVERSE | curses.A_DIM
def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None:
"""The file lines will always contain a blank empty string at the end to
simplify rendering. This should be called whenever the end of the file
might change.
"""
if not lines or lines[-1] != '':
lines.append('')
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]: def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
sha256 = hashlib.sha256() sha256 = hashlib.sha256()
@@ -65,7 +52,8 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
break break
else: else:
lines.append(line) lines.append(line)
_restore_lines_eof_invariant(lines) # always make sure we end in a newline
lines.append('')
(nl, _), = newlines.most_common(1) (nl, _), = newlines.most_common(1)
mixed = len({k for k, v in newlines.items() if v}) > 1 mixed = len({k for k, v in newlines.items() if v}) > 1
return lines, nl, mixed, sha256.hexdigest() return lines, nl, mixed, sha256.hexdigest()
@@ -73,13 +61,13 @@ def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
class Action: class Action:
def __init__( def __init__(
self, *, name: str, spy: ListSpy, self, *, name: str, modifications: List[Modification],
start_x: int, start_y: int, start_modified: bool, start_x: int, start_y: int, start_modified: bool,
end_x: int, end_y: int, end_modified: bool, end_x: int, end_y: int, end_modified: bool,
final: bool, final: bool,
): ):
self.name = name self.name = name
self.spy = spy self.modifications = modifications
self.start_x = start_x self.start_x = start_x
self.start_y = start_y self.start_y = start_y
self.start_modified = start_modified self.start_modified = start_modified
@@ -89,9 +77,8 @@ class Action:
self.final = final self.final = final
def apply(self, file: 'File') -> 'Action': def apply(self, file: 'File') -> 'Action':
spy = ListSpy(file.lines)
action = Action( action = Action(
name=self.name, spy=spy, name=self.name, modifications=file.buf.apply(self.modifications),
start_x=self.end_x, start_y=self.end_y, start_x=self.end_x, start_y=self.end_y,
start_modified=self.end_modified, start_modified=self.end_modified,
end_x=self.start_x, end_y=self.start_y, end_x=self.start_x, end_y=self.start_y,
@@ -99,11 +86,9 @@ class Action:
final=True, final=True,
) )
self.spy.undo(spy) file.buf.y = self.start_y
file.x = self.start_x file.buf.x = self.start_x
file.y = self.start_y
file.modified = self.start_modified file.modified = self.start_modified
file.touch(spy.min_line_touched)
return action return action
@@ -164,8 +149,8 @@ class _SearchIter:
self.reg = reg self.reg = reg
self.offset = offset self.offset = offset
self.wrapped = False self.wrapped = False
self._start_x = file.x + offset self._start_x = file.buf.x + offset
self._start_y = file.y self._start_y = file.buf.y
def __iter__(self) -> '_SearchIter': def __iter__(self) -> '_SearchIter':
return self return self
@@ -181,28 +166,28 @@ class _SearchIter:
return Found(y, match) return Found(y, match)
def __next__(self) -> Tuple[int, Match[str]]: def __next__(self) -> Tuple[int, Match[str]]:
x = self.file.x + self.offset x = self.file.buf.x + self.offset
y = self.file.y y = self.file.buf.y
match = self.reg.search(self.file.lines[y], x) match = self.reg.search(self.file.buf[y], x)
if match: if match:
return self._stop_if_past_original(y, match) return self._stop_if_past_original(y, match)
if self.wrapped: if self.wrapped:
for line_y in range(y + 1, self._start_y + 1): for line_y in range(y + 1, self._start_y + 1):
match = self.reg.search(self.file.lines[line_y]) match = self.reg.search(self.file.buf[line_y])
if match: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
else: else:
for line_y in range(y + 1, len(self.file.lines)): for line_y in range(y + 1, len(self.file.buf)):
match = self.reg.search(self.file.lines[line_y]) match = self.reg.search(self.file.buf[line_y])
if match: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
self.wrapped = True self.wrapped = True
for line_y in range(0, self._start_y + 1): for line_y in range(0, self._start_y + 1):
match = self.reg.search(self.file.lines[line_y]) match = self.reg.search(self.file.buf[line_y])
if match: if match:
return self._stop_if_past_original(line_y, match) return self._stop_if_past_original(line_y, match)
@@ -218,10 +203,10 @@ class File:
) -> None: ) -> None:
self.filename = filename self.filename = filename
self.modified = False self.modified = False
self.lines: MutableSequenceNoSlice = [] self.buf = Buf([])
self.nl = '\n' self.nl = '\n'
self.file_y = self.y = self.x = self.x_hint = 0
self.sha256: Optional[str] = None self.sha256: Optional[str] = None
self._in_edit_action = False
self.undo_stack: List[Action] = [] self.undo_stack: List[Action] = []
self.redo_stack: List[Action] = [] self.redo_stack: List[Action] = []
self._hl_factories = hl_factories self._hl_factories = hl_factories
@@ -231,7 +216,7 @@ class File:
self._file_hls: Tuple[FileHL, ...] = () self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded(self, status: Status, stdin: str) -> None: def ensure_loaded(self, status: Status, stdin: str) -> None:
if self.lines: if self.buf:
return return
if self.filename == '-': if self.filename == '-':
@@ -239,10 +224,10 @@ class File:
self.filename = None self.filename = None
self.modified = True self.modified = True
sio = io.StringIO(stdin) sio = io.StringIO(stdin)
self.lines, self.nl, mixed, self.sha256 = get_lines(sio) lines, self.nl, mixed, self.sha256 = get_lines(sio)
elif self.filename is not None and os.path.isfile(self.filename): elif self.filename is not None and os.path.isfile(self.filename):
with open(self.filename, newline='') as f: with open(self.filename, newline='') as f:
self.lines, self.nl, mixed, self.sha256 = get_lines(f) lines, self.nl, mixed, self.sha256 = get_lines(f)
else: else:
if self.filename is not None: if self.filename is not None:
if os.path.lexists(self.filename): if os.path.lexists(self.filename):
@@ -250,8 +235,9 @@ class File:
self.filename = None self.filename = None
else: else:
status.update('(new file)') status.update('(new file)')
sio = io.StringIO('') lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
self.buf = Buf(lines)
if mixed: if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}') status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -260,7 +246,7 @@ class File:
file_hls = [] file_hls = []
for factory in self._hl_factories: for factory in self._hl_factories:
if self.filename is not None: if self.filename is not None:
hl = factory.file_highlighter(self.filename, self.lines[0]) hl = factory.file_highlighter(self.filename, self.buf[0])
file_hls.append(hl) file_hls.append(hl)
else: else:
file_hls.append(factory.blank_file_highlighter()) file_hls.append(factory.blank_file_highlighter())
@@ -268,156 +254,109 @@ class File:
*file_hls, *file_hls,
self._trailing_whitespace, self._replace_hl, self.selection, self._trailing_whitespace, self._replace_hl, self.selection,
) )
for file_hl in self._file_hls:
file_hl.register_callbacks(self.buf)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>' return f'<{type(self).__name__} {self.filename!r}>'
# movement # movement
def scroll_screen_if_needed(self, margin: Margin) -> None:
# if the `y` is not on screen, make it so
if self.file_y <= self.y < self.file_y + margin.body_lines:
return
self.file_y = max(self.y - margin.body_lines // 2, 0)
def _scroll_amount(self) -> int:
return int(curses.LINES / 2 + .5)
def _set_x_after_vertical_movement(self) -> None:
self.x = min(len(self.lines[self.y]), self.x_hint)
def _increment_y(self, margin: Margin) -> None:
self.y += 1
if self.y >= self.file_y + margin.body_lines:
self.file_y += self._scroll_amount()
def _decrement_y(self) -> None:
self.y -= 1
if self.y < self.file_y:
self.file_y -= self._scroll_amount()
self.file_y = max(self.file_y, 0)
@action @action
def up(self, margin: Margin) -> None: def up(self, margin: Margin) -> None:
if self.y > 0: self.buf.up(margin)
self._decrement_y()
self._set_x_after_vertical_movement()
@action @action
def down(self, margin: Margin) -> None: def down(self, margin: Margin) -> None:
if self.y < len(self.lines) - 1: self.buf.down(margin)
self._increment_y(margin)
self._set_x_after_vertical_movement()
@action @action
def right(self, margin: Margin) -> None: def right(self, margin: Margin) -> None:
if self.x >= len(self.lines[self.y]): self.buf.right(margin)
if self.y < len(self.lines) - 1:
self.x = 0
self._increment_y(margin)
else:
self.x += 1
self.x_hint = self.x
@action @action
def left(self, margin: Margin) -> None: def left(self, margin: Margin) -> None:
if self.x == 0: self.buf.left(margin)
if self.y > 0:
self._decrement_y()
self.x = len(self.lines[self.y])
else:
self.x -= 1
self.x_hint = self.x
@action @action
def home(self, margin: Margin) -> None: def home(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.home()
@action @action
def end(self, margin: Margin) -> None: def end(self, margin: Margin) -> None:
self.x = self.x_hint = len(self.lines[self.y]) self.buf.end()
@action @action
def ctrl_up(self, margin: Margin) -> None: def ctrl_up(self, margin: Margin) -> None:
self.file_y = max(0, self.file_y - 1) self.buf.file_up(margin)
self.y = min(self.y, self.file_y + margin.body_lines - 1)
self._set_x_after_vertical_movement()
@action @action
def ctrl_down(self, margin: Margin) -> None: def ctrl_down(self, margin: Margin) -> None:
self.file_y = min(len(self.lines) - 1, self.file_y + 1) self.buf.file_down(margin)
self.y = max(self.y, self.file_y)
self._set_x_after_vertical_movement()
@action @action
def ctrl_right(self, margin: Margin) -> None: def ctrl_right(self, margin: Margin) -> None:
line = self.lines[self.y] line = self.buf[self.buf.y]
# if we're at the second to last character, jump to end of line # if we're at the second to last character, jump to end of line
if self.x == len(line) - 1: if self.buf.x == len(line) - 1:
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
# if we're at the end of the line, jump forward to the next non-ws # if we're at the end of the line, jump forward to the next non-ws
elif self.x == len(line): elif self.buf.x == len(line):
while ( while (
self.y < len(self.lines) - 1 and ( self.buf.y < len(self.buf) - 1 and (
self.x == len(self.lines[self.y]) or self.buf.x == len(self.buf[self.buf.y]) or
self.lines[self.y][self.x].isspace() self.buf[self.buf.y][self.buf.x].isspace()
) )
): ):
if self.x == len(self.lines[self.y]): self.buf.right(margin)
self._increment_y(margin)
self.x = self.x_hint = 0
else:
self.x = self.x_hint = self.x + 1
# if we're inside the line, jump to next position that's not our type # if we're inside the line, jump to next position that's not our type
else: else:
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
tp = line[self.x].isalnum() tp = line[self.buf.x].isalnum()
while self.x < len(line) and tp == line[self.x].isalnum(): while self.buf.x < len(line) and tp == line[self.buf.x].isalnum():
self.x = self.x_hint = self.x + 1 self.buf.right(margin)
@action @action
def ctrl_left(self, margin: Margin) -> None: def ctrl_left(self, margin: Margin) -> None:
line = self.lines[self.y] line = self.buf[self.buf.y]
# if we're at position 1 and it's not a space, go to the beginning # if we're at position 1 and it's not a space, go to the beginning
if self.x == 1 and not line[:self.x].isspace(): if self.buf.x == 1 and not line[:self.buf.x].isspace():
self.x = self.x_hint = 0 self.buf.left(margin)
# if we're at the beginning or it's all space up to here jump to the # if we're at the beginning or it's all space up to here jump to the
# end of the previous non-space line # end of the previous non-space line
elif self.x == 0 or line[:self.x].isspace(): elif self.buf.x == 0 or line[:self.buf.x].isspace():
self.x = self.x_hint = 0 self.buf.x = 0
while self.y > 0 and (self.x == 0 or not self.lines[self.y]): while self.buf.y > 0 and self.buf.x == 0:
self._decrement_y() self.buf.left(margin)
self.x = self.x_hint = len(self.lines[self.y])
else: else:
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
tp = line[self.x - 1].isalnum() tp = line[self.buf.x - 1].isalnum()
while self.x > 0 and tp == line[self.x - 1].isalnum(): while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum():
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
@action @action
def ctrl_home(self, margin: Margin) -> None: def ctrl_home(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
self.y = self.file_y = 0 self.buf.y = self.buf.file_y = 0
@action @action
def ctrl_end(self, margin: Margin) -> None: def ctrl_end(self, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@action @action
def go_to_line(self, lineno: int, margin: Margin) -> None: def go_to_line(self, lineno: int, margin: Margin) -> None:
self.x = self.x_hint = 0 self.buf.x = 0
if lineno == 0: if lineno == 0:
self.y = 0 self.buf.y = 0
elif lineno > len(self.lines): elif lineno > len(self.buf):
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
elif lineno < 0: elif lineno < 0:
self.y = max(0, lineno + len(self.lines)) self.buf.y = max(0, lineno + len(self.buf))
else: else:
self.y = lineno - 1 self.buf.y = lineno - 1
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@action @action
def search( def search(
@@ -432,14 +371,14 @@ class File:
except StopIteration: except StopIteration:
status.update('no matches') status.update('no matches')
else: else:
if line_y == self.y and match.start() == self.x: if line_y == self.buf.y and match.start() == self.buf.x:
status.update('this is the only occurrence') status.update('this is the only occurrence')
else: else:
if search.wrapped: if search.wrapped:
status.update('search wrapped') status.update('search wrapped')
self.y = line_y self.buf.y = line_y
self.x = self.x_hint = match.start() self.buf.x = match.start()
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@clear_selection @clear_selection
def replace( def replace(
@@ -454,21 +393,38 @@ class File:
res: Union[str, PromptResult] = '' res: Union[str, PromptResult] = ''
search = _SearchIter(self, reg, offset=0) search = _SearchIter(self, reg, offset=0)
for line_y, match in search: for line_y, match in search:
self.y = line_y end = match.end()
self.x = self.x_hint = match.start() self.buf.y = line_y
self.scroll_screen_if_needed(screen.margin) self.buf.x = match.start()
self.buf.scroll_screen_if_needed(screen.margin)
if res != 'a': # make `a` replace the rest of them if res != 'a': # make `a` replace the rest of them
with self._replace_hl.region(self.y, self.x, match.end()): with self._replace_hl.region(self.buf.y, self.buf.x, end):
screen.draw() screen.draw()
res = screen.quick_prompt('replace', ('yes', 'no', 'all')) res = screen.quick_prompt('replace', ('yes', 'no', 'all'))
if res in {'y', 'a'}: if res in {'y', 'a'}:
count += 1 count += 1
with self.edit_action_context('replace', final=True): with self.edit_action_context('replace', final=True):
replaced = match.expand(replace) replaced = match.expand(replace)
line = screen.file.lines[line_y] line = screen.file.buf[line_y]
line = line[:match.start()] + replaced + line[match.end():] if '\n' in replaced:
screen.file.lines[line_y] = line replaced_lines = replaced.split('\n')
search.offset = len(replaced) self.buf[line_y] = (
f'{line[:match.start()]}{replaced_lines[0]}'
)
for i, ins_line in enumerate(replaced_lines[1:-1], 1):
self.buf.insert(line_y + i, ins_line)
last_insert = line_y + len(replaced_lines) - 1
self.buf.insert(
last_insert, f'{replaced_lines[-1]}{line[end:]}',
)
self.buf.y = last_insert
self.buf.x = 0
search.offset = len(replaced_lines[-1])
else:
self.buf[line_y] = (
f'{line[:match.start()]}{replaced}{line[end:]}'
)
search.offset = len(replaced)
elif res == 'n': elif res == 'n':
search.offset = 1 search.offset = 1
else: else:
@@ -483,21 +439,21 @@ class File:
@action @action
def page_up(self, margin: Margin) -> None: def page_up(self, margin: Margin) -> None:
if self.y < margin.body_lines: if self.buf.y < margin.body_lines:
self.y = self.file_y = 0 self.buf.y = self.buf.file_y = 0
else: else:
pos = max(self.file_y - margin.page_size, 0) pos = max(self.buf.file_y - margin.page_size, 0)
self.y = self.file_y = pos self.buf.y = self.buf.file_y = pos
self.x = self.x_hint = 0 self.buf.x = 0
@action @action
def page_down(self, margin: Margin) -> None: def page_down(self, margin: Margin) -> None:
if self.file_y + margin.body_lines >= len(self.lines): if self.buf.file_y + margin.body_lines >= len(self.buf):
self.y = len(self.lines) - 1 self.buf.y = len(self.buf) - 1
else: else:
pos = self.file_y + margin.page_size pos = self.buf.file_y + margin.page_size
self.y = self.file_y = pos self.buf.y = self.buf.file_y = pos
self.x = self.x_hint = 0 self.buf.x = 0
# editing # editing
@@ -505,47 +461,51 @@ class File:
@clear_selection @clear_selection
def backspace(self, margin: Margin) -> None: def backspace(self, margin: Margin) -> None:
# backspace at the beginning of the file does nothing # backspace at the beginning of the file does nothing
if self.y == 0 and self.x == 0: if self.buf.y == 0 and self.buf.x == 0:
pass pass
# backspace at the end of the file does not change the contents # backspace at the end of the file does not change the contents
elif self.y == len(self.lines) - 1: elif self.buf.y == len(self.buf) - 1:
self._decrement_y() self.buf.left(margin)
self.x = self.x_hint = len(self.lines[self.y])
# at the beginning of the line, we join the current line and # at the beginning of the line, we join the current line and
# the previous line # the previous line
elif self.x == 0: elif self.buf.x == 0:
victim = self.lines.pop(self.y) y, victim = self.buf.y, self.buf.pop(self.buf.y)
new_x = len(self.lines[self.y - 1]) self.buf.left(margin)
self.lines[self.y - 1] += victim self.buf[y - 1] += victim
self._decrement_y()
self.x = self.x_hint = new_x
else: else:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x - 1] + s[self.x:] self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:]
self.x = self.x_hint = self.x - 1 self.buf.left(margin)
@edit_action('delete text', final=False) @edit_action('delete text', final=False)
@clear_selection @clear_selection
def delete(self, margin: Margin) -> None: def delete(self, margin: Margin) -> None:
# noop at end of the file if (
if self.y == len(self.lines) - 1: # noop at end of the file
self.buf.y == len(self.buf) - 1 or
# noop at end of last real line
(
self.buf.y == len(self.buf) - 2 and
self.buf.x == len(self.buf[self.buf.y])
)
):
pass pass
# if we're at the end of the line, collapse the line afterwards # if we're at the end of the line, collapse the line afterwards
elif self.x == len(self.lines[self.y]): elif self.buf.x == len(self.buf[self.buf.y]):
victim = self.lines.pop(self.y + 1) victim = self.buf.pop(self.buf.y + 1)
self.lines[self.y] += victim self.buf[self.buf.y] += victim
else: else:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x] + s[self.x + 1:] self.buf[self.buf.y] = s[:self.buf.x] + s[self.buf.x + 1:]
@edit_action('line break', final=False) @edit_action('line break', final=False)
@clear_selection @clear_selection
def enter(self, margin: Margin) -> None: def enter(self, margin: Margin) -> None:
s = self.lines[self.y] s = self.buf[self.buf.y]
self.lines[self.y] = s[:self.x] self.buf[self.buf.y] = s[:self.buf.x]
self.lines.insert(self.y + 1, s[self.x:]) self.buf.insert(self.buf.y + 1, s[self.buf.x:])
self._increment_y(margin) self.buf.down(margin)
self.x = self.x_hint = 0 self.buf.x = 0
@edit_action('indent selection', final=True) @edit_action('indent selection', final=True)
def _indent_selection(self, margin: Margin) -> None: def _indent_selection(self, margin: Margin) -> None:
@@ -553,21 +513,21 @@ class File:
sel_y, sel_x = self.selection.start sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1): for l_y in range(s_y, e_y + 1):
if self.lines[l_y]: if self.buf[l_y]:
self.lines[l_y] = ' ' * 4 + self.lines[l_y] self.buf[l_y] = ' ' * 4 + self.buf[l_y]
if l_y == self.y: if l_y == self.buf.y:
self.x = self.x_hint = self.x + 4 self.buf.x += 4
if l_y == sel_y and sel_x != 0: if l_y == sel_y and sel_x != 0:
sel_x += 4 sel_x += 4
self.selection.set(sel_y, sel_x, self.y, self.x) self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('insert tab', final=False) @edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None: def _tab(self, margin: Margin) -> None:
n = 4 - self.x % 4 n = 4 - self.buf.x % 4
line = self.lines[self.y] line = self.buf[self.buf.y]
self.lines[self.y] = line[:self.x] + n * ' ' + line[self.x:] self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
self.x = self.x_hint = self.x + n self.buf.x += n
_restore_lines_eof_invariant(self.lines) self.buf.restore_eof_invariant()
def tab(self, margin: Margin) -> None: def tab(self, margin: Margin) -> None:
if self.selection.start is not None: if self.selection.start is not None:
@@ -589,21 +549,21 @@ class File:
sel_y, sel_x = self.selection.start sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
for l_y in range(s_y, e_y + 1): for l_y in range(s_y, e_y + 1):
n = self._dedent_line(self.lines[l_y]) n = self._dedent_line(self.buf[l_y])
if n: if n:
self.lines[l_y] = self.lines[l_y][n:] self.buf[l_y] = self.buf[l_y][n:]
if l_y == self.y: if l_y == self.buf.y:
self.x = self.x_hint = max(self.x - n, 0) self.buf.x = max(self.buf.x - n, 0)
if l_y == sel_y: if l_y == sel_y:
sel_x = max(sel_x - n, 0) sel_x = max(sel_x - n, 0)
self.selection.set(sel_y, sel_x, self.y, self.x) self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('dedent', final=True) @edit_action('dedent', final=True)
def _dedent(self, margin: Margin) -> None: def _dedent(self, margin: Margin) -> None:
n = self._dedent_line(self.lines[self.y]) n = self._dedent_line(self.buf[self.buf.y])
if n: if n:
self.lines[self.y] = self.lines[self.y][n:] self.buf[self.buf.y] = self.buf[self.buf.y][n:]
self.x = self.x_hint = max(self.x - n, 0) self.buf.x = max(self.buf.x - n, 0)
def shift_tab(self, margin: Margin) -> None: def shift_tab(self, margin: Margin) -> None:
if self.selection.start is not None: if self.selection.start is not None:
@@ -617,20 +577,20 @@ class File:
ret = [] ret = []
(s_y, s_x), (e_y, e_x) = self.selection.get() (s_y, s_x), (e_y, e_x) = self.selection.get()
if s_y == e_y: if s_y == e_y:
ret.append(self.lines[s_y][s_x:e_x]) ret.append(self.buf[s_y][s_x:e_x])
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[s_y][e_x:] self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[s_y][e_x:]
else: else:
ret.append(self.lines[s_y][s_x:]) ret.append(self.buf[s_y][s_x:])
for l_y in range(s_y + 1, e_y): for l_y in range(s_y + 1, e_y):
ret.append(self.lines[l_y]) ret.append(self.buf[l_y])
ret.append(self.lines[e_y][:e_x]) ret.append(self.buf[e_y][:e_x])
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[e_y][e_x:] self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[e_y][e_x:]
for _ in range(s_y + 1, e_y + 1): for _ in range(s_y + 1, e_y + 1):
self.lines.pop(s_y + 1) self.buf.pop(s_y + 1)
self.y = s_y self.buf.y = s_y
self.x = self.x_hint = s_x self.buf.x = s_x
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
return tuple(ret) return tuple(ret)
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]: def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
@@ -639,21 +599,21 @@ class File:
cut_buffer = () cut_buffer = ()
with self.edit_action_context('cut', final=False): with self.edit_action_context('cut', final=False):
if self.y == len(self.lines) - 1: if self.buf.y == len(self.buf) - 1:
return cut_buffer return cut_buffer
else: else:
victim = self.lines.pop(self.y) victim = self.buf.pop(self.buf.y)
self.x = self.x_hint = 0 self.buf.x = 0
return cut_buffer + (victim,) return cut_buffer + (victim,)
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer: for cut_line in cut_buffer:
line = self.lines[self.y] line = self.buf[self.buf.y]
before, after = line[:self.x], line[self.x:] before, after = line[:self.buf.x], line[self.buf.x:]
self.lines[self.y] = before + cut_line self.buf[self.buf.y] = before + cut_line
self.lines.insert(self.y + 1, after) self.buf.insert(self.buf.y + 1, after)
self._increment_y(margin) self.buf.down(margin)
self.x = self.x_hint = 0 self.buf.x = 0
@edit_action('uncut', final=True) @edit_action('uncut', final=True)
@clear_selection @clear_selection
@@ -667,30 +627,31 @@ class File:
cut_buffer: Tuple[str, ...], margin: Margin, cut_buffer: Tuple[str, ...], margin: Margin,
) -> None: ) -> None:
self._uncut(cut_buffer, margin) self._uncut(cut_buffer, margin)
self._decrement_y() self.buf.up(margin)
self.x = self.x_hint = len(self.lines[self.y]) self.buf.x = len(self.buf[self.buf.y])
self.lines[self.y] += self.lines.pop(self.y + 1) self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
self.buf.restore_eof_invariant()
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None: def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
# self.lines intentionally does not support slicing so we use islice # self.buf intentionally does not support slicing so we use islice
lines = sorted(itertools.islice(self.lines, s_y, e_y)) lines = sorted(itertools.islice(self.buf, s_y, e_y))
for i, line in zip(range(s_y, e_y), lines): for i, line in zip(range(s_y, e_y), lines):
self.lines[i] = line self.buf[i] = line
self.y = s_y self.buf.y = s_y
self.x = self.x_hint = 0 self.buf.x = 0
self.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
@edit_action('sort', final=True) @edit_action('sort', final=True)
def sort(self, margin: Margin) -> None: def sort(self, margin: Margin) -> None:
self._sort(margin, 0, len(self.lines) - 1) self._sort(margin, 0, len(self.buf) - 1)
@edit_action('sort selection', final=True) @edit_action('sort selection', final=True)
@clear_selection @clear_selection
def sort_selection(self, margin: Margin) -> None: def sort_selection(self, margin: Margin) -> None:
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.lines) - 1) e_y = min(e_y + 1, len(self.buf) - 1)
if self.lines[e_y - 1] == '': if self.buf[e_y - 1] == '':
e_y -= 1 e_y -= 1
self._sort(margin, s_y, e_y) self._sort(margin, s_y, e_y)
@@ -716,7 +677,6 @@ class File:
b'kEND5': ctrl_end, b'kEND5': ctrl_end,
# editing # editing
b'KEY_BACKSPACE': backspace, b'KEY_BACKSPACE': backspace,
b'^H': backspace, # ^Backspace
b'KEY_DC': delete, b'KEY_DC': delete,
b'^M': enter, b'^M': enter,
b'^I': tab, b'^I': tab,
@@ -738,14 +698,12 @@ class File:
@edit_action('text', final=False) @edit_action('text', final=False)
@clear_selection @clear_selection
def c(self, wch: str, margin: Margin) -> None: def c(self, wch: str) -> None:
s = self.lines[self.y] self.buf.c(wch)
self.lines[self.y] = s[:self.x] + wch + s[self.x:] self.buf.restore_eof_invariant()
self.x = self.x_hint = self.x + len(wch)
_restore_lines_eof_invariant(self.lines)
def finalize_previous_action(self) -> None: def finalize_previous_action(self) -> None:
assert not isinstance(self.lines, ListSpy), 'nested edit/movement' assert not self._in_edit_action, 'nested edit/movement'
self.selection.clear() self.selection.clear()
if self.undo_stack: if self.undo_stack:
self.undo_stack[-1].final = True self.undo_stack[-1].final = True
@@ -764,106 +722,100 @@ class File:
final: bool, final: bool,
) -> Generator[None, None, None]: ) -> Generator[None, None, None]:
continue_last = self._continue_last_action(name) continue_last = self._continue_last_action(name)
if continue_last: if not continue_last and self.undo_stack:
spy = self.undo_stack[-1].spy self.undo_stack[-1].final = True
else:
if self.undo_stack:
self.undo_stack[-1].final = True
spy = ListSpy(self.lines)
before_x, before_line = self.x, self.y before_x, before_line = self.buf.x, self.buf.y
before_modified = self.modified before_modified = self.modified
assert not isinstance(self.lines, ListSpy), 'recursive action?' assert not self._in_edit_action, f'recursive action? {name}'
orig, self.lines = self.lines, spy self._in_edit_action = True
try: try:
yield with self.buf.record() as modifications:
yield
finally: finally:
self.lines = orig self._in_edit_action = False
self.redo_stack.clear() self.redo_stack.clear()
if continue_last: if continue_last:
self.undo_stack[-1].end_x = self.x self.undo_stack[-1].end_x = self.buf.x
self.undo_stack[-1].end_y = self.y self.undo_stack[-1].end_y = self.buf.y
self.touch(spy.min_line_touched) self.undo_stack[-1].modifications.extend(modifications)
elif spy.has_modifications: elif modifications:
self.modified = True self.modified = True
action = Action( action = Action(
name=name, spy=spy, name=name, modifications=modifications,
start_x=before_x, start_y=before_line, start_x=before_x, start_y=before_line,
start_modified=before_modified, start_modified=before_modified,
end_x=self.x, end_y=self.y, end_x=self.buf.x, end_y=self.buf.y,
end_modified=True, end_modified=True,
final=final, final=final,
) )
self.undo_stack.append(action) self.undo_stack.append(action)
self.touch(spy.min_line_touched)
@contextlib.contextmanager @contextlib.contextmanager
def select(self) -> Generator[None, None, None]: def select(self) -> Generator[None, None, None]:
if self.selection.start is None: if self.selection.start is None:
start = (self.y, self.x) start = (self.buf.y, self.buf.x)
else: else:
start = self.selection.start start = self.selection.start
try: try:
yield yield
finally: finally:
self.selection.set(*start, self.y, self.x) self.selection.set(*start, self.buf.y, self.buf.x)
# positioning # positioning
def rendered_y(self, margin: Margin) -> int:
return self.y - self.file_y + margin.header
def rendered_x(self) -> int:
return self.x - line_x(self.x, curses.COLS)
def move_cursor( def move_cursor(
self, self,
stdscr: 'curses._CursesWindow', stdscr: 'curses._CursesWindow',
margin: Margin, margin: Margin,
) -> None: ) -> None:
stdscr.move(self.rendered_y(margin), self.rendered_x()) stdscr.move(*self.buf.cursor_position(margin))
def touch(self, lineno: int) -> None:
for file_hl in self._file_hls:
file_hl.touch(lineno)
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(len(self.lines) - self.file_y, margin.body_lines) to_display = min(self.buf.displayable_count, margin.body_lines)
for file_hl in self._file_hls: for file_hl in self._file_hls:
file_hl.highlight_until(self.lines, self.file_y + to_display) # XXX: this will go away?
file_hl.highlight_until(self.buf, self.buf.file_y + to_display)
for i in range(to_display): for i in range(to_display):
draw_y = i + margin.header draw_y = i + margin.header
l_y = self.file_y + i l_y = self.buf.file_y + i
x = self.x if l_y == self.y else 0 stdscr.insstr(draw_y, 0, self.buf.rendered_line(l_y, margin))
line = scrolled_line(self.lines[l_y], x, curses.COLS)
stdscr.insstr(draw_y, 0, line)
l_x = line_x(x, curses.COLS) l_x = self.buf.line_x(margin) if l_y == self.buf.y else 0
l_x_max = l_x + curses.COLS l_x_max = l_x + margin.cols
for file_hl in self._file_hls: for file_hl in self._file_hls:
for region in file_hl.regions[l_y]: for region in file_hl.regions[l_y]:
if region.x >= l_x_max: l_positions = self.buf.line_positions(l_y)
r_x = l_positions[region.x]
# the selection highlight intentionally extends one past
# the end of the line, which won't have a position
if region.end == len(l_positions):
r_end = l_positions[-1] + 1
else:
r_end = l_positions[region.end]
if r_x >= l_x_max:
break break
elif region.end < l_x: elif r_end <= l_x:
continue continue
if l_x and region.x <= l_x: if l_x and r_x <= l_x:
if file_hl.include_edge: if file_hl.include_edge:
h_s_x = 0 h_s_x = 0
else: else:
h_s_x = 1 h_s_x = 1
else: else:
h_s_x = region.x - l_x h_s_x = r_x - l_x
if region.end >= l_x_max: if r_end >= l_x_max and l_x_max < l_positions[-1]:
if file_hl.include_edge: if file_hl.include_edge:
h_e_x = curses.COLS h_e_x = margin.cols
else: else:
h_e_x = curses.COLS - 1 h_e_x = margin.cols - 1
else: else:
h_e_x = region.end - l_x h_e_x = r_end - l_x
stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr) stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr)

View File

@@ -1,21 +1,20 @@
import contextlib
import functools import functools
import json import json
import os.path import os.path
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import FrozenSet
from typing import List from typing import List
from typing import Match from typing import Match
from typing import NamedTuple from typing import NamedTuple
from typing import Optional from typing import Optional
from typing import Sequence
from typing import Tuple from typing import Tuple
from typing import TypeVar from typing import TypeVar
from identify.identify import tags_from_filename from identify.identify import tags_from_filename
from babi._types import Protocol from babi._types import Protocol
from babi.fdict import FDict from babi.fdict import FChainMap
from babi.reg import _Reg from babi.reg import _Reg
from babi.reg import _RegSet from babi.reg import _RegSet
from babi.reg import ERR_REG from babi.reg import ERR_REG
@@ -68,6 +67,8 @@ class _Rule(Protocol):
def include(self) -> Optional[str]: ... def include(self) -> Optional[str]: ...
@property @property
def patterns(self) -> 'Tuple[_Rule, ...]': ... def patterns(self) -> 'Tuple[_Rule, ...]': ...
@property
def repository(self) -> 'FChainMap[str, _Rule]': ...
@uniquely_constructed @uniquely_constructed
@@ -84,9 +85,24 @@ class Rule(NamedTuple):
while_captures: Captures while_captures: Captures
include: Optional[str] include: Optional[str]
patterns: Tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
repository: FChainMap[str, _Rule]
@classmethod @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')) name = _split_name(dct.get('name'))
match = dct.get('match') match = dct.get('match')
begin = dct.get('begin') begin = dct.get('begin')
@@ -96,7 +112,7 @@ class Rule(NamedTuple):
if 'captures' in dct: if 'captures' in dct:
captures = tuple( captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['captures'].items() for k, v in dct['captures'].items()
) )
else: else:
@@ -104,7 +120,7 @@ class Rule(NamedTuple):
if 'beginCaptures' in dct: if 'beginCaptures' in dct:
begin_captures = tuple( begin_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['beginCaptures'].items() for k, v in dct['beginCaptures'].items()
) )
else: else:
@@ -112,7 +128,7 @@ class Rule(NamedTuple):
if 'endCaptures' in dct: if 'endCaptures' in dct:
end_captures = tuple( end_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['endCaptures'].items() for k, v in dct['endCaptures'].items()
) )
else: else:
@@ -120,7 +136,7 @@ class Rule(NamedTuple):
if 'whileCaptures' in dct: if 'whileCaptures' in dct:
while_captures = tuple( while_captures = tuple(
(int(k), Rule.from_dct(v)) (int(k), Rule.make(v, repository))
for k, v in dct['whileCaptures'].items() for k, v in dct['whileCaptures'].items()
) )
else: else:
@@ -142,7 +158,7 @@ class Rule(NamedTuple):
include = dct.get('include') include = dct.get('include')
if 'patterns' in dct: 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: else:
patterns = () patterns = ()
@@ -159,29 +175,33 @@ class Rule(NamedTuple):
while_captures=while_captures, while_captures=while_captures,
include=include, include=include,
patterns=patterns, patterns=patterns,
repository=repository,
) )
@uniquely_constructed @uniquely_constructed
class Grammar(NamedTuple): class Grammar(NamedTuple):
scope_name: str scope_name: str
repository: FChainMap[str, _Rule]
patterns: Tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
repository: FDict[str, _Rule]
@classmethod @classmethod
def from_data(cls, data: Dict[str, Any]) -> 'Grammar': def make(cls, data: Dict[str, Any]) -> 'Grammar':
scope_name = data['scopeName'] scope_name = data['scopeName']
patterns = tuple(Rule.from_dct(dct) for dct in data['patterns'])
if 'repository' in data: if 'repository' in data:
repository = FDict({ # this looks odd, but it's so we can have a self-referential
k: Rule.from_dct(dct) for k, dct in data['repository'].items() # 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: else:
repository = FDict({}) repository = FChainMap()
patterns = tuple(Rule.make(d, repository) for d in data['patterns'])
return cls( return cls(
scope_name=scope_name, scope_name=scope_name,
patterns=patterns,
repository=repository, repository=repository,
patterns=patterns,
) )
@@ -531,22 +551,23 @@ class Compiler:
def _include( def _include(
self, self,
grammar: Grammar, grammar: Grammar,
repository: FChainMap[str, _Rule],
s: str, s: str,
) -> Tuple[List[str], Tuple[_Rule, ...]]: ) -> Tuple[List[str], Tuple[_Rule, ...]]:
if s == '$self': if s == '$self':
return self._patterns(grammar, grammar.patterns) return self._patterns(grammar, grammar.patterns)
elif s == '$base': elif s == '$base':
grammar = self._grammars.grammar_for_scope(self._root_scope) grammar = self._grammars.grammar_for_scope(self._root_scope)
return self._include(grammar, '$self') return self._include(grammar, grammar.repository, '$self')
elif s.startswith('#'): elif s.startswith('#'):
return self._patterns(grammar, (grammar.repository[s[1:]],)) return self._patterns(grammar, (repository[s[1:]],))
elif '#' not in s: elif '#' not in s:
grammar = self._grammars.grammar_for_scope(s) grammar = self._grammars.grammar_for_scope(s)
return self._include(grammar, '$self') return self._include(grammar, grammar.repository, '$self')
else: else:
scope, _, s = s.partition('#') scope, _, s = s.partition('#')
grammar = self._grammars.grammar_for_scope(scope) grammar = self._grammars.grammar_for_scope(scope)
return self._include(grammar, f'#{s}') return self._include(grammar, grammar.repository, f'#{s}')
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
def _patterns( def _patterns(
@@ -558,7 +579,9 @@ class Compiler:
ret_rules: List[_Rule] = [] ret_rules: List[_Rule] = []
for rule in rules: for rule in rules:
if rule.include is not None: 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_regs.extend(tmp_regs)
ret_rules.extend(tmp_rules) ret_rules.extend(tmp_rules)
elif rule.match is None and rule.begin is None and rule.patterns: elif rule.match is None and rule.begin is None and rule.patterns:
@@ -618,8 +641,10 @@ class Compiler:
return PatternRule(rule.name, make_regset(*regs), rules) return PatternRule(rule.name, make_regset(*regs), rules)
def compile_rule(self, rule: _Rule) -> CompiledRule: def compile_rule(self, rule: _Rule) -> CompiledRule:
with contextlib.suppress(KeyError): try:
return self._c_rules[rule] return self._c_rules[rule]
except KeyError:
pass
grammar = self._rule_to_grammar[rule] grammar = self._rule_to_grammar[rule]
ret = self._c_rules[rule] = self._compile_rule(grammar, rule) ret = self._c_rules[rule] = self._compile_rule(grammar, rule)
@@ -627,41 +652,58 @@ class Compiler:
class Grammars: class Grammars:
def __init__(self, grammars: Sequence[Dict[str, Any]]) -> None: def __init__(self, *directories: str) -> None:
self._raw = {grammar['scopeName']: grammar for grammar in grammars} self._scope_to_files = {
self._find_scope = [ os.path.splitext(filename)[0]: os.path.join(directory, filename)
( for directory in directories
frozenset(grammar.get('fileTypes', ())), if os.path.exists(directory)
make_reg(grammar.get('firstLineMatch', '$impossible^')), for filename in sorted(os.listdir(directory))
grammar['scopeName'], if filename.endswith('.json')
) }
for grammar in grammars
]
self._parsed: Dict[str, Grammar] = {}
self._compilers: Dict[str, Compiler] = {}
@classmethod unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
def from_syntax_dir(cls, syntax_dir: str) -> 'Grammars': self._raw = {'source.unknown': unknown_grammar}
grammars = [{'scopeName': 'source.unknown', 'patterns': []}] self._file_types: List[Tuple[FrozenSet[str], str]] = []
if os.path.exists(syntax_dir): self._first_line: List[Tuple[_Reg, str]] = []
for filename in os.listdir(syntax_dir): self._parsed: Dict[str, Grammar] = {}
with open(os.path.join(syntax_dir, filename)) as f: self._compiled: Dict[str, Compiler] = {}
grammars.append(json.load(f))
return cls(grammars) def _raw_for_scope(self, scope: str) -> Dict[str, Any]:
try:
return self._raw[scope]
except KeyError:
pass
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: def grammar_for_scope(self, scope: str) -> Grammar:
with contextlib.suppress(KeyError): try:
return self._parsed[scope] return self._parsed[scope]
except KeyError:
pass
ret = self._parsed[scope] = Grammar.from_data(self._raw[scope]) raw = self._raw_for_scope(scope)
ret = self._parsed[scope] = Grammar.make(raw)
return ret return ret
def compiler_for_scope(self, scope: str) -> Compiler: def compiler_for_scope(self, scope: str) -> Compiler:
with contextlib.suppress(KeyError): try:
return self._compilers[scope] return self._compiled[scope]
except KeyError:
pass
grammar = self.grammar_for_scope(scope) grammar = self.grammar_for_scope(scope)
ret = self._compilers[scope] = Compiler(grammar, self) ret = self._compiled[scope] = Compiler(grammar, self)
return ret return ret
def blank_compiler(self) -> Compiler: def blank_compiler(self) -> Compiler:
@@ -669,20 +711,26 @@ class Grammars:
def compiler_for_file(self, filename: str, first_line: str) -> Compiler: def compiler_for_file(self, filename: str, first_line: str) -> Compiler:
for tag in tags_from_filename(filename) - {'text'}: for tag in tags_from_filename(filename) - {'text'}:
with contextlib.suppress(KeyError): try:
# TODO: this doesn't always match even if we detect it
return self.compiler_for_scope(f'source.{tag}') return self.compiler_for_scope(f'source.{tag}')
except KeyError:
pass
# 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('.') _, _, ext = os.path.basename(filename).rpartition('.')
for extensions, first_line_match, scope_name in self._find_scope: for extensions, scope in self._file_types:
if ( if ext in extensions:
ext in extensions or return self.compiler_for_scope(scope)
first_line_match.match(
first_line, 0, first_line=True, boundary=True, 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(scope_name)
else: return self.compiler_for_scope('source.unknown')
return self.compiler_for_scope('source.unknown')
def highlight_line( def highlight_line(

View File

@@ -2,7 +2,7 @@ from typing import NamedTuple
from typing import Tuple from typing import Tuple
from babi._types import Protocol from babi._types import Protocol
from babi.list_spy import SequenceNoSlice from babi.buf import Buf
class HL(NamedTuple): class HL(NamedTuple):
@@ -23,8 +23,8 @@ class FileHL(Protocol):
def include_edge(self) -> bool: ... def include_edge(self) -> bool: ...
@property @property
def regions(self) -> RegionsMapping: ... def regions(self) -> RegionsMapping: ...
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ... def highlight_until(self, lines: Buf, idx: int) -> None: ...
def touch(self, lineno: int) -> None: ... def register_callbacks(self, buf: Buf) -> None: ...
class HLFactory(Protocol): class HLFactory(Protocol):

View File

@@ -4,9 +4,9 @@ import curses
from typing import Dict from typing import Dict
from typing import Generator from typing import Generator
from babi.buf import Buf
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class Replace: class Replace:
@@ -15,10 +15,10 @@ class Replace:
def __init__(self) -> None: def __init__(self) -> None:
self.regions: Dict[int, HLs] = collections.defaultdict(tuple) self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: def highlight_until(self, lines: Buf, idx: int) -> None:
"""our highlight regions are populated in other ways""" """our highlight regions are populated in other ways"""
def touch(self, lineno: int) -> None: def register_callbacks(self, buf: Buf) -> None:
"""our highlight regions are populated in other ways""" """our highlight regions are populated in other ways"""
@contextlib.contextmanager @contextlib.contextmanager

View File

@@ -4,9 +4,9 @@ from typing import Dict
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from babi.buf import Buf
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class Selection: class Selection:
@@ -17,7 +17,10 @@ class Selection:
self.start: Optional[Tuple[int, int]] = None self.start: Optional[Tuple[int, int]] = None
self.end: Optional[Tuple[int, int]] = None self.end: Optional[Tuple[int, int]] = None
def highlight_until(self, lines: SequenceNoSlice, idx: 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: if self.start is None or self.end is None:
return return
@@ -36,9 +39,6 @@ class Selection:
) )
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),) self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
def touch(self, lineno: int) -> None:
"""our highlight regions are populated in other ways"""
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]: def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
assert self.start is not None and self.end is not None assert self.start is not None and self.end is not None
if self.start < self.end: if self.start < self.end:

View File

@@ -1,9 +1,13 @@
import curses import curses
from typing import Dict import functools
import math
from typing import Callable
from typing import List from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple from typing import Tuple
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.highlight import Compiler from babi.highlight import Compiler
from babi.highlight import Grammars from babi.highlight import Grammars
@@ -11,9 +15,9 @@ from babi.highlight import highlight_line
from babi.highlight import State from babi.highlight import State
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
from babi.theme import Style from babi.theme import Style
from babi.theme import Theme 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_config
from babi.user_data import xdg_data from babi.user_data import xdg_data
@@ -36,8 +40,10 @@ class FileSyntax:
self.regions: List[HLs] = [] self.regions: List[HLs] = []
self._states: List[State] = [] self._states: List[State] = []
self._hl_cache: Dict[str, Dict[State, Tuple[State, HLs]]] # this will be assigned a functools.lru_cache per instance for
self._hl_cache = {} # 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: def attr(self, style: Style) -> int:
pair = self._color_manager.color_pair(style.fg, style.bg) pair = self._color_manager.color_pair(style.fg, style.bg)
@@ -48,19 +54,14 @@ class FileSyntax:
curses.A_UNDERLINE * style.u curses.A_UNDERLINE * style.u
) )
def _hl( def _hl_uncached(
self, self,
state: State, state: State,
line: str, line: str,
i: int, first_line: bool,
) -> Tuple[State, HLs]: ) -> Tuple[State, HLs]:
try:
return self._hl_cache[line][state]
except KeyError:
pass
new_state, regions = highlight_line( 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 # remove the trailing newline
@@ -83,25 +84,42 @@ class FileSyntax:
else: else:
regs.append(HL(x=r.start, end=r.end, attr=attr)) regs.append(HL(x=r.start, end=r.end, attr=attr))
dct = self._hl_cache.setdefault(line, {}) return new_state, tuple(regs)
ret = dct[state] = (new_state, tuple(regs))
return ret 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: if not self._states:
state = self._compiler.root_state state = self._compiler.root_state
else: else:
state = self._states[-1] state = self._states[-1]
for i in range(len(self._states), idx): 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._states.append(state)
self.regions.append(regions) self.regions.append(regions)
def touch(self, lineno: int) -> None:
del self._states[lineno:]
del self.regions[lineno:]
class Syntax(NamedTuple): class Syntax(NamedTuple):
grammars: Grammars grammars: Grammars
@@ -140,7 +158,7 @@ class Syntax(NamedTuple):
stdscr: 'curses._CursesWindow', stdscr: 'curses._CursesWindow',
color_manager: ColorManager, color_manager: ColorManager,
) -> 'Syntax': ) -> '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')) theme = Theme.from_filename(xdg_config('theme.json'))
ret = cls(grammars, theme, color_manager) ret = cls(grammars, theme, color_manager)
ret._init_screen(stdscr) ret._init_screen(stdscr)

View File

@@ -1,10 +1,10 @@
import curses import curses
from typing import List from typing import List
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.hl.interface import HL from babi.hl.interface import HL
from babi.hl.interface import HLs from babi.hl.interface import HLs
from babi.list_spy import SequenceNoSlice
class TrailingWhitespace: class TrailingWhitespace:
@@ -30,9 +30,23 @@ class TrailingWhitespace:
attr = curses.color_pair(pair) attr = curses.color_pair(pair)
return (HL(x=i, end=len(line), attr=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): for i in range(len(self.regions), idx):
self.regions.append(self._trailing_ws(lines[i])) self.regions.append(self._trailing_ws(lines[i]))
def touch(self, lineno: int) -> None:
del self.regions[lineno:]

View File

@@ -1,3 +1,10 @@
import bisect
import curses
from typing import Tuple
from babi.cached_property import cached_property
def line_x(x: int, width: int) -> int: def line_x(x: int, width: int) -> int:
if x + 1 < width: if x + 1 < width:
return 0 return 0
@@ -13,15 +20,48 @@ def line_x(x: int, width: int) -> int:
) )
def scrolled_line(s: str, x: int, width: int) -> str: def scrolled_line(
l_x = line_x(x, width) s: str,
positions: Tuple[int, ...],
cursor_x: int,
width: int,
) -> str:
l_x = line_x(cursor_x, width)
if l_x: if l_x:
s = f'«{s[l_x + 1:]}' l_x_min = l_x + 1
if len(s) > width: start = bisect.bisect_left(positions, l_x_min)
return f'{s[:width - 1]}»' 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: else:
return s.ljust(width) return f'{pad_left}{s[start:]}'.ljust(width)
elif len(s) > width: elif positions[-1] > width:
return f'{s[:width - 1]}»' 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: 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

View File

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

View File

@@ -5,6 +5,7 @@ import sys
from typing import Optional from typing import Optional
from typing import Sequence from typing import Sequence
from babi.buf import Buf
from babi.file import File from babi.file import File
from babi.perf import Perf from babi.perf import Perf
from babi.perf import perf_log from babi.perf import perf_log
@@ -32,7 +33,7 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
return ret return ret
elif key.keyname == b'STRING': elif key.keyname == b'STRING':
assert isinstance(key.wch, str), key.wch assert isinstance(key.wch, str), key.wch
screen.file.c(key.wch, screen.margin) screen.file.c(key.wch)
else: else:
screen.status.update(f'unknown key: {key}') screen.status.update(f'unknown key: {key}')
@@ -68,7 +69,7 @@ def c_main(
def _key_debug(stdscr: 'curses._CursesWindow') -> int: def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen = Screen(stdscr, ['<<key debug>>'], Perf()) screen = Screen(stdscr, ['<<key debug>>'], Perf())
screen.file.lines = [''] screen.file.buf = Buf([''])
while True: while True:
screen.status.update('press q to quit') screen.status.update('press q to quit')
@@ -76,7 +77,7 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen.file.move_cursor(screen.stdscr, screen.margin) screen.file.move_cursor(screen.stdscr, screen.margin)
key = screen.get_char() key = screen.get_char()
screen.file.lines.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}') screen.file.buf.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
screen.file.down(screen.margin) screen.file.down(screen.margin)
if key.wch == curses.KEY_RESIZE: if key.wch == curses.KEY_RESIZE:
screen.resize() screen.resize()

View File

@@ -3,12 +3,20 @@ from typing import NamedTuple
class Margin(NamedTuple): class Margin(NamedTuple):
header: bool lines: int
footer: bool cols: int
@property
def header(self) -> bool:
return self.lines > 2
@property
def footer(self) -> bool:
return self.lines > 1
@property @property
def body_lines(self) -> int: def body_lines(self) -> int:
return curses.LINES - self.header - self.footer return self.lines - self.header - self.footer
@property @property
def page_size(self) -> int: def page_size(self) -> int:
@@ -17,11 +25,11 @@ class Margin(NamedTuple):
else: else:
return self.body_lines - 2 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 @classmethod
def from_current_screen(cls) -> 'Margin': def from_current_screen(cls) -> 'Margin':
if curses.LINES == 1: return cls(curses.LINES, curses.COLS)
return cls(header=False, footer=False)
elif curses.LINES == 2:
return cls(header=False, footer=True)
else:
return cls(header=True, footer=True)

View File

@@ -6,8 +6,7 @@ from typing import Tuple
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Union from typing import Union
from babi.horizontal_scrolling import line_x from babi.buf import Buf
from babi.horizontal_scrolling import scrolled_line
if TYPE_CHECKING: if TYPE_CHECKING:
from babi.main import Screen # XXX: circular from babi.main import Screen # XXX: circular
@@ -19,43 +18,54 @@ class Prompt:
def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None: def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None:
self._screen = screen self._screen = screen
self._prompt = prompt self._prompt = prompt
self._lst = lst self._buf = Buf(lst)
self._y = len(lst) - 1 self._buf.y = self._buf.file_y = len(lst) - 1
self._x = len(self._s) 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 @property
def _s(self) -> str: def _s(self) -> str:
return self._lst[self._y] return self._buf[self._buf.y]
@_s.setter @_s.setter
def _s(self, s: str) -> None: 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: def _render_prompt(self, *, base: Optional[str] = None) -> None:
base = base or self._prompt base = base or self._prompt
if not base or curses.COLS < 7: if not base or self._screen.margin.cols < 7:
prompt_s = '' prompt_s = ''
elif len(base) > curses.COLS - 6: elif len(base) > self._screen.margin.cols - 6:
prompt_s = f'{base[:curses.COLS - 7]}…: ' prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
else: else:
prompt_s = f'{base}: ' prompt_s = f'{base}: '
width = curses.COLS - len(prompt_s)
line = scrolled_line(self._s, self._x, width) width = self._screen.margin.cols - len(prompt_s)
cmd = f'{prompt_s}{line}' margin = self._screen.margin._replace(cols=width)
self._screen.stdscr.insstr(curses.LINES - 1, 0, cmd, curses.A_REVERSE) cmd = f'{prompt_s}{self._buf.rendered_line(self._buf.y, margin)}'
x = len(prompt_s) + self._x - line_x(self._x, width) prompt_line = self._screen.margin.lines - 1
self._screen.stdscr.move(curses.LINES - 1, x) 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: def _up(self) -> None:
self._y = max(0, self._y - 1) self._buf.up(self._screen.margin)
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _down(self) -> None: def _down(self) -> None:
self._y = min(len(self._lst) - 1, self._y + 1) self._buf.down(self._screen.margin)
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _right(self) -> None: 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: def _left(self) -> None:
self._x = max(0, self._x - 1) self._x = max(0, self._x - 1)
@@ -64,11 +74,11 @@ class Prompt:
self._x = 0 self._x = 0
def _end(self) -> None: def _end(self) -> None:
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _ctrl_left(self) -> None: def _ctrl_left(self) -> None:
if self._x <= 1: if self._x <= 1:
self._x = 0 self._buf.home()
else: else:
self._x -= 1 self._x -= 1
tp = self._s[self._x - 1].isalnum() tp = self._s[self._x - 1].isalnum()
@@ -77,7 +87,7 @@ class Prompt:
def _ctrl_right(self) -> None: def _ctrl_right(self) -> None:
if self._x >= len(self._s) - 1: if self._x >= len(self._s) - 1:
self._x = len(self._s) self._buf.end()
else: else:
self._x += 1 self._x += 1
tp = self._s[self._x].isalnum() tp = self._s[self._x].isalnum()
@@ -102,9 +112,9 @@ class Prompt:
def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]: def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]:
failed = False failed = False
for search_idx in range(idx, -1, -1): for search_idx in range(idx, -1, -1):
if s in self._lst[search_idx]: if s in self._buf[search_idx]:
idx = self._y = search_idx idx = self._buf.y = search_idx
self._x = self._lst[search_idx].index(s) self._x = self._buf[search_idx].index(s)
break break
else: else:
failed = True failed = True
@@ -112,7 +122,7 @@ class Prompt:
def _reverse_search(self) -> Union[None, str, PromptResult]: def _reverse_search(self) -> Union[None, str, PromptResult]:
reverse_s = '' reverse_s = ''
idx = self._y idx = self._buf.y
while True: while True:
fail, idx = self._check_failed(idx, reverse_s) fail, idx = self._check_failed(idx, reverse_s)
@@ -126,7 +136,7 @@ class Prompt:
key = self._screen.get_char() key = self._screen.get_char()
if key.keyname == b'KEY_RESIZE': if key.keyname == b'KEY_RESIZE':
self._screen.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] reverse_s = reverse_s[:-1]
elif key.keyname == b'^R': elif key.keyname == b'^R':
idx = max(0, idx - 1) idx = max(0, idx - 1)
@@ -163,7 +173,6 @@ class Prompt:
b'kLFT5': _ctrl_left, b'kLFT5': _ctrl_left,
# editing # editing
b'KEY_BACKSPACE': _backspace, b'KEY_BACKSPACE': _backspace,
b'^H': _backspace, # ^Backspace
b'KEY_DC': _delete, b'KEY_DC': _delete,
b'^K': _cut_to_end, b'^K': _cut_to_end,
# misc # misc
@@ -174,8 +183,7 @@ class Prompt:
} }
def _c(self, c: str) -> None: def _c(self, c: str) -> None:
self._s = self._s[:self._x] + c + self._s[self._x:] self._buf.c(c)
self._x += len(c)
def run(self) -> Union[PromptResult, str]: def run(self) -> Union[PromptResult, str]:
while True: while True:

View File

@@ -50,19 +50,19 @@ class _Reg:
@cached_property @cached_property
def _reg(self) -> onigurumacffi._Pattern: def _reg(self) -> onigurumacffi._Pattern:
return onigurumacffi.compile(self._pattern) return onigurumacffi.compile(_replace_esc(self._pattern, 'z'))
@cached_property @cached_property
def _reg_no_A(self) -> onigurumacffi._Pattern: 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 @cached_property
def _reg_no_G(self) -> onigurumacffi._Pattern: 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 @cached_property
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern: 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( def _get_reg(
self, self,

View File

@@ -61,6 +61,36 @@ SEQUENCE_KEYNAME = {
'\x1b[1;6H': b'kHOM6', # Shift + ^Home '\x1b[1;6H': b'kHOM6', # Shift + ^Home
'\x1b[1;6F': b'kEND6', # Shift + ^End '\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): class Key(NamedTuple):
@@ -105,7 +135,7 @@ class Screen:
else: else:
files = '' files = ''
version_width = len(VERSION_STR) + 2 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}' s = f' {VERSION_STR} {files}{centered}{files}'
self.stdscr.insstr(0, 0, s, curses.A_REVERSE) self.stdscr.insstr(0, 0, s, curses.A_REVERSE)
@@ -202,12 +232,10 @@ class Screen:
elif isinstance(wch, str) and wch.isprintable(): elif isinstance(wch, str) and wch.isprintable():
wch = self._get_string(wch) wch = self._get_string(wch)
return Key(wch, b'STRING') 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) key = wch if isinstance(wch, int) else ord(wch)
keyname = curses.keyname(key) keyname = curses.keyname(key)
keyname = KEYNAME_REWRITE.get(keyname, keyname)
return Key(wch, keyname) return Key(wch, keyname)
def get_char(self) -> Key: def get_char(self) -> Key:
@@ -225,7 +253,7 @@ class Screen:
def resize(self) -> None: def resize(self) -> None:
curses.update_lines_cols() curses.update_lines_cols()
self.margin = Margin.from_current_screen() 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() self.draw()
def quick_prompt( def quick_prompt(
@@ -236,13 +264,14 @@ class Screen:
opts = [opt[0] for opt in opt_strs] opts = [opt[0] for opt in opt_strs]
while True: while True:
x = 0 x = 0
prompt_line = self.margin.lines - 1
def _write(s: str, *, attr: int = curses.A_REVERSE) -> None: def _write(s: str, *, attr: int = curses.A_REVERSE) -> None:
nonlocal x nonlocal x
if x >= curses.COLS: if x >= self.margin.cols:
return return
self.stdscr.insstr(curses.LINES - 1, x, s, attr) self.stdscr.insstr(prompt_line, x, s, attr)
x += len(s) x += len(s)
_write(prompt) _write(prompt)
@@ -254,15 +283,15 @@ class Screen:
_write(', ') _write(', ')
_write(']?') _write(']?')
if x < curses.COLS - 1: if x < self.margin.cols - 1:
s = ' ' * (curses.COLS - x) s = ' ' * (self.margin.cols - x)
self.stdscr.insstr(curses.LINES - 1, x, s, curses.A_REVERSE) self.stdscr.insstr(prompt_line, x, s, curses.A_REVERSE)
x += 1 x += 1
else: else:
x = curses.COLS - 1 x = self.margin.cols - 1
self.stdscr.insstr(curses.LINES - 1, x, '', curses.A_REVERSE) self.stdscr.insstr(prompt_line, x, '', curses.A_REVERSE)
self.stdscr.move(curses.LINES - 1, x) self.stdscr.move(prompt_line, x)
key = self.get_char() key = self.get_char()
if key.keyname == b'KEY_RESIZE': if key.keyname == b'KEY_RESIZE':
@@ -317,9 +346,9 @@ class Screen:
self.file.go_to_line(lineno, self.margin) self.file.go_to_line(lineno, self.margin)
def current_position(self) -> None: def current_position(self) -> None:
line = f'line {self.file.y + 1}' line = f'line {self.file.buf.y + 1}'
col = f'col {self.file.x + 1}' col = f'col {self.file.buf.x + 1}'
line_count = max(len(self.file.lines) - 1, 1) line_count = max(len(self.file.buf) - 1, 1)
lines_word = 'line' if line_count == 1 else 'lines' lines_word = 'line' if line_count == 1 else 'lines'
self.status.update(f'{line}, {col} (of {line_count} {lines_word})') self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
@@ -358,7 +387,7 @@ class Screen:
else: else:
action = from_stack.pop() action = from_stack.pop()
to_stack.append(action.apply(self.file)) 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.status.update(f'{op}: {action.name}')
self.file.selection.clear() self.file.selection.clear()
@@ -385,6 +414,8 @@ class Screen:
def command(self) -> Optional[EditResult]: def command(self) -> Optional[EditResult]:
response = self.prompt('', history='command') response = self.prompt('', history='command')
if response == ':q': if response == ':q':
return self.quit_save_modified()
elif response == ':q!':
return EditResult.EXIT return EditResult.EXIT
elif response == ':w': elif response == ':w':
self.save() self.save()
@@ -416,12 +447,12 @@ class Screen:
self.file.filename = filename self.file.filename = filename
if os.path.isfile(self.file.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) *_, sha256 = get_lines(f)
else: else:
sha256 = hashlib.sha256(b'').hexdigest() 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() sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
# the file on disk is the same as when we opened it # the file on disk is the same as when we opened it
@@ -429,12 +460,12 @@ class Screen:
self.status.update('(file changed on disk, not implemented)') self.status.update('(file changed on disk, not implemented)')
return PromptResult.CANCELLED return PromptResult.CANCELLED
with open(self.file.filename, 'w') as f: with open(self.file.filename, 'w', newline='') as f:
f.write(contents) f.write(contents)
self.file.modified = False self.file.modified = False
self.file.sha256 = sha256_to_save 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' lines = 'lines' if num_lines != 1 else 'line'
self.status.update(f'saved! ({num_lines} {lines} written)') self.status.update(f'saved! ({num_lines} {lines} written)')

View File

@@ -18,14 +18,14 @@ class Status:
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
if margin.footer or self._status: 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: if self._status:
status = f' {self._status} ' status = f' {self._status} '
x = (curses.COLS - len(status)) // 2 x = (margin.cols - len(status)) // 2
if x < 0: if x < 0:
x = 0 x = 0
status = status.strip() 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: def tick(self, margin: Margin) -> None:
# when the window is only 1-tall, hide the status quicker # when the window is only 1-tall, hide the status quicker

69
babi/textmate_demo.py Normal file
View 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())

View File

@@ -1,4 +1,5 @@
import os.path import os.path
import sys
def _xdg(*path: str, env: str, default: str) -> str: 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: def xdg_config(*path: str) -> str:
return _xdg(*path, env='XDG_CONFIG_HOME', default='~/.config') return _xdg(*path, env='XDG_CONFIG_HOME', default='~/.config')
def prefix_data(*path: str) -> str:
return os.path.join(sys.prefix, 'share/babi', *path)

View File

@@ -1,86 +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('docker', Ext.PLIST, 'https://raw.githubusercontent.com/moby/moby/c7ad2b866/contrib/syntax/textmate/Docker.tmbundle/Syntaxes/Dockerfile.tmLanguage'), # 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('make', Ext.PLIST, 'https://raw.githubusercontent.com/fadeevab/make.tmbundle/fd57c0552/Syntaxes/Makefile.plist'), # 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('puppet', Ext.PLIST, 'https://raw.githubusercontent.com/lingua-pupuli/puppet-editor-syntax/dc414b8a/syntaxes/puppet.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
# TODO: https://github.com/atom/language-xml/pull/99
Syntax('xml', Ext.CSON, 'https://raw.githubusercontent.com/asottile/language-xml/2d76bc1f/grammars/xml.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())

View File

@@ -39,7 +39,6 @@ def json_with_comments(s: bytes) -> Any:
idx = match.end() idx = match.end()
match = TOKEN.search(s, idx) match = TOKEN.search(s, idx)
print(bio.getvalue())
bio.seek(0) bio.seek(0)
return json.load(bio) return json.load(bio)

View File

@@ -3,3 +3,4 @@ coverage
git+https://github.com/asottile/hecate@875567f git+https://github.com/asottile/hecate@875567f
pytest pytest
remote-pdb remote-pdb
wcwidth

View File

@@ -1,6 +1,6 @@
[metadata] [metadata]
name = babi name = babi
version = 0.0.2 version = 0.0.7
description = a text editor description = a text editor
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
@@ -22,6 +22,7 @@ classifiers =
[options] [options]
packages = find: packages = find:
install_requires = install_requires =
babi-grammars
identify identify
onigurumacffi>=0.0.10 onigurumacffi>=0.0.10
importlib_metadata>=1;python_version<"3.8" importlib_metadata>=1;python_version<"3.8"
@@ -31,6 +32,7 @@ python_requires = >=3.6.1
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
babi = babi.main:main babi = babi.main:main
babi-textmate-demo = babi.textmate_demo:main
[options.packages.find] [options.packages.find]
exclude = exclude =

2
testing/vsc_test/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/package-lock.json

View File

@@ -0,0 +1,5 @@
{
"dependencies": [
"vscode-textmate"
]
}

51
testing/vsc_test/vsc.js Normal file
View 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
View 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']

17
tests/conftest.py Normal file
View 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

View File

@@ -1,3 +1,6 @@
import pytest
from babi.fdict import FChainMap
from babi.fdict import FDict 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 # mostly because this shouldn't get hit elsewhere but is uesful for
# debugging purposes # debugging purposes
assert repr(FDict({1: 2, 3: 4})) == 'FDict({1: 2, 3: 4})' 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

View File

@@ -9,6 +9,7 @@ from typing import Union
from unittest import mock from unittest import mock
import pytest import pytest
import wcwidth
from babi._types import Protocol from babi._types import Protocol
from babi.main import main from babi.main import main
@@ -16,6 +17,13 @@ from babi.screen import VERSION_STR
from testing.runner import PrintsErrorRunner 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) @pytest.fixture(autouse=True)
def xdg_data_home(tmpdir): def xdg_data_home(tmpdir):
data_home = tmpdir.join('data_home') data_home = tmpdir.join('data_home')
@@ -39,7 +47,6 @@ def ten_lines(tmpdir):
class Screen: class Screen:
def __init__(self, width, height): def __init__(self, width, height):
self.disabled = True
self.nodelay = False self.nodelay = False
self.width = width self.width = width
self.height = height self.height = height
@@ -57,6 +64,16 @@ class Screen:
self._prev_screenshot = ret self._prev_screenshot = ret
return ret return ret
def addstr(self, y, x, s, attr):
self.lines[y] = self.lines[y][:x] + s + self.lines[y][x + len(s):]
line_attr = self.attrs[y]
new = [attr] * len(s)
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
self.y = y
self.x = x + wcwidth.wcswidth(s)
def insstr(self, y, x, s, attr): def insstr(self, y, x, s, attr):
line = self.lines[y] line = self.lines[y]
self.lines[y] = (line[:x] + s + line[x:])[:self.width] self.lines[y] = (line[:x] + s + line[x:])[:self.width]
@@ -66,6 +83,7 @@ class Screen:
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width] self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
def chgat(self, y, x, n, attr): 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 self.attrs[y][x:x + n] = [attr] * n
def move(self, y, x): def move(self, y, x):
@@ -166,7 +184,8 @@ class CursesError(NamedTuple):
class CursesScreen: class CursesScreen:
def __init__(self, runner): def __init__(self, screen, runner):
self._screen = screen
self._runner = runner self._runner = runner
self._bkgd_attr = (-1, -1, 0) self._bkgd_attr = (-1, -1, 0)
@@ -190,20 +209,26 @@ class CursesScreen:
pass pass
def nodelay(self, val): def nodelay(self, val):
self._runner.screen.nodelay = val self._screen.nodelay = val
def addstr(self, y, x, s, attr=0):
self._screen.addstr(y, x, s, self._to_attr(attr))
def insstr(self, y, x, s, attr=0): def insstr(self, y, x, s, attr=0):
self._runner.screen.insstr(y, x, s, self._to_attr(attr)) self._screen.insstr(y, x, s, self._to_attr(attr))
def clrtoeol(self): def clrtoeol(self):
s = self._runner.screen.width * ' ' s = self._screen.width * ' '
self.insstr(self._runner.screen.y, self._runner.screen.x, s) self.insstr(self._screen.y, self._screen.x, s)
def chgat(self, y, x, n, attr): def chgat(self, y, x, n, attr):
self._runner.screen.chgat(y, x, n, self._to_attr(attr)) self._screen.chgat(y, x, n, self._to_attr(attr))
def move(self, y, x): def move(self, y, x):
self._runner.screen.move(y, x) self._screen.move(y, x)
def getyx(self):
return self._screen.y, self._screen.x
def get_wch(self): def get_wch(self):
return self._runner._get_wch() return self._runner._get_wch()
@@ -365,8 +390,8 @@ class DeferredRunner:
def _curses__noop(self, *_, **__): def _curses__noop(self, *_, **__):
pass pass
_curses_cbreak = _curses_noecho = _curses_nonl = _curses__noop _curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_raw = _curses_use_default_colors = _curses__noop _curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
_curses_error = curses.error # so we don't mock the exception _curses_error = curses.error # so we don't mock the exception
@@ -392,11 +417,10 @@ class DeferredRunner:
def _curses_initscr(self): def _curses_initscr(self):
self._curses_update_lines_cols() self._curses_update_lines_cols()
self.screen.disabled = False return CursesScreen(self.screen, self)
return CursesScreen(self)
def _curses_endwin(self): def _curses_newwin(self, height, width):
self.screen.disabled = True return CursesScreen(Screen(width, height), self)
def _curses_not_implemented(self, fn): def _curses_not_implemented(self, fn):
def fn_inner(*args, **kwargs): def fn_inner(*args, **kwargs):

View File

@@ -132,3 +132,21 @@ def test_selection_cut_uncut_selection_offscreen_x(run):
h.await_text_missing('hello') h.await_text_missing('hello')
h.press('^K') h.press('^K')
h.await_text('hello\n') 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)

View File

@@ -411,3 +411,78 @@ def test_sequence_handling(run_only_fake):
h.press(' test7') h.press(' test7')
h.await_text('test1 test2 test3 test4 test5 test6 test7') h.await_text('test1 test2 test3 test4 test5 test6 test7')
h.await_text(r'\x1b[1;') h.await_text(r'\x1b[1;')
def test_indentation_using_tabs(run, tmpdir):
f = tmpdir.join('f')
f.write(
f'123456789\n'
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',
)

View File

@@ -272,3 +272,31 @@ def test_replace_separate_line_after_wrapping(run, ten_lines):
h.await_text_missing('line_0') h.await_text_missing('line_0')
h.press('y') h.press('y')
h.await_text_missing('line_1') 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')

View File

@@ -1,6 +1,7 @@
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit
from testing.runner import trigger_command_mode
def test_mixed_newlines(run, tmpdir): 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'") 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): def test_new_file(run):
with run('this_is_a_new_file') as h, and_exit(h): with run('this_is_a_new_file') as h, and_exit(h):
h.await_text('this_is_a_new_file') h.await_text('this_is_a_new_file')
@@ -189,3 +215,38 @@ def test_save_on_exit_resize(run, tmpdir):
h.await_text('file is modified - save [yes, no]?') h.await_text('file is modified - save [yes, no]?')
h.press('^C') h.press('^C')
h.await_text('cancelled') 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()

View File

@@ -15,6 +15,7 @@ THEME = json.dumps({
'settings': {'foreground': '#5f0000', 'background': '#ff5f5f'}, 'settings': {'foreground': '#5f0000', 'background': '#ff5f5f'},
}, },
{'scope': 'tqs', 'settings': {'foreground': '#00005f'}}, {'scope': 'tqs', 'settings': {'foreground': '#00005f'}},
{'scope': 'qmark', 'settings': {'foreground': '#5f0000'}},
{'scope': 'b', 'settings': {'fontStyle': 'bold'}}, {'scope': 'b', 'settings': {'fontStyle': 'bold'}},
{'scope': 'i', 'settings': {'fontStyle': 'italic'}}, {'scope': 'i', 'settings': {'fontStyle': 'italic'}},
{'scope': 'u', 'settings': {'fontStyle': 'underline'}}, {'scope': 'u', 'settings': {'fontStyle': 'underline'}},
@@ -28,6 +29,7 @@ SYNTAX = json.dumps({
{'match': r'#.*$\n?', 'name': 'comment'}, {'match': r'#.*$\n?', 'name': 'comment'},
{'match': r'^-.*$\n?', 'name': 'diffremove'}, {'match': r'^-.*$\n?', 'name': 'diffremove'},
{'begin': '"""', 'end': '"""', 'name': 'tqs'}, {'begin': '"""', 'end': '"""', 'name': 'tqs'},
{'match': r'\?', 'name': 'qmark'},
], ],
}) })
DEMO_S = '''\ DEMO_S = '''\
@@ -43,7 +45,7 @@ still more
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def theme_and_grammar(xdg_data_home, xdg_config_home): def theme_and_grammar(xdg_data_home, xdg_config_home):
xdg_config_home.join('babi/theme.json').ensure().write(THEME) 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 @pytest.fixture
@@ -97,3 +99,57 @@ def test_syntax_highlighting_off_screen_does_not_crash(run, tmpdir):
h.await_text('"""b"""') h.await_text('"""b"""')
expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2 expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2
h.assert_screen_attr_equals(1, expected) 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')

View File

@@ -97,6 +97,24 @@ def test_delete_at_end_of_line(run, tmpdir):
h.await_text('f *') 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): def test_press_enter_beginning_of_file(run, tmpdir):
f = tmpdir.join('f') f = tmpdir.join('f')
f.write('hello world') f.write('hello world')

View File

@@ -1,34 +1,37 @@
from babi.highlight import Grammars import pytest
from babi.highlight import highlight_line from babi.highlight import highlight_line
from babi.highlight import Region from babi.highlight import Region
def test_grammar_matches_extension_only_name(): def test_grammar_matches_extension_only_name(make_grammars):
data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']} data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']}
grammars = Grammars([data]) grammars = make_grammars(data)
compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi') compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi')
assert compiler.root_state.entries[0].scope[0] == 'shell' assert compiler.root_state.entries[0].scope[0] == 'shell'
def test_grammar_matches_via_identify_tag(): def test_grammar_matches_via_identify_tag(make_grammars):
data = {'scopeName': 'source.ini', 'patterns': []} grammars = make_grammars({'scopeName': 'source.ini', 'patterns': []})
grammars = Grammars([data])
compiler = grammars.compiler_for_file('setup.cfg', '') compiler = grammars.compiler_for_file('setup.cfg', '')
assert compiler.root_state.entries[0].scope[0] == 'source.ini' assert compiler.root_state.entries[0].scope[0] == 'source.ini'
def _compiler_state(*grammar_dcts): @pytest.fixture
grammars = Grammars(grammar_dcts) def compiler_state(make_grammars):
compiler = grammars.compiler_for_scope(grammar_dcts[0]['scopeName']) def _compiler_state(*grammar_dcts):
return compiler, compiler.root_state 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 = { grammar = {
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{'name': 'aaa', 'match': r'\Aa+'}], '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_0,) = highlight_line(compiler, state, 'aaa', True)
state, (region_1,) = highlight_line(compiler, state, 'aaa', False) state, (region_1,) = highlight_line(compiler, state, 'aaa', False)
@@ -51,8 +54,8 @@ BEGIN_END_NO_NL = {
} }
def test_backslash_g_inline(): def test_backslash_g_inline(compiler_state):
compiler, state = _compiler_state(BEGIN_END_NO_NL) compiler, state = compiler_state(BEGIN_END_NO_NL)
_, regions = highlight_line(compiler, state, 'xaax', True) _, regions = highlight_line(compiler, state, 'xaax', True)
assert regions == ( assert regions == (
@@ -63,8 +66,8 @@ def test_backslash_g_inline():
) )
def test_backslash_g_next_line(): def test_backslash_g_next_line(compiler_state):
compiler, state = _compiler_state(BEGIN_END_NO_NL) compiler, state = compiler_state(BEGIN_END_NO_NL)
state, regions1 = highlight_line(compiler, state, 'x\n', True) state, regions1 = highlight_line(compiler, state, 'x\n', True)
state, regions2 = highlight_line(compiler, state, 'aax\n', False) state, regions2 = highlight_line(compiler, state, 'aax\n', False)
@@ -81,8 +84,8 @@ def test_backslash_g_next_line():
) )
def test_end_before_other_match(): def test_end_before_other_match(compiler_state):
compiler, state = _compiler_state(BEGIN_END_NO_NL) compiler, state = compiler_state(BEGIN_END_NO_NL)
state, regions = highlight_line(compiler, state, 'xazzx', True) state, regions = highlight_line(compiler, state, 'xazzx', True)
@@ -107,8 +110,8 @@ BEGIN_END_NL = {
} }
def test_backslash_g_captures_nl(): def test_backslash_g_captures_nl(compiler_state):
compiler, state = _compiler_state(BEGIN_END_NL) compiler, state = compiler_state(BEGIN_END_NL)
state, regions1 = highlight_line(compiler, state, 'x\n', True) state, regions1 = highlight_line(compiler, state, 'x\n', True)
state, regions2 = highlight_line(compiler, state, 'aax\n', False) state, regions2 = highlight_line(compiler, state, 'aax\n', False)
@@ -124,8 +127,8 @@ def test_backslash_g_captures_nl():
) )
def test_backslash_g_captures_nl_next_line(): def test_backslash_g_captures_nl_next_line(compiler_state):
compiler, state = _compiler_state(BEGIN_END_NL) compiler, state = compiler_state(BEGIN_END_NL)
state, regions1 = highlight_line(compiler, state, 'x\n', True) state, regions1 = highlight_line(compiler, state, 'x\n', True)
state, regions2 = highlight_line(compiler, state, 'aa\n', False) state, regions2 = highlight_line(compiler, state, 'aa\n', False)
@@ -147,8 +150,8 @@ def test_backslash_g_captures_nl_next_line():
) )
def test_while_no_nl(): def test_while_no_nl(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{ 'patterns': [{
'begin': '> ', 'begin': '> ',
@@ -182,8 +185,8 @@ def test_while_no_nl():
) )
def test_complex_captures(): def test_complex_captures(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -213,8 +216,8 @@ def test_complex_captures():
) )
def test_captures_multiple_applied_to_same_capture(): def test_captures_multiple_applied_to_same_capture(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -256,8 +259,8 @@ def test_captures_multiple_applied_to_same_capture():
) )
def test_captures_ignores_empty(): def test_captures_ignores_empty(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{ 'patterns': [{
'match': '(.*) hi', 'match': '(.*) hi',
@@ -279,8 +282,8 @@ def test_captures_ignores_empty():
) )
def test_captures_ignores_invalid_out_of_bounds(): def test_captures_ignores_invalid_out_of_bounds(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{'match': '.', 'captures': {'1': {'name': 'oob'}}}], 'patterns': [{'match': '.', 'captures': {'1': {'name': 'oob'}}}],
}) })
@@ -292,8 +295,8 @@ def test_captures_ignores_invalid_out_of_bounds():
) )
def test_captures_begin_end(): def test_captures_begin_end(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -314,8 +317,8 @@ def test_captures_begin_end():
) )
def test_captures_while_captures(): def test_captures_while_captures(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -343,8 +346,8 @@ def test_captures_while_captures():
) )
def test_captures_implies_begin_end_captures(): def test_captures_implies_begin_end_captures(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -364,8 +367,8 @@ def test_captures_implies_begin_end_captures():
) )
def test_captures_implies_begin_while_captures(): def test_captures_implies_begin_while_captures(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -392,8 +395,8 @@ def test_captures_implies_begin_while_captures():
) )
def test_include_self(): def test_include_self(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -416,8 +419,8 @@ def test_include_self():
) )
def test_include_repository_rule(): def test_include_repository_rule(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{'include': '#impl'}], 'patterns': [{'include': '#impl'}],
'repository': { 'repository': {
@@ -438,8 +441,40 @@ def test_include_repository_rule():
) )
def test_include_other_grammar(): def test_include_with_nested_repositories(compiler_state):
compiler, state = _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', 'scopeName': 'test',
'patterns': [ 'patterns': [
@@ -494,8 +529,8 @@ def test_include_other_grammar():
) )
def test_include_base(): def test_include_base(compiler_state):
compiler, state = _compiler_state( compiler, state = compiler_state(
{ {
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
@@ -542,8 +577,8 @@ def test_include_base():
) )
def test_rule_with_begin_and_no_end(): def test_rule_with_begin_and_no_end(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [ 'patterns': [
{ {
@@ -566,8 +601,8 @@ def test_rule_with_begin_and_no_end():
) )
def test_begin_end_substitute_special_chars(): def test_begin_end_substitute_special_chars(compiler_state):
compiler, state = _compiler_state({ compiler, state = compiler_state({
'scopeName': 'test', 'scopeName': 'test',
'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}], 'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}],
}) })
@@ -579,3 +614,26 @@ def test_begin_end_substitute_special_chars():
Region(1, 7, ('test', 'italic')), Region(1, 7, ('test', 'italic')),
Region(7, 8, ('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')),
)

View File

@@ -4,8 +4,9 @@ from unittest import mock
import pytest import pytest
from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.highlight import Grammars from babi.hl.interface import HL
from babi.hl.syntax import Syntax from babi.hl.syntax import Syntax
from babi.theme import Color from babi.theme import Color
from babi.theme import Theme from babi.theme import Theme
@@ -71,8 +72,8 @@ THEME = Theme.from_dct({
@pytest.fixture @pytest.fixture
def syntax(tmpdir): def syntax(make_grammars):
return Syntax(Grammars.from_syntax_dir(tmpdir), THEME, ColorManager.make()) return Syntax(make_grammars(), THEME, ColorManager.make())
def test_init_screen_low_color(stdscr, syntax): def test_init_screen_low_color(stdscr, syntax):
@@ -149,3 +150,20 @@ def test_style_attributes_applied(stdscr, syntax):
style = THEME.select(('keyword.python',)) style = THEME.select(('keyword.python',))
attr = syntax.blank_file_highlighter().attr(style) attr = syntax.blank_file_highlighter().attr(style)
assert attr == 2 << 8 | curses.A_BOLD 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),),
(),
]

View File

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

View 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'