Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b55ebfd0e |
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.0.1
|
||||
rev: v2.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@@ -10,35 +10,35 @@ repos:
|
||||
- id: double-quote-string-fixer
|
||||
- id: name-tests-test
|
||||
- id: requirements-txt-fixer
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 4.0.1
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.7.9
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-typing-imports==1.7.0]
|
||||
- repo: https://github.com/pre-commit/mirrors-autopep8
|
||||
rev: v1.5.7
|
||||
rev: v1.5
|
||||
hooks:
|
||||
- id: autopep8
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v2.6.0
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: [--py3-plus, --add-import, 'from __future__ import annotations']
|
||||
args: [--py3-plus]
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v2.2.1
|
||||
rev: v2.0.1
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.29.1
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.20.0
|
||||
rev: v1.7.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.910-1
|
||||
rev: v0.770
|
||||
hooks:
|
||||
- id: mypy
|
||||
|
||||
35
README.md
35
README.md
@@ -1,8 +1,5 @@
|
||||
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
||||
[](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
|
||||
[](https://results.pre-commit.ci/latest/github/asottile/babi/master)
|
||||
|
||||

|
||||
|
||||
babi
|
||||
====
|
||||
@@ -15,16 +12,9 @@ a text editor, eventually...
|
||||
|
||||
### why is it called babi?
|
||||
|
||||
I used to 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
|
||||
keyboard, when the right hand is shifted left by one, `nano` becomes `babi`.
|
||||
|
||||
### babi vs. nano
|
||||
|
||||
here is a youtube video where I discuss the motivation for creating and using
|
||||
`babi` instead of `nano`:
|
||||
|
||||
[](https://youtu.be/WyR1hAGmR3g)
|
||||
|
||||
### quitting babi
|
||||
|
||||
currently you can quit `babi` by using <kbd>^X</kbd> (or via <kbd>esc</kbd> +
|
||||
@@ -55,7 +45,7 @@ these are all of the current key bindings in babi
|
||||
- <kbd>tab</kbd> / <kbd>shift-tab</kbd>: indent or dedent current line (or
|
||||
selection)
|
||||
- <kbd>^K</kbd> / <kbd>^U</kbd>: cut and uncut the current line (or selection)
|
||||
- <kbd>M-u</kbd> / <kbd>M-U</kbd> or <kbd>M-e</kbd>: undo / redo
|
||||
- <kbd>M-u</kbd> / <kbd>M-U</kbd>: undo / redo
|
||||
- <kbd>^W</kbd>: search
|
||||
- <kbd>^\\</kbd>: search and replace
|
||||
- <kbd>^C</kbd>: show the current position in the file
|
||||
@@ -87,25 +77,6 @@ here's a modified vs dark plus theme that works:
|
||||
./bin/download-theme vs-dark-asottile https://gist.github.com/asottile/b465856c82b1aaa4ba8c7c6314a72e13/raw/22d602fb355fb12b04f176a733941ba5713bc36c/vs_dark_asottile.json
|
||||
```
|
||||
|
||||
### keyboard shortcuts on macos
|
||||
|
||||
to get the most out of babi's built in keyboard shortcuts, a few settings must
|
||||
be changed on macos with Terminal.app:
|
||||
|
||||
- in **System Preferences**: **Keyboard** > **Shortcuts** >
|
||||
**Mission Control**: disable or rebind "Move left a space" and
|
||||
"Move right a space" (the defaults `⌃ →` and `⌃ ←` conflict)
|
||||
- in **Terminal.app**: **Terminal** > **Preferences** > **Profiles** >
|
||||
**Keyboard**:
|
||||
- check **Use Option as Meta key**
|
||||
- ensure the following keys are enabled:
|
||||
- `⌃ →`: `\033[1;5C`
|
||||
- `⌃ ←`: `\033[1;5D`
|
||||
- `⇧ ↑`: `\033[1;2A`
|
||||
- `⇧ ↓`: `\033[1;2B`
|
||||
- `⇧ →`: `\033[1;2C`
|
||||
- `⇧ ←`: `\033[1;2D`
|
||||
|
||||
## demos
|
||||
|
||||
most things work! here's a few screenshots
|
||||
@@ -116,7 +87,7 @@ this opens the file, displays it, and can be edited and can save! unknown keys
|
||||
are displayed as errors in the status bar. babi will scroll if the cursor
|
||||
goes off screen either from resize events or from movement. babi can edit
|
||||
multiple files. babi has a command mode (so you can quit it like vim
|
||||
<kbd>:q</kbd>!). babi also supports syntax highlighting
|
||||
<kbd>:q</kbd>!). babi also support syntax highlighting
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ resources:
|
||||
type: github
|
||||
endpoint: github
|
||||
name: asottile/azure-pipeline-templates
|
||||
ref: refs/tags/v2.1.0
|
||||
ref: refs/tags/v1.0.0
|
||||
|
||||
jobs:
|
||||
- template: job--pre-commit.yml@asottile
|
||||
- template: job--python-tox.yml@asottile
|
||||
parameters:
|
||||
toxenvs: [py37, py38, py39]
|
||||
toxenvs: [py36, py37, py38]
|
||||
os: linux
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from babi.main import main
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
exit(main())
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
90
babi/buf.py
90
babi/buf.py
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -18,25 +19,25 @@ DelCallback = Callable[['Buf', int, str], None]
|
||||
InsCallback = Callable[['Buf', int], None]
|
||||
|
||||
|
||||
def _offsets(s: str, tab_size: int) -> tuple[int, ...]:
|
||||
def _offsets(s: str) -> Tuple[int, ...]:
|
||||
ret = [0]
|
||||
for c in s:
|
||||
if c == '\t':
|
||||
ret.append(ret[-1] + (tab_size - ret[-1] % tab_size))
|
||||
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: ...
|
||||
def __call__(self, buf: 'Buf') -> None: ...
|
||||
|
||||
|
||||
class SetModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: Buf) -> None:
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf[self.idx] = self.s
|
||||
|
||||
|
||||
@@ -44,29 +45,27 @@ class InsModification(NamedTuple):
|
||||
idx: int
|
||||
s: str
|
||||
|
||||
def __call__(self, buf: Buf) -> None:
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
buf.insert(self.idx, self.s)
|
||||
|
||||
|
||||
class DelModification(NamedTuple):
|
||||
idx: int
|
||||
|
||||
def __call__(self, buf: Buf) -> None:
|
||||
def __call__(self, buf: 'Buf') -> None:
|
||||
del buf[self.idx]
|
||||
|
||||
|
||||
class Buf:
|
||||
def __init__(self, lines: list[str], tab_size: int = 4) -> None:
|
||||
def __init__(self, lines: List[str]) -> None:
|
||||
self._lines = lines
|
||||
self.expandtabs = True
|
||||
self.tab_size = tab_size
|
||||
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._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[tuple[int, ...] | None] = []
|
||||
self._positions: List[Optional[Tuple[int, ...]]] = []
|
||||
|
||||
# read only interface
|
||||
|
||||
@@ -131,16 +130,12 @@ class Buf:
|
||||
return victim
|
||||
|
||||
def restore_eof_invariant(self) -> None:
|
||||
"""the file lines will always contain a blank empty string at the end'
|
||||
"""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('')
|
||||
|
||||
def set_tab_size(self, tab_size: int) -> None:
|
||||
self.tab_size = tab_size
|
||||
self._positions = [None]
|
||||
|
||||
# event handling
|
||||
|
||||
def add_set_callback(self, cb: SetCallback) -> None:
|
||||
@@ -162,16 +157,16 @@ class Buf:
|
||||
self._ins_callbacks.remove(cb)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def record(self) -> Generator[list[Modification], None, None]:
|
||||
modifications: list[Modification] = []
|
||||
def record(self) -> Generator[List[Modification], None, None]:
|
||||
modifications: List[Modification] = []
|
||||
|
||||
def set_cb(buf: Buf, idx: int, victim: str) -> None:
|
||||
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:
|
||||
def del_cb(buf: 'Buf', idx: int, victim: str) -> None:
|
||||
modifications.append(InsModification(idx, victim))
|
||||
|
||||
def ins_cb(buf: Buf, idx: int) -> None:
|
||||
def ins_cb(buf: 'Buf', idx: int) -> None:
|
||||
modifications.append(DelModification(idx))
|
||||
|
||||
self.add_set_callback(set_cb)
|
||||
@@ -184,7 +179,7 @@ class Buf:
|
||||
self.remove_del_callback(del_cb)
|
||||
self.remove_set_callback(set_cb)
|
||||
|
||||
def apply(self, modifications: list[Modification]) -> list[Modification]:
|
||||
def apply(self, modifications: List[Modification]) -> List[Modification]:
|
||||
with self.record() as ret_modifications:
|
||||
for modification in reversed(modifications):
|
||||
modification(self)
|
||||
@@ -208,24 +203,23 @@ class Buf:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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, ...]:
|
||||
def line_positions(self, idx: int) -> Tuple[int, ...]:
|
||||
self._extend_positions(idx)
|
||||
value = self._positions[idx]
|
||||
if value is None:
|
||||
value = _offsets(self._lines[idx], self.tab_size)
|
||||
self._positions[idx] = value
|
||||
value = self._positions[idx] = _offsets(self._lines[idx])
|
||||
return value
|
||||
|
||||
def line_x(self, margin: Margin) -> int:
|
||||
@@ -235,24 +229,18 @@ class Buf:
|
||||
def _cursor_x(self) -> int:
|
||||
return self.line_positions(self.y)[self.x]
|
||||
|
||||
def cursor_position(self, margin: Margin) -> tuple[int, int]:
|
||||
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
|
||||
|
||||
@property
|
||||
def tab_string(self) -> str:
|
||||
if self.expandtabs:
|
||||
return ' ' * self.tab_size
|
||||
else:
|
||||
return '\t'
|
||||
|
||||
def rendered_line(self, idx: int, margin: Margin) -> str:
|
||||
x = self._cursor_x if idx == self.y else 0
|
||||
expanded = self._lines[idx].expandtabs(self.tab_size)
|
||||
return scrolled_line(expanded, x, margin.cols)
|
||||
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
|
||||
|
||||
@@ -287,7 +275,7 @@ class Buf:
|
||||
if self.x >= len(self._lines[self.y]):
|
||||
if self.y < len(self._lines) - 1:
|
||||
self.down(margin)
|
||||
self.x = 0
|
||||
self.home()
|
||||
else:
|
||||
self.x += 1
|
||||
|
||||
@@ -295,10 +283,16 @@ class Buf:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self.up(margin)
|
||||
self.x = len(self._lines[self.y])
|
||||
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:
|
||||
@@ -312,3 +306,9 @@ class Buf:
|
||||
self.file_y += 1
|
||||
if self.y < self.file_y:
|
||||
self.down(margin)
|
||||
|
||||
# key input
|
||||
|
||||
def c(self, s: str) -> None:
|
||||
self[self.y] = self[self.y][:self.x] + s + self[self.y][self.x:]
|
||||
self.x += len(s)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
if sys.version_info >= (3, 8): # pragma: no cover (>=py38)
|
||||
@@ -7,6 +5,8 @@ if sys.version_info >= (3, 8): # pragma: no cover (>=py38)
|
||||
else: # pragma: no cover (<py38)
|
||||
from typing import Callable
|
||||
from typing import Generic
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
TSelf = TypeVar('TSelf')
|
||||
@@ -18,8 +18,8 @@ else: # pragma: no cover (<py38)
|
||||
|
||||
def __get__(
|
||||
self,
|
||||
instance: TSelf | None,
|
||||
owner: type[TSelf] | None = None,
|
||||
instance: Optional[TSelf],
|
||||
owner: Optional[Type[TSelf]] = None,
|
||||
) -> TRet:
|
||||
assert instance is not None
|
||||
ret = instance.__dict__[self._func.__name__] = self._func(instance)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
# TODO: find a standard which defines these
|
||||
@@ -13,7 +11,7 @@ class Color(NamedTuple):
|
||||
b: int
|
||||
|
||||
@classmethod
|
||||
def parse(cls, s: str) -> Color:
|
||||
def parse(cls, s: str) -> 'Color':
|
||||
if s.startswith('#') and len(s) >= 7:
|
||||
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
||||
elif s.startswith('#'):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.color import Color
|
||||
@@ -18,19 +19,19 @@ class KD(Protocol):
|
||||
@property
|
||||
def n(self) -> int: ...
|
||||
@property
|
||||
def left(self) -> KD | None: ...
|
||||
def left(self) -> Optional['KD']: ...
|
||||
@property
|
||||
def right(self) -> KD | None: ...
|
||||
def right(self) -> Optional['KD']: ...
|
||||
|
||||
|
||||
class _KD(NamedTuple):
|
||||
color: Color
|
||||
n: int
|
||||
left: KD | None
|
||||
right: KD | None
|
||||
left: Optional[KD]
|
||||
right: Optional[KD]
|
||||
|
||||
|
||||
def _build(colors: list[tuple[Color, int]], depth: int = 0) -> KD | None:
|
||||
def _build(colors: List[Tuple[Color, int]], depth: int = 0) -> Optional[KD]:
|
||||
if not colors:
|
||||
return None
|
||||
|
||||
@@ -45,11 +46,11 @@ def _build(colors: list[tuple[Color, int]], depth: int = 0) -> KD | None:
|
||||
)
|
||||
|
||||
|
||||
def nearest(color: Color, colors: KD | None) -> int:
|
||||
def nearest(color: Color, colors: Optional[KD]) -> int:
|
||||
best = 0
|
||||
dist = 2 ** 32
|
||||
|
||||
def _search(kd: KD | None, *, depth: int) -> None:
|
||||
def _search(kd: Optional[KD], *, depth: int) -> None:
|
||||
nonlocal best
|
||||
nonlocal dist
|
||||
|
||||
@@ -76,7 +77,7 @@ def nearest(color: Color, colors: KD | None) -> int:
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def make_256() -> KD | None:
|
||||
def make_256() -> Optional[KD]:
|
||||
vals = (0, 95, 135, 175, 215, 255)
|
||||
colors = [
|
||||
(Color(r, g, b), i)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi import color_kd
|
||||
from babi.color import Color
|
||||
|
||||
|
||||
def _color_to_curses(color: Color) -> tuple[int, int, int]:
|
||||
def _color_to_curses(color: Color) -> Tuple[int, int, int]:
|
||||
factor = 1000 / 255
|
||||
return int(color.r * factor), int(color.g * factor), int(color.b * factor)
|
||||
|
||||
|
||||
class ColorManager(NamedTuple):
|
||||
colors: dict[Color, int]
|
||||
raw_pairs: dict[tuple[int, int], int]
|
||||
colors: Dict[Color, int]
|
||||
raw_pairs: Dict[Tuple[int, int], int]
|
||||
|
||||
def init_color(self, color: Color) -> None:
|
||||
if curses.can_change_color():
|
||||
@@ -26,13 +27,12 @@ class ColorManager(NamedTuple):
|
||||
else:
|
||||
self.colors[color] = -1
|
||||
|
||||
def color_pair(self, fg: Color | None, bg: Color | None) -> int:
|
||||
def color_pair(self, fg: Optional[Color], bg: Optional[Color]) -> int:
|
||||
fg_i = self.colors[fg] if fg is not None else -1
|
||||
bg_i = self.colors[bg] if bg is not None else -1
|
||||
return self.raw_color_pair(fg_i, bg_i)
|
||||
|
||||
def raw_color_pair(self, fg: int, bg: int) -> int:
|
||||
if curses.COLORS > 0:
|
||||
try:
|
||||
return self.raw_pairs[(fg, bg)]
|
||||
except KeyError:
|
||||
@@ -41,9 +41,7 @@ class ColorManager(NamedTuple):
|
||||
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
|
||||
curses.init_pair(n, fg, bg)
|
||||
return n
|
||||
else:
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def make(cls) -> ColorManager:
|
||||
def make(cls) -> 'ColorManager':
|
||||
return cls({}, {})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic
|
||||
from typing import Iterable
|
||||
from typing import Mapping
|
||||
|
||||
196
babi/file.py
196
babi/file.py
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
@@ -8,17 +6,20 @@ import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import os.path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
from typing import Generator
|
||||
from typing import IO
|
||||
from typing import List
|
||||
from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Pattern
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.buf import Modification
|
||||
@@ -37,10 +38,8 @@ if TYPE_CHECKING:
|
||||
|
||||
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
|
||||
|
||||
WS_RE = re.compile(r'^\s*')
|
||||
|
||||
|
||||
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()
|
||||
lines = []
|
||||
newlines = collections.Counter({'\n': 0}) # default to `\n`
|
||||
@@ -62,7 +61,7 @@ def get_lines(sio: IO[str]) -> tuple[list[str], str, bool, str]:
|
||||
|
||||
class Action:
|
||||
def __init__(
|
||||
self, *, name: str, modifications: list[Modification],
|
||||
self, *, name: str, modifications: List[Modification],
|
||||
start_x: int, start_y: int, start_modified: bool,
|
||||
end_x: int, end_y: int, end_modified: bool,
|
||||
final: bool,
|
||||
@@ -77,7 +76,7 @@ class Action:
|
||||
self.end_modified = end_modified
|
||||
self.final = final
|
||||
|
||||
def apply(self, file: File) -> Action:
|
||||
def apply(self, file: 'File') -> 'Action':
|
||||
action = Action(
|
||||
name=self.name, modifications=file.buf.apply(self.modifications),
|
||||
start_x=self.end_x, start_y=self.end_y,
|
||||
@@ -96,7 +95,7 @@ class Action:
|
||||
|
||||
def action(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def action_inner(self: File, *args: Any, **kwargs: Any) -> Any:
|
||||
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
self.finalize_previous_action()
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, action_inner)
|
||||
@@ -109,7 +108,7 @@ def edit_action(
|
||||
) -> Callable[[TCallable], TCallable]:
|
||||
def edit_action_decorator(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def edit_action_inner(self: File, *args: Any, **kwargs: Any) -> Any:
|
||||
def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.edit_action_context(name, final=final):
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, edit_action_inner)
|
||||
@@ -118,7 +117,7 @@ def edit_action(
|
||||
|
||||
def keep_selection(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def keep_selection_inner(self: File, *args: Any, **kwargs: Any) -> Any:
|
||||
def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.select():
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, keep_selection_inner)
|
||||
@@ -126,7 +125,7 @@ def keep_selection(func: TCallable) -> TCallable:
|
||||
|
||||
def clear_selection(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def clear_selection_inner(self: File, *args: Any, **kwargs: Any) -> Any:
|
||||
def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
ret = func(self, *args, **kwargs)
|
||||
self.selection.clear()
|
||||
return ret
|
||||
@@ -141,7 +140,7 @@ class Found(NamedTuple):
|
||||
class _SearchIter:
|
||||
def __init__(
|
||||
self,
|
||||
file: File,
|
||||
file: 'File',
|
||||
reg: Pattern[str],
|
||||
*,
|
||||
offset: int,
|
||||
@@ -153,7 +152,7 @@ class _SearchIter:
|
||||
self._start_x = file.buf.x + offset
|
||||
self._start_y = file.buf.y
|
||||
|
||||
def __iter__(self) -> _SearchIter:
|
||||
def __iter__(self) -> '_SearchIter':
|
||||
return self
|
||||
|
||||
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
|
||||
@@ -166,7 +165,7 @@ class _SearchIter:
|
||||
raise StopIteration()
|
||||
return Found(y, match)
|
||||
|
||||
def __next__(self) -> tuple[int, Match[str]]:
|
||||
def __next__(self) -> Tuple[int, Match[str]]:
|
||||
x = self.file.buf.x + self.offset
|
||||
y = self.file.buf.y
|
||||
|
||||
@@ -198,32 +197,25 @@ class _SearchIter:
|
||||
class File:
|
||||
def __init__(
|
||||
self,
|
||||
filename: str | None,
|
||||
initial_line: int,
|
||||
filename: Optional[str],
|
||||
color_manager: ColorManager,
|
||||
hl_factories: tuple[HLFactory, ...],
|
||||
hl_factories: Tuple[HLFactory, ...],
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
self.initial_line = initial_line
|
||||
self.modified = False
|
||||
self.buf = Buf([])
|
||||
self.nl = '\n'
|
||||
self.sha256: str | None = None
|
||||
self.sha256: Optional[str] = None
|
||||
self._in_edit_action = False
|
||||
self.undo_stack: list[Action] = []
|
||||
self.redo_stack: list[Action] = []
|
||||
self.undo_stack: List[Action] = []
|
||||
self.redo_stack: List[Action] = []
|
||||
self._hl_factories = hl_factories
|
||||
self._trailing_whitespace = TrailingWhitespace(color_manager)
|
||||
self._replace_hl = Replace()
|
||||
self.selection = Selection()
|
||||
self._file_hls: tuple[FileHL, ...] = ()
|
||||
self._file_hls: Tuple[FileHL, ...] = ()
|
||||
|
||||
def ensure_loaded(
|
||||
self,
|
||||
status: Status,
|
||||
margin: Margin,
|
||||
stdin: str,
|
||||
) -> None:
|
||||
def ensure_loaded(self, status: Status, stdin: str) -> None:
|
||||
if self.buf:
|
||||
return
|
||||
|
||||
@@ -234,7 +226,7 @@ class File:
|
||||
sio = io.StringIO(stdin)
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
elif self.filename is not None and os.path.isfile(self.filename):
|
||||
with open(self.filename, encoding='UTF-8', newline='') as f:
|
||||
with open(self.filename, newline='') as f:
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(f)
|
||||
else:
|
||||
if self.filename is not None:
|
||||
@@ -245,7 +237,7 @@ class File:
|
||||
status.update('(new file)')
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
|
||||
|
||||
self.buf = Buf(lines, self.buf.tab_size)
|
||||
self.buf = Buf(lines)
|
||||
|
||||
if mixed:
|
||||
status.update(f'mixed newlines will be converted to {self.nl!r}')
|
||||
@@ -265,8 +257,6 @@ class File:
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.register_callbacks(self.buf)
|
||||
|
||||
self.go_to_line(self.initial_line, margin)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<{type(self).__name__} {self.filename!r}>'
|
||||
|
||||
@@ -290,11 +280,11 @@ class File:
|
||||
|
||||
@action
|
||||
def home(self, margin: Margin) -> None:
|
||||
self.buf.x = 0
|
||||
self.buf.home()
|
||||
|
||||
@action
|
||||
def end(self, margin: Margin) -> None:
|
||||
self.buf.x = len(self.buf[self.buf.y])
|
||||
self.buf.end()
|
||||
|
||||
@action
|
||||
def ctrl_up(self, margin: Margin) -> None:
|
||||
@@ -393,14 +383,14 @@ class File:
|
||||
@clear_selection
|
||||
def replace(
|
||||
self,
|
||||
screen: Screen,
|
||||
screen: 'Screen',
|
||||
reg: Pattern[str],
|
||||
replace: str,
|
||||
) -> None:
|
||||
self.finalize_previous_action()
|
||||
|
||||
count = 0
|
||||
res: str | PromptResult = ''
|
||||
res: Union[str, PromptResult] = ''
|
||||
search = _SearchIter(self, reg, offset=0)
|
||||
for line_y, match in search:
|
||||
end = match.end()
|
||||
@@ -474,11 +464,7 @@ class File:
|
||||
if self.buf.y == 0 and self.buf.x == 0:
|
||||
pass
|
||||
# backspace at the end of the file does not change the contents
|
||||
elif (
|
||||
self.buf.y == len(self.buf) - 1 and
|
||||
# still allow backspace if there are 2+ blank lines
|
||||
self.buf[self.buf.y - 1] != ''
|
||||
):
|
||||
elif self.buf.y == len(self.buf) - 1:
|
||||
self.buf.left(margin)
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
@@ -526,29 +512,20 @@ class File:
|
||||
assert self.selection.start is not None
|
||||
sel_y, sel_x = self.selection.start
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
tab_string = self.buf.tab_string
|
||||
tab_size = len(tab_string)
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
if self.buf[l_y]:
|
||||
self.buf[l_y] = tab_string + self.buf[l_y]
|
||||
self.buf[l_y] = ' ' * 4 + self.buf[l_y]
|
||||
if l_y == self.buf.y:
|
||||
self.buf.x += tab_size
|
||||
self.buf.x += 4
|
||||
if l_y == sel_y and sel_x != 0:
|
||||
sel_x += tab_size
|
||||
sel_x += 4
|
||||
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
tab_string = self.buf.tab_string
|
||||
if tab_string == '\t':
|
||||
n = 1
|
||||
else:
|
||||
n = self.buf.tab_size - self.buf.x % self.buf.tab_size
|
||||
tab_string = tab_string[:n]
|
||||
n = 4 - self.buf.x % 4
|
||||
line = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = (
|
||||
line[:self.buf.x] + tab_string + line[self.buf.x:]
|
||||
)
|
||||
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
|
||||
self.buf.x += n
|
||||
self.buf.restore_eof_invariant()
|
||||
|
||||
@@ -558,10 +535,11 @@ class File:
|
||||
else:
|
||||
self._tab(margin)
|
||||
|
||||
def _dedent_line(self, s: str) -> int:
|
||||
bound = min(len(s), len(self.buf.tab_string))
|
||||
@staticmethod
|
||||
def _dedent_line(s: str) -> int:
|
||||
bound = min(len(s), 4)
|
||||
i = 0
|
||||
while i < bound and s[i] in (' ', '\t'):
|
||||
while i < bound and s[i] == ' ':
|
||||
i += 1
|
||||
return i
|
||||
|
||||
@@ -595,7 +573,7 @@ class File:
|
||||
|
||||
@edit_action('cut selection', final=True)
|
||||
@clear_selection
|
||||
def cut_selection(self, margin: Margin) -> tuple[str, ...]:
|
||||
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
|
||||
ret = []
|
||||
(s_y, s_x), (e_y, e_x) = self.selection.get()
|
||||
if s_y == e_y:
|
||||
@@ -615,7 +593,7 @@ class File:
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
return tuple(ret)
|
||||
|
||||
def cut(self, cut_buffer: tuple[str, ...]) -> tuple[str, ...]:
|
||||
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
|
||||
# only continue a cut if the last action is a non-final cut
|
||||
if not self._continue_last_action('cut'):
|
||||
cut_buffer = ()
|
||||
@@ -628,7 +606,7 @@ class File:
|
||||
self.buf.x = 0
|
||||
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:
|
||||
line = self.buf[self.buf.y]
|
||||
before, after = line[:self.buf.x], line[self.buf.x:]
|
||||
@@ -639,14 +617,14 @@ class File:
|
||||
|
||||
@edit_action('uncut', final=True)
|
||||
@clear_selection
|
||||
def uncut(self, cut_buffer: tuple[str, ...], margin: Margin) -> None:
|
||||
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
|
||||
@edit_action('uncut selection', final=True)
|
||||
@clear_selection
|
||||
def uncut_selection(
|
||||
self,
|
||||
cut_buffer: tuple[str, ...], margin: Margin,
|
||||
cut_buffer: Tuple[str, ...], margin: Margin,
|
||||
) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
self.buf.up(margin)
|
||||
@@ -654,9 +632,9 @@ class File:
|
||||
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, reverse: bool) -> None:
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
|
||||
# self.buf intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
|
||||
lines = sorted(itertools.islice(self.buf, s_y, e_y))
|
||||
for i, line in zip(range(s_y, e_y), lines):
|
||||
self.buf[i] = line
|
||||
|
||||
@@ -664,78 +642,18 @@ class File:
|
||||
self.buf.x = 0
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
def _selection_lines(self) -> tuple[int, int]:
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin) -> None:
|
||||
self._sort(margin, 0, len(self.buf) - 1)
|
||||
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin) -> None:
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
e_y = min(e_y + 1, len(self.buf) - 1)
|
||||
if self.buf[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
return s_y, e_y
|
||||
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin, reverse: bool = False) -> None:
|
||||
self._sort(margin, 0, len(self.buf) - 1, reverse=reverse)
|
||||
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
|
||||
s_y, e_y = self._selection_lines()
|
||||
self._sort(margin, s_y, e_y, reverse=reverse)
|
||||
|
||||
def _is_commented(self, lineno: int, prefix: str) -> bool:
|
||||
return self.buf[lineno].lstrip().startswith(prefix)
|
||||
|
||||
def _indent(self, lineno: int) -> str:
|
||||
ws_match = WS_RE.match(self.buf[lineno])
|
||||
assert ws_match is not None
|
||||
return ws_match[0]
|
||||
|
||||
def _minimum_indent_for_selection(self) -> int:
|
||||
s_y, e_y = self._selection_lines()
|
||||
return min(len(self._indent(lineno)) for lineno in range(s_y, e_y))
|
||||
|
||||
def _comment_remove(self, lineno: int, prefix: str) -> None:
|
||||
line = self.buf[lineno]
|
||||
indent = self._indent(lineno)
|
||||
ws_len = len(indent)
|
||||
|
||||
if line.startswith(f'{prefix} ', ws_len):
|
||||
self.buf[lineno] = f'{indent}{line[ws_len + len(prefix) + 1:]}'
|
||||
elif line.startswith(prefix, ws_len):
|
||||
self.buf[lineno] = f'{indent}{line[ws_len + len(prefix):]}'
|
||||
|
||||
if self.buf.y == lineno and self.buf.x > ws_len:
|
||||
self.buf.x -= len(line) - len(self.buf[lineno])
|
||||
|
||||
def _comment_add(self, lineno: int, prefix: str, s_offset: int) -> None:
|
||||
line = self.buf[lineno]
|
||||
|
||||
if not line:
|
||||
self.buf[lineno] = f'{prefix}'
|
||||
else:
|
||||
self.buf[lineno] = f'{line[:s_offset]}{prefix} {line[s_offset:]}'
|
||||
|
||||
if lineno == self.buf.y and self.buf.x > s_offset:
|
||||
self.buf.x += len(self.buf[lineno]) - len(line)
|
||||
|
||||
@edit_action('comment', final=True)
|
||||
def toggle_comment(self, prefix: str) -> None:
|
||||
if self._is_commented(self.buf.y, prefix):
|
||||
self._comment_remove(self.buf.y, prefix)
|
||||
else:
|
||||
ws_len = len(self._indent(self.buf.y))
|
||||
self._comment_add(self.buf.y, prefix, ws_len)
|
||||
|
||||
@edit_action('comment selection', final=True)
|
||||
@clear_selection
|
||||
def toggle_comment_selection(self, prefix: str) -> None:
|
||||
s_y, e_y = self._selection_lines()
|
||||
commented = self._is_commented(s_y, prefix)
|
||||
minimum_indent = self._minimum_indent_for_selection()
|
||||
for lineno in range(s_y, e_y):
|
||||
if commented:
|
||||
self._comment_remove(lineno, prefix)
|
||||
else:
|
||||
self._comment_add(lineno, prefix, minimum_indent)
|
||||
self._sort(margin, s_y, e_y)
|
||||
|
||||
DISPATCH = {
|
||||
# movement
|
||||
@@ -780,10 +698,8 @@ class File:
|
||||
|
||||
@edit_action('text', final=False)
|
||||
@clear_selection
|
||||
def c(self, wch: str, margin: Margin) -> None:
|
||||
s = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
|
||||
self.buf.x += len(wch)
|
||||
def c(self, wch: str) -> None:
|
||||
self.buf.c(wch)
|
||||
self.buf.restore_eof_invariant()
|
||||
|
||||
def finalize_previous_action(self) -> None:
|
||||
@@ -850,12 +766,12 @@ class File:
|
||||
|
||||
def move_cursor(
|
||||
self,
|
||||
stdscr: curses._CursesWindow,
|
||||
stdscr: 'curses._CursesWindow',
|
||||
margin: Margin,
|
||||
) -> None:
|
||||
stdscr.move(*self.buf.cursor_position(margin))
|
||||
|
||||
def draw(self, stdscr: curses._CursesWindow, margin: Margin) -> None:
|
||||
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
|
||||
to_display = min(self.buf.displayable_count, margin.body_lines)
|
||||
|
||||
for file_hl in self._file_hls:
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os.path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import FrozenSet
|
||||
from typing import List
|
||||
from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import TypeVar
|
||||
|
||||
@@ -32,7 +34,7 @@ def uniquely_constructed(t: T) -> T:
|
||||
return t
|
||||
|
||||
|
||||
def _split_name(s: str | None) -> tuple[str, ...]:
|
||||
def _split_name(s: Optional[str]) -> Tuple[str, ...]:
|
||||
if s is None:
|
||||
return ()
|
||||
else:
|
||||
@@ -42,17 +44,17 @@ def _split_name(s: str | None) -> tuple[str, ...]:
|
||||
class _Rule(Protocol):
|
||||
"""hax for recursive types python/mypy#731"""
|
||||
@property
|
||||
def name(self) -> tuple[str, ...]: ...
|
||||
def name(self) -> Tuple[str, ...]: ...
|
||||
@property
|
||||
def match(self) -> str | None: ...
|
||||
def match(self) -> Optional[str]: ...
|
||||
@property
|
||||
def begin(self) -> str | None: ...
|
||||
def begin(self) -> Optional[str]: ...
|
||||
@property
|
||||
def end(self) -> str | None: ...
|
||||
def end(self) -> Optional[str]: ...
|
||||
@property
|
||||
def while_(self) -> str | None: ...
|
||||
def while_(self) -> Optional[str]: ...
|
||||
@property
|
||||
def content_name(self) -> tuple[str, ...]: ...
|
||||
def content_name(self) -> Tuple[str, ...]: ...
|
||||
@property
|
||||
def captures(self) -> Captures: ...
|
||||
@property
|
||||
@@ -62,39 +64,39 @@ class _Rule(Protocol):
|
||||
@property
|
||||
def while_captures(self) -> Captures: ...
|
||||
@property
|
||||
def include(self) -> str | None: ...
|
||||
def include(self) -> Optional[str]: ...
|
||||
@property
|
||||
def patterns(self) -> tuple[_Rule, ...]: ...
|
||||
def patterns(self) -> 'Tuple[_Rule, ...]': ...
|
||||
@property
|
||||
def repository(self) -> FChainMap[str, _Rule]: ...
|
||||
def repository(self) -> 'FChainMap[str, _Rule]': ...
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class Rule(NamedTuple):
|
||||
name: tuple[str, ...]
|
||||
match: str | None
|
||||
begin: str | None
|
||||
end: str | None
|
||||
while_: str | None
|
||||
content_name: tuple[str, ...]
|
||||
name: Tuple[str, ...]
|
||||
match: Optional[str]
|
||||
begin: Optional[str]
|
||||
end: Optional[str]
|
||||
while_: Optional[str]
|
||||
content_name: Tuple[str, ...]
|
||||
captures: Captures
|
||||
begin_captures: Captures
|
||||
end_captures: Captures
|
||||
while_captures: Captures
|
||||
include: str | None
|
||||
patterns: tuple[_Rule, ...]
|
||||
include: Optional[str]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
repository: FChainMap[str, _Rule]
|
||||
|
||||
@classmethod
|
||||
def make(
|
||||
cls,
|
||||
dct: dict[str, Any],
|
||||
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_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)
|
||||
@@ -181,15 +183,15 @@ class Rule(NamedTuple):
|
||||
class Grammar(NamedTuple):
|
||||
scope_name: str
|
||||
repository: FChainMap[str, _Rule]
|
||||
patterns: tuple[_Rule, ...]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
|
||||
@classmethod
|
||||
def make(cls, data: dict[str, Any]) -> Grammar:
|
||||
def make(cls, data: Dict[str, Any]) -> 'Grammar':
|
||||
scope_name = data['scopeName']
|
||||
if 'repository' in data:
|
||||
# this looks odd, but it's so we can have a self-referential
|
||||
# immutable-after-construction chain map
|
||||
repository_dct: dict[str, _Rule] = {}
|
||||
repository_dct: Dict[str, _Rule] = {}
|
||||
repository = FChainMap(repository_dct)
|
||||
for k, dct in data['repository'].items():
|
||||
repository_dct[k] = Rule.make(dct, repository)
|
||||
@@ -210,54 +212,54 @@ class Region(NamedTuple):
|
||||
|
||||
|
||||
class State(NamedTuple):
|
||||
entries: tuple[Entry, ...]
|
||||
while_stack: tuple[tuple[WhileRule, int], ...]
|
||||
entries: Tuple['Entry', ...]
|
||||
while_stack: Tuple[Tuple['WhileRule', int], ...]
|
||||
|
||||
@classmethod
|
||||
def root(cls, entry: Entry) -> State:
|
||||
def root(cls, entry: 'Entry') -> 'State':
|
||||
return cls((entry,), ())
|
||||
|
||||
@property
|
||||
def cur(self) -> Entry:
|
||||
def cur(self) -> 'Entry':
|
||||
return self.entries[-1]
|
||||
|
||||
def push(self, entry: Entry) -> State:
|
||||
def push(self, entry: 'Entry') -> 'State':
|
||||
return self._replace(entries=(*self.entries, entry))
|
||||
|
||||
def pop(self) -> State:
|
||||
def pop(self) -> 'State':
|
||||
return self._replace(entries=self.entries[:-1])
|
||||
|
||||
def push_while(self, rule: WhileRule, entry: Entry) -> State:
|
||||
def push_while(self, rule: 'WhileRule', entry: 'Entry') -> 'State':
|
||||
entries = (*self.entries, entry)
|
||||
while_stack = (*self.while_stack, (rule, len(entries)))
|
||||
return self._replace(entries=entries, while_stack=while_stack)
|
||||
|
||||
def pop_while(self) -> State:
|
||||
def pop_while(self) -> 'State':
|
||||
entries, while_stack = self.entries[:-1], self.while_stack[:-1]
|
||||
return self._replace(entries=entries, while_stack=while_stack)
|
||||
|
||||
|
||||
class CompiledRule(Protocol):
|
||||
@property
|
||||
def name(self) -> tuple[str, ...]: ...
|
||||
def name(self) -> Tuple[str, ...]: ...
|
||||
|
||||
def start(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
match: Match[str],
|
||||
state: State,
|
||||
) -> tuple[State, bool, Regions]:
|
||||
) -> Tuple[State, bool, Regions]:
|
||||
...
|
||||
|
||||
def search(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
...
|
||||
|
||||
|
||||
@@ -265,25 +267,24 @@ class CompiledRegsetRule(CompiledRule, Protocol):
|
||||
@property
|
||||
def regset(self) -> _RegSet: ...
|
||||
@property
|
||||
def u_rules(self) -> tuple[_Rule, ...]: ...
|
||||
def u_rules(self) -> Tuple[_Rule, ...]: ...
|
||||
|
||||
|
||||
class Entry(NamedTuple):
|
||||
scope: tuple[str, ...]
|
||||
scope: Tuple[str, ...]
|
||||
rule: CompiledRule
|
||||
start: tuple[str, int]
|
||||
reg: _Reg = ERR_REG
|
||||
boundary: bool = False
|
||||
|
||||
|
||||
def _inner_capture_parse(
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
start: int,
|
||||
s: str,
|
||||
scope: Scope,
|
||||
rule: CompiledRule,
|
||||
) -> Regions:
|
||||
state = State.root(Entry(scope + rule.name, rule, (s, 0)))
|
||||
state = State.root(Entry(scope + rule.name, rule))
|
||||
_, regions = highlight_line(compiler, state, s, first_line=False)
|
||||
return tuple(
|
||||
r._replace(start=r.start + start, end=r.end + start) for r in regions
|
||||
@@ -291,12 +292,12 @@ def _inner_capture_parse(
|
||||
|
||||
|
||||
def _captures(
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
scope: Scope,
|
||||
match: Match[str],
|
||||
captures: Captures,
|
||||
) -> Regions:
|
||||
ret: list[Region] = []
|
||||
ret: List[Region] = []
|
||||
pos, pos_end = match.span()
|
||||
for i, u_rule in captures:
|
||||
try:
|
||||
@@ -345,12 +346,12 @@ def _captures(
|
||||
|
||||
def _do_regset(
|
||||
idx: int,
|
||||
match: Match[str] | None,
|
||||
match: Optional[Match[str]],
|
||||
rule: CompiledRegsetRule,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
pos: int,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
@@ -367,114 +368,104 @@ def _do_regset(
|
||||
|
||||
@uniquely_constructed
|
||||
class PatternRule(NamedTuple):
|
||||
name: tuple[str, ...]
|
||||
name: Tuple[str, ...]
|
||||
regset: _RegSet
|
||||
u_rules: tuple[_Rule, ...]
|
||||
u_rules: Tuple[_Rule, ...]
|
||||
|
||||
def start(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
match: Match[str],
|
||||
state: State,
|
||||
) -> tuple[State, bool, Regions]:
|
||||
) -> Tuple[State, bool, Regions]:
|
||||
raise AssertionError(f'unreachable {self}')
|
||||
|
||||
def search(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
idx, match = self.regset.search(line, pos, first_line, boundary)
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class MatchRule(NamedTuple):
|
||||
name: tuple[str, ...]
|
||||
name: Tuple[str, ...]
|
||||
captures: Captures
|
||||
|
||||
def start(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
match: Match[str],
|
||||
state: State,
|
||||
) -> tuple[State, bool, Regions]:
|
||||
) -> Tuple[State, bool, Regions]:
|
||||
scope = state.cur.scope + self.name
|
||||
return state, False, _captures(compiler, scope, match, self.captures)
|
||||
|
||||
def search(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
raise AssertionError(f'unreachable {self}')
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class EndRule(NamedTuple):
|
||||
name: tuple[str, ...]
|
||||
content_name: tuple[str, ...]
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
begin_captures: Captures
|
||||
end_captures: Captures
|
||||
end: str
|
||||
regset: _RegSet
|
||||
u_rules: tuple[_Rule, ...]
|
||||
u_rules: Tuple[_Rule, ...]
|
||||
|
||||
def start(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
match: Match[str],
|
||||
state: State,
|
||||
) -> tuple[State, bool, Regions]:
|
||||
) -> Tuple[State, bool, Regions]:
|
||||
scope = state.cur.scope + self.name
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(expand_escaped(match, self.end))
|
||||
start = (match.string, match.start())
|
||||
state = state.push(Entry(next_scope, self, start, reg, boundary))
|
||||
state = state.push(Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
|
||||
def _end_ret(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
pos: int,
|
||||
m: Match[str],
|
||||
) -> tuple[State, int, bool, Regions]:
|
||||
) -> Tuple[State, int, bool, Regions]:
|
||||
ret = []
|
||||
if m.start() > pos:
|
||||
ret.append(Region(pos, m.start(), state.cur.scope))
|
||||
ret.extend(_captures(compiler, state.cur.scope, m, self.end_captures))
|
||||
# this is probably a bug in the grammar, but it pushed and popped at
|
||||
# the same position.
|
||||
# we'll advance the highlighter by one position to get past the loop
|
||||
# this appears to be what vs code does as well
|
||||
if state.entries[-1].start == (m.string, m.end()):
|
||||
ret.append(Region(m.end(), m.end() + 1, state.cur.scope))
|
||||
end = m.end() + 1
|
||||
else:
|
||||
end = m.end()
|
||||
return state.pop(), end, False, tuple(ret)
|
||||
return state.pop(), m.end(), False, tuple(ret)
|
||||
|
||||
def search(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
end_match = state.cur.reg.search(line, pos, first_line, boundary)
|
||||
if end_match is not None and end_match.start() == pos:
|
||||
return self._end_ret(compiler, state, pos, end_match)
|
||||
@@ -491,40 +482,38 @@ class EndRule(NamedTuple):
|
||||
|
||||
@uniquely_constructed
|
||||
class WhileRule(NamedTuple):
|
||||
name: tuple[str, ...]
|
||||
content_name: tuple[str, ...]
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
begin_captures: Captures
|
||||
while_captures: Captures
|
||||
while_: str
|
||||
regset: _RegSet
|
||||
u_rules: tuple[_Rule, ...]
|
||||
u_rules: Tuple[_Rule, ...]
|
||||
|
||||
def start(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
match: Match[str],
|
||||
state: State,
|
||||
) -> tuple[State, bool, Regions]:
|
||||
) -> Tuple[State, bool, Regions]:
|
||||
scope = state.cur.scope + self.name
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(expand_escaped(match, self.while_))
|
||||
start = (match.string, match.start())
|
||||
entry = Entry(next_scope, self, start, reg, boundary)
|
||||
state = state.push_while(self, entry)
|
||||
state = state.push_while(self, Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
|
||||
def continues(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[int, bool, Regions]]:
|
||||
match = state.cur.reg.match(line, pos, first_line, boundary)
|
||||
if match is None:
|
||||
return None
|
||||
@@ -534,25 +523,25 @@ class WhileRule(NamedTuple):
|
||||
|
||||
def search(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[State, int, bool, Regions] | None:
|
||||
) -> Optional[Tuple[State, int, bool, Regions]]:
|
||||
idx, match = self.regset.search(line, pos, first_line, boundary)
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
class Compiler:
|
||||
def __init__(self, grammar: Grammar, grammars: Grammars) -> None:
|
||||
def __init__(self, grammar: Grammar, grammars: 'Grammars') -> None:
|
||||
self._root_scope = grammar.scope_name
|
||||
self._grammars = grammars
|
||||
self._rule_to_grammar: dict[_Rule, Grammar] = {}
|
||||
self._c_rules: dict[_Rule, CompiledRule] = {}
|
||||
self._rule_to_grammar: Dict[_Rule, Grammar] = {}
|
||||
self._c_rules: Dict[_Rule, CompiledRule] = {}
|
||||
root = self._compile_root(grammar)
|
||||
self.root_state = State.root(Entry(root.name, root, ('', 0)))
|
||||
self.root_state = State.root(Entry(root.name, root))
|
||||
|
||||
def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule:
|
||||
self._rule_to_grammar[rule] = grammar
|
||||
@@ -564,7 +553,7 @@ class Compiler:
|
||||
grammar: Grammar,
|
||||
repository: FChainMap[str, _Rule],
|
||||
s: str,
|
||||
) -> tuple[list[str], tuple[_Rule, ...]]:
|
||||
) -> Tuple[List[str], Tuple[_Rule, ...]]:
|
||||
if s == '$self':
|
||||
return self._patterns(grammar, grammar.patterns)
|
||||
elif s == '$base':
|
||||
@@ -584,10 +573,10 @@ class Compiler:
|
||||
def _patterns(
|
||||
self,
|
||||
grammar: Grammar,
|
||||
rules: tuple[_Rule, ...],
|
||||
) -> tuple[list[str], tuple[_Rule, ...]]:
|
||||
rules: Tuple[_Rule, ...],
|
||||
) -> Tuple[List[str], Tuple[_Rule, ...]]:
|
||||
ret_regs = []
|
||||
ret_rules: list[_Rule] = []
|
||||
ret_rules: List[_Rule] = []
|
||||
for rule in rules:
|
||||
if rule.include is not None:
|
||||
tmp_regs, tmp_rules = self._include(
|
||||
@@ -674,19 +663,19 @@ class Grammars:
|
||||
|
||||
unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
|
||||
self._raw = {'source.unknown': unknown_grammar}
|
||||
self._file_types: list[tuple[frozenset[str], str]] = []
|
||||
self._first_line: list[tuple[_Reg, str]] = []
|
||||
self._parsed: dict[str, Grammar] = {}
|
||||
self._compiled: dict[str, Compiler] = {}
|
||||
self._file_types: List[Tuple[FrozenSet[str], str]] = []
|
||||
self._first_line: List[Tuple[_Reg, str]] = []
|
||||
self._parsed: Dict[str, Grammar] = {}
|
||||
self._compiled: Dict[str, Compiler] = {}
|
||||
|
||||
def _raw_for_scope(self, scope: str) -> dict[str, Any]:
|
||||
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, encoding='UTF-8') as f:
|
||||
with open(grammar_path) as f:
|
||||
ret = self._raw[scope] = json.load(f)
|
||||
|
||||
file_types = frozenset(ret.get('fileTypes', ()))
|
||||
@@ -745,12 +734,12 @@ class Grammars:
|
||||
|
||||
|
||||
def highlight_line(
|
||||
compiler: Compiler,
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
line: str,
|
||||
first_line: bool,
|
||||
) -> tuple[State, Regions]:
|
||||
ret: list[Region] = []
|
||||
) -> Tuple[State, Regions]:
|
||||
ret: List[Region] = []
|
||||
pos = 0
|
||||
boundary = state.cur.boundary
|
||||
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import os.path
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
|
||||
from babi.user_data import xdg_data
|
||||
|
||||
|
||||
class History:
|
||||
def __init__(self) -> None:
|
||||
self._orig_len: dict[str, int] = collections.defaultdict(int)
|
||||
self.data: dict[str, list[str]] = collections.defaultdict(list)
|
||||
self.prev: dict[str, str] = {}
|
||||
self._orig_len: Dict[str, int] = collections.defaultdict(int)
|
||||
self.data: Dict[str, List[str]] = collections.defaultdict(list)
|
||||
self.prev: Dict[str, str] = {}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def save(self) -> Generator[None, None, None]:
|
||||
history_dir = xdg_data('history')
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
for filename in os.listdir(history_dir):
|
||||
history_filename = os.path.join(history_dir, filename)
|
||||
with open(history_filename, encoding='UTF-8') as f:
|
||||
with open(os.path.join(history_dir, filename)) as f:
|
||||
self.data[filename] = f.read().splitlines()
|
||||
self._orig_len[filename] = len(self.data[filename])
|
||||
try:
|
||||
@@ -29,6 +28,5 @@ class History:
|
||||
for k, v in self.data.items():
|
||||
new_history = v[self._orig_len[k]:]
|
||||
if new_history:
|
||||
history_filename = os.path.join(history_dir, k)
|
||||
with open(history_filename, 'a+', encoding='UTF-8') as f:
|
||||
with open(os.path.join(history_dir, k), 'a+') as f:
|
||||
f.write('\n'.join(new_history) + '\n')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
|
||||
from babi.buf import Buf
|
||||
@@ -14,7 +13,7 @@ class Replace:
|
||||
include_edge = True
|
||||
|
||||
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: Buf, idx: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.hl.interface import HL
|
||||
@@ -12,9 +13,9 @@ class Selection:
|
||||
include_edge = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.regions: dict[int, HLs] = collections.defaultdict(tuple)
|
||||
self.start: tuple[int, int] | None = None
|
||||
self.end: tuple[int, int] | None = None
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
self.start: Optional[Tuple[int, int]] = None
|
||||
self.end: Optional[Tuple[int, int]] = None
|
||||
|
||||
def register_callbacks(self, buf: Buf) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
@@ -38,7 +39,7 @@ class Selection:
|
||||
)
|
||||
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
||||
|
||||
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
|
||||
if self.start < self.end:
|
||||
return self.start, self.end
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import functools
|
||||
import math
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
@@ -20,7 +21,7 @@ from babi.user_data import prefix_data
|
||||
from babi.user_data import xdg_config
|
||||
from babi.user_data import xdg_data
|
||||
|
||||
A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # not always present
|
||||
A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # new in py37
|
||||
|
||||
|
||||
class FileSyntax:
|
||||
@@ -36,12 +37,12 @@ class FileSyntax:
|
||||
self._theme = theme
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: list[HLs] = []
|
||||
self._states: list[State] = []
|
||||
self.regions: List[HLs] = []
|
||||
self._states: List[State] = []
|
||||
|
||||
# this will be assigned a functools.lru_cache per instance for
|
||||
# better hit rate and memory usage
|
||||
self._hl: Callable[[State, str, bool], tuple[State, HLs]] | None
|
||||
self._hl: Optional[Callable[[State, str, bool], Tuple[State, HLs]]]
|
||||
self._hl = None
|
||||
|
||||
def attr(self, style: Style) -> int:
|
||||
@@ -58,7 +59,7 @@ class FileSyntax:
|
||||
state: State,
|
||||
line: str,
|
||||
first_line: bool,
|
||||
) -> tuple[State, HLs]:
|
||||
) -> Tuple[State, HLs]:
|
||||
new_state, regions = highlight_line(
|
||||
self._compiler, state, f'{line}\n', first_line=first_line,
|
||||
)
|
||||
@@ -67,7 +68,7 @@ class FileSyntax:
|
||||
new_end = regions[-1]._replace(end=regions[-1].end - 1)
|
||||
regions = regions[:-1] + (new_end,)
|
||||
|
||||
regs: list[HL] = []
|
||||
regs: List[HL] = []
|
||||
for r in regions:
|
||||
style = self._theme.select(r.scope)
|
||||
if style == self._theme.default:
|
||||
@@ -114,7 +115,8 @@ class FileSyntax:
|
||||
state = self._states[-1]
|
||||
|
||||
for i in range(len(self._states), idx):
|
||||
state, regions = self._hl(state, lines[i], i == 0)
|
||||
# https://github.com/python/mypy/issues/8579
|
||||
state, regions = self._hl(state, lines[i], i == 0) # type: ignore
|
||||
self._states.append(state)
|
||||
self.regions.append(regions)
|
||||
|
||||
@@ -132,7 +134,7 @@ class Syntax(NamedTuple):
|
||||
compiler = self.grammars.blank_compiler()
|
||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||
|
||||
def _init_screen(self, stdscr: curses._CursesWindow) -> None:
|
||||
def _init_screen(self, stdscr: 'curses._CursesWindow') -> None:
|
||||
default_fg, default_bg = self.theme.default.fg, self.theme.default.bg
|
||||
all_colors = {c for c in (default_fg, default_bg) if c is not None}
|
||||
todo = list(self.theme.rules.children.values())
|
||||
@@ -153,9 +155,9 @@ class Syntax(NamedTuple):
|
||||
@classmethod
|
||||
def from_screen(
|
||||
cls,
|
||||
stdscr: curses._CursesWindow,
|
||||
stdscr: 'curses._CursesWindow',
|
||||
color_manager: ColorManager,
|
||||
) -> Syntax:
|
||||
) -> 'Syntax':
|
||||
grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1'))
|
||||
theme = Theme.from_filename(xdg_config('theme.json'))
|
||||
ret = cls(grammars, theme, color_manager)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
from typing import List
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.color_manager import ColorManager
|
||||
@@ -14,7 +13,7 @@ class TrailingWhitespace:
|
||||
def __init__(self, color_manager: ColorManager) -> None:
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: list[HLs] = []
|
||||
self.regions: List[HLs] = []
|
||||
|
||||
def _trailing_ws(self, line: str) -> HLs:
|
||||
if not line:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import curses
|
||||
from typing import Tuple
|
||||
|
||||
from babi.cached_property import cached_property
|
||||
|
||||
@@ -20,23 +20,42 @@ def line_x(x: int, width: int) -> int:
|
||||
)
|
||||
|
||||
|
||||
def scrolled_line(s: str, x: int, width: int) -> str:
|
||||
l_x = line_x(x, width)
|
||||
def scrolled_line(
|
||||
s: str,
|
||||
positions: Tuple[int, ...],
|
||||
cursor_x: int,
|
||||
width: int,
|
||||
) -> str:
|
||||
l_x = line_x(cursor_x, width)
|
||||
if l_x:
|
||||
s = f'«{s[l_x + 1:]}'
|
||||
if len(s) > width:
|
||||
return f'{s[:width - 1]}»'
|
||||
l_x_min = l_x + 1
|
||||
start = bisect.bisect_left(positions, l_x_min)
|
||||
pad_left = '«' * (positions[start] - l_x)
|
||||
|
||||
l_x_max = l_x + width
|
||||
if positions[-1] > l_x_max:
|
||||
end_max = l_x_max - 1
|
||||
end = bisect.bisect_left(positions, end_max)
|
||||
if positions[end] > end_max:
|
||||
end -= 1
|
||||
pad_right = '»' * (l_x_max - positions[end])
|
||||
return f'{pad_left}{s[start:end].expandtabs(4)}{pad_right}'
|
||||
else:
|
||||
return s.ljust(width)
|
||||
elif len(s) > width:
|
||||
return f'{s[:width - 1]}»'
|
||||
return f'{pad_left}{s[start:]}'.ljust(width)
|
||||
elif positions[-1] > width:
|
||||
end_max = width - 1
|
||||
end = bisect.bisect_left(positions, end_max)
|
||||
if positions[end] > end_max:
|
||||
end -= 1
|
||||
pad_right = '»' * (width - positions[end])
|
||||
return f'{s[:end].expandtabs(4)}{pad_right}'
|
||||
else:
|
||||
return s.ljust(width)
|
||||
return s.expandtabs(4).ljust(width)
|
||||
|
||||
|
||||
class _CalcWidth:
|
||||
@cached_property
|
||||
def _window(self) -> curses._CursesWindow:
|
||||
def _window(self) -> 'curses._CursesWindow':
|
||||
return curses.newwin(1, 10)
|
||||
|
||||
def wcwidth(self, c: str) -> int:
|
||||
|
||||
72
babi/main.py
72
babi/main.py
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import curses
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.buf import Buf
|
||||
@@ -17,11 +14,10 @@ from babi.screen import make_stdscr
|
||||
from babi.screen import Screen
|
||||
|
||||
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
|
||||
POSITION_RE = re.compile(r'^\+-?\d+$')
|
||||
|
||||
|
||||
def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status, screen.margin, stdin)
|
||||
screen.file.ensure_loaded(screen.status, stdin)
|
||||
|
||||
while True:
|
||||
screen.status.tick(screen.margin)
|
||||
@@ -37,19 +33,18 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
return ret
|
||||
elif key.keyname == b'STRING':
|
||||
assert isinstance(key.wch, str), key.wch
|
||||
screen.file.c(key.wch, screen.margin)
|
||||
screen.file.c(key.wch)
|
||||
else:
|
||||
screen.status.update(f'unknown key: {key}')
|
||||
|
||||
|
||||
def c_main(
|
||||
stdscr: curses._CursesWindow,
|
||||
filenames: list[str | None],
|
||||
positions: list[int],
|
||||
stdscr: 'curses._CursesWindow',
|
||||
args: argparse.Namespace,
|
||||
stdin: str,
|
||||
perf: Perf,
|
||||
) -> int:
|
||||
screen = Screen(stdscr, filenames, positions, perf)
|
||||
with perf_log(args.perf_log) as perf:
|
||||
screen = Screen(stdscr, args.filenames or [None], perf)
|
||||
with screen.history.save():
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
@@ -72,8 +67,8 @@ def c_main(
|
||||
return 0
|
||||
|
||||
|
||||
def _key_debug(stdscr: curses._CursesWindow, perf: Perf) -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], [0], perf)
|
||||
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], Perf())
|
||||
screen.file.buf = Buf([''])
|
||||
|
||||
while True:
|
||||
@@ -90,38 +85,7 @@ def _key_debug(stdscr: curses._CursesWindow, perf: Perf) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _filenames(filenames: list[str]) -> tuple[list[str | None], list[int]]:
|
||||
if not filenames:
|
||||
return [None], [0]
|
||||
|
||||
ret_filenames: list[str | None] = []
|
||||
ret_positions = []
|
||||
|
||||
filenames_iter = iter(filenames)
|
||||
for filename in filenames_iter:
|
||||
if POSITION_RE.match(filename):
|
||||
# in the success case we get:
|
||||
#
|
||||
# position_s = +...
|
||||
# filename = (the next thing)
|
||||
#
|
||||
# in the error case we only need to reset `position_s` as
|
||||
# `filename` is already correct
|
||||
position_s = filename
|
||||
try:
|
||||
filename = next(filenames_iter)
|
||||
except StopIteration:
|
||||
position_s = '+0'
|
||||
ret_positions.append(int(position_s[1:]))
|
||||
ret_filenames.append(filename)
|
||||
else:
|
||||
ret_positions.append(0)
|
||||
ret_filenames.append(filename)
|
||||
|
||||
return ret_filenames, ret_positions
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||
parser.add_argument('--perf-log')
|
||||
@@ -132,24 +96,18 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if '-' in args.filenames:
|
||||
print('reading stdin...', file=sys.stderr)
|
||||
stdin = sys.stdin.buffer.read().decode()
|
||||
stdin = sys.stdin.read()
|
||||
tty = os.open(CONSOLE, os.O_RDONLY)
|
||||
os.dup2(tty, sys.stdin.fileno())
|
||||
else:
|
||||
stdin = ''
|
||||
|
||||
# ignore backgrounding signals, we'll handle those in curses
|
||||
# fixes a problem with ^Z on termination which would break the terminal
|
||||
if sys.platform != 'win32': # pragma: win32 no cover # pragma: no branch
|
||||
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
|
||||
|
||||
with perf_log(args.perf_log) as perf, make_stdscr() as stdscr:
|
||||
with make_stdscr() as stdscr:
|
||||
if args.key_debug:
|
||||
return _key_debug(stdscr, perf)
|
||||
return _key_debug(stdscr)
|
||||
else:
|
||||
filenames, positions = _filenames(args.filenames)
|
||||
return c_main(stdscr, filenames, positions, stdin, perf)
|
||||
return c_main(stdscr, args, stdin)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
exit(main())
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -33,5 +31,5 @@ class Margin(NamedTuple):
|
||||
return int(self.lines / 2 + .5)
|
||||
|
||||
@classmethod
|
||||
def from_current_screen(cls) -> Margin:
|
||||
def from_current_screen(cls) -> 'Margin':
|
||||
return cls(curses.LINES, curses.COLS)
|
||||
|
||||
17
babi/perf.py
17
babi/perf.py
@@ -1,17 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import cProfile
|
||||
import time
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class Perf:
|
||||
def __init__(self) -> None:
|
||||
self._prof: cProfile.Profile | None = None
|
||||
self._records: list[tuple[str, float]] = []
|
||||
self._name: str | None = None
|
||||
self._time: float | None = None
|
||||
self._prof: Optional[cProfile.Profile] = None
|
||||
self._records: List[Tuple[str, float]] = []
|
||||
self._name: Optional[str] = None
|
||||
self._time: Optional[float] = None
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
if self._prof:
|
||||
@@ -35,14 +36,14 @@ class Perf:
|
||||
def save_profiles(self, filename: str) -> None:
|
||||
assert self._prof is not None
|
||||
self._prof.dump_stats(f'{filename}.pstats')
|
||||
with open(filename, 'w', encoding='UTF-8') as f:
|
||||
with open(filename, 'w') as f:
|
||||
f.write('μs\tevent\n')
|
||||
for name, duration in self._records:
|
||||
f.write(f'{int(duration * 1000 * 1000)}\t{name}\n')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def perf_log(filename: str | None) -> Generator[Perf, None, None]:
|
||||
def perf_log(filename: Optional[str]) -> Generator[Perf, None, None]:
|
||||
perf = Perf()
|
||||
if filename is None:
|
||||
yield perf
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import enum
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Union
|
||||
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.buf import Buf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babi.main import Screen # XXX: circular
|
||||
@@ -14,22 +15,30 @@ PromptResult = enum.Enum('PromptResult', 'CANCELLED')
|
||||
|
||||
|
||||
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._prompt = prompt
|
||||
self._lst = lst
|
||||
self._y = len(lst) - 1
|
||||
self._buf = Buf(lst)
|
||||
self._buf.y = self._buf.file_y = len(lst) - 1
|
||||
self._x = len(self._s)
|
||||
|
||||
@property
|
||||
def _x(self) -> int:
|
||||
return self._buf.x
|
||||
|
||||
@_x.setter
|
||||
def _x(self, x: int) -> None:
|
||||
self._buf.x = x
|
||||
|
||||
@property
|
||||
def _s(self) -> str:
|
||||
return self._lst[self._y]
|
||||
return self._buf[self._buf.y]
|
||||
|
||||
@_s.setter
|
||||
def _s(self, s: str) -> None:
|
||||
self._lst[self._y] = s
|
||||
self._buf[self._buf.y] = s
|
||||
|
||||
def _render_prompt(self, *, base: str | None = None) -> None:
|
||||
def _render_prompt(self, *, base: Optional[str] = None) -> None:
|
||||
base = base or self._prompt
|
||||
if not base or self._screen.margin.cols < 7:
|
||||
prompt_s = ''
|
||||
@@ -37,24 +46,26 @@ class Prompt:
|
||||
prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
|
||||
else:
|
||||
prompt_s = f'{base}: '
|
||||
|
||||
width = self._screen.margin.cols - len(prompt_s)
|
||||
line = scrolled_line(self._s, self._x, width)
|
||||
cmd = f'{prompt_s}{line}'
|
||||
margin = self._screen.margin._replace(cols=width)
|
||||
cmd = f'{prompt_s}{self._buf.rendered_line(self._buf.y, margin)}'
|
||||
prompt_line = self._screen.margin.lines - 1
|
||||
self._screen.stdscr.insstr(prompt_line, 0, cmd, curses.A_REVERSE)
|
||||
x = len(prompt_s) + self._x - line_x(self._x, width)
|
||||
self._screen.stdscr.move(prompt_line, x)
|
||||
|
||||
_, x_off = self._buf.cursor_position(margin)
|
||||
self._screen.stdscr.move(prompt_line, len(prompt_s) + x_off)
|
||||
|
||||
def _up(self) -> None:
|
||||
self._y = max(0, self._y - 1)
|
||||
self._x = len(self._s)
|
||||
self._buf.up(self._screen.margin)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _down(self) -> None:
|
||||
self._y = min(len(self._lst) - 1, self._y + 1)
|
||||
self._x = len(self._s)
|
||||
self._buf.down(self._screen.margin)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _right(self) -> None:
|
||||
self._x = min(len(self._s), self._x + 1)
|
||||
self._x = min(len(self._buf[self._buf.y]), self._x + 1)
|
||||
|
||||
def _left(self) -> None:
|
||||
self._x = max(0, self._x - 1)
|
||||
@@ -63,11 +74,11 @@ class Prompt:
|
||||
self._x = 0
|
||||
|
||||
def _end(self) -> None:
|
||||
self._x = len(self._s)
|
||||
self._x = len(self._buf[self._buf.y])
|
||||
|
||||
def _ctrl_left(self) -> None:
|
||||
if self._x <= 1:
|
||||
self._x = 0
|
||||
self._buf.home()
|
||||
else:
|
||||
self._x -= 1
|
||||
tp = self._s[self._x - 1].isalnum()
|
||||
@@ -76,7 +87,7 @@ class Prompt:
|
||||
|
||||
def _ctrl_right(self) -> None:
|
||||
if self._x >= len(self._s) - 1:
|
||||
self._x = len(self._s)
|
||||
self._buf.end()
|
||||
else:
|
||||
self._x += 1
|
||||
tp = self._s[self._x].isalnum()
|
||||
@@ -98,20 +109,20 @@ class Prompt:
|
||||
def _resize(self) -> None:
|
||||
self._screen.resize()
|
||||
|
||||
def _check_failed(self, idx: int, s: str) -> tuple[bool, int]:
|
||||
def _check_failed(self, idx: int, s: str) -> Tuple[bool, int]:
|
||||
failed = False
|
||||
for search_idx in range(idx, -1, -1):
|
||||
if s in self._lst[search_idx]:
|
||||
idx = self._y = search_idx
|
||||
self._x = self._lst[search_idx].index(s)
|
||||
if s in self._buf[search_idx]:
|
||||
idx = self._buf.y = search_idx
|
||||
self._x = self._buf[search_idx].index(s)
|
||||
break
|
||||
else:
|
||||
failed = True
|
||||
return failed, idx
|
||||
|
||||
def _reverse_search(self) -> None | str | PromptResult:
|
||||
def _reverse_search(self) -> Union[None, str, PromptResult]:
|
||||
reverse_s = ''
|
||||
idx = self._y
|
||||
idx = self._buf.y
|
||||
while True:
|
||||
fail, idx = self._check_failed(idx, reverse_s)
|
||||
|
||||
@@ -172,10 +183,9 @@ class Prompt:
|
||||
}
|
||||
|
||||
def _c(self, c: str) -> None:
|
||||
self._s = self._s[:self._x] + c + self._s[self._x:]
|
||||
self._x += len(c)
|
||||
self._buf.c(c)
|
||||
|
||||
def run(self) -> PromptResult | str:
|
||||
def run(self) -> Union[PromptResult, str]:
|
||||
while True:
|
||||
self._render_prompt()
|
||||
|
||||
|
||||
127
babi/reg.py
127
babi/reg.py
@@ -1,49 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from typing import Match
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
import onigurumacffi
|
||||
|
||||
from babi.cached_property import cached_property
|
||||
|
||||
_BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)')
|
||||
|
||||
|
||||
_FLAGS = {
|
||||
# (first_line, boundary)
|
||||
(False, False): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
|
||||
),
|
||||
(False, True): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING
|
||||
),
|
||||
(True, False): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
|
||||
),
|
||||
(True, True): onigurumacffi.OnigSearchOption.NOT_END_STRING,
|
||||
}
|
||||
def _replace_esc(s: str, chars: str) -> str:
|
||||
"""replace the given escape sequences of `chars` with \\uffff"""
|
||||
for c in chars:
|
||||
if f'\\{c}' in s:
|
||||
break
|
||||
else:
|
||||
return s
|
||||
|
||||
b = []
|
||||
i = 0
|
||||
length = len(s)
|
||||
while i < length:
|
||||
try:
|
||||
sbi = s.index('\\', i)
|
||||
except ValueError:
|
||||
b.append(s[i:])
|
||||
break
|
||||
if sbi > i:
|
||||
b.append(s[i:sbi])
|
||||
b.append('\\')
|
||||
i = sbi + 1
|
||||
if i < length:
|
||||
if s[i] in chars:
|
||||
b.append('\uffff')
|
||||
else:
|
||||
b.append(s[i])
|
||||
i += 1
|
||||
return ''.join(b)
|
||||
|
||||
|
||||
class _Reg:
|
||||
def __init__(self, s: str) -> None:
|
||||
self._pattern = s
|
||||
self._reg = onigurumacffi.compile(self._pattern)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'{type(self).__name__}({self._pattern!r})'
|
||||
|
||||
@cached_property
|
||||
def _reg(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'z'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Az'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Gz'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'AGz'))
|
||||
|
||||
def _get_reg(
|
||||
self,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> onigurumacffi._Pattern:
|
||||
if boundary:
|
||||
if first_line:
|
||||
return self._reg
|
||||
else:
|
||||
return self._reg_no_A
|
||||
else:
|
||||
if first_line:
|
||||
return self._reg_no_G
|
||||
else:
|
||||
return self._reg_no_A_no_G
|
||||
|
||||
def search(
|
||||
self,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> Match[str] | None:
|
||||
return self._reg.search(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
) -> Optional[Match[str]]:
|
||||
return self._get_reg(first_line, boundary).search(line, pos)
|
||||
|
||||
def match(
|
||||
self,
|
||||
@@ -51,27 +95,54 @@ class _Reg:
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> Match[str] | None:
|
||||
return self._reg.match(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
) -> Optional[Match[str]]:
|
||||
return self._get_reg(first_line, boundary).match(line, pos)
|
||||
|
||||
|
||||
class _RegSet:
|
||||
def __init__(self, *s: str) -> None:
|
||||
self._patterns = s
|
||||
self._set = onigurumacffi.compile_regset(*self._patterns)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
args = ', '.join(repr(s) for s in self._patterns)
|
||||
return f'{type(self).__name__}({args})'
|
||||
|
||||
@cached_property
|
||||
def _set(self) -> onigurumacffi._RegSet:
|
||||
return onigurumacffi.compile_regset(*self._patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_A(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'A') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_G(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'G') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_A_no_G(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'AG') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
def search(
|
||||
self,
|
||||
line: str,
|
||||
pos: int,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> tuple[int, Match[str] | None]:
|
||||
return self._set.search(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
) -> Tuple[int, Optional[Match[str]]]:
|
||||
if boundary:
|
||||
if first_line:
|
||||
return self._set.search(line, pos)
|
||||
else:
|
||||
return self._set_no_A.search(line, pos)
|
||||
else:
|
||||
if first_line:
|
||||
return self._set_no_G.search(line, pos)
|
||||
else:
|
||||
return self._set_no_A_no_G.search(line, pos)
|
||||
|
||||
|
||||
def expand_escaped(match: Match[str], s: str) -> str:
|
||||
@@ -80,4 +151,4 @@ def expand_escaped(match: Match[str], s: str) -> str:
|
||||
|
||||
make_reg = functools.lru_cache(maxsize=None)(_Reg)
|
||||
make_regset = functools.lru_cache(maxsize=None)(_RegSet)
|
||||
ERR_REG = make_reg('$ ^')
|
||||
ERR_REG = make_reg(')this pattern always triggers an error when used(')
|
||||
|
||||
143
babi/screen.py
143
babi/screen.py
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import curses
|
||||
import enum
|
||||
@@ -7,11 +5,14 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sre_parse
|
||||
import sys
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Pattern
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.file import Action
|
||||
@@ -37,8 +38,6 @@ EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
|
||||
SEQUENCE_KEYNAME = {
|
||||
'\x1bOH': b'KEY_HOME',
|
||||
'\x1bOF': b'KEY_END',
|
||||
'\x1b[1~': b'KEY_HOME',
|
||||
'\x1b[4~': b'KEY_END',
|
||||
'\x1b[1;2A': b'KEY_SR',
|
||||
'\x1b[1;2B': b'KEY_SF',
|
||||
'\x1b[1;2C': b'KEY_SRIGHT',
|
||||
@@ -61,7 +60,6 @@ SEQUENCE_KEYNAME = {
|
||||
'\x1b[1;6D': b'kLFT6', # Shift + ^Left
|
||||
'\x1b[1;6H': b'kHOM6', # Shift + ^Home
|
||||
'\x1b[1;6F': b'kEND6', # Shift + ^End
|
||||
'\x1b[~': b'KEY_BTAB', # Shift + Tab
|
||||
}
|
||||
KEYNAME_REWRITE = {
|
||||
# windows-curses: numeric pad arrow keys
|
||||
@@ -83,11 +81,8 @@ KEYNAME_REWRITE = {
|
||||
b'CTL_DOWN': b'kDN5',
|
||||
b'CTL_RIGHT': b'kRIT5',
|
||||
b'CTL_LEFT': b'kLFT5',
|
||||
b'CTL_HOME': b'kHOM5',
|
||||
b'CTL_END': b'kEND5',
|
||||
b'ALT_RIGHT': b'kRIT3',
|
||||
b'ALT_LEFT': b'kLFT3',
|
||||
b'ALT_E': b'M-e',
|
||||
# windows-curses: idk why these are different
|
||||
b'KEY_SUP': b'KEY_SR',
|
||||
b'KEY_SDOWN': b'KEY_SF',
|
||||
@@ -95,39 +90,36 @@ KEYNAME_REWRITE = {
|
||||
b'^?': b'KEY_BACKSPACE',
|
||||
# linux, perhaps others
|
||||
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
|
||||
b'^D': b'KEY_DC',
|
||||
b'PADENTER': b'^M', # Enter on numpad
|
||||
}
|
||||
|
||||
|
||||
class Key(NamedTuple):
|
||||
wch: int | str
|
||||
wch: Union[int, str]
|
||||
keyname: bytes
|
||||
|
||||
|
||||
class Screen:
|
||||
def __init__(
|
||||
self,
|
||||
stdscr: curses._CursesWindow,
|
||||
filenames: list[str | None],
|
||||
initial_lines: list[int],
|
||||
stdscr: 'curses._CursesWindow',
|
||||
filenames: List[Optional[str]],
|
||||
perf: Perf,
|
||||
) -> None:
|
||||
self.stdscr = stdscr
|
||||
self.color_manager = ColorManager.make()
|
||||
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
|
||||
self.files = [
|
||||
File(filename, line, self.color_manager, self.hl_factories)
|
||||
for filename, line in zip(filenames, initial_lines)
|
||||
File(filename, self.color_manager, self.hl_factories)
|
||||
for filename in filenames
|
||||
]
|
||||
self.i = 0
|
||||
self.history = History()
|
||||
self.perf = perf
|
||||
self.status = Status()
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.cut_buffer: tuple[str, ...] = ()
|
||||
self.cut_buffer: Tuple[str, ...] = ()
|
||||
self.cut_selection = False
|
||||
self._buffered_input: int | str | None = None
|
||||
self._buffered_input: Union[int, str, None] = None
|
||||
|
||||
@property
|
||||
def file(self) -> File:
|
||||
@@ -229,9 +221,6 @@ class Screen:
|
||||
if self._buffered_input is not None:
|
||||
wch, self._buffered_input = self._buffered_input, None
|
||||
else:
|
||||
try:
|
||||
wch = self.stdscr.get_wch()
|
||||
except curses.error: # pragma: no cover (macos bug?)
|
||||
wch = self.stdscr.get_wch()
|
||||
if isinstance(wch, str) and wch == '\x1b':
|
||||
wch = self._get_sequence(wch)
|
||||
@@ -270,9 +259,9 @@ class Screen:
|
||||
def quick_prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
opt_strs: tuple[str, ...],
|
||||
) -> str | PromptResult:
|
||||
opts = {opt[0] for opt in opt_strs}
|
||||
opt_strs: Tuple[str, ...],
|
||||
) -> Union[str, PromptResult]:
|
||||
opts = [opt[0] for opt in opt_strs]
|
||||
while True:
|
||||
x = 0
|
||||
prompt_line = self.margin.lines - 1
|
||||
@@ -309,18 +298,18 @@ class Screen:
|
||||
self.resize()
|
||||
elif key.keyname == b'^C':
|
||||
return self.status.cancelled()
|
||||
elif isinstance(key.wch, str) and key.wch.lower() in opts:
|
||||
return key.wch.lower()
|
||||
elif isinstance(key.wch, str) and key.wch in opts:
|
||||
return key.wch
|
||||
|
||||
def prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
history: str | None = None,
|
||||
history: Optional[str] = None,
|
||||
default_prev: bool = False,
|
||||
default: str | None = None,
|
||||
) -> str | PromptResult:
|
||||
default: Optional[str] = None,
|
||||
) -> Union[str, PromptResult]:
|
||||
default = default or ''
|
||||
self.status.clear()
|
||||
if history is not None:
|
||||
@@ -377,7 +366,7 @@ class Screen:
|
||||
else:
|
||||
self.file.uncut(self.cut_buffer, self.margin)
|
||||
|
||||
def _get_search_re(self, prompt: str) -> Pattern[str] | PromptResult:
|
||||
def _get_search_re(self, prompt: str) -> Union[Pattern[str], PromptResult]:
|
||||
response = self.prompt(prompt, history='search', default_prev=True)
|
||||
if response is PromptResult.CANCELLED:
|
||||
return response
|
||||
@@ -390,8 +379,8 @@ class Screen:
|
||||
def _undo_redo(
|
||||
self,
|
||||
op: str,
|
||||
from_stack: list[Action],
|
||||
to_stack: list[Action],
|
||||
from_stack: List[Action],
|
||||
to_stack: List[Action],
|
||||
) -> None:
|
||||
if not from_stack:
|
||||
self.status.update(f'nothing to {op}!')
|
||||
@@ -420,18 +409,11 @@ class Screen:
|
||||
'replace with', history='replace', allow_empty=True,
|
||||
)
|
||||
if response is not PromptResult.CANCELLED:
|
||||
try:
|
||||
sre_parse.parse_template(response, search_response)
|
||||
except re.error:
|
||||
self.status.update('invalid replacement string')
|
||||
else:
|
||||
self.file.replace(self, search_response, response)
|
||||
|
||||
def command(self) -> EditResult | None:
|
||||
def command(self) -> Optional[EditResult]:
|
||||
response = self.prompt('', history='command')
|
||||
if response is PromptResult.CANCELLED:
|
||||
pass
|
||||
elif response == ':q':
|
||||
if response == ':q':
|
||||
return self.quit_save_modified()
|
||||
elif response == ':q!':
|
||||
return EditResult.EXIT
|
||||
@@ -446,45 +428,11 @@ class Screen:
|
||||
else:
|
||||
self.file.sort(self.margin)
|
||||
self.status.update('sorted!')
|
||||
elif response == ':sort!':
|
||||
if self.file.selection.start:
|
||||
self.file.sort_selection(self.margin, reverse=True)
|
||||
else:
|
||||
self.file.sort(self.margin, reverse=True)
|
||||
self.status.update('sorted!')
|
||||
elif response.startswith((':tabstop ', ':tabsize ')):
|
||||
_, _, tab_size = response.partition(' ')
|
||||
try:
|
||||
parsed_tab_size = int(tab_size)
|
||||
except ValueError:
|
||||
self.status.update(f'invalid size: {tab_size}')
|
||||
else:
|
||||
if parsed_tab_size <= 0:
|
||||
self.status.update(f'invalid size: {parsed_tab_size}')
|
||||
else:
|
||||
for file in self.files:
|
||||
file.buf.set_tab_size(parsed_tab_size)
|
||||
self.status.update('updated!')
|
||||
elif response.startswith(':expandtabs'):
|
||||
for file in self.files:
|
||||
file.buf.expandtabs = True
|
||||
self.status.update('updated!')
|
||||
elif response.startswith(':noexpandtabs'):
|
||||
for file in self.files:
|
||||
file.buf.expandtabs = False
|
||||
self.status.update('updated!')
|
||||
elif response == ':comment' or response.startswith(':comment '):
|
||||
_, _, comment = response.partition(' ')
|
||||
comment = (comment or '#').strip()
|
||||
if self.file.selection.start:
|
||||
self.file.toggle_comment_selection(comment)
|
||||
else:
|
||||
self.file.toggle_comment(comment)
|
||||
else:
|
||||
elif response is not PromptResult.CANCELLED:
|
||||
self.status.update(f'invalid command: {response}')
|
||||
return None
|
||||
|
||||
def save(self) -> PromptResult | None:
|
||||
def save(self) -> Optional[PromptResult]:
|
||||
self.file.finalize_previous_action()
|
||||
|
||||
# TODO: make directories if they don't exist
|
||||
@@ -498,28 +446,22 @@ class Screen:
|
||||
else:
|
||||
self.file.filename = filename
|
||||
|
||||
if not os.path.isfile(self.file.filename):
|
||||
sha256: str | None = None
|
||||
else:
|
||||
with open(self.file.filename, encoding='UTF-8', newline='') as f:
|
||||
if os.path.isfile(self.file.filename):
|
||||
with open(self.file.filename, newline='') as f:
|
||||
*_, sha256 = get_lines(f)
|
||||
else:
|
||||
sha256 = hashlib.sha256(b'').hexdigest()
|
||||
|
||||
contents = self.file.nl.join(self.file.buf)
|
||||
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
|
||||
|
||||
# the file on disk is the same as when we opened it
|
||||
if sha256 not in (None, self.file.sha256, sha256_to_save):
|
||||
if sha256 not in (self.file.sha256, sha256_to_save):
|
||||
self.status.update('(file changed on disk, not implemented)')
|
||||
return PromptResult.CANCELLED
|
||||
|
||||
try:
|
||||
with open(
|
||||
self.file.filename, 'w', encoding='UTF-8', newline='',
|
||||
) as f:
|
||||
with open(self.file.filename, 'w', newline='') as f:
|
||||
f.write(contents)
|
||||
except OSError as e:
|
||||
self.status.update(f'cannot save file: {e}')
|
||||
return PromptResult.CANCELLED
|
||||
|
||||
self.file.modified = False
|
||||
self.file.sha256 = sha256_to_save
|
||||
@@ -536,7 +478,7 @@ class Screen:
|
||||
first = False
|
||||
return None
|
||||
|
||||
def save_filename(self) -> PromptResult | None:
|
||||
def save_filename(self) -> Optional[PromptResult]:
|
||||
response = self.prompt('enter filename', default=self.file.filename)
|
||||
if response is PromptResult.CANCELLED:
|
||||
return PromptResult.CANCELLED
|
||||
@@ -544,16 +486,16 @@ class Screen:
|
||||
self.file.filename = response
|
||||
return self.save()
|
||||
|
||||
def open_file(self) -> EditResult | None:
|
||||
def open_file(self) -> Optional[EditResult]:
|
||||
response = self.prompt('enter filename', history='open')
|
||||
if response is not PromptResult.CANCELLED:
|
||||
opened = File(response, 0, self.color_manager, self.hl_factories)
|
||||
opened = File(response, self.color_manager, self.hl_factories)
|
||||
self.files.append(opened)
|
||||
return EditResult.OPEN
|
||||
else:
|
||||
return None
|
||||
|
||||
def quit_save_modified(self) -> EditResult | None:
|
||||
def quit_save_modified(self) -> Optional[EditResult]:
|
||||
if self.file.modified:
|
||||
response = self.quick_prompt(
|
||||
'file is modified - save', ('yes', 'no'),
|
||||
@@ -571,9 +513,6 @@ class Screen:
|
||||
return EditResult.EXIT
|
||||
|
||||
def background(self) -> None:
|
||||
if sys.platform == 'win32': # pragma: win32 cover
|
||||
self.status.update('cannot run babi in background on Windows')
|
||||
else: # pragma: win32 no cover
|
||||
curses.endwin()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
self.stdscr = _init_screen()
|
||||
@@ -587,7 +526,6 @@ class Screen:
|
||||
b'^U': uncut,
|
||||
b'M-u': undo,
|
||||
b'M-U': redo,
|
||||
b'M-e': redo,
|
||||
b'^W': search,
|
||||
b'^\\': replace,
|
||||
b'^[': command,
|
||||
@@ -601,12 +539,9 @@ class Screen:
|
||||
}
|
||||
|
||||
|
||||
def _init_screen() -> curses._CursesWindow:
|
||||
def _init_screen() -> 'curses._CursesWindow':
|
||||
# set the escape delay so curses does not pause waiting for sequences
|
||||
if (
|
||||
sys.version_info >= (3, 9) and
|
||||
hasattr(curses, 'set_escdelay')
|
||||
): # pragma: no cover
|
||||
if sys.version_info >= (3, 9): # pragma: no cover
|
||||
curses.set_escdelay(25)
|
||||
else: # pragma: no cover
|
||||
os.environ.setdefault('ESCDELAY', '25')
|
||||
@@ -627,7 +562,7 @@ def _init_screen() -> curses._CursesWindow:
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def make_stdscr() -> Generator[curses._CursesWindow, None, None]:
|
||||
def make_stdscr() -> Generator['curses._CursesWindow', None, None]:
|
||||
"""essentially `curses.wrapper` but split out to implement ^Z"""
|
||||
try:
|
||||
yield _init_screen()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
|
||||
from babi.margin import Margin
|
||||
@@ -18,7 +16,7 @@ class Status:
|
||||
def clear(self) -> None:
|
||||
self._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:
|
||||
stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols)
|
||||
if self._status:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.highlight import Compiler
|
||||
@@ -38,7 +37,7 @@ def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
|
||||
|
||||
if theme.default.bg is not None:
|
||||
print('\x1b[48;2;{r};{g};{b}m'.format(**theme.default.bg._asdict()))
|
||||
with open(filename, encoding='UTF-8') as f:
|
||||
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)
|
||||
@@ -48,14 +47,14 @@ def _highlight_output(theme: Theme, compiler: Compiler, filename: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
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, encoding='UTF-8') as f:
|
||||
with open(args.filename) as f:
|
||||
first_line = next(f, '')
|
||||
|
||||
theme = Theme.from_filename(args.theme)
|
||||
@@ -67,4 +66,4 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
exit(main())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os.path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.color import Color
|
||||
@@ -12,32 +13,32 @@ from babi.fdict import FDict
|
||||
|
||||
|
||||
class Style(NamedTuple):
|
||||
fg: Color | None
|
||||
bg: Color | None
|
||||
fg: Optional[Color]
|
||||
bg: Optional[Color]
|
||||
b: bool
|
||||
i: bool
|
||||
u: bool
|
||||
|
||||
@classmethod
|
||||
def blank(cls) -> Style:
|
||||
def blank(cls) -> 'Style':
|
||||
return cls(fg=None, bg=None, b=False, i=False, u=False)
|
||||
|
||||
|
||||
class PartialStyle(NamedTuple):
|
||||
fg: Color | None = None
|
||||
bg: Color | None = None
|
||||
b: bool | None = None
|
||||
i: bool | None = None
|
||||
u: bool | None = None
|
||||
fg: Optional[Color] = None
|
||||
bg: Optional[Color] = None
|
||||
b: Optional[bool] = None
|
||||
i: Optional[bool] = None
|
||||
u: Optional[bool] = None
|
||||
|
||||
def overlay_on(self, dct: dict[str, Any]) -> None:
|
||||
def overlay_on(self, dct: Dict[str, Any]) -> None:
|
||||
for attr in self._fields:
|
||||
value = getattr(self, attr)
|
||||
if value is not None:
|
||||
dct[attr] = value
|
||||
|
||||
@classmethod
|
||||
def from_dct(cls, dct: dict[str, Any]) -> PartialStyle:
|
||||
def from_dct(cls, dct: Dict[str, Any]) -> 'PartialStyle':
|
||||
kv = cls()._asdict()
|
||||
if 'foreground' in dct:
|
||||
kv['fg'] = Color.parse(dct['foreground'])
|
||||
@@ -56,7 +57,7 @@ class _TrieNode(Protocol):
|
||||
@property
|
||||
def style(self) -> PartialStyle: ...
|
||||
@property
|
||||
def children(self) -> FDict[str, _TrieNode]: ...
|
||||
def children(self) -> FDict[str, '_TrieNode']: ...
|
||||
|
||||
|
||||
class TrieNode(NamedTuple):
|
||||
@@ -64,7 +65,7 @@ class TrieNode(NamedTuple):
|
||||
children: FDict[str, _TrieNode]
|
||||
|
||||
@classmethod
|
||||
def from_dct(cls, dct: dict[str, Any]) -> _TrieNode:
|
||||
def from_dct(cls, dct: Dict[str, Any]) -> _TrieNode:
|
||||
children = FDict({
|
||||
k: TrieNode.from_dct(v) for k, v in dct['children'].items()
|
||||
})
|
||||
@@ -76,7 +77,7 @@ class Theme(NamedTuple):
|
||||
rules: _TrieNode
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def select(self, scope: tuple[str, ...]) -> Style:
|
||||
def select(self, scope: Tuple[str, ...]) -> Style:
|
||||
if not scope:
|
||||
return self.default
|
||||
else:
|
||||
@@ -91,7 +92,7 @@ class Theme(NamedTuple):
|
||||
return Style(**style)
|
||||
|
||||
@classmethod
|
||||
def from_dct(cls, data: dict[str, Any]) -> Theme:
|
||||
def from_dct(cls, data: Dict[str, Any]) -> 'Theme':
|
||||
default = Style.blank()._asdict()
|
||||
|
||||
for k in ('foreground', 'editor.foreground'):
|
||||
@@ -104,7 +105,7 @@ class Theme(NamedTuple):
|
||||
default['bg'] = Color.parse(data['colors'][k])
|
||||
break
|
||||
|
||||
root: dict[str, Any] = {'children': {}}
|
||||
root: Dict[str, Any] = {'children': {}}
|
||||
rules = data.get('tokenColors', []) + data.get('settings', [])
|
||||
for rule in rules:
|
||||
if 'scope' not in rule:
|
||||
@@ -138,13 +139,13 @@ class Theme(NamedTuple):
|
||||
return cls(Style(**default), TrieNode.from_dct(root))
|
||||
|
||||
@classmethod
|
||||
def blank(cls) -> Theme:
|
||||
def blank(cls) -> 'Theme':
|
||||
return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
|
||||
|
||||
@classmethod
|
||||
def from_filename(cls, filename: str) -> Theme:
|
||||
def from_filename(cls, filename: str) -> 'Theme':
|
||||
if not os.path.exists(filename):
|
||||
return cls.blank()
|
||||
else:
|
||||
with open(filename, encoding='UTF-8') as f:
|
||||
with open(filename) as f:
|
||||
return cls.from_dct(json.load(f))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
@@ -87,4 +85,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
exit(main())
|
||||
|
||||
@@ -3,3 +3,4 @@ coverage
|
||||
git+https://github.com/asottile/hecate@875567f
|
||||
pytest
|
||||
remote-pdb
|
||||
wcwidth
|
||||
|
||||
23
setup.cfg
23
setup.cfg
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = babi
|
||||
version = 0.0.24
|
||||
version = 0.0.7
|
||||
description = a text editor
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
@@ -13,10 +13,9 @@ classifiers =
|
||||
License :: OSI Approved :: MIT License
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: Implementation :: CPython
|
||||
Programming Language :: Python :: Implementation :: PyPy
|
||||
|
||||
@@ -25,21 +24,21 @@ packages = find:
|
||||
install_requires =
|
||||
babi-grammars
|
||||
identify
|
||||
onigurumacffi>=0.0.18
|
||||
importlib-metadata>=1;python_version<"3.8"
|
||||
onigurumacffi>=0.0.10
|
||||
importlib_metadata>=1;python_version<"3.8"
|
||||
windows-curses;sys_platform=="win32"
|
||||
python_requires = >=3.7
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
tests*
|
||||
testing*
|
||||
python_requires = >=3.6.1
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
babi = babi.main:main
|
||||
babi-textmate-demo = babi.textmate_demo:main
|
||||
|
||||
[options.packages.find]
|
||||
exclude =
|
||||
tests*
|
||||
testing*
|
||||
|
||||
[bdist_wheel]
|
||||
universal = True
|
||||
|
||||
@@ -53,8 +52,6 @@ disallow_any_generics = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[mypy-testing.*]
|
||||
disallow_untyped_defs = false
|
||||
|
||||
2
setup.py
2
setup.py
@@ -1,4 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from setuptools import setup
|
||||
setup()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import curses
|
||||
import enum
|
||||
import re
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
from hecate import Runner
|
||||
|
||||
@@ -34,7 +34,7 @@ def to_attrs(screen, width):
|
||||
fg = bg = -1
|
||||
attr = 0
|
||||
idx = 0
|
||||
ret: list[list[tuple[int, int, int]]]
|
||||
ret: List[List[Tuple[int, int, int]]]
|
||||
ret = [[] for _ in range(len(screen.splitlines()))]
|
||||
|
||||
for tp, match in tokenize_colors(screen):
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.buf import Buf
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from babi import color_kd
|
||||
from babi.color import Color
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.color import Color
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.color import Color
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.fdict import FChainMap
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def three_lines_with_indentation(tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('line_0\n line_1\n line_2')
|
||||
return f
|
||||
|
||||
|
||||
def test_comment_some_code(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('# line_0\n# line_1\nline_2\n')
|
||||
|
||||
|
||||
def test_comment_empty_line_trailing_whitespace(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('1\n\n2\n')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('# 1\n#\n# 2')
|
||||
|
||||
|
||||
def test_comment_some_code_with_alternate_comment_character(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment //')
|
||||
|
||||
h.await_text('// line_0\n// line_1\nline_2\n')
|
||||
|
||||
|
||||
def test_comment_partially_commented(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('#')
|
||||
h.press('S-Down')
|
||||
h.await_text('#line_0\nline_1\nline_2')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\nline_1\nline_2\n')
|
||||
|
||||
|
||||
def test_comment_partially_uncommented(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('Down')
|
||||
h.press('#')
|
||||
h.press('Up')
|
||||
h.press('S-Down')
|
||||
h.await_text('line_0\n#line_1\nline_2')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('# line_0\n# #line_1\nline_2\n')
|
||||
|
||||
|
||||
def test_comment_single_line(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('# line_0\nline_1\n')
|
||||
|
||||
|
||||
def test_uncomment_single_line(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('#')
|
||||
h.await_text('#line_0\nline_1\n')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\nline_1\n')
|
||||
|
||||
|
||||
def test_comment_with_trailing_whitespace(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment // ')
|
||||
|
||||
h.await_text('// line_0\nline_1\n')
|
||||
|
||||
|
||||
def test_comment_some_code_with_indentation(run, three_lines_with_indentation):
|
||||
with run(str(three_lines_with_indentation)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('# line_0\n# line_1\n line_2\n')
|
||||
|
||||
h.press('S-Up')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\n line_1\n line_2\n')
|
||||
|
||||
|
||||
def test_comment_some_code_on_indent_part(run, three_lines_with_indentation):
|
||||
with run(str(three_lines_with_indentation)) as h, and_exit(h):
|
||||
h.press('Down')
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\n # line_1\n # line_2\n')
|
||||
|
||||
h.press('S-Up')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\n line_1\n line_2\n')
|
||||
|
||||
|
||||
def test_comment_some_code_on_tabs_part(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('line_0\n\tline_1\n\t\tline_2')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.await_text('line_0\n line_1\n line_2')
|
||||
h.press('Down')
|
||||
h.press('S-Down')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\n # line_1\n # line_2')
|
||||
|
||||
h.press('S-Up')
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('line_0\n line_1\n line_2')
|
||||
|
||||
|
||||
def test_comment_cursor_at_end_of_line(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('# ')
|
||||
h.press('End')
|
||||
h.await_cursor_position(x=8, y=1)
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_cursor_position(x=6, y=1)
|
||||
|
||||
|
||||
def test_add_comment_moves_cursor(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('End')
|
||||
|
||||
h.await_cursor_position(x=6, y=1)
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_cursor_position(x=8, y=1)
|
||||
|
||||
|
||||
def test_do_not_move_if_cursor_before_comment(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('\t\tfoo')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('Right')
|
||||
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('comment', ('# ', '#'))
|
||||
def test_remove_comment_with_comment_elsewhere_in_line(run, tmpdir, comment):
|
||||
f = tmpdir.join('f')
|
||||
f.write(f'{comment}print("not a # comment here!")\n')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':comment')
|
||||
|
||||
h.await_text('\nprint("not a # comment here!")\n')
|
||||
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import curses
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
from typing import Tuple
|
||||
from typing import Union
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import wcwidth
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.main import main
|
||||
@@ -70,7 +72,7 @@ class Screen:
|
||||
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
|
||||
|
||||
self.y = y
|
||||
self.x = x + len(s)
|
||||
self.x = x + wcwidth.wcswidth(s)
|
||||
|
||||
def insstr(self, y, x, s, attr):
|
||||
line = self.lines[y]
|
||||
@@ -147,7 +149,7 @@ class AssertScreenLineEquals(NamedTuple):
|
||||
|
||||
class AssertScreenAttrEquals(NamedTuple):
|
||||
n: int
|
||||
attr: list[tuple[int, int, int]]
|
||||
attr: List[Tuple[int, int, int]]
|
||||
|
||||
def __call__(self, screen: Screen) -> None:
|
||||
assert screen.attrs[self.n] == self.attr
|
||||
@@ -169,7 +171,7 @@ class Resize(NamedTuple):
|
||||
|
||||
|
||||
class KeyPress(NamedTuple):
|
||||
wch: int | str
|
||||
wch: Union[int, str]
|
||||
|
||||
def __call__(self, screen: Screen) -> None:
|
||||
raise AssertionError('unreachable')
|
||||
@@ -235,7 +237,7 @@ class CursesScreen:
|
||||
class Key(NamedTuple):
|
||||
tmux: str
|
||||
curses: bytes
|
||||
wch: int | str
|
||||
wch: Union[int, str]
|
||||
|
||||
@property
|
||||
def value(self) -> int:
|
||||
@@ -289,7 +291,6 @@ KEYS = [
|
||||
Key('^_', b'^_', '\x1f'),
|
||||
Key('^\\', b'^\\', '\x1c'),
|
||||
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
|
||||
Key('^D', b'^D', '\x04'),
|
||||
]
|
||||
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
|
||||
KEYS_CURSES = {k.value: k.curses for k in KEYS}
|
||||
@@ -299,11 +300,10 @@ class DeferredRunner:
|
||||
def __init__(self, command, width=80, height=24, term='screen'):
|
||||
self.command = command
|
||||
self._i = 0
|
||||
self._ops: list[Op] = []
|
||||
self._ops: List[Op] = []
|
||||
self.color_pairs = {0: (7, 0)}
|
||||
self.screen = Screen(width, height)
|
||||
self._n_colors, self._can_change_color = {
|
||||
'xterm-mono': (0, False),
|
||||
'screen': (8, False),
|
||||
'screen-256color': (256, False),
|
||||
'xterm-256color': (256, True),
|
||||
@@ -392,7 +392,6 @@ class DeferredRunner:
|
||||
|
||||
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
|
||||
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
|
||||
_curses_set_escdelay = _curses__noop
|
||||
|
||||
_curses_error = curses.error # so we don't mock the exception
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
def test_set_expandtabs(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('a')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':expandtabs')
|
||||
h.await_text('updated!')
|
||||
h.press('Tab')
|
||||
h.press('^S')
|
||||
assert f.read() == ' a\n'
|
||||
|
||||
|
||||
def test_set_noexpandtabs(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('a')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':noexpandtabs')
|
||||
h.await_text('updated!')
|
||||
h.press('Tab')
|
||||
h.press('^S')
|
||||
assert f.read() == '\ta\n'
|
||||
|
||||
|
||||
def test_indent_with_expandtabs(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('a\nb\nc')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':noexpandtabs')
|
||||
h.await_text('updated!')
|
||||
for _ in range(3):
|
||||
h.press('S-Down')
|
||||
h.press('Tab')
|
||||
h.press('^S')
|
||||
assert f.read() == '\ta\n\tb\n\tc\n'
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
def test_indent_at_beginning_of_line(run):
|
||||
@@ -15,12 +12,11 @@ def test_indent_at_beginning_of_line(run):
|
||||
|
||||
def test_indent_not_full_tab(run):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('hello')
|
||||
h.press('Home')
|
||||
h.press('Right')
|
||||
h.press('h')
|
||||
h.press('Tab')
|
||||
h.press('ello')
|
||||
h.await_text('h ello')
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
h.await_cursor_position(x=8, y=1)
|
||||
|
||||
|
||||
def test_indent_fixes_eof(run):
|
||||
@@ -90,20 +86,6 @@ def test_dedent_selection(run, tmpdir):
|
||||
h.await_text('\n1\n2\n 3\n')
|
||||
|
||||
|
||||
def test_dedent_selection_with_noexpandtabs(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('1\n\t2\n\t\t3\n')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':noexpandtabs')
|
||||
h.await_text('updated!')
|
||||
for _ in range(3):
|
||||
h.press('S-Down')
|
||||
h.press('BTab')
|
||||
h.press('^S')
|
||||
assert f.read() == '1\n2\n\t3\n'
|
||||
|
||||
|
||||
def test_dedent_beginning_of_line(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write(' hi\n')
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
def test_open_file_named_plus_something(run):
|
||||
with run('+3') as h, and_exit(h):
|
||||
h.await_text(' +3')
|
||||
|
||||
|
||||
def test_initial_position_one_file(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello\nworld\n')
|
||||
|
||||
with run('+2', str(f)) as h, and_exit(h):
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
|
||||
|
||||
def test_initial_position_multiple_files(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('1\n2\n3\n4\n')
|
||||
g = tmpdir.join('g')
|
||||
g.write('5\n6\n7\n8\n')
|
||||
|
||||
with run('+2', str(f), '+3', str(g)) as h, and_exit(h):
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
|
||||
h.press('^X')
|
||||
|
||||
h.await_cursor_position(x=0, y=3)
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
@@ -417,10 +415,18 @@ def test_sequence_handling(run_only_fake):
|
||||
|
||||
def test_indentation_using_tabs(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write(f'123456789\n\t12\t{"x" * 20}\n')
|
||||
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')
|
||||
h.await_text(
|
||||
'123456789\n'
|
||||
' 12 xxxxxxxxxxx»\n'
|
||||
' not long\n',
|
||||
)
|
||||
|
||||
h.press('Down')
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
@@ -440,3 +446,43 @@ def test_indentation_using_tabs(run, tmpdir):
|
||||
h.await_cursor_position(x=4, y=2)
|
||||
h.press('Up')
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
|
||||
|
||||
def test_movement_with_wide_characters(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write(
|
||||
f'{"🙃" * 20}\n'
|
||||
f'a{"🙃" * 20}\n',
|
||||
)
|
||||
|
||||
with run(str(f), width=20) as h, and_exit(h):
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
h.press('Right')
|
||||
h.await_text(
|
||||
'««🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
for _ in range(6):
|
||||
h.press('Right')
|
||||
h.await_text(
|
||||
'««🙃🙃🙃🙃🙃🙃🙃\n'
|
||||
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
h.press('Down')
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'«🙃🙃🙃🙃🙃🙃🙃🙃\n',
|
||||
)
|
||||
|
||||
h.press('Left')
|
||||
h.await_text(
|
||||
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
|
||||
'«🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
@@ -22,16 +20,6 @@ def test_replace_invalid_regex(run):
|
||||
h.await_text("invalid regex: '('")
|
||||
|
||||
|
||||
def test_replace_invalid_replacement(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_0')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('\\')
|
||||
h.await_text('invalid replacement string')
|
||||
|
||||
|
||||
def test_replace_cancel_at_replace_string(run):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('^\\')
|
||||
@@ -42,8 +30,7 @@ def test_replace_cancel_at_replace_string(run):
|
||||
h.await_text('cancelled')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ('y', 'Y'))
|
||||
def test_replace_actual_contents(run, ten_lines, key):
|
||||
def test_replace_actual_contents(run, ten_lines):
|
||||
with run(str(ten_lines)) as h, and_exit(h):
|
||||
h.press('^\\')
|
||||
h.await_text('search (to replace):')
|
||||
@@ -51,7 +38,7 @@ def test_replace_actual_contents(run, ten_lines, key):
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press(key)
|
||||
h.press('y')
|
||||
h.await_text_missing('line_0')
|
||||
h.await_text('ohai')
|
||||
h.await_text(' *')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
@@ -130,24 +128,12 @@ def test_save_file_when_it_did_not_exist(run, tmpdir):
|
||||
assert f.read() == 'hello world\n'
|
||||
|
||||
|
||||
def test_saving_file_permission_denied(run, tmpdir):
|
||||
f = tmpdir.join('f').ensure()
|
||||
f.chmod(0o400)
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('hello world')
|
||||
h.press('^S')
|
||||
# the filename message is missing as it is too long to be captured
|
||||
h.await_text('cannot save file: [Errno 13] Permission denied:')
|
||||
h.await_text(' *')
|
||||
|
||||
|
||||
def test_save_via_ctrl_o(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('hello world')
|
||||
h.press('^O')
|
||||
h.await_text('enter filename: ')
|
||||
h.await_text(f'enter filename: ')
|
||||
h.press('Enter')
|
||||
h.await_text('saved! (1 line written)')
|
||||
assert f.read() == 'hello world\n'
|
||||
@@ -164,18 +150,6 @@ def test_save_via_ctrl_o_set_filename(run, tmpdir):
|
||||
assert f.read() == 'hello world\n'
|
||||
|
||||
|
||||
def test_save_via_ctrl_o_new_filename(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('wat\n')
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('^O')
|
||||
h.await_text('enter filename: ')
|
||||
h.press_and_enter('new')
|
||||
h.await_text('saved! (1 line written)')
|
||||
assert f.read() == 'wat\n'
|
||||
assert tmpdir.join('fnew').read() == 'wat\n'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ('^C', 'Enter'))
|
||||
def test_save_via_ctrl_o_cancelled(run, key):
|
||||
with run() as h, and_exit(h):
|
||||
@@ -263,7 +237,7 @@ def test_vim_save_on_exit(run, tmpdir):
|
||||
h.press_and_enter(':q')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text('enter filename: ')
|
||||
h.await_text(f'enter filename: ')
|
||||
h.press('Enter')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
@@ -23,16 +21,6 @@ def test_sort_entire_file(run, unsorted):
|
||||
assert unsorted.read() == 'a\nb\nc\nd\n'
|
||||
|
||||
|
||||
def test_reverse_sort_entire_file(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':sort!')
|
||||
h.await_text('sorted!')
|
||||
h.await_cursor_position(x=0, y=1)
|
||||
h.press('^S')
|
||||
assert unsorted.read() == 'd\nc\nb\na\n'
|
||||
|
||||
|
||||
def test_sort_selection(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
@@ -44,18 +32,6 @@ def test_sort_selection(run, unsorted):
|
||||
assert unsorted.read() == 'b\nd\nc\na\n'
|
||||
|
||||
|
||||
def test_reverse_sort_selection(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
h.press('Down')
|
||||
h.press('S-Down')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':sort!')
|
||||
h.await_text('sorted!')
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
h.press('^S')
|
||||
assert unsorted.read() == 'd\nc\nb\na\n'
|
||||
|
||||
|
||||
def test_sort_selection_does_not_include_eof(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
for _ in range(5):
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
import json
|
||||
|
||||
@@ -155,8 +153,3 @@ def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir):
|
||||
h.press('Enter')
|
||||
|
||||
h.await_text('foo\n x\nx\ny\n')
|
||||
|
||||
|
||||
def test_does_not_crash_with_no_color_support(run):
|
||||
with run(term='xterm-mono') as h, and_exit(h):
|
||||
pass
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize('setting', ('tabsize', 'tabstop'))
|
||||
def test_set_tabstop(run, setting):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(f':{setting} 2')
|
||||
h.await_text('updated!')
|
||||
h.press('Tab')
|
||||
h.await_text('\n a')
|
||||
h.await_cursor_position(x=2, y=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tabsize', ('-1', '0', 'wat'))
|
||||
def test_set_invalid_tabstop(run, tabsize):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(f':tabstop {tabsize}')
|
||||
h.await_text(f'invalid size: {tabsize}')
|
||||
h.press('Tab')
|
||||
h.await_text(' a')
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
@@ -52,18 +50,6 @@ def test_backspace_at_end_of_file_still_allows_scrolling_down(run, tmpdir):
|
||||
h.await_text_missing('*')
|
||||
|
||||
|
||||
def test_backspace_deletes_newline_at_end_of_file(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('foo\n\n')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('^End')
|
||||
h.press('BSpace')
|
||||
h.press('^S')
|
||||
|
||||
assert f.read() == 'foo\n'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ('BSpace', '^H'))
|
||||
def test_backspace_deletes_text(run, tmpdir, key):
|
||||
f = tmpdir.join('f')
|
||||
@@ -86,15 +72,14 @@ def test_delete_at_end_of_file(run, tmpdir):
|
||||
h.await_text_missing('*')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', ('DC', '^D'))
|
||||
def test_delete_removes_character_afterwards(run, tmpdir, key):
|
||||
def test_delete_removes_character_afterwards(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.await_text('hello world')
|
||||
h.press('Right')
|
||||
h.press(key)
|
||||
h.press('DC')
|
||||
h.await_text('hllo world')
|
||||
h.await_text('f *')
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
@@ -13,8 +9,7 @@ def test_nothing_to_undo_redo(run):
|
||||
h.await_text('nothing to redo!')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('r', ('M-U', 'M-e'))
|
||||
def test_undo_redo(run, r):
|
||||
def test_undo_redo(run):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
@@ -22,7 +17,7 @@ def test_undo_redo(run, r):
|
||||
h.await_text('undo: text')
|
||||
h.await_text_missing('hello')
|
||||
h.await_text_missing(' *')
|
||||
h.press(r)
|
||||
h.press('M-U')
|
||||
h.await_text('redo: text')
|
||||
h.await_text('hello')
|
||||
h.await_text(' *')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
@@ -10,7 +8,7 @@ from babi.file import get_lines
|
||||
|
||||
|
||||
def test_position_repr():
|
||||
ret = repr(File('f.txt', 0, ColorManager.make(), ()))
|
||||
ret = repr(File('f.txt', ColorManager.make(), ()))
|
||||
assert ret == "<File 'f.txt'>"
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.highlight import highlight_line
|
||||
@@ -639,25 +637,3 @@ def test_backslash_z(compiler_state):
|
||||
assert regions2 == (
|
||||
Region(0, 6, ('test', 'comment')),
|
||||
)
|
||||
|
||||
|
||||
def test_buggy_begin_end_grammar(compiler_state):
|
||||
# before this would result in an infinite loop of start / end
|
||||
compiler, state = compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
'begin': '(?=</style)',
|
||||
'end': '(?=</style)',
|
||||
'name': 'css',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, 'test </style', True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 5, ('test',)),
|
||||
Region(5, 6, ('test', 'css')),
|
||||
Region(6, 12, ('test',)),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import curses
|
||||
from unittest import mock
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi import main
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('in_filenames', 'expected_filenames', 'expected_positions'),
|
||||
(
|
||||
([], [None], [0]),
|
||||
(['+3'], ['+3'], [0]),
|
||||
(['f'], ['f'], [0]),
|
||||
(['+3', 'f'], ['f'], [3]),
|
||||
(['+-3', 'f'], ['f'], [-3]),
|
||||
(['+3', '+3'], ['+3'], [3]),
|
||||
(['+2', 'f', '+5', 'g'], ['f', 'g'], [2, 5]),
|
||||
),
|
||||
)
|
||||
def test_filenames(in_filenames, expected_filenames, expected_positions):
|
||||
filenames, positions = main._filenames(in_filenames)
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import onigurumacffi
|
||||
import pytest
|
||||
|
||||
@@ -37,8 +35,9 @@ def test_reg_other_escapes_left_untouched():
|
||||
def test_reg_not_out_of_bounds_at_end():
|
||||
# the only way this is triggerable is with an illegal regex, we'd rather
|
||||
# produce an error about the regex being wrong than an IndexError
|
||||
reg = _Reg('\\A\\')
|
||||
with pytest.raises(onigurumacffi.OnigError) as excinfo:
|
||||
_Reg('\\A\\')
|
||||
reg.search('\\', 0, first_line=False, boundary=False)
|
||||
msg, = excinfo.value.args
|
||||
assert msg == 'end pattern at escape'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.color import Color
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
|
||||
Reference in New Issue
Block a user