1 Commits
master ... wc

Author SHA1 Message Date
Anthony Sottile
9b55ebfd0e wip 2020-04-17 17:09:16 -07:00
77 changed files with 657 additions and 1285 deletions

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1 rev: v2.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -10,35 +10,35 @@ repos:
- id: double-quote-string-fixer - id: double-quote-string-fixer
- id: name-tests-test - id: name-tests-test
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/PyCQA/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 4.0.1 rev: 3.7.9
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0] additional_dependencies: [flake8-typing-imports==1.7.0]
- repo: https://github.com/pre-commit/mirrors-autopep8 - repo: https://github.com/pre-commit/mirrors-autopep8
rev: v1.5.7 rev: v1.5
hooks: hooks:
- id: autopep8 - id: autopep8
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0 rev: v2.1.0
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
args: [--py3-plus, --add-import, 'from __future__ import annotations'] args: [--py3-plus]
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
rev: v2.2.1 rev: v2.0.1
hooks: hooks:
- id: add-trailing-comma - id: add-trailing-comma
args: [--py36-plus] args: [--py36-plus]
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v2.29.1 rev: v2.1.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py37-plus] args: [--py36-plus]
- repo: https://github.com/asottile/setup-cfg-fmt - repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.0 rev: v1.7.0
hooks: hooks:
- id: setup-cfg-fmt - id: setup-cfg-fmt
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.910-1 rev: v0.770
hooks: hooks:
- id: mypy - id: mypy

View File

@@ -1,8 +1,5 @@
[![Build Status](https://dev.azure.com/asottile/asottile/_apis/build/status/asottile.babi?branchName=master)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master) [![Build Status](https://dev.azure.com/asottile/asottile/_apis/build/status/asottile.babi?branchName=master)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
[![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/29/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master) [![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/29/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=29&branchName=master)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/babi/master.svg)](https://results.pre-commit.ci/latest/github/asottile/babi/master)
![babi logo](https://user-images.githubusercontent.com/1810591/89981369-9ed84e80-dc28-11ea-9708-5f4c49c09632.png)
babi babi
==== ====
@@ -15,16 +12,9 @@ a text editor, eventually...
### why is it called babi? ### 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`. 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`:
[![youtube video about babi](https://img.youtube.com/vi/WyR1hAGmR3g/mqdefault.jpg)](https://youtu.be/WyR1hAGmR3g)
### quitting babi ### quitting babi
currently you can quit `babi` by using <kbd>^X</kbd> (or via <kbd>esc</kbd> + 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 - <kbd>tab</kbd> / <kbd>shift-tab</kbd>: indent or dedent current line (or
selection) selection)
- <kbd>^K</kbd> / <kbd>^U</kbd>: cut and uncut the 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>^W</kbd>: search
- <kbd>^\\</kbd>: search and replace - <kbd>^\\</kbd>: search and replace
- <kbd>^C</kbd>: show the current position in the file - <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 ./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 ## demos
most things work! here's a few screenshots 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 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 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 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
![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png) ![](https://i.fluffy.cc/5WFZBJ4mWs7wtThD9strQnGlJqw4Z9KS.png)

View File

@@ -10,10 +10,11 @@ resources:
type: github type: github
endpoint: github endpoint: github
name: asottile/azure-pipeline-templates name: asottile/azure-pipeline-templates
ref: refs/tags/v2.1.0 ref: refs/tags/v1.0.0
jobs: jobs:
- template: job--pre-commit.yml@asottile
- template: job--python-tox.yml@asottile - template: job--python-tox.yml@asottile
parameters: parameters:
toxenvs: [py37, py38, py39] toxenvs: [py36, py37, py38]
os: linux os: linux

View File

@@ -1,6 +1,4 @@
from __future__ import annotations
from babi.main import main from babi.main import main
if __name__ == '__main__': if __name__ == '__main__':
raise SystemExit(main()) exit(main())

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
import bisect import bisect
import contextlib import contextlib
from typing import Callable from typing import Callable
from typing import Generator from typing import Generator
from typing import Iterator from typing import Iterator
from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol from babi._types import Protocol
from babi.horizontal_scrolling import line_x from babi.horizontal_scrolling import line_x
@@ -18,25 +19,25 @@ DelCallback = Callable[['Buf', int, str], None]
InsCallback = Callable[['Buf', int], None] InsCallback = Callable[['Buf', int], None]
def _offsets(s: str, tab_size: int) -> tuple[int, ...]: def _offsets(s: str) -> Tuple[int, ...]:
ret = [0] ret = [0]
for c in s: for c in s:
if c == '\t': if c == '\t':
ret.append(ret[-1] + (tab_size - ret[-1] % tab_size)) ret.append(ret[-1] + (4 - ret[-1] % 4))
else: else:
ret.append(ret[-1] + wcwidth(c)) ret.append(ret[-1] + wcwidth(c))
return tuple(ret) return tuple(ret)
class Modification(Protocol): class Modification(Protocol):
def __call__(self, buf: Buf) -> None: ... def __call__(self, buf: 'Buf') -> None: ...
class SetModification(NamedTuple): class SetModification(NamedTuple):
idx: int idx: int
s: str s: str
def __call__(self, buf: Buf) -> None: def __call__(self, buf: 'Buf') -> None:
buf[self.idx] = self.s buf[self.idx] = self.s
@@ -44,29 +45,27 @@ class InsModification(NamedTuple):
idx: int idx: int
s: str s: str
def __call__(self, buf: Buf) -> None: def __call__(self, buf: 'Buf') -> None:
buf.insert(self.idx, self.s) buf.insert(self.idx, self.s)
class DelModification(NamedTuple): class DelModification(NamedTuple):
idx: int idx: int
def __call__(self, buf: Buf) -> None: def __call__(self, buf: 'Buf') -> None:
del buf[self.idx] del buf[self.idx]
class Buf: class Buf:
def __init__(self, lines: list[str], tab_size: int = 4) -> None: def __init__(self, lines: List[str]) -> None:
self._lines = lines self._lines = lines
self.expandtabs = True
self.tab_size = tab_size
self.file_y = self.y = self._x = self._x_hint = 0 self.file_y = self.y = self._x = self._x_hint = 0
self._set_callbacks: list[SetCallback] = [self._set_cb] self._set_callbacks: List[SetCallback] = [self._set_cb]
self._del_callbacks: list[DelCallback] = [self._del_cb] self._del_callbacks: List[DelCallback] = [self._del_cb]
self._ins_callbacks: list[InsCallback] = [self._ins_cb] self._ins_callbacks: List[InsCallback] = [self._ins_cb]
self._positions: list[tuple[int, ...] | None] = [] self._positions: List[Optional[Tuple[int, ...]]] = []
# read only interface # read only interface
@@ -131,16 +130,12 @@ class Buf:
return victim return victim
def restore_eof_invariant(self) -> None: 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 to simplify rendering. call this whenever the last line may change
""" """
if self[-1] != '': if self[-1] != '':
self.append('') self.append('')
def set_tab_size(self, tab_size: int) -> None:
self.tab_size = tab_size
self._positions = [None]
# event handling # event handling
def add_set_callback(self, cb: SetCallback) -> None: def add_set_callback(self, cb: SetCallback) -> None:
@@ -162,16 +157,16 @@ class Buf:
self._ins_callbacks.remove(cb) self._ins_callbacks.remove(cb)
@contextlib.contextmanager @contextlib.contextmanager
def record(self) -> Generator[list[Modification], None, None]: def record(self) -> Generator[List[Modification], None, None]:
modifications: list[Modification] = [] 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)) 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)) 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)) modifications.append(DelModification(idx))
self.add_set_callback(set_cb) self.add_set_callback(set_cb)
@@ -184,7 +179,7 @@ class Buf:
self.remove_del_callback(del_cb) self.remove_del_callback(del_cb)
self.remove_set_callback(set_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: with self.record() as ret_modifications:
for modification in reversed(modifications): for modification in reversed(modifications):
modification(self) modification(self)
@@ -208,24 +203,23 @@ class Buf:
def _extend_positions(self, idx: int) -> None: def _extend_positions(self, idx: int) -> None:
self._positions.extend([None] * (1 + idx - len(self._positions))) 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._extend_positions(idx)
self._positions[idx] = None 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) self._extend_positions(idx)
del self._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._extend_positions(idx)
self._positions.insert(idx, None) 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) self._extend_positions(idx)
value = self._positions[idx] value = self._positions[idx]
if value is None: if value is None:
value = _offsets(self._lines[idx], self.tab_size) value = self._positions[idx] = _offsets(self._lines[idx])
self._positions[idx] = value
return value return value
def line_x(self, margin: Margin) -> int: def line_x(self, margin: Margin) -> int:
@@ -235,24 +229,18 @@ class Buf:
def _cursor_x(self) -> int: def _cursor_x(self) -> int:
return self.line_positions(self.y)[self.x] 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 y = self.y - self.file_y + margin.header
x = self._cursor_x - self.line_x(margin) x = self._cursor_x - self.line_x(margin)
return y, x return y, x
# rendered lines # 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: def rendered_line(self, idx: int, margin: Margin) -> str:
x = self._cursor_x if idx == self.y else 0 line = self._lines[idx]
expanded = self._lines[idx].expandtabs(self.tab_size) positions = self.line_positions(idx)
return scrolled_line(expanded, x, margin.cols) cursor_x = self._cursor_x if idx == self.y else 0
return scrolled_line(line, positions, cursor_x, margin.cols)
# movement # movement
@@ -287,7 +275,7 @@ class Buf:
if self.x >= len(self._lines[self.y]): if self.x >= len(self._lines[self.y]):
if self.y < len(self._lines) - 1: if self.y < len(self._lines) - 1:
self.down(margin) self.down(margin)
self.x = 0 self.home()
else: else:
self.x += 1 self.x += 1
@@ -295,10 +283,16 @@ class Buf:
if self.x == 0: if self.x == 0:
if self.y > 0: if self.y > 0:
self.up(margin) self.up(margin)
self.x = len(self._lines[self.y]) self.end()
else: else:
self.x -= 1 self.x -= 1
def home(self) -> None:
self.x = 0
def end(self) -> None:
self.x = len(self._lines[self.y])
# screen movement # screen movement
def file_up(self, margin: Margin) -> None: def file_up(self, margin: Margin) -> None:
@@ -312,3 +306,9 @@ class Buf:
self.file_y += 1 self.file_y += 1
if self.y < self.file_y: if self.y < self.file_y:
self.down(margin) self.down(margin)
# key input
def c(self, s: str) -> None:
self[self.y] = self[self.y][:self.x] + s + self[self.y][self.x:]
self.x += len(s)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import sys import sys
if sys.version_info >= (3, 8): # pragma: no cover (>=py38) 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) else: # pragma: no cover (<py38)
from typing import Callable from typing import Callable
from typing import Generic from typing import Generic
from typing import Optional
from typing import Type
from typing import TypeVar from typing import TypeVar
TSelf = TypeVar('TSelf') TSelf = TypeVar('TSelf')
@@ -18,8 +18,8 @@ else: # pragma: no cover (<py38)
def __get__( def __get__(
self, self,
instance: TSelf | None, instance: Optional[TSelf],
owner: type[TSelf] | None = None, owner: Optional[Type[TSelf]] = None,
) -> TRet: ) -> TRet:
assert instance is not None assert instance is not None
ret = instance.__dict__[self._func.__name__] = self._func(instance) ret = instance.__dict__[self._func.__name__] = self._func(instance)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import NamedTuple from typing import NamedTuple
# TODO: find a standard which defines these # TODO: find a standard which defines these
@@ -13,7 +11,7 @@ class Color(NamedTuple):
b: int b: int
@classmethod @classmethod
def parse(cls, s: str) -> Color: def parse(cls, s: str) -> 'Color':
if s.startswith('#') and len(s) >= 7: 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)) return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
elif s.startswith('#'): elif s.startswith('#'):

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
import functools import functools
import itertools import itertools
from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol from babi._types import Protocol
from babi.color import Color from babi.color import Color
@@ -18,19 +19,19 @@ class KD(Protocol):
@property @property
def n(self) -> int: ... def n(self) -> int: ...
@property @property
def left(self) -> KD | None: ... def left(self) -> Optional['KD']: ...
@property @property
def right(self) -> KD | None: ... def right(self) -> Optional['KD']: ...
class _KD(NamedTuple): class _KD(NamedTuple):
color: Color color: Color
n: int n: int
left: KD | None left: Optional[KD]
right: KD | None 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: if not colors:
return None 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 best = 0
dist = 2 ** 32 dist = 2 ** 32
def _search(kd: KD | None, *, depth: int) -> None: def _search(kd: Optional[KD], *, depth: int) -> None:
nonlocal best nonlocal best
nonlocal dist nonlocal dist
@@ -76,7 +77,7 @@ def nearest(color: Color, colors: KD | None) -> int:
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def make_256() -> KD | None: def make_256() -> Optional[KD]:
vals = (0, 95, 135, 175, 215, 255) vals = (0, 95, 135, 175, 215, 255)
colors = [ colors = [
(Color(r, g, b), i) (Color(r, g, b), i)

View File

@@ -1,20 +1,21 @@
from __future__ import annotations
import curses import curses
from typing import Dict
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi import color_kd from babi import color_kd
from babi.color import Color 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 factor = 1000 / 255
return int(color.r * factor), int(color.g * factor), int(color.b * factor) return int(color.r * factor), int(color.g * factor), int(color.b * factor)
class ColorManager(NamedTuple): class ColorManager(NamedTuple):
colors: dict[Color, int] colors: Dict[Color, int]
raw_pairs: dict[tuple[int, int], int] raw_pairs: Dict[Tuple[int, int], int]
def init_color(self, color: Color) -> None: def init_color(self, color: Color) -> None:
if curses.can_change_color(): if curses.can_change_color():
@@ -26,24 +27,21 @@ class ColorManager(NamedTuple):
else: else:
self.colors[color] = -1 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 fg_i = self.colors[fg] if fg is not None else -1
bg_i = self.colors[bg] if bg 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) return self.raw_color_pair(fg_i, bg_i)
def raw_color_pair(self, fg: int, bg: int) -> int: def raw_color_pair(self, fg: int, bg: int) -> int:
if curses.COLORS > 0: try:
try: return self.raw_pairs[(fg, bg)]
return self.raw_pairs[(fg, bg)] except KeyError:
except KeyError: pass
pass
n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1 n = self.raw_pairs[(fg, bg)] = len(self.raw_pairs) + 1
curses.init_pair(n, fg, bg) curses.init_pair(n, fg, bg)
return n return n
else:
return 0
@classmethod @classmethod
def make(cls) -> ColorManager: def make(cls) -> 'ColorManager':
return cls({}, {}) return cls({}, {})

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import Generic from typing import Generic
from typing import Iterable from typing import Iterable
from typing import Mapping from typing import Mapping

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import collections import collections
import contextlib import contextlib
import curses import curses
@@ -8,17 +6,20 @@ import hashlib
import io import io
import itertools import itertools
import os.path import os.path
import re
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import cast from typing import cast
from typing import Generator from typing import Generator
from typing import IO from typing import IO
from typing import List
from typing import Match from typing import Match
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Pattern from typing import Pattern
from typing import Tuple
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
from typing import Union
from babi.buf import Buf from babi.buf import Buf
from babi.buf import Modification from babi.buf import Modification
@@ -37,10 +38,8 @@ if TYPE_CHECKING:
TCallable = TypeVar('TCallable', bound=Callable[..., Any]) 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() sha256 = hashlib.sha256()
lines = [] lines = []
newlines = collections.Counter({'\n': 0}) # default to `\n` 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: class Action:
def __init__( def __init__(
self, *, name: str, modifications: list[Modification], self, *, name: str, modifications: List[Modification],
start_x: int, start_y: int, start_modified: bool, start_x: int, start_y: int, start_modified: bool,
end_x: int, end_y: int, end_modified: bool, end_x: int, end_y: int, end_modified: bool,
final: bool, final: bool,
@@ -77,7 +76,7 @@ class Action:
self.end_modified = end_modified self.end_modified = end_modified
self.final = final self.final = final
def apply(self, file: File) -> Action: def apply(self, file: 'File') -> 'Action':
action = Action( action = Action(
name=self.name, modifications=file.buf.apply(self.modifications), name=self.name, modifications=file.buf.apply(self.modifications),
start_x=self.end_x, start_y=self.end_y, start_x=self.end_x, start_y=self.end_y,
@@ -96,7 +95,7 @@ class Action:
def action(func: TCallable) -> TCallable: def action(func: TCallable) -> TCallable:
@functools.wraps(func) @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() self.finalize_previous_action()
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return cast(TCallable, action_inner) return cast(TCallable, action_inner)
@@ -109,7 +108,7 @@ def edit_action(
) -> Callable[[TCallable], TCallable]: ) -> Callable[[TCallable], TCallable]:
def edit_action_decorator(func: TCallable) -> TCallable: def edit_action_decorator(func: TCallable) -> TCallable:
@functools.wraps(func) @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): with self.edit_action_context(name, final=final):
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return cast(TCallable, edit_action_inner) return cast(TCallable, edit_action_inner)
@@ -118,7 +117,7 @@ def edit_action(
def keep_selection(func: TCallable) -> TCallable: def keep_selection(func: TCallable) -> TCallable:
@functools.wraps(func) @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(): with self.select():
return func(self, *args, **kwargs) return func(self, *args, **kwargs)
return cast(TCallable, keep_selection_inner) return cast(TCallable, keep_selection_inner)
@@ -126,7 +125,7 @@ def keep_selection(func: TCallable) -> TCallable:
def clear_selection(func: TCallable) -> TCallable: def clear_selection(func: TCallable) -> TCallable:
@functools.wraps(func) @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) ret = func(self, *args, **kwargs)
self.selection.clear() self.selection.clear()
return ret return ret
@@ -141,7 +140,7 @@ class Found(NamedTuple):
class _SearchIter: class _SearchIter:
def __init__( def __init__(
self, self,
file: File, file: 'File',
reg: Pattern[str], reg: Pattern[str],
*, *,
offset: int, offset: int,
@@ -153,7 +152,7 @@ class _SearchIter:
self._start_x = file.buf.x + offset self._start_x = file.buf.x + offset
self._start_y = file.buf.y self._start_y = file.buf.y
def __iter__(self) -> _SearchIter: def __iter__(self) -> '_SearchIter':
return self return self
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found: def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
@@ -166,7 +165,7 @@ class _SearchIter:
raise StopIteration() raise StopIteration()
return Found(y, match) return Found(y, match)
def __next__(self) -> tuple[int, Match[str]]: def __next__(self) -> Tuple[int, Match[str]]:
x = self.file.buf.x + self.offset x = self.file.buf.x + self.offset
y = self.file.buf.y y = self.file.buf.y
@@ -198,32 +197,25 @@ class _SearchIter:
class File: class File:
def __init__( def __init__(
self, self,
filename: str | None, filename: Optional[str],
initial_line: int,
color_manager: ColorManager, color_manager: ColorManager,
hl_factories: tuple[HLFactory, ...], hl_factories: Tuple[HLFactory, ...],
) -> None: ) -> None:
self.filename = filename self.filename = filename
self.initial_line = initial_line
self.modified = False self.modified = False
self.buf = Buf([]) self.buf = Buf([])
self.nl = '\n' self.nl = '\n'
self.sha256: str | None = None self.sha256: Optional[str] = None
self._in_edit_action = False self._in_edit_action = False
self.undo_stack: list[Action] = [] self.undo_stack: List[Action] = []
self.redo_stack: list[Action] = [] self.redo_stack: List[Action] = []
self._hl_factories = hl_factories self._hl_factories = hl_factories
self._trailing_whitespace = TrailingWhitespace(color_manager) self._trailing_whitespace = TrailingWhitespace(color_manager)
self._replace_hl = Replace() self._replace_hl = Replace()
self.selection = Selection() self.selection = Selection()
self._file_hls: tuple[FileHL, ...] = () self._file_hls: Tuple[FileHL, ...] = ()
def ensure_loaded( def ensure_loaded(self, status: Status, stdin: str) -> None:
self,
status: Status,
margin: Margin,
stdin: str,
) -> None:
if self.buf: if self.buf:
return return
@@ -234,7 +226,7 @@ class File:
sio = io.StringIO(stdin) sio = io.StringIO(stdin)
lines, self.nl, mixed, self.sha256 = get_lines(sio) lines, self.nl, mixed, self.sha256 = get_lines(sio)
elif self.filename is not None and os.path.isfile(self.filename): elif self.filename is not None and os.path.isfile(self.filename):
with open(self.filename, encoding='UTF-8', newline='') as f: with open(self.filename, newline='') as f:
lines, self.nl, mixed, self.sha256 = get_lines(f) lines, self.nl, mixed, self.sha256 = get_lines(f)
else: else:
if self.filename is not None: if self.filename is not None:
@@ -245,7 +237,7 @@ class File:
status.update('(new file)') status.update('(new file)')
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO('')) lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
self.buf = Buf(lines, self.buf.tab_size) self.buf = Buf(lines)
if mixed: if mixed:
status.update(f'mixed newlines will be converted to {self.nl!r}') status.update(f'mixed newlines will be converted to {self.nl!r}')
@@ -265,8 +257,6 @@ class File:
for file_hl in self._file_hls: for file_hl in self._file_hls:
file_hl.register_callbacks(self.buf) file_hl.register_callbacks(self.buf)
self.go_to_line(self.initial_line, margin)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<{type(self).__name__} {self.filename!r}>' return f'<{type(self).__name__} {self.filename!r}>'
@@ -290,11 +280,11 @@ class File:
@action @action
def home(self, margin: Margin) -> None: def home(self, margin: Margin) -> None:
self.buf.x = 0 self.buf.home()
@action @action
def end(self, margin: Margin) -> None: def end(self, margin: Margin) -> None:
self.buf.x = len(self.buf[self.buf.y]) self.buf.end()
@action @action
def ctrl_up(self, margin: Margin) -> None: def ctrl_up(self, margin: Margin) -> None:
@@ -393,14 +383,14 @@ class File:
@clear_selection @clear_selection
def replace( def replace(
self, self,
screen: Screen, screen: 'Screen',
reg: Pattern[str], reg: Pattern[str],
replace: str, replace: str,
) -> None: ) -> None:
self.finalize_previous_action() self.finalize_previous_action()
count = 0 count = 0
res: str | PromptResult = '' res: Union[str, PromptResult] = ''
search = _SearchIter(self, reg, offset=0) search = _SearchIter(self, reg, offset=0)
for line_y, match in search: for line_y, match in search:
end = match.end() end = match.end()
@@ -474,11 +464,7 @@ class File:
if self.buf.y == 0 and self.buf.x == 0: if self.buf.y == 0 and self.buf.x == 0:
pass pass
# backspace at the end of the file does not change the contents # backspace at the end of the file does not change the contents
elif ( elif self.buf.y == len(self.buf) - 1:
self.buf.y == len(self.buf) - 1 and
# still allow backspace if there are 2+ blank lines
self.buf[self.buf.y - 1] != ''
):
self.buf.left(margin) self.buf.left(margin)
# at the beginning of the line, we join the current line and # at the beginning of the line, we join the current line and
# the previous line # the previous line
@@ -526,29 +512,20 @@ class File:
assert self.selection.start is not None assert self.selection.start is not None
sel_y, sel_x = self.selection.start sel_y, sel_x = self.selection.start
(s_y, _), (e_y, _) = self.selection.get() (s_y, _), (e_y, _) = self.selection.get()
tab_string = self.buf.tab_string
tab_size = len(tab_string)
for l_y in range(s_y, e_y + 1): for l_y in range(s_y, e_y + 1):
if self.buf[l_y]: 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: if l_y == self.buf.y:
self.buf.x += tab_size self.buf.x += 4
if l_y == sel_y and sel_x != 0: 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) self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
@edit_action('insert tab', final=False) @edit_action('insert tab', final=False)
def _tab(self, margin: Margin) -> None: def _tab(self, margin: Margin) -> None:
tab_string = self.buf.tab_string n = 4 - self.buf.x % 4
if tab_string == '\t':
n = 1
else:
n = self.buf.tab_size - self.buf.x % self.buf.tab_size
tab_string = tab_string[:n]
line = self.buf[self.buf.y] line = self.buf[self.buf.y]
self.buf[self.buf.y] = ( self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
line[:self.buf.x] + tab_string + line[self.buf.x:]
)
self.buf.x += n self.buf.x += n
self.buf.restore_eof_invariant() self.buf.restore_eof_invariant()
@@ -558,10 +535,11 @@ class File:
else: else:
self._tab(margin) self._tab(margin)
def _dedent_line(self, s: str) -> int: @staticmethod
bound = min(len(s), len(self.buf.tab_string)) def _dedent_line(s: str) -> int:
bound = min(len(s), 4)
i = 0 i = 0
while i < bound and s[i] in (' ', '\t'): while i < bound and s[i] == ' ':
i += 1 i += 1
return i return i
@@ -595,7 +573,7 @@ class File:
@edit_action('cut selection', final=True) @edit_action('cut selection', final=True)
@clear_selection @clear_selection
def cut_selection(self, margin: Margin) -> tuple[str, ...]: def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
ret = [] ret = []
(s_y, s_x), (e_y, e_x) = self.selection.get() (s_y, s_x), (e_y, e_x) = self.selection.get()
if s_y == e_y: if s_y == e_y:
@@ -615,7 +593,7 @@ class File:
self.buf.scroll_screen_if_needed(margin) self.buf.scroll_screen_if_needed(margin)
return tuple(ret) return tuple(ret)
def cut(self, cut_buffer: tuple[str, ...]) -> tuple[str, ...]: def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
# only continue a cut if the last action is a non-final cut # only continue a cut if the last action is a non-final cut
if not self._continue_last_action('cut'): if not self._continue_last_action('cut'):
cut_buffer = () cut_buffer = ()
@@ -628,7 +606,7 @@ class File:
self.buf.x = 0 self.buf.x = 0
return cut_buffer + (victim,) return cut_buffer + (victim,)
def _uncut(self, cut_buffer: tuple[str, ...], margin: Margin) -> None: def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
for cut_line in cut_buffer: for cut_line in cut_buffer:
line = self.buf[self.buf.y] line = self.buf[self.buf.y]
before, after = line[:self.buf.x], line[self.buf.x:] before, after = line[:self.buf.x], line[self.buf.x:]
@@ -639,14 +617,14 @@ class File:
@edit_action('uncut', final=True) @edit_action('uncut', final=True)
@clear_selection @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) self._uncut(cut_buffer, margin)
@edit_action('uncut selection', final=True) @edit_action('uncut selection', final=True)
@clear_selection @clear_selection
def uncut_selection( def uncut_selection(
self, self,
cut_buffer: tuple[str, ...], margin: Margin, cut_buffer: Tuple[str, ...], margin: Margin,
) -> None: ) -> None:
self._uncut(cut_buffer, margin) self._uncut(cut_buffer, margin)
self.buf.up(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[self.buf.y] += self.buf.pop(self.buf.y + 1)
self.buf.restore_eof_invariant() 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 # 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): for i, line in zip(range(s_y, e_y), lines):
self.buf[i] = line self.buf[i] = line
@@ -664,78 +642,18 @@ class File:
self.buf.x = 0 self.buf.x = 0
self.buf.scroll_screen_if_needed(margin) 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() (s_y, _), (e_y, _) = self.selection.get()
e_y = min(e_y + 1, len(self.buf) - 1) e_y = min(e_y + 1, len(self.buf) - 1)
if self.buf[e_y - 1] == '': if self.buf[e_y - 1] == '':
e_y -= 1 e_y -= 1
return s_y, e_y self._sort(margin, 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)
DISPATCH = { DISPATCH = {
# movement # movement
@@ -780,10 +698,8 @@ class File:
@edit_action('text', final=False) @edit_action('text', final=False)
@clear_selection @clear_selection
def c(self, wch: str, margin: Margin) -> None: def c(self, wch: str) -> None:
s = self.buf[self.buf.y] self.buf.c(wch)
self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
self.buf.x += len(wch)
self.buf.restore_eof_invariant() self.buf.restore_eof_invariant()
def finalize_previous_action(self) -> None: def finalize_previous_action(self) -> None:
@@ -850,12 +766,12 @@ class File:
def move_cursor( def move_cursor(
self, self,
stdscr: curses._CursesWindow, stdscr: 'curses._CursesWindow',
margin: Margin, margin: Margin,
) -> None: ) -> None:
stdscr.move(*self.buf.cursor_position(margin)) stdscr.move(*self.buf.cursor_position(margin))
def draw(self, stdscr: curses._CursesWindow, margin: Margin) -> None: def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
to_display = min(self.buf.displayable_count, margin.body_lines) to_display = min(self.buf.displayable_count, margin.body_lines)
for file_hl in self._file_hls: for file_hl in self._file_hls:

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import functools import functools
import json import json
import os.path import os.path
from typing import Any from typing import Any
from typing import Dict
from typing import FrozenSet
from typing import List
from typing import Match from typing import Match
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple from typing import Tuple
from typing import TypeVar from typing import TypeVar
@@ -32,7 +34,7 @@ def uniquely_constructed(t: T) -> T:
return t return t
def _split_name(s: str | None) -> tuple[str, ...]: def _split_name(s: Optional[str]) -> Tuple[str, ...]:
if s is None: if s is None:
return () return ()
else: else:
@@ -42,17 +44,17 @@ def _split_name(s: str | None) -> tuple[str, ...]:
class _Rule(Protocol): class _Rule(Protocol):
"""hax for recursive types python/mypy#731""" """hax for recursive types python/mypy#731"""
@property @property
def name(self) -> tuple[str, ...]: ... def name(self) -> Tuple[str, ...]: ...
@property @property
def match(self) -> str | None: ... def match(self) -> Optional[str]: ...
@property @property
def begin(self) -> str | None: ... def begin(self) -> Optional[str]: ...
@property @property
def end(self) -> str | None: ... def end(self) -> Optional[str]: ...
@property @property
def while_(self) -> str | None: ... def while_(self) -> Optional[str]: ...
@property @property
def content_name(self) -> tuple[str, ...]: ... def content_name(self) -> Tuple[str, ...]: ...
@property @property
def captures(self) -> Captures: ... def captures(self) -> Captures: ...
@property @property
@@ -62,39 +64,39 @@ class _Rule(Protocol):
@property @property
def while_captures(self) -> Captures: ... def while_captures(self) -> Captures: ...
@property @property
def include(self) -> str | None: ... def include(self) -> Optional[str]: ...
@property @property
def patterns(self) -> tuple[_Rule, ...]: ... def patterns(self) -> 'Tuple[_Rule, ...]': ...
@property @property
def repository(self) -> FChainMap[str, _Rule]: ... def repository(self) -> 'FChainMap[str, _Rule]': ...
@uniquely_constructed @uniquely_constructed
class Rule(NamedTuple): class Rule(NamedTuple):
name: tuple[str, ...] name: Tuple[str, ...]
match: str | None match: Optional[str]
begin: str | None begin: Optional[str]
end: str | None end: Optional[str]
while_: str | None while_: Optional[str]
content_name: tuple[str, ...] content_name: Tuple[str, ...]
captures: Captures captures: Captures
begin_captures: Captures begin_captures: Captures
end_captures: Captures end_captures: Captures
while_captures: Captures while_captures: Captures
include: str | None include: Optional[str]
patterns: tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
repository: FChainMap[str, _Rule] repository: FChainMap[str, _Rule]
@classmethod @classmethod
def make( def make(
cls, cls,
dct: dict[str, Any], dct: Dict[str, Any],
parent_repository: FChainMap[str, _Rule], parent_repository: FChainMap[str, _Rule],
) -> _Rule: ) -> _Rule:
if 'repository' in dct: if 'repository' in dct:
# this looks odd, but it's so we can have a self-referential # this looks odd, but it's so we can have a self-referential
# immutable-after-construction chain map # immutable-after-construction chain map
repository_dct: dict[str, _Rule] = {} repository_dct: Dict[str, _Rule] = {}
repository = FChainMap(parent_repository, repository_dct) repository = FChainMap(parent_repository, repository_dct)
for k, sub_dct in dct['repository'].items(): for k, sub_dct in dct['repository'].items():
repository_dct[k] = Rule.make(sub_dct, repository) repository_dct[k] = Rule.make(sub_dct, repository)
@@ -181,15 +183,15 @@ class Rule(NamedTuple):
class Grammar(NamedTuple): class Grammar(NamedTuple):
scope_name: str scope_name: str
repository: FChainMap[str, _Rule] repository: FChainMap[str, _Rule]
patterns: tuple[_Rule, ...] patterns: Tuple[_Rule, ...]
@classmethod @classmethod
def make(cls, data: dict[str, Any]) -> Grammar: def make(cls, data: Dict[str, Any]) -> 'Grammar':
scope_name = data['scopeName'] scope_name = data['scopeName']
if 'repository' in data: if 'repository' in data:
# this looks odd, but it's so we can have a self-referential # this looks odd, but it's so we can have a self-referential
# immutable-after-construction chain map # immutable-after-construction chain map
repository_dct: dict[str, _Rule] = {} repository_dct: Dict[str, _Rule] = {}
repository = FChainMap(repository_dct) repository = FChainMap(repository_dct)
for k, dct in data['repository'].items(): for k, dct in data['repository'].items():
repository_dct[k] = Rule.make(dct, repository) repository_dct[k] = Rule.make(dct, repository)
@@ -210,54 +212,54 @@ class Region(NamedTuple):
class State(NamedTuple): class State(NamedTuple):
entries: tuple[Entry, ...] entries: Tuple['Entry', ...]
while_stack: tuple[tuple[WhileRule, int], ...] while_stack: Tuple[Tuple['WhileRule', int], ...]
@classmethod @classmethod
def root(cls, entry: Entry) -> State: def root(cls, entry: 'Entry') -> 'State':
return cls((entry,), ()) return cls((entry,), ())
@property @property
def cur(self) -> Entry: def cur(self) -> 'Entry':
return self.entries[-1] return self.entries[-1]
def push(self, entry: Entry) -> State: def push(self, entry: 'Entry') -> 'State':
return self._replace(entries=(*self.entries, entry)) return self._replace(entries=(*self.entries, entry))
def pop(self) -> State: def pop(self) -> 'State':
return self._replace(entries=self.entries[:-1]) 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) entries = (*self.entries, entry)
while_stack = (*self.while_stack, (rule, len(entries))) while_stack = (*self.while_stack, (rule, len(entries)))
return self._replace(entries=entries, while_stack=while_stack) 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] entries, while_stack = self.entries[:-1], self.while_stack[:-1]
return self._replace(entries=entries, while_stack=while_stack) return self._replace(entries=entries, while_stack=while_stack)
class CompiledRule(Protocol): class CompiledRule(Protocol):
@property @property
def name(self) -> tuple[str, ...]: ... def name(self) -> Tuple[str, ...]: ...
def start( def start(
self, self,
compiler: Compiler, compiler: 'Compiler',
match: Match[str], match: Match[str],
state: State, state: State,
) -> tuple[State, bool, Regions]: ) -> Tuple[State, bool, Regions]:
... ...
def search( def search(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> tuple[State, int, bool, Regions] | None: ) -> Optional[Tuple[State, int, bool, Regions]]:
... ...
@@ -265,25 +267,24 @@ class CompiledRegsetRule(CompiledRule, Protocol):
@property @property
def regset(self) -> _RegSet: ... def regset(self) -> _RegSet: ...
@property @property
def u_rules(self) -> tuple[_Rule, ...]: ... def u_rules(self) -> Tuple[_Rule, ...]: ...
class Entry(NamedTuple): class Entry(NamedTuple):
scope: tuple[str, ...] scope: Tuple[str, ...]
rule: CompiledRule rule: CompiledRule
start: tuple[str, int]
reg: _Reg = ERR_REG reg: _Reg = ERR_REG
boundary: bool = False boundary: bool = False
def _inner_capture_parse( def _inner_capture_parse(
compiler: Compiler, compiler: 'Compiler',
start: int, start: int,
s: str, s: str,
scope: Scope, scope: Scope,
rule: CompiledRule, rule: CompiledRule,
) -> Regions: ) -> 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) _, regions = highlight_line(compiler, state, s, first_line=False)
return tuple( return tuple(
r._replace(start=r.start + start, end=r.end + start) for r in regions r._replace(start=r.start + start, end=r.end + start) for r in regions
@@ -291,12 +292,12 @@ def _inner_capture_parse(
def _captures( def _captures(
compiler: Compiler, compiler: 'Compiler',
scope: Scope, scope: Scope,
match: Match[str], match: Match[str],
captures: Captures, captures: Captures,
) -> Regions: ) -> Regions:
ret: list[Region] = [] ret: List[Region] = []
pos, pos_end = match.span() pos, pos_end = match.span()
for i, u_rule in captures: for i, u_rule in captures:
try: try:
@@ -345,12 +346,12 @@ def _captures(
def _do_regset( def _do_regset(
idx: int, idx: int,
match: Match[str] | None, match: Optional[Match[str]],
rule: CompiledRegsetRule, rule: CompiledRegsetRule,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
pos: int, pos: int,
) -> tuple[State, int, bool, Regions] | None: ) -> Optional[Tuple[State, int, bool, Regions]]:
if match is None: if match is None:
return None return None
@@ -367,114 +368,104 @@ def _do_regset(
@uniquely_constructed @uniquely_constructed
class PatternRule(NamedTuple): class PatternRule(NamedTuple):
name: tuple[str, ...] name: Tuple[str, ...]
regset: _RegSet regset: _RegSet
u_rules: tuple[_Rule, ...] u_rules: Tuple[_Rule, ...]
def start( def start(
self, self,
compiler: Compiler, compiler: 'Compiler',
match: Match[str], match: Match[str],
state: State, state: State,
) -> tuple[State, bool, Regions]: ) -> Tuple[State, bool, Regions]:
raise AssertionError(f'unreachable {self}') raise AssertionError(f'unreachable {self}')
def search( def search(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: 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) idx, match = self.regset.search(line, pos, first_line, boundary)
return _do_regset(idx, match, self, compiler, state, pos) return _do_regset(idx, match, self, compiler, state, pos)
@uniquely_constructed @uniquely_constructed
class MatchRule(NamedTuple): class MatchRule(NamedTuple):
name: tuple[str, ...] name: Tuple[str, ...]
captures: Captures captures: Captures
def start( def start(
self, self,
compiler: Compiler, compiler: 'Compiler',
match: Match[str], match: Match[str],
state: State, state: State,
) -> tuple[State, bool, Regions]: ) -> Tuple[State, bool, Regions]:
scope = state.cur.scope + self.name scope = state.cur.scope + self.name
return state, False, _captures(compiler, scope, match, self.captures) return state, False, _captures(compiler, scope, match, self.captures)
def search( def search(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> tuple[State, int, bool, Regions] | None: ) -> Optional[Tuple[State, int, bool, Regions]]:
raise AssertionError(f'unreachable {self}') raise AssertionError(f'unreachable {self}')
@uniquely_constructed @uniquely_constructed
class EndRule(NamedTuple): class EndRule(NamedTuple):
name: tuple[str, ...] name: Tuple[str, ...]
content_name: tuple[str, ...] content_name: Tuple[str, ...]
begin_captures: Captures begin_captures: Captures
end_captures: Captures end_captures: Captures
end: str end: str
regset: _RegSet regset: _RegSet
u_rules: tuple[_Rule, ...] u_rules: Tuple[_Rule, ...]
def start( def start(
self, self,
compiler: Compiler, compiler: 'Compiler',
match: Match[str], match: Match[str],
state: State, state: State,
) -> tuple[State, bool, Regions]: ) -> Tuple[State, bool, Regions]:
scope = state.cur.scope + self.name scope = state.cur.scope + self.name
next_scope = scope + self.content_name next_scope = scope + self.content_name
boundary = match.end() == len(match.string) boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.end)) reg = make_reg(expand_escaped(match, self.end))
start = (match.string, match.start()) state = state.push(Entry(next_scope, self, reg, boundary))
state = state.push(Entry(next_scope, self, start, reg, boundary))
regions = _captures(compiler, scope, match, self.begin_captures) regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions return state, True, regions
def _end_ret( def _end_ret(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
pos: int, pos: int,
m: Match[str], m: Match[str],
) -> tuple[State, int, bool, Regions]: ) -> Tuple[State, int, bool, Regions]:
ret = [] ret = []
if m.start() > pos: if m.start() > pos:
ret.append(Region(pos, m.start(), state.cur.scope)) ret.append(Region(pos, m.start(), state.cur.scope))
ret.extend(_captures(compiler, state.cur.scope, m, self.end_captures)) 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 return state.pop(), m.end(), False, tuple(ret)
# 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)
def search( def search(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: 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) end_match = state.cur.reg.search(line, pos, first_line, boundary)
if end_match is not None and end_match.start() == pos: if end_match is not None and end_match.start() == pos:
return self._end_ret(compiler, state, pos, end_match) return self._end_ret(compiler, state, pos, end_match)
@@ -491,40 +482,38 @@ class EndRule(NamedTuple):
@uniquely_constructed @uniquely_constructed
class WhileRule(NamedTuple): class WhileRule(NamedTuple):
name: tuple[str, ...] name: Tuple[str, ...]
content_name: tuple[str, ...] content_name: Tuple[str, ...]
begin_captures: Captures begin_captures: Captures
while_captures: Captures while_captures: Captures
while_: str while_: str
regset: _RegSet regset: _RegSet
u_rules: tuple[_Rule, ...] u_rules: Tuple[_Rule, ...]
def start( def start(
self, self,
compiler: Compiler, compiler: 'Compiler',
match: Match[str], match: Match[str],
state: State, state: State,
) -> tuple[State, bool, Regions]: ) -> Tuple[State, bool, Regions]:
scope = state.cur.scope + self.name scope = state.cur.scope + self.name
next_scope = scope + self.content_name next_scope = scope + self.content_name
boundary = match.end() == len(match.string) boundary = match.end() == len(match.string)
reg = make_reg(expand_escaped(match, self.while_)) reg = make_reg(expand_escaped(match, self.while_))
start = (match.string, match.start()) state = state.push_while(self, Entry(next_scope, self, reg, boundary))
entry = Entry(next_scope, self, start, reg, boundary)
state = state.push_while(self, entry)
regions = _captures(compiler, scope, match, self.begin_captures) regions = _captures(compiler, scope, match, self.begin_captures)
return state, True, regions return state, True, regions
def continues( def continues(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> tuple[int, bool, Regions] | None: ) -> Optional[Tuple[int, bool, Regions]]:
match = state.cur.reg.match(line, pos, first_line, boundary) match = state.cur.reg.match(line, pos, first_line, boundary)
if match is None: if match is None:
return None return None
@@ -534,25 +523,25 @@ class WhileRule(NamedTuple):
def search( def search(
self, self,
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: 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) idx, match = self.regset.search(line, pos, first_line, boundary)
return _do_regset(idx, match, self, compiler, state, pos) return _do_regset(idx, match, self, compiler, state, pos)
class Compiler: 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._root_scope = grammar.scope_name
self._grammars = grammars self._grammars = grammars
self._rule_to_grammar: dict[_Rule, Grammar] = {} self._rule_to_grammar: Dict[_Rule, Grammar] = {}
self._c_rules: dict[_Rule, CompiledRule] = {} self._c_rules: Dict[_Rule, CompiledRule] = {}
root = self._compile_root(grammar) 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: def _visit_rule(self, grammar: Grammar, rule: _Rule) -> _Rule:
self._rule_to_grammar[rule] = grammar self._rule_to_grammar[rule] = grammar
@@ -564,7 +553,7 @@ class Compiler:
grammar: Grammar, grammar: Grammar,
repository: FChainMap[str, _Rule], repository: FChainMap[str, _Rule],
s: str, s: str,
) -> tuple[list[str], tuple[_Rule, ...]]: ) -> Tuple[List[str], Tuple[_Rule, ...]]:
if s == '$self': if s == '$self':
return self._patterns(grammar, grammar.patterns) return self._patterns(grammar, grammar.patterns)
elif s == '$base': elif s == '$base':
@@ -584,10 +573,10 @@ class Compiler:
def _patterns( def _patterns(
self, self,
grammar: Grammar, grammar: Grammar,
rules: tuple[_Rule, ...], rules: Tuple[_Rule, ...],
) -> tuple[list[str], tuple[_Rule, ...]]: ) -> Tuple[List[str], Tuple[_Rule, ...]]:
ret_regs = [] ret_regs = []
ret_rules: list[_Rule] = [] ret_rules: List[_Rule] = []
for rule in rules: for rule in rules:
if rule.include is not None: if rule.include is not None:
tmp_regs, tmp_rules = self._include( tmp_regs, tmp_rules = self._include(
@@ -674,19 +663,19 @@ class Grammars:
unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []} unknown_grammar = {'scopeName': 'source.unknown', 'patterns': []}
self._raw = {'source.unknown': unknown_grammar} self._raw = {'source.unknown': unknown_grammar}
self._file_types: list[tuple[frozenset[str], str]] = [] self._file_types: List[Tuple[FrozenSet[str], str]] = []
self._first_line: list[tuple[_Reg, str]] = [] self._first_line: List[Tuple[_Reg, str]] = []
self._parsed: dict[str, Grammar] = {} self._parsed: Dict[str, Grammar] = {}
self._compiled: dict[str, Compiler] = {} 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: try:
return self._raw[scope] return self._raw[scope]
except KeyError: except KeyError:
pass pass
grammar_path = self._scope_to_files.pop(scope) 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) ret = self._raw[scope] = json.load(f)
file_types = frozenset(ret.get('fileTypes', ())) file_types = frozenset(ret.get('fileTypes', ()))
@@ -745,12 +734,12 @@ class Grammars:
def highlight_line( def highlight_line(
compiler: Compiler, compiler: 'Compiler',
state: State, state: State,
line: str, line: str,
first_line: bool, first_line: bool,
) -> tuple[State, Regions]: ) -> Tuple[State, Regions]:
ret: list[Region] = [] ret: List[Region] = []
pos = 0 pos = 0
boundary = state.cur.boundary boundary = state.cur.boundary

View File

@@ -1,26 +1,25 @@
from __future__ import annotations
import collections import collections
import contextlib import contextlib
import os.path import os.path
from typing import Dict
from typing import Generator from typing import Generator
from typing import List
from babi.user_data import xdg_data from babi.user_data import xdg_data
class History: class History:
def __init__(self) -> None: def __init__(self) -> None:
self._orig_len: dict[str, int] = collections.defaultdict(int) self._orig_len: Dict[str, int] = collections.defaultdict(int)
self.data: dict[str, list[str]] = collections.defaultdict(list) self.data: Dict[str, List[str]] = collections.defaultdict(list)
self.prev: dict[str, str] = {} self.prev: Dict[str, str] = {}
@contextlib.contextmanager @contextlib.contextmanager
def save(self) -> Generator[None, None, None]: def save(self) -> Generator[None, None, None]:
history_dir = xdg_data('history') history_dir = xdg_data('history')
os.makedirs(history_dir, exist_ok=True) os.makedirs(history_dir, exist_ok=True)
for filename in os.listdir(history_dir): for filename in os.listdir(history_dir):
history_filename = os.path.join(history_dir, filename) with open(os.path.join(history_dir, filename)) as f:
with open(history_filename, encoding='UTF-8') as f:
self.data[filename] = f.read().splitlines() self.data[filename] = f.read().splitlines()
self._orig_len[filename] = len(self.data[filename]) self._orig_len[filename] = len(self.data[filename])
try: try:
@@ -29,6 +28,5 @@ class History:
for k, v in self.data.items(): for k, v in self.data.items():
new_history = v[self._orig_len[k]:] new_history = v[self._orig_len[k]:]
if new_history: if new_history:
history_filename = os.path.join(history_dir, k) with open(os.path.join(history_dir, k), 'a+') as f:
with open(history_filename, 'a+', encoding='UTF-8') as f:
f.write('\n'.join(new_history) + '\n') f.write('\n'.join(new_history) + '\n')

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import NamedTuple from typing import NamedTuple
from typing import Tuple from typing import Tuple

View File

@@ -1,8 +1,7 @@
from __future__ import annotations
import collections import collections
import contextlib import contextlib
import curses import curses
from typing import Dict
from typing import Generator from typing import Generator
from babi.buf import Buf from babi.buf import Buf
@@ -14,7 +13,7 @@ class Replace:
include_edge = True include_edge = True
def __init__(self) -> None: def __init__(self) -> None:
self.regions: dict[int, HLs] = collections.defaultdict(tuple) self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
def highlight_until(self, lines: Buf, idx: int) -> None: def highlight_until(self, lines: Buf, idx: int) -> None:
"""our highlight regions are populated in other ways""" """our highlight regions are populated in other ways"""

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
import collections import collections
import curses import curses
from typing import Dict
from typing import Optional
from typing import Tuple
from babi.buf import Buf from babi.buf import Buf
from babi.hl.interface import HL from babi.hl.interface import HL
@@ -12,9 +13,9 @@ class Selection:
include_edge = True include_edge = True
def __init__(self) -> None: def __init__(self) -> None:
self.regions: dict[int, HLs] = collections.defaultdict(tuple) self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
self.start: tuple[int, int] | None = None self.start: Optional[Tuple[int, int]] = None
self.end: tuple[int, int] | None = None self.end: Optional[Tuple[int, int]] = None
def register_callbacks(self, buf: Buf) -> None: def register_callbacks(self, buf: Buf) -> None:
"""our highlight regions are populated in other ways""" """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),) 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 assert self.start is not None and self.end is not None
if self.start < self.end: if self.start < self.end:
return self.start, self.end return self.start, self.end

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import curses import curses
import functools import functools
import math import math
from typing import Callable from typing import Callable
from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi.buf import Buf from babi.buf import Buf
from babi.color_manager import ColorManager 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_config
from babi.user_data import xdg_data 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: class FileSyntax:
@@ -36,12 +37,12 @@ class FileSyntax:
self._theme = theme self._theme = theme
self._color_manager = color_manager self._color_manager = color_manager
self.regions: list[HLs] = [] self.regions: List[HLs] = []
self._states: list[State] = [] self._states: List[State] = []
# this will be assigned a functools.lru_cache per instance for # this will be assigned a functools.lru_cache per instance for
# better hit rate and memory usage # 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 self._hl = None
def attr(self, style: Style) -> int: def attr(self, style: Style) -> int:
@@ -58,7 +59,7 @@ class FileSyntax:
state: State, state: State,
line: str, line: str,
first_line: bool, first_line: bool,
) -> tuple[State, HLs]: ) -> Tuple[State, HLs]:
new_state, regions = highlight_line( new_state, regions = highlight_line(
self._compiler, state, f'{line}\n', first_line=first_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) new_end = regions[-1]._replace(end=regions[-1].end - 1)
regions = regions[:-1] + (new_end,) regions = regions[:-1] + (new_end,)
regs: list[HL] = [] regs: List[HL] = []
for r in regions: for r in regions:
style = self._theme.select(r.scope) style = self._theme.select(r.scope)
if style == self._theme.default: if style == self._theme.default:
@@ -114,7 +115,8 @@ class FileSyntax:
state = self._states[-1] state = self._states[-1]
for i in range(len(self._states), idx): for i in range(len(self._states), idx):
state, regions = self._hl(state, lines[i], i == 0) # https://github.com/python/mypy/issues/8579
state, regions = self._hl(state, lines[i], i == 0) # type: ignore
self._states.append(state) self._states.append(state)
self.regions.append(regions) self.regions.append(regions)
@@ -132,7 +134,7 @@ class Syntax(NamedTuple):
compiler = self.grammars.blank_compiler() compiler = self.grammars.blank_compiler()
return FileSyntax(compiler, self.theme, self.color_manager) 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 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} all_colors = {c for c in (default_fg, default_bg) if c is not None}
todo = list(self.theme.rules.children.values()) todo = list(self.theme.rules.children.values())
@@ -153,9 +155,9 @@ class Syntax(NamedTuple):
@classmethod @classmethod
def from_screen( def from_screen(
cls, cls,
stdscr: curses._CursesWindow, stdscr: 'curses._CursesWindow',
color_manager: ColorManager, color_manager: ColorManager,
) -> Syntax: ) -> 'Syntax':
grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1')) grammars = Grammars(prefix_data('grammar_v1'), xdg_data('grammar_v1'))
theme = Theme.from_filename(xdg_config('theme.json')) theme = Theme.from_filename(xdg_config('theme.json'))
ret = cls(grammars, theme, color_manager) ret = cls(grammars, theme, color_manager)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import curses import curses
from typing import List
from babi.buf import Buf from babi.buf import Buf
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
@@ -14,7 +13,7 @@ class TrailingWhitespace:
def __init__(self, color_manager: ColorManager) -> None: def __init__(self, color_manager: ColorManager) -> None:
self._color_manager = color_manager self._color_manager = color_manager
self.regions: list[HLs] = [] self.regions: List[HLs] = []
def _trailing_ws(self, line: str) -> HLs: def _trailing_ws(self, line: str) -> HLs:
if not line: if not line:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations import bisect
import curses import curses
from typing import Tuple
from babi.cached_property import cached_property 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: def scrolled_line(
l_x = line_x(x, width) s: str,
positions: Tuple[int, ...],
cursor_x: int,
width: int,
) -> str:
l_x = line_x(cursor_x, width)
if l_x: if l_x:
s = f'«{s[l_x + 1:]}' l_x_min = l_x + 1
if len(s) > width: start = bisect.bisect_left(positions, l_x_min)
return f'{s[:width - 1]}»' pad_left = '«' * (positions[start] - l_x)
l_x_max = l_x + width
if positions[-1] > l_x_max:
end_max = l_x_max - 1
end = bisect.bisect_left(positions, end_max)
if positions[end] > end_max:
end -= 1
pad_right = '»' * (l_x_max - positions[end])
return f'{pad_left}{s[start:end].expandtabs(4)}{pad_right}'
else: else:
return s.ljust(width) return f'{pad_left}{s[start:]}'.ljust(width)
elif len(s) > width: elif positions[-1] > width:
return f'{s[:width - 1]}»' end_max = width - 1
end = bisect.bisect_left(positions, end_max)
if positions[end] > end_max:
end -= 1
pad_right = '»' * (width - positions[end])
return f'{s[:end].expandtabs(4)}{pad_right}'
else: else:
return s.ljust(width) return s.expandtabs(4).ljust(width)
class _CalcWidth: class _CalcWidth:
@cached_property @cached_property
def _window(self) -> curses._CursesWindow: def _window(self) -> 'curses._CursesWindow':
return curses.newwin(1, 10) return curses.newwin(1, 10)
def wcwidth(self, c: str) -> int: def wcwidth(self, c: str) -> int:

View File

@@ -1,11 +1,8 @@
from __future__ import annotations
import argparse import argparse
import curses import curses
import os import os
import re
import signal
import sys import sys
from typing import Optional
from typing import Sequence from typing import Sequence
from babi.buf import Buf from babi.buf import Buf
@@ -17,11 +14,10 @@ from babi.screen import make_stdscr
from babi.screen import Screen from babi.screen import Screen
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty' CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
POSITION_RE = re.compile(r'^\+-?\d+$')
def _edit(screen: Screen, stdin: str) -> EditResult: 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: while True:
screen.status.tick(screen.margin) screen.status.tick(screen.margin)
@@ -37,43 +33,42 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
return ret return ret
elif key.keyname == b'STRING': elif key.keyname == b'STRING':
assert isinstance(key.wch, str), key.wch assert isinstance(key.wch, str), key.wch
screen.file.c(key.wch, screen.margin) screen.file.c(key.wch)
else: else:
screen.status.update(f'unknown key: {key}') screen.status.update(f'unknown key: {key}')
def c_main( def c_main(
stdscr: curses._CursesWindow, stdscr: 'curses._CursesWindow',
filenames: list[str | None], args: argparse.Namespace,
positions: list[int],
stdin: str, stdin: str,
perf: Perf,
) -> int: ) -> int:
screen = Screen(stdscr, filenames, positions, perf) with perf_log(args.perf_log) as perf:
with screen.history.save(): screen = Screen(stdscr, args.filenames or [None], perf)
while screen.files: with screen.history.save():
screen.i = screen.i % len(screen.files) while screen.files:
res = _edit(screen, stdin) screen.i = screen.i % len(screen.files)
if res == EditResult.EXIT: res = _edit(screen, stdin)
del screen.files[screen.i] if res == EditResult.EXIT:
# always go to the next file except at the end del screen.files[screen.i]
screen.i = min(screen.i, len(screen.files) - 1) # always go to the next file except at the end
screen.status.clear() screen.i = min(screen.i, len(screen.files) - 1)
elif res == EditResult.NEXT: screen.status.clear()
screen.i += 1 elif res == EditResult.NEXT:
screen.status.clear() screen.i += 1
elif res == EditResult.PREV: screen.status.clear()
screen.i -= 1 elif res == EditResult.PREV:
screen.status.clear() screen.i -= 1
elif res == EditResult.OPEN: screen.status.clear()
screen.i = len(screen.files) - 1 elif res == EditResult.OPEN:
else: screen.i = len(screen.files) - 1
raise AssertionError(f'unreachable {res}') else:
raise AssertionError(f'unreachable {res}')
return 0 return 0
def _key_debug(stdscr: curses._CursesWindow, perf: Perf) -> int: def _key_debug(stdscr: 'curses._CursesWindow') -> int:
screen = Screen(stdscr, ['<<key debug>>'], [0], perf) screen = Screen(stdscr, ['<<key debug>>'], Perf())
screen.file.buf = Buf(['']) screen.file.buf = Buf([''])
while True: while True:
@@ -90,38 +85,7 @@ def _key_debug(stdscr: curses._CursesWindow, perf: Perf) -> int:
return 0 return 0
def _filenames(filenames: list[str]) -> tuple[list[str | None], list[int]]: def main(argv: Optional[Sequence[str]] = None) -> 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:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('filenames', metavar='filename', nargs='*') parser.add_argument('filenames', metavar='filename', nargs='*')
parser.add_argument('--perf-log') parser.add_argument('--perf-log')
@@ -132,24 +96,18 @@ def main(argv: Sequence[str] | None = None) -> int:
if '-' in args.filenames: if '-' in args.filenames:
print('reading stdin...', file=sys.stderr) print('reading stdin...', file=sys.stderr)
stdin = sys.stdin.buffer.read().decode() stdin = sys.stdin.read()
tty = os.open(CONSOLE, os.O_RDONLY) tty = os.open(CONSOLE, os.O_RDONLY)
os.dup2(tty, sys.stdin.fileno()) os.dup2(tty, sys.stdin.fileno())
else: else:
stdin = '' stdin = ''
# ignore backgrounding signals, we'll handle those in curses with make_stdscr() as stdscr:
# 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:
if args.key_debug: if args.key_debug:
return _key_debug(stdscr, perf) return _key_debug(stdscr)
else: else:
filenames, positions = _filenames(args.filenames) return c_main(stdscr, args, stdin)
return c_main(stdscr, filenames, positions, stdin, perf)
if __name__ == '__main__': if __name__ == '__main__':
raise SystemExit(main()) exit(main())

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import curses import curses
from typing import NamedTuple from typing import NamedTuple
@@ -33,5 +31,5 @@ class Margin(NamedTuple):
return int(self.lines / 2 + .5) return int(self.lines / 2 + .5)
@classmethod @classmethod
def from_current_screen(cls) -> Margin: def from_current_screen(cls) -> 'Margin':
return cls(curses.LINES, curses.COLS) return cls(curses.LINES, curses.COLS)

View File

@@ -1,17 +1,18 @@
from __future__ import annotations
import contextlib import contextlib
import cProfile import cProfile
import time import time
from typing import Generator from typing import Generator
from typing import List
from typing import Optional
from typing import Tuple
class Perf: class Perf:
def __init__(self) -> None: def __init__(self) -> None:
self._prof: cProfile.Profile | None = None self._prof: Optional[cProfile.Profile] = None
self._records: list[tuple[str, float]] = [] self._records: List[Tuple[str, float]] = []
self._name: str | None = None self._name: Optional[str] = None
self._time: float | None = None self._time: Optional[float] = None
def start(self, name: str) -> None: def start(self, name: str) -> None:
if self._prof: if self._prof:
@@ -35,14 +36,14 @@ class Perf:
def save_profiles(self, filename: str) -> None: def save_profiles(self, filename: str) -> None:
assert self._prof is not None assert self._prof is not None
self._prof.dump_stats(f'{filename}.pstats') 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') f.write('μs\tevent\n')
for name, duration in self._records: for name, duration in self._records:
f.write(f'{int(duration * 1000 * 1000)}\t{name}\n') f.write(f'{int(duration * 1000 * 1000)}\t{name}\n')
@contextlib.contextmanager @contextlib.contextmanager
def perf_log(filename: str | None) -> Generator[Perf, None, None]: def perf_log(filename: Optional[str]) -> Generator[Perf, None, None]:
perf = Perf() perf = Perf()
if filename is None: if filename is None:
yield perf yield perf

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
import curses import curses
import enum import enum
from typing import List
from typing import Optional
from typing import Tuple
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Union
from babi.horizontal_scrolling import line_x from babi.buf import Buf
from babi.horizontal_scrolling import scrolled_line
if TYPE_CHECKING: if TYPE_CHECKING:
from babi.main import Screen # XXX: circular from babi.main import Screen # XXX: circular
@@ -14,22 +15,30 @@ PromptResult = enum.Enum('PromptResult', 'CANCELLED')
class Prompt: class Prompt:
def __init__(self, screen: Screen, prompt: str, lst: list[str]) -> None: def __init__(self, screen: 'Screen', prompt: str, lst: List[str]) -> None:
self._screen = screen self._screen = screen
self._prompt = prompt self._prompt = prompt
self._lst = lst self._buf = Buf(lst)
self._y = len(lst) - 1 self._buf.y = self._buf.file_y = len(lst) - 1
self._x = len(self._s) self._x = len(self._s)
@property
def _x(self) -> int:
return self._buf.x
@_x.setter
def _x(self, x: int) -> None:
self._buf.x = x
@property @property
def _s(self) -> str: def _s(self) -> str:
return self._lst[self._y] return self._buf[self._buf.y]
@_s.setter @_s.setter
def _s(self, s: str) -> None: def _s(self, s: str) -> None:
self._lst[self._y] = s self._buf[self._buf.y] = s
def _render_prompt(self, *, base: str | None = None) -> None: def _render_prompt(self, *, base: Optional[str] = None) -> None:
base = base or self._prompt base = base or self._prompt
if not base or self._screen.margin.cols < 7: if not base or self._screen.margin.cols < 7:
prompt_s = '' prompt_s = ''
@@ -37,24 +46,26 @@ class Prompt:
prompt_s = f'{base[:self._screen.margin.cols - 7]}…: ' prompt_s = f'{base[:self._screen.margin.cols - 7]}…: '
else: else:
prompt_s = f'{base}: ' prompt_s = f'{base}: '
width = self._screen.margin.cols - len(prompt_s) width = self._screen.margin.cols - len(prompt_s)
line = scrolled_line(self._s, self._x, width) margin = self._screen.margin._replace(cols=width)
cmd = f'{prompt_s}{line}' cmd = f'{prompt_s}{self._buf.rendered_line(self._buf.y, margin)}'
prompt_line = self._screen.margin.lines - 1 prompt_line = self._screen.margin.lines - 1
self._screen.stdscr.insstr(prompt_line, 0, cmd, curses.A_REVERSE) 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: def _up(self) -> None:
self._y = max(0, self._y - 1) self._buf.up(self._screen.margin)
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _down(self) -> None: def _down(self) -> None:
self._y = min(len(self._lst) - 1, self._y + 1) self._buf.down(self._screen.margin)
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _right(self) -> None: def _right(self) -> None:
self._x = min(len(self._s), self._x + 1) self._x = min(len(self._buf[self._buf.y]), self._x + 1)
def _left(self) -> None: def _left(self) -> None:
self._x = max(0, self._x - 1) self._x = max(0, self._x - 1)
@@ -63,11 +74,11 @@ class Prompt:
self._x = 0 self._x = 0
def _end(self) -> None: def _end(self) -> None:
self._x = len(self._s) self._x = len(self._buf[self._buf.y])
def _ctrl_left(self) -> None: def _ctrl_left(self) -> None:
if self._x <= 1: if self._x <= 1:
self._x = 0 self._buf.home()
else: else:
self._x -= 1 self._x -= 1
tp = self._s[self._x - 1].isalnum() tp = self._s[self._x - 1].isalnum()
@@ -76,7 +87,7 @@ class Prompt:
def _ctrl_right(self) -> None: def _ctrl_right(self) -> None:
if self._x >= len(self._s) - 1: if self._x >= len(self._s) - 1:
self._x = len(self._s) self._buf.end()
else: else:
self._x += 1 self._x += 1
tp = self._s[self._x].isalnum() tp = self._s[self._x].isalnum()
@@ -98,20 +109,20 @@ class Prompt:
def _resize(self) -> None: def _resize(self) -> None:
self._screen.resize() 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 failed = False
for search_idx in range(idx, -1, -1): for search_idx in range(idx, -1, -1):
if s in self._lst[search_idx]: if s in self._buf[search_idx]:
idx = self._y = search_idx idx = self._buf.y = search_idx
self._x = self._lst[search_idx].index(s) self._x = self._buf[search_idx].index(s)
break break
else: else:
failed = True failed = True
return failed, idx return failed, idx
def _reverse_search(self) -> None | str | PromptResult: def _reverse_search(self) -> Union[None, str, PromptResult]:
reverse_s = '' reverse_s = ''
idx = self._y idx = self._buf.y
while True: while True:
fail, idx = self._check_failed(idx, reverse_s) fail, idx = self._check_failed(idx, reverse_s)
@@ -172,10 +183,9 @@ class Prompt:
} }
def _c(self, c: str) -> None: def _c(self, c: str) -> None:
self._s = self._s[:self._x] + c + self._s[self._x:] self._buf.c(c)
self._x += len(c)
def run(self) -> PromptResult | str: def run(self) -> Union[PromptResult, str]:
while True: while True:
self._render_prompt() self._render_prompt()

View File

@@ -1,49 +1,93 @@
from __future__ import annotations
import functools import functools
import re import re
from typing import Match from typing import Match
from typing import Optional
from typing import Tuple
import onigurumacffi import onigurumacffi
from babi.cached_property import cached_property
_BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)') _BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)')
_FLAGS = { def _replace_esc(s: str, chars: str) -> str:
# (first_line, boundary) """replace the given escape sequences of `chars` with \\uffff"""
(False, False): ( for c in chars:
onigurumacffi.OnigSearchOption.NOT_END_STRING | if f'\\{c}' in s:
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING | break
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION else:
), return s
(False, True): (
onigurumacffi.OnigSearchOption.NOT_END_STRING | b = []
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING i = 0
), length = len(s)
(True, False): ( while i < length:
onigurumacffi.OnigSearchOption.NOT_END_STRING | try:
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION sbi = s.index('\\', i)
), except ValueError:
(True, True): onigurumacffi.OnigSearchOption.NOT_END_STRING, 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: class _Reg:
def __init__(self, s: str) -> None: def __init__(self, s: str) -> None:
self._pattern = s self._pattern = s
self._reg = onigurumacffi.compile(self._pattern)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'{type(self).__name__}({self._pattern!r})' 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( def search(
self, self,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> Match[str] | None: ) -> Optional[Match[str]]:
return self._reg.search(line, pos, flags=_FLAGS[first_line, boundary]) return self._get_reg(first_line, boundary).search(line, pos)
def match( def match(
self, self,
@@ -51,27 +95,54 @@ class _Reg:
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> Match[str] | None: ) -> Optional[Match[str]]:
return self._reg.match(line, pos, flags=_FLAGS[first_line, boundary]) return self._get_reg(first_line, boundary).match(line, pos)
class _RegSet: class _RegSet:
def __init__(self, *s: str) -> None: def __init__(self, *s: str) -> None:
self._patterns = s self._patterns = s
self._set = onigurumacffi.compile_regset(*self._patterns)
def __repr__(self) -> str: def __repr__(self) -> str:
args = ', '.join(repr(s) for s in self._patterns) args = ', '.join(repr(s) for s in self._patterns)
return f'{type(self).__name__}({args})' 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( def search(
self, self,
line: str, line: str,
pos: int, pos: int,
first_line: bool, first_line: bool,
boundary: bool, boundary: bool,
) -> tuple[int, Match[str] | None]: ) -> Tuple[int, Optional[Match[str]]]:
return self._set.search(line, pos, flags=_FLAGS[first_line, boundary]) 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: 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_reg = functools.lru_cache(maxsize=None)(_Reg)
make_regset = functools.lru_cache(maxsize=None)(_RegSet) make_regset = functools.lru_cache(maxsize=None)(_RegSet)
ERR_REG = make_reg('$ ^') ERR_REG = make_reg(')this pattern always triggers an error when used(')

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import contextlib import contextlib
import curses import curses
import enum import enum
@@ -7,11 +5,14 @@ import hashlib
import os import os
import re import re
import signal import signal
import sre_parse
import sys import sys
from typing import Generator from typing import Generator
from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Pattern from typing import Pattern
from typing import Tuple
from typing import Union
from babi.color_manager import ColorManager from babi.color_manager import ColorManager
from babi.file import Action from babi.file import Action
@@ -37,8 +38,6 @@ EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
SEQUENCE_KEYNAME = { SEQUENCE_KEYNAME = {
'\x1bOH': b'KEY_HOME', '\x1bOH': b'KEY_HOME',
'\x1bOF': b'KEY_END', '\x1bOF': b'KEY_END',
'\x1b[1~': b'KEY_HOME',
'\x1b[4~': b'KEY_END',
'\x1b[1;2A': b'KEY_SR', '\x1b[1;2A': b'KEY_SR',
'\x1b[1;2B': b'KEY_SF', '\x1b[1;2B': b'KEY_SF',
'\x1b[1;2C': b'KEY_SRIGHT', '\x1b[1;2C': b'KEY_SRIGHT',
@@ -61,7 +60,6 @@ SEQUENCE_KEYNAME = {
'\x1b[1;6D': b'kLFT6', # Shift + ^Left '\x1b[1;6D': b'kLFT6', # Shift + ^Left
'\x1b[1;6H': b'kHOM6', # Shift + ^Home '\x1b[1;6H': b'kHOM6', # Shift + ^Home
'\x1b[1;6F': b'kEND6', # Shift + ^End '\x1b[1;6F': b'kEND6', # Shift + ^End
'\x1b[~': b'KEY_BTAB', # Shift + Tab
} }
KEYNAME_REWRITE = { KEYNAME_REWRITE = {
# windows-curses: numeric pad arrow keys # windows-curses: numeric pad arrow keys
@@ -83,11 +81,8 @@ KEYNAME_REWRITE = {
b'CTL_DOWN': b'kDN5', b'CTL_DOWN': b'kDN5',
b'CTL_RIGHT': b'kRIT5', b'CTL_RIGHT': b'kRIT5',
b'CTL_LEFT': b'kLFT5', b'CTL_LEFT': b'kLFT5',
b'CTL_HOME': b'kHOM5',
b'CTL_END': b'kEND5',
b'ALT_RIGHT': b'kRIT3', b'ALT_RIGHT': b'kRIT3',
b'ALT_LEFT': b'kLFT3', b'ALT_LEFT': b'kLFT3',
b'ALT_E': b'M-e',
# windows-curses: idk why these are different # windows-curses: idk why these are different
b'KEY_SUP': b'KEY_SR', b'KEY_SUP': b'KEY_SR',
b'KEY_SDOWN': b'KEY_SF', b'KEY_SDOWN': b'KEY_SF',
@@ -95,39 +90,36 @@ KEYNAME_REWRITE = {
b'^?': b'KEY_BACKSPACE', b'^?': b'KEY_BACKSPACE',
# linux, perhaps others # linux, perhaps others
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
b'^D': b'KEY_DC',
b'PADENTER': b'^M', # Enter on numpad
} }
class Key(NamedTuple): class Key(NamedTuple):
wch: int | str wch: Union[int, str]
keyname: bytes keyname: bytes
class Screen: class Screen:
def __init__( def __init__(
self, self,
stdscr: curses._CursesWindow, stdscr: 'curses._CursesWindow',
filenames: list[str | None], filenames: List[Optional[str]],
initial_lines: list[int],
perf: Perf, perf: Perf,
) -> None: ) -> None:
self.stdscr = stdscr self.stdscr = stdscr
self.color_manager = ColorManager.make() self.color_manager = ColorManager.make()
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),) self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
self.files = [ self.files = [
File(filename, line, self.color_manager, self.hl_factories) File(filename, self.color_manager, self.hl_factories)
for filename, line in zip(filenames, initial_lines) for filename in filenames
] ]
self.i = 0 self.i = 0
self.history = History() self.history = History()
self.perf = perf self.perf = perf
self.status = Status() self.status = Status()
self.margin = Margin.from_current_screen() self.margin = Margin.from_current_screen()
self.cut_buffer: tuple[str, ...] = () self.cut_buffer: Tuple[str, ...] = ()
self.cut_selection = False self.cut_selection = False
self._buffered_input: int | str | None = None self._buffered_input: Union[int, str, None] = None
@property @property
def file(self) -> File: def file(self) -> File:
@@ -229,10 +221,7 @@ class Screen:
if self._buffered_input is not None: if self._buffered_input is not None:
wch, self._buffered_input = self._buffered_input, None wch, self._buffered_input = self._buffered_input, None
else: else:
try: wch = self.stdscr.get_wch()
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': if isinstance(wch, str) and wch == '\x1b':
wch = self._get_sequence(wch) wch = self._get_sequence(wch)
if len(wch) == 2: if len(wch) == 2:
@@ -270,9 +259,9 @@ class Screen:
def quick_prompt( def quick_prompt(
self, self,
prompt: str, prompt: str,
opt_strs: tuple[str, ...], opt_strs: Tuple[str, ...],
) -> str | PromptResult: ) -> Union[str, PromptResult]:
opts = {opt[0] for opt in opt_strs} opts = [opt[0] for opt in opt_strs]
while True: while True:
x = 0 x = 0
prompt_line = self.margin.lines - 1 prompt_line = self.margin.lines - 1
@@ -309,18 +298,18 @@ class Screen:
self.resize() self.resize()
elif key.keyname == b'^C': elif key.keyname == b'^C':
return self.status.cancelled() return self.status.cancelled()
elif isinstance(key.wch, str) and key.wch.lower() in opts: elif isinstance(key.wch, str) and key.wch in opts:
return key.wch.lower() return key.wch
def prompt( def prompt(
self, self,
prompt: str, prompt: str,
*, *,
allow_empty: bool = False, allow_empty: bool = False,
history: str | None = None, history: Optional[str] = None,
default_prev: bool = False, default_prev: bool = False,
default: str | None = None, default: Optional[str] = None,
) -> str | PromptResult: ) -> Union[str, PromptResult]:
default = default or '' default = default or ''
self.status.clear() self.status.clear()
if history is not None: if history is not None:
@@ -377,7 +366,7 @@ class Screen:
else: else:
self.file.uncut(self.cut_buffer, self.margin) 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) response = self.prompt(prompt, history='search', default_prev=True)
if response is PromptResult.CANCELLED: if response is PromptResult.CANCELLED:
return response return response
@@ -390,8 +379,8 @@ class Screen:
def _undo_redo( def _undo_redo(
self, self,
op: str, op: str,
from_stack: list[Action], from_stack: List[Action],
to_stack: list[Action], to_stack: List[Action],
) -> None: ) -> None:
if not from_stack: if not from_stack:
self.status.update(f'nothing to {op}!') self.status.update(f'nothing to {op}!')
@@ -420,18 +409,11 @@ class Screen:
'replace with', history='replace', allow_empty=True, 'replace with', history='replace', allow_empty=True,
) )
if response is not PromptResult.CANCELLED: if response is not PromptResult.CANCELLED:
try: self.file.replace(self, search_response, response)
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') response = self.prompt('', history='command')
if response is PromptResult.CANCELLED: if response == ':q':
pass
elif response == ':q':
return self.quit_save_modified() return self.quit_save_modified()
elif response == ':q!': elif response == ':q!':
return EditResult.EXIT return EditResult.EXIT
@@ -446,45 +428,11 @@ class Screen:
else: else:
self.file.sort(self.margin) self.file.sort(self.margin)
self.status.update('sorted!') self.status.update('sorted!')
elif response == ':sort!': elif response is not PromptResult.CANCELLED:
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:
self.status.update(f'invalid command: {response}') self.status.update(f'invalid command: {response}')
return None return None
def save(self) -> PromptResult | None: def save(self) -> Optional[PromptResult]:
self.file.finalize_previous_action() self.file.finalize_previous_action()
# TODO: make directories if they don't exist # TODO: make directories if they don't exist
@@ -498,28 +446,22 @@ class Screen:
else: else:
self.file.filename = filename self.file.filename = filename
if not os.path.isfile(self.file.filename): if os.path.isfile(self.file.filename):
sha256: str | None = None with open(self.file.filename, newline='') as f:
else:
with open(self.file.filename, encoding='UTF-8', newline='') as f:
*_, sha256 = get_lines(f) *_, sha256 = get_lines(f)
else:
sha256 = hashlib.sha256(b'').hexdigest()
contents = self.file.nl.join(self.file.buf) contents = self.file.nl.join(self.file.buf)
sha256_to_save = hashlib.sha256(contents.encode()).hexdigest() sha256_to_save = hashlib.sha256(contents.encode()).hexdigest()
# the file on disk is the same as when we opened it # the file on disk is the same as when we opened it
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)') self.status.update('(file changed on disk, not implemented)')
return PromptResult.CANCELLED return PromptResult.CANCELLED
try: with open(self.file.filename, 'w', newline='') as f:
with open( f.write(contents)
self.file.filename, 'w', encoding='UTF-8', 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.modified = False
self.file.sha256 = sha256_to_save self.file.sha256 = sha256_to_save
@@ -536,7 +478,7 @@ class Screen:
first = False first = False
return None return None
def save_filename(self) -> PromptResult | None: def save_filename(self) -> Optional[PromptResult]:
response = self.prompt('enter filename', default=self.file.filename) response = self.prompt('enter filename', default=self.file.filename)
if response is PromptResult.CANCELLED: if response is PromptResult.CANCELLED:
return PromptResult.CANCELLED return PromptResult.CANCELLED
@@ -544,16 +486,16 @@ class Screen:
self.file.filename = response self.file.filename = response
return self.save() return self.save()
def open_file(self) -> EditResult | None: def open_file(self) -> Optional[EditResult]:
response = self.prompt('enter filename', history='open') response = self.prompt('enter filename', history='open')
if response is not PromptResult.CANCELLED: 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) self.files.append(opened)
return EditResult.OPEN return EditResult.OPEN
else: else:
return None return None
def quit_save_modified(self) -> EditResult | None: def quit_save_modified(self) -> Optional[EditResult]:
if self.file.modified: if self.file.modified:
response = self.quick_prompt( response = self.quick_prompt(
'file is modified - save', ('yes', 'no'), 'file is modified - save', ('yes', 'no'),
@@ -571,13 +513,10 @@ class Screen:
return EditResult.EXIT return EditResult.EXIT
def background(self) -> None: def background(self) -> None:
if sys.platform == 'win32': # pragma: win32 cover curses.endwin()
self.status.update('cannot run babi in background on Windows') os.kill(os.getpid(), signal.SIGSTOP)
else: # pragma: win32 no cover self.stdscr = _init_screen()
curses.endwin() self.resize()
os.kill(os.getpid(), signal.SIGSTOP)
self.stdscr = _init_screen()
self.resize()
DISPATCH = { DISPATCH = {
b'KEY_RESIZE': resize, b'KEY_RESIZE': resize,
@@ -587,7 +526,6 @@ class Screen:
b'^U': uncut, b'^U': uncut,
b'M-u': undo, b'M-u': undo,
b'M-U': redo, b'M-U': redo,
b'M-e': redo,
b'^W': search, b'^W': search,
b'^\\': replace, b'^\\': replace,
b'^[': command, 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 # set the escape delay so curses does not pause waiting for sequences
if ( if sys.version_info >= (3, 9): # pragma: no cover
sys.version_info >= (3, 9) and
hasattr(curses, 'set_escdelay')
): # pragma: no cover
curses.set_escdelay(25) curses.set_escdelay(25)
else: # pragma: no cover else: # pragma: no cover
os.environ.setdefault('ESCDELAY', '25') os.environ.setdefault('ESCDELAY', '25')
@@ -627,7 +562,7 @@ def _init_screen() -> curses._CursesWindow:
@contextlib.contextmanager @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""" """essentially `curses.wrapper` but split out to implement ^Z"""
try: try:
yield _init_screen() yield _init_screen()

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import curses import curses
from babi.margin import Margin from babi.margin import Margin
@@ -18,7 +16,7 @@ class Status:
def clear(self) -> None: def clear(self) -> None:
self._status = '' 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: if margin.footer or self._status:
stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols) stdscr.insstr(margin.lines - 1, 0, ' ' * margin.cols)
if self._status: if self._status:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import argparse import argparse
from typing import Optional
from typing import Sequence from typing import Sequence
from babi.highlight import Compiler 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: if theme.default.bg is not None:
print('\x1b[48;2;{r};{g};{b}m'.format(**theme.default.bg._asdict())) 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): for line_idx, line in enumerate(f):
first_line = line_idx == 0 first_line = line_idx == 0
state, regions = highlight_line(compiler, state, line, first_line) 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 return 0
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--theme', default=xdg_config('theme.json')) parser.add_argument('--theme', default=xdg_config('theme.json'))
parser.add_argument('--grammar-dir', default=prefix_data('grammar_v1')) parser.add_argument('--grammar-dir', default=prefix_data('grammar_v1'))
parser.add_argument('filename') parser.add_argument('filename')
args = parser.parse_args(argv) args = parser.parse_args(argv)
with open(args.filename, encoding='UTF-8') as f: with open(args.filename) as f:
first_line = next(f, '') first_line = next(f, '')
theme = Theme.from_filename(args.theme) theme = Theme.from_filename(args.theme)
@@ -67,4 +66,4 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == '__main__': if __name__ == '__main__':
raise SystemExit(main()) exit(main())

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import functools import functools
import json import json
import os.path import os.path
from typing import Any from typing import Any
from typing import Dict
from typing import NamedTuple from typing import NamedTuple
from typing import Optional
from typing import Tuple
from babi._types import Protocol from babi._types import Protocol
from babi.color import Color from babi.color import Color
@@ -12,32 +13,32 @@ from babi.fdict import FDict
class Style(NamedTuple): class Style(NamedTuple):
fg: Color | None fg: Optional[Color]
bg: Color | None bg: Optional[Color]
b: bool b: bool
i: bool i: bool
u: bool u: bool
@classmethod @classmethod
def blank(cls) -> Style: def blank(cls) -> 'Style':
return cls(fg=None, bg=None, b=False, i=False, u=False) return cls(fg=None, bg=None, b=False, i=False, u=False)
class PartialStyle(NamedTuple): class PartialStyle(NamedTuple):
fg: Color | None = None fg: Optional[Color] = None
bg: Color | None = None bg: Optional[Color] = None
b: bool | None = None b: Optional[bool] = None
i: bool | None = None i: Optional[bool] = None
u: bool | None = 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: for attr in self._fields:
value = getattr(self, attr) value = getattr(self, attr)
if value is not None: if value is not None:
dct[attr] = value dct[attr] = value
@classmethod @classmethod
def from_dct(cls, dct: dict[str, Any]) -> PartialStyle: def from_dct(cls, dct: Dict[str, Any]) -> 'PartialStyle':
kv = cls()._asdict() kv = cls()._asdict()
if 'foreground' in dct: if 'foreground' in dct:
kv['fg'] = Color.parse(dct['foreground']) kv['fg'] = Color.parse(dct['foreground'])
@@ -56,7 +57,7 @@ class _TrieNode(Protocol):
@property @property
def style(self) -> PartialStyle: ... def style(self) -> PartialStyle: ...
@property @property
def children(self) -> FDict[str, _TrieNode]: ... def children(self) -> FDict[str, '_TrieNode']: ...
class TrieNode(NamedTuple): class TrieNode(NamedTuple):
@@ -64,7 +65,7 @@ class TrieNode(NamedTuple):
children: FDict[str, _TrieNode] children: FDict[str, _TrieNode]
@classmethod @classmethod
def from_dct(cls, dct: dict[str, Any]) -> _TrieNode: def from_dct(cls, dct: Dict[str, Any]) -> _TrieNode:
children = FDict({ children = FDict({
k: TrieNode.from_dct(v) for k, v in dct['children'].items() k: TrieNode.from_dct(v) for k, v in dct['children'].items()
}) })
@@ -76,7 +77,7 @@ class Theme(NamedTuple):
rules: _TrieNode rules: _TrieNode
@functools.lru_cache(maxsize=None) @functools.lru_cache(maxsize=None)
def select(self, scope: tuple[str, ...]) -> Style: def select(self, scope: Tuple[str, ...]) -> Style:
if not scope: if not scope:
return self.default return self.default
else: else:
@@ -91,7 +92,7 @@ class Theme(NamedTuple):
return Style(**style) return Style(**style)
@classmethod @classmethod
def from_dct(cls, data: dict[str, Any]) -> Theme: def from_dct(cls, data: Dict[str, Any]) -> 'Theme':
default = Style.blank()._asdict() default = Style.blank()._asdict()
for k in ('foreground', 'editor.foreground'): for k in ('foreground', 'editor.foreground'):
@@ -104,7 +105,7 @@ class Theme(NamedTuple):
default['bg'] = Color.parse(data['colors'][k]) default['bg'] = Color.parse(data['colors'][k])
break break
root: dict[str, Any] = {'children': {}} root: Dict[str, Any] = {'children': {}}
rules = data.get('tokenColors', []) + data.get('settings', []) rules = data.get('tokenColors', []) + data.get('settings', [])
for rule in rules: for rule in rules:
if 'scope' not in rule: if 'scope' not in rule:
@@ -138,13 +139,13 @@ class Theme(NamedTuple):
return cls(Style(**default), TrieNode.from_dct(root)) return cls(Style(**default), TrieNode.from_dct(root))
@classmethod @classmethod
def blank(cls) -> Theme: def blank(cls) -> 'Theme':
return cls(Style.blank(), TrieNode.from_dct({'children': {}})) return cls(Style.blank(), TrieNode.from_dct({'children': {}}))
@classmethod @classmethod
def from_filename(cls, filename: str) -> Theme: def from_filename(cls, filename: str) -> 'Theme':
if not os.path.exists(filename): if not os.path.exists(filename):
return cls.blank() return cls.blank()
else: else:
with open(filename, encoding='UTF-8') as f: with open(filename) as f:
return cls.from_dct(json.load(f)) return cls.from_dct(json.load(f))

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import os.path import os.path
import sys import sys

View File

@@ -1,6 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
import argparse import argparse
import io import io
import json import json
@@ -87,4 +85,4 @@ def main() -> int:
if __name__ == '__main__': if __name__ == '__main__':
raise SystemExit(main()) exit(main())

View File

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

View File

@@ -1,6 +1,6 @@
[metadata] [metadata]
name = babi name = babi
version = 0.0.24 version = 0.0.7
description = a text editor description = a text editor
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown
@@ -13,10 +13,9 @@ classifiers =
License :: OSI Approved :: MIT License License :: OSI Approved :: MIT License
Programming Language :: Python :: 3 Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy Programming Language :: Python :: Implementation :: PyPy
@@ -25,21 +24,21 @@ packages = find:
install_requires = install_requires =
babi-grammars babi-grammars
identify identify
onigurumacffi>=0.0.18 onigurumacffi>=0.0.10
importlib-metadata>=1;python_version<"3.8" importlib_metadata>=1;python_version<"3.8"
windows-curses;sys_platform=="win32" windows-curses;sys_platform=="win32"
python_requires = >=3.7 python_requires = >=3.6.1
[options.packages.find]
exclude =
tests*
testing*
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
babi = babi.main:main babi = babi.main:main
babi-textmate-demo = babi.textmate_demo:main babi-textmate-demo = babi.textmate_demo:main
[options.packages.find]
exclude =
tests*
testing*
[bdist_wheel] [bdist_wheel]
universal = True universal = True
@@ -53,8 +52,6 @@ disallow_any_generics = true
disallow_incomplete_defs = true disallow_incomplete_defs = true
disallow_untyped_defs = true disallow_untyped_defs = true
no_implicit_optional = true no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
[mypy-testing.*] [mypy-testing.*]
disallow_untyped_defs = false disallow_untyped_defs = false

View File

@@ -1,4 +1,2 @@
from __future__ import annotations
from setuptools import setup from setuptools import setup
setup() setup()

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
import contextlib import contextlib
import curses import curses
import enum import enum
import re import re
from typing import List
from typing import Tuple
from hecate import Runner from hecate import Runner
@@ -34,7 +34,7 @@ def to_attrs(screen, width):
fg = bg = -1 fg = bg = -1
attr = 0 attr = 0
idx = 0 idx = 0
ret: list[list[tuple[int, int, int]]] ret: List[List[Tuple[int, int, int]]]
ret = [[] for _ in range(len(screen.splitlines()))] ret = [[] for _ in range(len(screen.splitlines()))]
for tp, match in tokenize_colors(screen): for tp, match in tokenize_colors(screen):

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.buf import Buf from babi.buf import Buf

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from babi import color_kd from babi import color_kd
from babi.color import Color from babi.color import Color

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.color import Color from babi.color import Color

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.color import Color from babi.color import Color

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import json import json
import pytest import pytest

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.fdict import FChainMap from babi.fdict import FChainMap

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit

View File

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

View File

@@ -1,13 +1,15 @@
from __future__ import annotations
import contextlib import contextlib
import curses import curses
import os import os
import sys import sys
from typing import List
from typing import NamedTuple from typing import NamedTuple
from typing import Tuple
from typing import Union
from unittest import mock from unittest import mock
import pytest import pytest
import wcwidth
from babi._types import Protocol from babi._types import Protocol
from babi.main import main from babi.main import main
@@ -70,7 +72,7 @@ class Screen:
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):] self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
self.y = y self.y = y
self.x = x + len(s) self.x = x + wcwidth.wcswidth(s)
def insstr(self, y, x, s, attr): def insstr(self, y, x, s, attr):
line = self.lines[y] line = self.lines[y]
@@ -147,7 +149,7 @@ class AssertScreenLineEquals(NamedTuple):
class AssertScreenAttrEquals(NamedTuple): class AssertScreenAttrEquals(NamedTuple):
n: int n: int
attr: list[tuple[int, int, int]] attr: List[Tuple[int, int, int]]
def __call__(self, screen: Screen) -> None: def __call__(self, screen: Screen) -> None:
assert screen.attrs[self.n] == self.attr assert screen.attrs[self.n] == self.attr
@@ -169,7 +171,7 @@ class Resize(NamedTuple):
class KeyPress(NamedTuple): class KeyPress(NamedTuple):
wch: int | str wch: Union[int, str]
def __call__(self, screen: Screen) -> None: def __call__(self, screen: Screen) -> None:
raise AssertionError('unreachable') raise AssertionError('unreachable')
@@ -235,7 +237,7 @@ class CursesScreen:
class Key(NamedTuple): class Key(NamedTuple):
tmux: str tmux: str
curses: bytes curses: bytes
wch: int | str wch: Union[int, str]
@property @property
def value(self) -> int: def value(self) -> int:
@@ -289,7 +291,6 @@ KEYS = [
Key('^_', b'^_', '\x1f'), Key('^_', b'^_', '\x1f'),
Key('^\\', b'^\\', '\x1c'), Key('^\\', b'^\\', '\x1c'),
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE), Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
Key('^D', b'^D', '\x04'),
] ]
KEYS_TMUX = {k.tmux: k.wch for k in KEYS} KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
KEYS_CURSES = {k.value: k.curses 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'): def __init__(self, command, width=80, height=24, term='screen'):
self.command = command self.command = command
self._i = 0 self._i = 0
self._ops: list[Op] = [] self._ops: List[Op] = []
self.color_pairs = {0: (7, 0)} self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height) self.screen = Screen(width, height)
self._n_colors, self._can_change_color = { self._n_colors, self._can_change_color = {
'xterm-mono': (0, False),
'screen': (8, False), 'screen': (8, False),
'screen-256color': (256, False), 'screen-256color': (256, False),
'xterm-256color': (256, True), 'xterm-256color': (256, True),
@@ -392,7 +392,6 @@ class DeferredRunner:
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop _curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _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 _curses_error = curses.error # so we don't mock the exception

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit

View File

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

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,7 +1,4 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit
from testing.runner import trigger_command_mode
def test_indent_at_beginning_of_line(run): 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): def test_indent_not_full_tab(run):
with run() as h, and_exit(h): with run() as h, and_exit(h):
h.press('hello') h.press('h')
h.press('Home')
h.press('Right')
h.press('Tab') h.press('Tab')
h.press('ello')
h.await_text('h 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): def test_indent_fixes_eof(run):
@@ -90,20 +86,6 @@ def test_dedent_selection(run, tmpdir):
h.await_text('\n1\n2\n 3\n') 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): def test_dedent_beginning_of_line(run, tmpdir):
f = tmpdir.join('f') f = tmpdir.join('f')
f.write(' hi\n') f.write(' hi\n')

View File

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

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import curses import curses
from babi.screen import VERSION_STR from babi.screen import VERSION_STR

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit
@@ -417,10 +415,18 @@ def test_sequence_handling(run_only_fake):
def test_indentation_using_tabs(run, tmpdir): def test_indentation_using_tabs(run, tmpdir):
f = tmpdir.join('f') 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): 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.press('Down')
h.await_cursor_position(x=0, y=2) 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.await_cursor_position(x=4, y=2)
h.press('Up') h.press('Up')
h.await_cursor_position(x=4, y=1) h.await_cursor_position(x=4, y=1)
def test_movement_with_wide_characters(run, tmpdir):
f = tmpdir.join('f')
f.write(
f'{"🙃" * 20}\n'
f'a{"🙃" * 20}\n',
)
with run(str(f), width=20) as h, and_exit(h):
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
for _ in range(10):
h.press('Right')
h.await_text(
'««🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
for _ in range(6):
h.press('Right')
h.await_text(
'««🙃🙃🙃🙃🙃🙃🙃\n'
'a🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)
h.press('Down')
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'«🙃🙃🙃🙃🙃🙃🙃🙃\n',
)
h.press('Left')
h.await_text(
'🙃🙃🙃🙃🙃🙃🙃🙃🙃»»\n'
'«🙃🙃🙃🙃🙃🙃🙃🙃🙃»\n',
)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit
@@ -22,16 +20,6 @@ def test_replace_invalid_regex(run):
h.await_text("invalid regex: '('") 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): def test_replace_cancel_at_replace_string(run):
with run() as h, and_exit(h): with run() as h, and_exit(h):
h.press('^\\') h.press('^\\')
@@ -42,8 +30,7 @@ def test_replace_cancel_at_replace_string(run):
h.await_text('cancelled') h.await_text('cancelled')
@pytest.mark.parametrize('key', ('y', 'Y')) def test_replace_actual_contents(run, ten_lines):
def test_replace_actual_contents(run, ten_lines, key):
with run(str(ten_lines)) as h, and_exit(h): with run(str(ten_lines)) as h, and_exit(h):
h.press('^\\') h.press('^\\')
h.await_text('search (to replace):') 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.await_text('replace with:')
h.press_and_enter('ohai') h.press_and_enter('ohai')
h.await_text('replace [yes, no, all]?') h.await_text('replace [yes, no, all]?')
h.press(key) h.press('y')
h.await_text_missing('line_0') h.await_text_missing('line_0')
h.await_text('ohai') h.await_text('ohai')
h.await_text(' *') h.await_text(' *')

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from babi.screen import VERSION_STR from babi.screen import VERSION_STR
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit 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' 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): def test_save_via_ctrl_o(run, tmpdir):
f = tmpdir.join('f') f = tmpdir.join('f')
with run(str(f)) as h, and_exit(h): with run(str(f)) as h, and_exit(h):
h.press('hello world') h.press('hello world')
h.press('^O') h.press('^O')
h.await_text('enter filename: ') h.await_text(f'enter filename: ')
h.press('Enter') h.press('Enter')
h.await_text('saved! (1 line written)') h.await_text('saved! (1 line written)')
assert f.read() == 'hello world\n' 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' 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')) @pytest.mark.parametrize('key', ('^C', 'Enter'))
def test_save_via_ctrl_o_cancelled(run, key): def test_save_via_ctrl_o_cancelled(run, key):
with run() as h, and_exit(h): 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.press_and_enter(':q')
h.await_text('file is modified - save [yes, no]?') h.await_text('file is modified - save [yes, no]?')
h.press('y') h.press('y')
h.await_text('enter filename: ') h.await_text(f'enter filename: ')
h.press('Enter') h.press('Enter')
h.await_exit() h.await_exit()

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit 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' 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): def test_sort_selection(run, unsorted):
with run(str(unsorted)) as h, and_exit(h): with run(str(unsorted)) as h, and_exit(h):
h.press('S-Down') h.press('S-Down')
@@ -44,18 +32,6 @@ def test_sort_selection(run, unsorted):
assert unsorted.read() == 'b\nd\nc\na\n' 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): def test_sort_selection_does_not_include_eof(run, unsorted):
with run(str(unsorted)) as h, and_exit(h): with run(str(unsorted)) as h, and_exit(h):
for _ in range(5): for _ in range(5):

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import shlex import shlex
import sys import sys

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import shlex import shlex
import sys import sys

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import curses import curses
import json import json
@@ -155,8 +153,3 @@ def test_syntax_highlighting_tabs_after_line_creation(run, tmpdir):
h.press('Enter') h.press('Enter')
h.await_text('foo\n x\nx\ny\n') 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

View File

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

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from testing.runner import and_exit 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('*') 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')) @pytest.mark.parametrize('key', ('BSpace', '^H'))
def test_backspace_deletes_text(run, tmpdir, key): def test_backspace_deletes_text(run, tmpdir, key):
f = tmpdir.join('f') f = tmpdir.join('f')
@@ -86,15 +72,14 @@ def test_delete_at_end_of_file(run, tmpdir):
h.await_text_missing('*') h.await_text_missing('*')
@pytest.mark.parametrize('key', ('DC', '^D')) def test_delete_removes_character_afterwards(run, tmpdir):
def test_delete_removes_character_afterwards(run, tmpdir, key):
f = tmpdir.join('f') f = tmpdir.join('f')
f.write('hello world') f.write('hello world')
with run(str(f)) as h, and_exit(h): with run(str(f)) as h, and_exit(h):
h.await_text('hello world') h.await_text('hello world')
h.press('Right') h.press('Right')
h.press(key) h.press('DC')
h.await_text('hllo world') h.await_text('hllo world')
h.await_text('f *') h.await_text('f *')

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import curses import curses
from testing.runner import and_exit from testing.runner import and_exit

View File

@@ -1,7 +1,3 @@
from __future__ import annotations
import pytest
from testing.runner import and_exit from testing.runner import and_exit
@@ -13,8 +9,7 @@ def test_nothing_to_undo_redo(run):
h.await_text('nothing to redo!') h.await_text('nothing to redo!')
@pytest.mark.parametrize('r', ('M-U', 'M-e')) def test_undo_redo(run):
def test_undo_redo(run, r):
with run() as h, and_exit(h): with run() as h, and_exit(h):
h.press('hello') h.press('hello')
h.await_text('hello') h.await_text('hello')
@@ -22,7 +17,7 @@ def test_undo_redo(run, r):
h.await_text('undo: text') h.await_text('undo: text')
h.await_text_missing('hello') h.await_text_missing('hello')
h.await_text_missing(' *') h.await_text_missing(' *')
h.press(r) h.press('M-U')
h.await_text('redo: text') h.await_text('redo: text')
h.await_text('hello') h.await_text('hello')
h.await_text(' *') h.await_text(' *')

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import io import io
import pytest import pytest
@@ -10,7 +8,7 @@ from babi.file import get_lines
def test_position_repr(): def test_position_repr():
ret = repr(File('f.txt', 0, ColorManager.make(), ())) ret = repr(File('f.txt', ColorManager.make(), ()))
assert ret == "<File 'f.txt'>" assert ret == "<File 'f.txt'>"

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.highlight import highlight_line from babi.highlight import highlight_line
@@ -639,25 +637,3 @@ def test_backslash_z(compiler_state):
assert regions2 == ( assert regions2 == (
Region(0, 6, ('test', 'comment')), 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',)),
)

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import contextlib import contextlib
import curses import curses
from unittest import mock from unittest import mock

View File

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

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import onigurumacffi import onigurumacffi
import pytest import pytest
@@ -37,8 +35,9 @@ def test_reg_other_escapes_left_untouched():
def test_reg_not_out_of_bounds_at_end(): def test_reg_not_out_of_bounds_at_end():
# the only way this is triggerable is with an illegal regex, we'd rather # 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 # produce an error about the regex being wrong than an IndexError
reg = _Reg('\\A\\')
with pytest.raises(onigurumacffi.OnigError) as excinfo: with pytest.raises(onigurumacffi.OnigError) as excinfo:
_Reg('\\A\\') reg.search('\\', 0, first_line=False, boundary=False)
msg, = excinfo.value.args msg, = excinfo.value.args
assert msg == 'end pattern at escape' assert msg == 'end pattern at escape'

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import json import json
import pytest import pytest

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import pytest import pytest
from babi.color import Color from babi.color import Color

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import os import os
from unittest import mock from unittest import mock

View File

@@ -1,5 +1,5 @@
[tox] [tox]
envlist = py37,pre-commit envlist = py36,py37,pre-commit
[testenv] [testenv]
deps = -rrequirements-dev.txt deps = -rrequirements-dev.txt