Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d4fa1a07 | ||
|
|
c186adcc6c | ||
|
|
bdf07b8cb3 | ||
|
|
bf1c3d1ee1 | ||
|
|
f1772ec829 | ||
|
|
84b489bb9b | ||
|
|
175fd61119 | ||
|
|
01bb6d91b9 | ||
|
|
ffd5c87118 | ||
|
|
87f3e32f36 | ||
|
|
d20be693d2 | ||
|
|
d826b8b472 | ||
|
|
25173c5dca | ||
|
|
b2ebfa7b48 | ||
|
|
efa6561200 | ||
|
|
b683657f23 | ||
|
|
b59d03858c | ||
|
|
6ec1da061b | ||
|
|
c08557b6ca | ||
|
|
006c2bc8e4 | ||
|
|
080f6e1d54 | ||
|
|
e77a660029 | ||
|
|
e32e5b8c05 | ||
|
|
08638f990c | ||
|
|
414adffa9b | ||
|
|
8d77d5792a | ||
|
|
c85c50c207 | ||
|
|
d5376ca6f2 | ||
|
|
31e7c9345b | ||
|
|
41543f8d6c | ||
|
|
1be4e80edd |
@@ -20,12 +20,12 @@ repos:
|
||||
hooks:
|
||||
- id: autopep8
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v1.9.0
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: [--py3-plus]
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v1.5.0
|
||||
rev: v2.0.1
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
args: [--py36-plus]
|
||||
@@ -34,6 +34,10 @@ repos:
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.7.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.770
|
||||
hooks:
|
||||
|
||||
@@ -23,6 +23,7 @@ these are all of the current key bindings in babi
|
||||
- <kbd>^S</kbd>: save
|
||||
- <kbd>^O</kbd>: save as
|
||||
- <kbd>^X</kbd>: quit
|
||||
- <kbd>^P</kbd>: open file
|
||||
- arrow keys: movement
|
||||
- <kbd>^A</kbd> / <kbd>home</kbd>: move to beginning of line
|
||||
- <kbd>^E</kbd> / <kbd>end</kbd>: move to end of line
|
||||
@@ -65,7 +66,8 @@ the syntax highlighting setup is a bit manual right now
|
||||
1. from a clone of babi, run `./bin/download-syntax` -- you will likely need
|
||||
to install some additional packages to download them (`pip install cson`)
|
||||
2. find a visual studio code theme, convert it to json (if it is not already
|
||||
json) and put it at `~/.config/babi/theme.json`
|
||||
json) and put it at `~/.config/babi/theme.json`. a helper script is
|
||||
provided to make this easier: `./bin/download-theme NAME URL`
|
||||
|
||||
## demos
|
||||
|
||||
|
||||
@@ -2,7 +2,5 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Protocol # python3.8+
|
||||
from typing_extensions import TypedDict # python3.8+
|
||||
else:
|
||||
Protocol = object
|
||||
TypedDict = dict
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
# TODO: find a standard which defines these
|
||||
# limited number of "named" colors
|
||||
NAMED_COLORS = {'white': '#ffffff', 'black': '#000000'}
|
||||
|
||||
|
||||
class Color(NamedTuple):
|
||||
r: int
|
||||
@@ -8,4 +12,9 @@ class Color(NamedTuple):
|
||||
|
||||
@classmethod
|
||||
def parse(cls, s: str) -> 'Color':
|
||||
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
||||
if s.startswith('#') and len(s) >= 7:
|
||||
return cls(r=int(s[1:3], 16), g=int(s[3:5], 16), b=int(s[5:7], 16))
|
||||
elif s.startswith('#'):
|
||||
return cls.parse(f'#{s[1] * 2}{s[2] * 2}{s[3] * 2}')
|
||||
else:
|
||||
return cls.parse(NAMED_COLORS[s])
|
||||
|
||||
@@ -19,19 +19,16 @@ class ColorManager(NamedTuple):
|
||||
raw_pairs: Dict[Tuple[int, int], int]
|
||||
|
||||
def init_color(self, color: Color) -> None:
|
||||
if curses.COLORS < 256:
|
||||
return
|
||||
elif curses.can_change_color():
|
||||
if curses.can_change_color():
|
||||
n = min(self.colors.values(), default=256) - 1
|
||||
self.colors[color] = n
|
||||
curses.init_color(n, *_color_to_curses(color))
|
||||
else:
|
||||
elif curses.COLORS >= 256:
|
||||
self.colors[color] = color_kd.nearest(color, color_kd.make_256())
|
||||
else:
|
||||
self.colors[color] = -1
|
||||
|
||||
def color_pair(self, fg: Optional[Color], bg: Optional[Color]) -> int:
|
||||
if curses.COLORS < 256:
|
||||
return 0
|
||||
|
||||
fg_i = self.colors[fg] if fg is not None else -1
|
||||
bg_i = self.colors[bg] if bg is not None else -1
|
||||
return self.raw_color_pair(fg_i, bg_i)
|
||||
|
||||
199
babi/file.py
199
babi/file.py
@@ -21,8 +21,12 @@ from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import FileHL
|
||||
from babi.hl.interface import HLFactory
|
||||
from babi.hl.replace import Replace
|
||||
from babi.hl.selection import Selection
|
||||
from babi.hl.trailing_whitespace import TrailingWhitespace
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.list_spy import ListSpy
|
||||
@@ -138,7 +142,7 @@ def clear_selection(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def clear_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
ret = func(self, *args, **kwargs)
|
||||
self.select_start = None
|
||||
self.selection.clear()
|
||||
return ret
|
||||
return cast(TCallable, clear_selection_inner)
|
||||
|
||||
@@ -209,6 +213,7 @@ class File:
|
||||
def __init__(
|
||||
self,
|
||||
filename: Optional[str],
|
||||
color_manager: ColorManager,
|
||||
hl_factories: Tuple[HLFactory, ...],
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
@@ -219,15 +224,23 @@ class File:
|
||||
self.sha256: Optional[str] = None
|
||||
self.undo_stack: List[Action] = []
|
||||
self.redo_stack: List[Action] = []
|
||||
self.select_start: Optional[Tuple[int, int]] = None
|
||||
self._hl_factories = hl_factories
|
||||
self._trailing_whitespace = TrailingWhitespace(color_manager)
|
||||
self._replace_hl = Replace()
|
||||
self.selection = Selection()
|
||||
self._file_hls: Tuple[FileHL, ...] = ()
|
||||
|
||||
def ensure_loaded(self, status: Status) -> None:
|
||||
def ensure_loaded(self, status: Status, stdin: str) -> None:
|
||||
if self.lines:
|
||||
return
|
||||
|
||||
if self.filename is not None and os.path.isfile(self.filename):
|
||||
if self.filename == '-':
|
||||
status.update('(from stdin)')
|
||||
self.filename = None
|
||||
self.modified = True
|
||||
sio = io.StringIO(stdin)
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
elif self.filename is not None and os.path.isfile(self.filename):
|
||||
with open(self.filename, newline='') as f:
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(f)
|
||||
else:
|
||||
@@ -247,11 +260,14 @@ class File:
|
||||
file_hls = []
|
||||
for factory in self._hl_factories:
|
||||
if self.filename is not None:
|
||||
# TODO: this does an extra read
|
||||
file_hls.append(factory.get_file_highlighter(self.filename))
|
||||
hl = factory.file_highlighter(self.filename, self.lines[0])
|
||||
file_hls.append(hl)
|
||||
else:
|
||||
file_hls.append(factory.get_blank_file_highlighter())
|
||||
self._file_hls = tuple(file_hls)
|
||||
file_hls.append(factory.blank_file_highlighter())
|
||||
self._file_hls = (
|
||||
*file_hls,
|
||||
self._trailing_whitespace, self._replace_hl, self.selection,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<{type(self).__name__} {self.filename!r}>'
|
||||
@@ -434,13 +450,6 @@ class File:
|
||||
) -> None:
|
||||
self.finalize_previous_action()
|
||||
|
||||
def highlight() -> None:
|
||||
self.highlight(
|
||||
screen.stdscr, screen.margin,
|
||||
y=self.y, x=self.x, n=len(match[0]),
|
||||
color=HIGHLIGHT, include_edge=True,
|
||||
)
|
||||
|
||||
count = 0
|
||||
res: Union[str, PromptResult] = ''
|
||||
search = _SearchIter(self, reg, offset=0)
|
||||
@@ -449,12 +458,9 @@ class File:
|
||||
self.x = self.x_hint = match.start()
|
||||
self.scroll_screen_if_needed(screen.margin)
|
||||
if res != 'a': # make `a` replace the rest of them
|
||||
screen.draw()
|
||||
highlight()
|
||||
with screen.resize_cb(highlight):
|
||||
res = screen.quick_prompt(
|
||||
'replace [y(es), n(o), a(ll)]?', 'yna',
|
||||
)
|
||||
with self._replace_hl.region(self.y, self.x, match.end()):
|
||||
screen.draw()
|
||||
res = screen.quick_prompt('replace', ('yes', 'no', 'all'))
|
||||
if res in {'y', 'a'}:
|
||||
count += 1
|
||||
with self.edit_action_context('replace', final=True):
|
||||
@@ -543,16 +549,17 @@ class File:
|
||||
|
||||
@edit_action('indent selection', final=True)
|
||||
def _indent_selection(self, margin: Margin) -> None:
|
||||
assert self.select_start is not None
|
||||
sel_y, sel_x = self.select_start
|
||||
(s_y, _), (e_y, _) = self._get_selection()
|
||||
assert self.selection.start is not None
|
||||
sel_y, sel_x = self.selection.start
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
if self.lines[l_y]:
|
||||
self.lines[l_y] = ' ' * 4 + self.lines[l_y]
|
||||
if l_y == sel_y and sel_x != 0:
|
||||
self.select_start = (sel_y, sel_x + 4)
|
||||
if l_y == self.y:
|
||||
self.x = self.x_hint = self.x + 4
|
||||
if l_y == sel_y and sel_x != 0:
|
||||
sel_x += 4
|
||||
self.selection.set(sel_y, sel_x, self.y, self.x)
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
@@ -563,7 +570,7 @@ class File:
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
def tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
if self.selection.start is not None:
|
||||
self._indent_selection(margin)
|
||||
else:
|
||||
self._tab(margin)
|
||||
@@ -578,17 +585,18 @@ class File:
|
||||
|
||||
@edit_action('dedent selection', final=True)
|
||||
def _dedent_selection(self, margin: Margin) -> None:
|
||||
assert self.select_start is not None
|
||||
sel_y, sel_x = self.select_start
|
||||
(s_y, _), (e_y, _) = self._get_selection()
|
||||
assert self.selection.start is not None
|
||||
sel_y, sel_x = self.selection.start
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
n = self._dedent_line(self.lines[l_y])
|
||||
if n:
|
||||
self.lines[l_y] = self.lines[l_y][n:]
|
||||
if l_y == sel_y:
|
||||
self.select_start = (sel_y, max(sel_x - n, 0))
|
||||
if l_y == self.y:
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
if l_y == sel_y:
|
||||
sel_x = max(sel_x - n, 0)
|
||||
self.selection.set(sel_y, sel_x, self.y, self.x)
|
||||
|
||||
@edit_action('dedent', final=True)
|
||||
def _dedent(self, margin: Margin) -> None:
|
||||
@@ -598,7 +606,7 @@ class File:
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
|
||||
def shift_tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
if self.selection.start is not None:
|
||||
self._dedent_selection(margin)
|
||||
else:
|
||||
self._dedent(margin)
|
||||
@@ -607,7 +615,7 @@ class File:
|
||||
@clear_selection
|
||||
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
|
||||
ret = []
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
(s_y, s_x), (e_y, e_x) = self.selection.get()
|
||||
if s_y == e_y:
|
||||
ret.append(self.lines[s_y][s_x:e_x])
|
||||
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[s_y][e_x:]
|
||||
@@ -680,7 +688,7 @@ class File:
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin) -> None:
|
||||
(s_y, _), (e_y, _) = self._get_selection()
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
e_y = min(e_y + 1, len(self.lines) - 1)
|
||||
if self.lines[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
@@ -738,7 +746,7 @@ class File:
|
||||
|
||||
def finalize_previous_action(self) -> None:
|
||||
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
|
||||
self.select_start = None
|
||||
self.selection.clear()
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
|
||||
@@ -791,14 +799,14 @@ class File:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def select(self) -> Generator[None, None, None]:
|
||||
if self.select_start is None:
|
||||
select_start = (self.y, self.x)
|
||||
if self.selection.start is None:
|
||||
start = (self.y, self.x)
|
||||
else:
|
||||
select_start = self.select_start
|
||||
start = self.selection.start
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.select_start = select_start
|
||||
self.selection.set(*start, self.y, self.x)
|
||||
|
||||
# positioning
|
||||
|
||||
@@ -815,95 +823,50 @@ class File:
|
||||
) -> None:
|
||||
stdscr.move(self.rendered_y(margin), self.rendered_x())
|
||||
|
||||
def _get_selection(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
||||
assert self.select_start is not None
|
||||
select_end = (self.y, self.x)
|
||||
if select_end < self.select_start:
|
||||
return select_end, self.select_start
|
||||
else:
|
||||
return self.select_start, select_end
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.touch(lineno)
|
||||
|
||||
def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None:
|
||||
to_display = min(len(self.lines) - self.file_y, margin.body_lines)
|
||||
for i in range(to_display):
|
||||
line_idx = self.file_y + i
|
||||
line = self.lines[line_idx]
|
||||
x = self.x if line_idx == self.y else 0
|
||||
line = scrolled_line(line, x, curses.COLS)
|
||||
stdscr.insstr(i + margin.header, 0, line)
|
||||
for i in range(to_display, margin.body_lines):
|
||||
stdscr.move(i + margin.header, 0)
|
||||
stdscr.clrtoeol()
|
||||
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.highlight_until(self.lines, self.file_y + to_display)
|
||||
|
||||
for i in range(self.file_y, self.file_y + to_display):
|
||||
for i in range(to_display):
|
||||
draw_y = i + margin.header
|
||||
l_y = self.file_y + i
|
||||
x = self.x if l_y == self.y else 0
|
||||
line = scrolled_line(self.lines[l_y], x, curses.COLS)
|
||||
stdscr.insstr(draw_y, 0, line)
|
||||
|
||||
l_x = line_x(x, curses.COLS)
|
||||
l_x_max = l_x + curses.COLS
|
||||
for file_hl in self._file_hls:
|
||||
for region in file_hl.regions[i]:
|
||||
self.highlight(
|
||||
stdscr, margin,
|
||||
y=i, include_edge=False, **region,
|
||||
)
|
||||
for region in file_hl.regions[l_y]:
|
||||
if region.x >= l_x_max:
|
||||
break
|
||||
elif region.end < l_x:
|
||||
continue
|
||||
|
||||
if self.select_start is not None:
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
if l_x and region.x <= l_x:
|
||||
if file_hl.include_edge:
|
||||
h_s_x = 0
|
||||
else:
|
||||
h_s_x = 1
|
||||
else:
|
||||
h_s_x = region.x - l_x
|
||||
|
||||
if s_y == e_y:
|
||||
self.highlight(
|
||||
stdscr, margin,
|
||||
y=s_y, x=s_x, n=e_x - s_x,
|
||||
color=HIGHLIGHT, include_edge=True,
|
||||
)
|
||||
else:
|
||||
self.highlight(
|
||||
stdscr, margin,
|
||||
y=s_y, x=s_x, n=len(self.lines[s_y]) - s_x + 1,
|
||||
color=HIGHLIGHT, include_edge=True,
|
||||
)
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
self.highlight(
|
||||
stdscr, margin,
|
||||
y=l_y, x=0, n=len(self.lines[l_y]) + 1,
|
||||
color=HIGHLIGHT, include_edge=True,
|
||||
)
|
||||
self.highlight(
|
||||
stdscr, margin,
|
||||
y=e_y, x=0, n=e_x,
|
||||
color=HIGHLIGHT, include_edge=True,
|
||||
)
|
||||
if region.end >= l_x_max:
|
||||
if file_hl.include_edge:
|
||||
h_e_x = curses.COLS
|
||||
else:
|
||||
h_e_x = curses.COLS - 1
|
||||
else:
|
||||
h_e_x = region.end - l_x
|
||||
|
||||
def highlight(
|
||||
self,
|
||||
stdscr: 'curses._CursesWindow', margin: Margin,
|
||||
*,
|
||||
y: int, x: int, n: int, color: int,
|
||||
include_edge: bool,
|
||||
) -> None:
|
||||
h_y = y - self.file_y + margin.header
|
||||
if y == self.y:
|
||||
l_x = line_x(self.x, curses.COLS)
|
||||
# TODO: include edge left detection
|
||||
if x < l_x:
|
||||
h_x = 0
|
||||
n -= l_x - x
|
||||
else:
|
||||
h_x = x - l_x
|
||||
else:
|
||||
l_x = 0
|
||||
h_x = x
|
||||
if not include_edge and len(self.lines[y]) > l_x + curses.COLS:
|
||||
h_n = min(curses.COLS - h_x - 1, n)
|
||||
else:
|
||||
h_n = n
|
||||
if (
|
||||
h_y < margin.header or
|
||||
h_y > margin.header + margin.body_lines or
|
||||
h_x >= curses.COLS
|
||||
):
|
||||
return
|
||||
stdscr.chgat(h_y, h_x, h_n, color)
|
||||
stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr)
|
||||
|
||||
for i in range(to_display, margin.body_lines):
|
||||
stdscr.move(i + margin.header, 0)
|
||||
stdscr.clrtoeol()
|
||||
|
||||
@@ -4,26 +4,37 @@ import json
|
||||
import os.path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import FrozenSet
|
||||
from typing import List
|
||||
from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Tuple
|
||||
from typing import TypeVar
|
||||
|
||||
from identify.identify import tags_from_filename
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi.fdict import FDict
|
||||
from babi.reg import _Reg
|
||||
from babi.reg import _RegSet
|
||||
from babi.reg import ERR_REG
|
||||
from babi.reg import expand_escaped
|
||||
from babi.reg import make_reg
|
||||
from babi.reg import make_regset
|
||||
|
||||
T = TypeVar('T')
|
||||
Scope = Tuple[str, ...]
|
||||
Regions = Tuple['Region', ...]
|
||||
Captures = Tuple[Tuple[int, '_Rule'], ...]
|
||||
|
||||
|
||||
def uniquely_constructed(t: T) -> T:
|
||||
"""avoid tuple.__hash__ for "singleton" constructed objects"""
|
||||
t.__hash__ = object.__hash__ # type: ignore
|
||||
return t
|
||||
|
||||
|
||||
def _split_name(s: Optional[str]) -> Tuple[str, ...]:
|
||||
if s is None:
|
||||
return ()
|
||||
@@ -59,6 +70,7 @@ class _Rule(Protocol):
|
||||
def patterns(self) -> 'Tuple[_Rule, ...]': ...
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class Rule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
match: Optional[str]
|
||||
@@ -114,6 +126,10 @@ class Rule(NamedTuple):
|
||||
else:
|
||||
while_captures = ()
|
||||
|
||||
# some grammars (at least xml) have begin rules with no end
|
||||
if begin is not None and end is None and while_ is None:
|
||||
end = '$impossible^'
|
||||
|
||||
# Using the captures key for a begin/end/while rule is short-hand for
|
||||
# giving both beginCaptures and endCaptures with same values
|
||||
if begin and end and captures:
|
||||
@@ -146,24 +162,15 @@ class Rule(NamedTuple):
|
||||
)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class Grammar(NamedTuple):
|
||||
scope_name: str
|
||||
first_line_match: Optional[_Reg]
|
||||
file_types: FrozenSet[str]
|
||||
patterns: Tuple[_Rule, ...]
|
||||
repository: FDict[str, _Rule]
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'Grammar':
|
||||
scope_name = data['scopeName']
|
||||
if 'firstLineMatch' in data:
|
||||
first_line_match: Optional[_Reg] = make_reg(data['firstLineMatch'])
|
||||
else:
|
||||
first_line_match = None
|
||||
if 'fileTypes' in data:
|
||||
file_types = frozenset(data['fileTypes'])
|
||||
else:
|
||||
file_types = frozenset()
|
||||
patterns = tuple(Rule.from_dct(dct) for dct in data['patterns'])
|
||||
if 'repository' in data:
|
||||
repository = FDict({
|
||||
@@ -173,40 +180,10 @@ class Grammar(NamedTuple):
|
||||
repository = FDict({})
|
||||
return cls(
|
||||
scope_name=scope_name,
|
||||
first_line_match=first_line_match,
|
||||
file_types=file_types,
|
||||
patterns=patterns,
|
||||
repository=repository,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, filename: str) -> 'Grammar':
|
||||
with open(filename) as f:
|
||||
return cls.from_data(json.load(f))
|
||||
|
||||
@classmethod
|
||||
def blank(cls) -> 'Grammar':
|
||||
return cls(
|
||||
scope_name='source.unknown',
|
||||
first_line_match=None,
|
||||
file_types=frozenset(),
|
||||
patterns=(),
|
||||
repository=FDict({}),
|
||||
)
|
||||
|
||||
def matches_file(self, filename: str, first_line: str) -> bool:
|
||||
_, ext = os.path.splitext(filename)
|
||||
if ext.lstrip('.') in self.file_types:
|
||||
return True
|
||||
elif self.first_line_match is not None:
|
||||
return bool(
|
||||
self.first_line_match.match(
|
||||
first_line, 0, first_line=True, boundary=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class Region(NamedTuple):
|
||||
start: int
|
||||
@@ -369,6 +346,7 @@ def _do_regset(
|
||||
return state, match.end(), boundary, tuple(ret)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class PatternRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
regset: _RegSet
|
||||
@@ -395,6 +373,7 @@ class PatternRule(NamedTuple):
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class MatchRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
captures: Captures
|
||||
@@ -420,6 +399,7 @@ class MatchRule(NamedTuple):
|
||||
raise AssertionError(f'unreachable {self}')
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class EndRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
@@ -439,7 +419,7 @@ class EndRule(NamedTuple):
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(match.expand(self.end))
|
||||
reg = make_reg(expand_escaped(match, self.end))
|
||||
state = state.push(Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
@@ -480,6 +460,7 @@ class EndRule(NamedTuple):
|
||||
return _do_regset(idx, match, self, compiler, state, pos)
|
||||
|
||||
|
||||
@uniquely_constructed
|
||||
class WhileRule(NamedTuple):
|
||||
name: Tuple[str, ...]
|
||||
content_name: Tuple[str, ...]
|
||||
@@ -499,7 +480,7 @@ class WhileRule(NamedTuple):
|
||||
next_scope = scope + self.content_name
|
||||
|
||||
boundary = match.end() == len(match.string)
|
||||
reg = make_reg(match.expand(self.while_))
|
||||
reg = make_reg(expand_escaped(match, self.while_))
|
||||
state = state.push_while(self, Entry(next_scope, self, reg, boundary))
|
||||
regions = _captures(compiler, scope, match, self.begin_captures)
|
||||
return state, True, regions
|
||||
@@ -534,7 +515,7 @@ class WhileRule(NamedTuple):
|
||||
|
||||
|
||||
class Compiler:
|
||||
def __init__(self, grammar: Grammar, grammars: Dict[str, Grammar]) -> None:
|
||||
def __init__(self, grammar: Grammar, grammars: 'Grammars') -> None:
|
||||
self._root_scope = grammar.scope_name
|
||||
self._grammars = grammars
|
||||
self._rule_to_grammar: Dict[_Rule, Grammar] = {}
|
||||
@@ -555,14 +536,17 @@ class Compiler:
|
||||
if s == '$self':
|
||||
return self._patterns(grammar, grammar.patterns)
|
||||
elif s == '$base':
|
||||
return self._include(self._grammars[self._root_scope], '$self')
|
||||
grammar = self._grammars.grammar_for_scope(self._root_scope)
|
||||
return self._include(grammar, '$self')
|
||||
elif s.startswith('#'):
|
||||
return self._patterns(grammar, (grammar.repository[s[1:]],))
|
||||
elif '#' not in s:
|
||||
return self._include(self._grammars[s], '$self')
|
||||
grammar = self._grammars.grammar_for_scope(s)
|
||||
return self._include(grammar, '$self')
|
||||
else:
|
||||
scope, _, s = s.partition('#')
|
||||
return self._include(self._grammars[scope], f'#{s}')
|
||||
grammar = self._grammars.grammar_for_scope(scope)
|
||||
return self._include(grammar, f'#{s}')
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _patterns(
|
||||
@@ -643,49 +627,64 @@ class Compiler:
|
||||
|
||||
|
||||
class Grammars:
|
||||
def __init__(self, grammars: List[Grammar]) -> None:
|
||||
self.grammars = {grammar.scope_name: grammar for grammar in grammars}
|
||||
self._compilers: Dict[Grammar, Compiler] = {}
|
||||
def __init__(self, grammars: Sequence[Dict[str, Any]]) -> None:
|
||||
self._raw = {grammar['scopeName']: grammar for grammar in grammars}
|
||||
self._find_scope = [
|
||||
(
|
||||
frozenset(grammar.get('fileTypes', ())),
|
||||
make_reg(grammar.get('firstLineMatch', '$impossible^')),
|
||||
grammar['scopeName'],
|
||||
)
|
||||
for grammar in grammars
|
||||
]
|
||||
self._parsed: Dict[str, Grammar] = {}
|
||||
self._compilers: Dict[str, Compiler] = {}
|
||||
|
||||
@classmethod
|
||||
def from_syntax_dir(cls, syntax_dir: str) -> 'Grammars':
|
||||
grammars = [Grammar.blank()]
|
||||
grammars = [{'scopeName': 'source.unknown', 'patterns': []}]
|
||||
if os.path.exists(syntax_dir):
|
||||
grammars.extend(
|
||||
Grammar.parse(os.path.join(syntax_dir, filename))
|
||||
for filename in os.listdir(syntax_dir)
|
||||
)
|
||||
for filename in os.listdir(syntax_dir):
|
||||
with open(os.path.join(syntax_dir, filename)) as f:
|
||||
grammars.append(json.load(f))
|
||||
return cls(grammars)
|
||||
|
||||
def _compiler_for_grammar(self, grammar: Grammar) -> Compiler:
|
||||
def grammar_for_scope(self, scope: str) -> Grammar:
|
||||
with contextlib.suppress(KeyError):
|
||||
return self._compilers[grammar]
|
||||
return self._parsed[scope]
|
||||
|
||||
ret = self._compilers[grammar] = Compiler(grammar, self.grammars)
|
||||
ret = self._parsed[scope] = Grammar.from_data(self._raw[scope])
|
||||
return ret
|
||||
|
||||
def compiler_for_scope(self, scope: str) -> Compiler:
|
||||
return self._compiler_for_grammar(self.grammars[scope])
|
||||
with contextlib.suppress(KeyError):
|
||||
return self._compilers[scope]
|
||||
|
||||
grammar = self.grammar_for_scope(scope)
|
||||
ret = self._compilers[scope] = Compiler(grammar, self)
|
||||
return ret
|
||||
|
||||
def blank_compiler(self) -> Compiler:
|
||||
return self.compiler_for_scope('source.unknown')
|
||||
|
||||
def compiler_for_file(self, filename: str) -> Compiler:
|
||||
if os.path.exists(filename):
|
||||
with open(filename) as f:
|
||||
first_line = next(f, '')
|
||||
def compiler_for_file(self, filename: str, first_line: str) -> Compiler:
|
||||
for tag in tags_from_filename(filename) - {'text'}:
|
||||
with contextlib.suppress(KeyError):
|
||||
return self.compiler_for_scope(f'source.{tag}')
|
||||
|
||||
_, _, ext = os.path.basename(filename).rpartition('.')
|
||||
for extensions, first_line_match, scope_name in self._find_scope:
|
||||
if (
|
||||
ext in extensions or
|
||||
first_line_match.match(
|
||||
first_line, 0, first_line=True, boundary=True,
|
||||
)
|
||||
):
|
||||
return self.compiler_for_scope(scope_name)
|
||||
else:
|
||||
first_line = ''
|
||||
for grammar in self.grammars.values():
|
||||
if grammar.matches_file(filename, first_line):
|
||||
break
|
||||
else:
|
||||
grammar = self.grammars['source.unknown']
|
||||
|
||||
return self._compiler_for_grammar(grammar)
|
||||
return self.compiler_for_scope('source.unknown')
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def highlight_line(
|
||||
compiler: 'Compiler',
|
||||
state: State,
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
from typing import Sequence
|
||||
from typing import NamedTuple
|
||||
from typing import Tuple
|
||||
|
||||
from babi._types import Protocol
|
||||
from babi._types import TypedDict
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class CursesRegion(TypedDict):
|
||||
class HL(NamedTuple):
|
||||
x: int
|
||||
n: int
|
||||
color: int
|
||||
end: int
|
||||
attr: int
|
||||
|
||||
|
||||
CursesRegions = Tuple[CursesRegion, ...]
|
||||
HLs = Tuple[HL, ...]
|
||||
|
||||
|
||||
class RegionsMapping(Protocol):
|
||||
def __getitem__(self, idx: int) -> HLs: ...
|
||||
|
||||
|
||||
class FileHL(Protocol):
|
||||
@property
|
||||
def regions(self) -> Sequence[CursesRegions]: ...
|
||||
def include_edge(self) -> bool: ...
|
||||
@property
|
||||
def regions(self) -> RegionsMapping: ...
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None: ...
|
||||
def touch(self, lineno: int) -> None: ...
|
||||
|
||||
|
||||
class HLFactory(Protocol):
|
||||
def get_file_highlighter(self, filename: str) -> FileHL: ...
|
||||
def get_blank_file_highlighter(self) -> FileHL: ...
|
||||
def file_highlighter(self, filename: str, first_line: str) -> FileHL: ...
|
||||
def blank_file_highlighter(self) -> FileHL: ...
|
||||
|
||||
32
babi/hl/replace.py
Normal file
32
babi/hl/replace.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class Replace:
|
||||
include_edge = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def region(self, y: int, x: int, end: int) -> Generator[None, None, None]:
|
||||
# XXX: this assumes pair 1 is the background
|
||||
attr = curses.A_REVERSE | curses.A_DIM | curses.color_pair(1)
|
||||
self.regions[y] = (HL(x=x, end=end, attr=attr),)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
del self.regions[y]
|
||||
58
babi/hl/selection.py
Normal file
58
babi/hl/selection.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import collections
|
||||
import curses
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class Selection:
|
||||
include_edge = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.regions: Dict[int, HLs] = collections.defaultdict(tuple)
|
||||
self.start: Optional[Tuple[int, int]] = None
|
||||
self.end: Optional[Tuple[int, int]] = None
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
if self.start is None or self.end is None:
|
||||
return
|
||||
|
||||
# XXX: this assumes pair 1 is the background
|
||||
attr = curses.A_REVERSE | curses.A_DIM | curses.color_pair(1)
|
||||
(s_y, s_x), (e_y, e_x) = self.get()
|
||||
if s_y == e_y:
|
||||
self.regions[s_y] = (HL(x=s_x, end=e_x, attr=attr),)
|
||||
else:
|
||||
self.regions[s_y] = (
|
||||
HL(x=s_x, end=len(lines[s_y]) + 1, attr=attr),
|
||||
)
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
self.regions[l_y] = (
|
||||
HL(x=0, end=len(lines[l_y]) + 1, attr=attr),
|
||||
)
|
||||
self.regions[e_y] = (HL(x=0, end=e_x, attr=attr),)
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
"""our highlight regions are populated in other ways"""
|
||||
|
||||
def get(self) -> Tuple[Tuple[int, int], Tuple[int, int]]:
|
||||
assert self.start is not None and self.end is not None
|
||||
if self.start < self.end:
|
||||
return self.start, self.end
|
||||
else:
|
||||
return self.end, self.start
|
||||
|
||||
def clear(self) -> None:
|
||||
if self.start is not None and self.end is not None:
|
||||
(s_y, _), (e_y, _) = self.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
del self.regions[l_y]
|
||||
self.start = self.end = None
|
||||
|
||||
def set(self, s_y: int, s_x: int, e_y: int, e_x: int) -> None:
|
||||
self.clear()
|
||||
self.start, self.end = (s_y, s_x), (e_y, e_x)
|
||||
@@ -9,8 +9,8 @@ from babi.highlight import Compiler
|
||||
from babi.highlight import Grammars
|
||||
from babi.highlight import highlight_line
|
||||
from babi.highlight import State
|
||||
from babi.hl.interface import CursesRegion
|
||||
from babi.hl.interface import CursesRegions
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
from babi.theme import Style
|
||||
from babi.theme import Theme
|
||||
@@ -21,6 +21,8 @@ A_ITALIC = getattr(curses, 'A_ITALIC', 0x80000000) # new in py37
|
||||
|
||||
|
||||
class FileSyntax:
|
||||
include_edge = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
@@ -31,10 +33,10 @@ class FileSyntax:
|
||||
self._theme = theme
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: List[CursesRegions] = []
|
||||
self.regions: List[HLs] = []
|
||||
self._states: List[State] = []
|
||||
|
||||
self._hl_cache: Dict[str, Dict[State, Tuple[State, CursesRegions]]]
|
||||
self._hl_cache: Dict[str, Dict[State, Tuple[State, HLs]]]
|
||||
self._hl_cache = {}
|
||||
|
||||
def attr(self, style: Style) -> int:
|
||||
@@ -51,7 +53,7 @@ class FileSyntax:
|
||||
state: State,
|
||||
line: str,
|
||||
i: int,
|
||||
) -> Tuple[State, CursesRegions]:
|
||||
) -> Tuple[State, HLs]:
|
||||
try:
|
||||
return self._hl_cache[line][state]
|
||||
except KeyError:
|
||||
@@ -65,22 +67,21 @@ class FileSyntax:
|
||||
new_end = regions[-1]._replace(end=regions[-1].end - 1)
|
||||
regions = regions[:-1] + (new_end,)
|
||||
|
||||
regs: List[CursesRegion] = []
|
||||
regs: List[HL] = []
|
||||
for r in regions:
|
||||
style = self._theme.select(r.scope)
|
||||
if style == self._theme.default:
|
||||
continue
|
||||
|
||||
n = r.end - r.start
|
||||
attr = self.attr(style)
|
||||
if (
|
||||
regs and
|
||||
regs[-1]['color'] == attr and
|
||||
regs[-1]['x'] + regs[-1]['n'] == r.start
|
||||
regs[-1].attr == attr and
|
||||
regs[-1].end == r.start
|
||||
):
|
||||
regs[-1]['n'] += n
|
||||
regs[-1] = regs[-1]._replace(end=r.end)
|
||||
else:
|
||||
regs.append(CursesRegion(x=r.start, n=n, color=attr))
|
||||
regs.append(HL(x=r.start, end=r.end, attr=attr))
|
||||
|
||||
dct = self._hl_cache.setdefault(line, {})
|
||||
ret = dct[state] = (new_state, tuple(regs))
|
||||
@@ -107,11 +108,11 @@ class Syntax(NamedTuple):
|
||||
theme: Theme
|
||||
color_manager: ColorManager
|
||||
|
||||
def get_file_highlighter(self, filename: str) -> FileSyntax:
|
||||
compiler = self.grammars.compiler_for_file(filename)
|
||||
def file_highlighter(self, filename: str, first_line: str) -> FileSyntax:
|
||||
compiler = self.grammars.compiler_for_file(filename, first_line)
|
||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||
|
||||
def get_blank_file_highlighter(self) -> FileSyntax:
|
||||
def blank_file_highlighter(self) -> FileSyntax:
|
||||
compiler = self.grammars.blank_compiler()
|
||||
return FileSyntax(compiler, self.theme, self.color_manager)
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import curses
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.hl.interface import CursesRegion
|
||||
from babi.hl.interface import CursesRegions
|
||||
from babi.hl.interface import HL
|
||||
from babi.hl.interface import HLs
|
||||
from babi.list_spy import SequenceNoSlice
|
||||
|
||||
|
||||
class FileTrailingWhitespace:
|
||||
class TrailingWhitespace:
|
||||
include_edge = False
|
||||
|
||||
def __init__(self, color_manager: ColorManager) -> None:
|
||||
self._color_manager = color_manager
|
||||
|
||||
self.regions: List[CursesRegions] = []
|
||||
self.regions: List[HLs] = []
|
||||
|
||||
def _trailing_ws(self, line: str) -> CursesRegions:
|
||||
def _trailing_ws(self, line: str) -> HLs:
|
||||
if not line:
|
||||
return ()
|
||||
|
||||
@@ -27,7 +28,7 @@ class FileTrailingWhitespace:
|
||||
else:
|
||||
pair = self._color_manager.raw_color_pair(-1, curses.COLOR_RED)
|
||||
attr = curses.color_pair(pair)
|
||||
return (CursesRegion(x=i, n=len(line) - i, color=attr),)
|
||||
return (HL(x=i, end=len(line), attr=attr),)
|
||||
|
||||
def highlight_until(self, lines: SequenceNoSlice, idx: int) -> None:
|
||||
for i in range(len(self.regions), idx):
|
||||
@@ -35,14 +36,3 @@ class FileTrailingWhitespace:
|
||||
|
||||
def touch(self, lineno: int) -> None:
|
||||
del self.regions[lineno:]
|
||||
|
||||
|
||||
class TrailingWhitespace(NamedTuple):
|
||||
color_manager: ColorManager
|
||||
|
||||
def get_file_highlighter(self, filename: str) -> FileTrailingWhitespace:
|
||||
# no file-specific behaviour
|
||||
return self.get_blank_file_highlighter()
|
||||
|
||||
def get_blank_file_highlighter(self) -> FileTrailingWhitespace:
|
||||
return FileTrailingWhitespace(self.color_manager)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
def line_x(x: int, width: int) -> int:
|
||||
margin = min(width - 3, 6)
|
||||
if x + 1 < width:
|
||||
return 0
|
||||
elif width == 1:
|
||||
return x
|
||||
else:
|
||||
margin = min(width - 3, 6)
|
||||
return (
|
||||
width - margin - 2 +
|
||||
(x + 1 - width) //
|
||||
@@ -17,7 +17,7 @@ def scrolled_line(s: str, x: int, width: int) -> str:
|
||||
l_x = line_x(x, width)
|
||||
if l_x:
|
||||
s = f'«{s[l_x + 1:]}'
|
||||
if l_x and len(s) > width:
|
||||
if len(s) > width:
|
||||
return f'{s[:width - 1]}»'
|
||||
else:
|
||||
return s.ljust(width)
|
||||
|
||||
55
babi/main.py
55
babi/main.py
@@ -1,17 +1,22 @@
|
||||
import argparse
|
||||
import curses
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
|
||||
from babi.file import File
|
||||
from babi.perf import Perf
|
||||
from babi.perf import perf_log
|
||||
from babi.screen import EditResult
|
||||
from babi.screen import make_stdscr
|
||||
from babi.screen import Screen
|
||||
|
||||
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
|
||||
|
||||
def _edit(screen: Screen) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status)
|
||||
|
||||
def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status, stdin)
|
||||
|
||||
while True:
|
||||
screen.status.tick(screen.margin)
|
||||
@@ -32,15 +37,21 @@ def _edit(screen: Screen) -> EditResult:
|
||||
screen.status.update(f'unknown key: {key}')
|
||||
|
||||
|
||||
def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> int:
|
||||
def c_main(
|
||||
stdscr: 'curses._CursesWindow',
|
||||
args: argparse.Namespace,
|
||||
stdin: str,
|
||||
) -> int:
|
||||
with perf_log(args.perf_log) as perf:
|
||||
screen = Screen(stdscr, args.filenames or [None], perf)
|
||||
with screen.history.save():
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
res = _edit(screen)
|
||||
res = _edit(screen, stdin)
|
||||
if res == EditResult.EXIT:
|
||||
del screen.files[screen.i]
|
||||
# always go to the next file except at the end
|
||||
screen.i = min(screen.i, len(screen.files) - 1)
|
||||
screen.status.clear()
|
||||
elif res == EditResult.NEXT:
|
||||
screen.i += 1
|
||||
@@ -48,19 +59,53 @@ def c_main(stdscr: 'curses._CursesWindow', args: argparse.Namespace) -> int:
|
||||
elif res == EditResult.PREV:
|
||||
screen.i -= 1
|
||||
screen.status.clear()
|
||||
elif res == EditResult.OPEN:
|
||||
screen.i = len(screen.files) - 1
|
||||
else:
|
||||
raise AssertionError(f'unreachable {res}')
|
||||
return 0
|
||||
|
||||
|
||||
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], Perf())
|
||||
screen.file.lines = ['']
|
||||
|
||||
while True:
|
||||
screen.status.update('press q to quit')
|
||||
screen.draw()
|
||||
screen.file.move_cursor(screen.stdscr, screen.margin)
|
||||
|
||||
key = screen.get_char()
|
||||
screen.file.lines.insert(-1, f'{key.wch!r} {key.keyname.decode()!r}')
|
||||
screen.file.down(screen.margin)
|
||||
if key.wch == curses.KEY_RESIZE:
|
||||
screen.resize()
|
||||
if key.wch == 'q':
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||
parser.add_argument('--perf-log')
|
||||
parser.add_argument(
|
||||
'--key-debug', action='store_true', help=argparse.SUPPRESS,
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if '-' in args.filenames:
|
||||
print('reading stdin...', file=sys.stderr)
|
||||
stdin = sys.stdin.read()
|
||||
tty = os.open(CONSOLE, os.O_RDONLY)
|
||||
os.dup2(tty, sys.stdin.fileno())
|
||||
else:
|
||||
stdin = ''
|
||||
|
||||
with make_stdscr() as stdscr:
|
||||
return c_main(stdscr, args)
|
||||
if args.key_debug:
|
||||
return _key_debug(stdscr)
|
||||
else:
|
||||
return c_main(stdscr, args, stdin)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import functools
|
||||
import re
|
||||
from typing import Match
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
@@ -7,6 +8,8 @@ import onigurumacffi
|
||||
|
||||
from babi.cached_property import cached_property
|
||||
|
||||
_BACKREF_RE = re.compile(r'((?<!\\)(?:\\\\)*)\\([0-9]+)')
|
||||
|
||||
|
||||
def _replace_esc(s: str, chars: str) -> str:
|
||||
"""replace the given escape sequences of `chars` with \\uffff"""
|
||||
@@ -142,6 +145,10 @@ class _RegSet:
|
||||
return self._set_no_A_no_G.search(line, pos)
|
||||
|
||||
|
||||
def expand_escaped(match: Match[str], s: str) -> str:
|
||||
return _BACKREF_RE.sub(lambda m: f'{m[1]}{re.escape(match[int(m[2])])}', s)
|
||||
|
||||
|
||||
make_reg = functools.lru_cache(maxsize=None)(_Reg)
|
||||
make_regset = functools.lru_cache(maxsize=None)(_RegSet)
|
||||
ERR_REG = make_reg(')this pattern always triggers an error when used(')
|
||||
|
||||
@@ -6,7 +6,6 @@ import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from typing import Callable
|
||||
from typing import Generator
|
||||
from typing import List
|
||||
from typing import NamedTuple
|
||||
@@ -21,15 +20,19 @@ from babi.file import File
|
||||
from babi.file import get_lines
|
||||
from babi.history import History
|
||||
from babi.hl.syntax import Syntax
|
||||
from babi.hl.trailing_whitespace import TrailingWhitespace
|
||||
from babi.margin import Margin
|
||||
from babi.perf import Perf
|
||||
from babi.prompt import Prompt
|
||||
from babi.prompt import PromptResult
|
||||
from babi.status import Status
|
||||
|
||||
VERSION_STR = 'babi v0'
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV')
|
||||
if sys.version_info >= (3, 8): # pragma: no cover (py38+)
|
||||
import importlib.metadata as importlib_metadata
|
||||
else: # pragma: no cover (<py38)
|
||||
import importlib_metadata
|
||||
|
||||
VERSION_STR = f'babi v{importlib_metadata.version("babi")}'
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV OPEN')
|
||||
|
||||
# TODO: find a place to populate these, surely there's a database somewhere
|
||||
SEQUENCE_KEYNAME = {
|
||||
@@ -73,12 +76,12 @@ class Screen:
|
||||
perf: Perf,
|
||||
) -> None:
|
||||
self.stdscr = stdscr
|
||||
color_manager = ColorManager.make()
|
||||
hl_factories = (
|
||||
Syntax.from_screen(stdscr, color_manager),
|
||||
TrailingWhitespace(color_manager),
|
||||
)
|
||||
self.files = [File(f, hl_factories) for f in filenames]
|
||||
self.color_manager = ColorManager.make()
|
||||
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
|
||||
self.files = [
|
||||
File(filename, self.color_manager, self.hl_factories)
|
||||
for filename in filenames
|
||||
]
|
||||
self.i = 0
|
||||
self.history = History()
|
||||
self.perf = perf
|
||||
@@ -86,7 +89,6 @@ class Screen:
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.cut_buffer: Tuple[str, ...] = ()
|
||||
self.cut_selection = False
|
||||
self._resize_cb: Optional[Callable[[], None]] = None
|
||||
self._buffered_input: Union[int, str, None] = None
|
||||
|
||||
@property
|
||||
@@ -220,30 +222,46 @@ class Screen:
|
||||
self.file.draw(self.stdscr, self.margin)
|
||||
self.status.draw(self.stdscr, self.margin)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def resize_cb(self, f: Callable[[], None]) -> Generator[None, None, None]:
|
||||
assert self._resize_cb is None, self._resize_cb
|
||||
self._resize_cb = f
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._resize_cb = None
|
||||
|
||||
def resize(self) -> None:
|
||||
curses.update_lines_cols()
|
||||
self.margin = Margin.from_current_screen()
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.draw()
|
||||
if self._resize_cb is not None:
|
||||
self._resize_cb()
|
||||
|
||||
def quick_prompt(self, prompt: str, opts: str) -> Union[str, PromptResult]:
|
||||
def quick_prompt(
|
||||
self,
|
||||
prompt: str,
|
||||
opt_strs: Tuple[str, ...],
|
||||
) -> Union[str, PromptResult]:
|
||||
opts = [opt[0] for opt in opt_strs]
|
||||
while True:
|
||||
s = prompt.ljust(curses.COLS)
|
||||
if len(s) > curses.COLS:
|
||||
s = f'{s[:curses.COLS - 1]}…'
|
||||
self.stdscr.insstr(curses.LINES - 1, 0, s, curses.A_REVERSE)
|
||||
x = min(curses.COLS - 1, len(prompt) + 1)
|
||||
x = 0
|
||||
|
||||
def _write(s: str, *, attr: int = curses.A_REVERSE) -> None:
|
||||
nonlocal x
|
||||
|
||||
if x >= curses.COLS:
|
||||
return
|
||||
self.stdscr.insstr(curses.LINES - 1, x, s, attr)
|
||||
x += len(s)
|
||||
|
||||
_write(prompt)
|
||||
_write(' [')
|
||||
for i, opt_str in enumerate(opt_strs):
|
||||
_write(opt_str[0], attr=curses.A_REVERSE | curses.A_BOLD)
|
||||
_write(opt_str[1:])
|
||||
if i != len(opt_strs) - 1:
|
||||
_write(', ')
|
||||
_write(']?')
|
||||
|
||||
if x < curses.COLS - 1:
|
||||
s = ' ' * (curses.COLS - x)
|
||||
self.stdscr.insstr(curses.LINES - 1, x, s, curses.A_REVERSE)
|
||||
x += 1
|
||||
else:
|
||||
x = curses.COLS - 1
|
||||
self.stdscr.insstr(curses.LINES - 1, x, '…', curses.A_REVERSE)
|
||||
|
||||
self.stdscr.move(curses.LINES - 1, x)
|
||||
|
||||
key = self.get_char()
|
||||
@@ -306,7 +324,7 @@ class Screen:
|
||||
self.status.update(f'{line}, {col} (of {line_count} {lines_word})')
|
||||
|
||||
def cut(self) -> None:
|
||||
if self.file.select_start:
|
||||
if self.file.selection.start:
|
||||
self.cut_buffer = self.file.cut_selection(self.margin)
|
||||
self.cut_selection = True
|
||||
else:
|
||||
@@ -342,6 +360,7 @@ class Screen:
|
||||
to_stack.append(action.apply(self.file))
|
||||
self.file.scroll_screen_if_needed(self.margin)
|
||||
self.status.update(f'{op}: {action.name}')
|
||||
self.file.selection.clear()
|
||||
|
||||
def undo(self) -> None:
|
||||
self._undo_redo('undo', self.file.undo_stack, self.file.redo_stack)
|
||||
@@ -373,7 +392,7 @@ class Screen:
|
||||
self.save()
|
||||
return EditResult.EXIT
|
||||
elif response == ':sort':
|
||||
if self.file.select_start:
|
||||
if self.file.selection.start:
|
||||
self.file.sort_selection(self.margin)
|
||||
else:
|
||||
self.file.sort(self.margin)
|
||||
@@ -436,10 +455,19 @@ class Screen:
|
||||
self.file.filename = response
|
||||
return self.save()
|
||||
|
||||
def open_file(self) -> Optional[EditResult]:
|
||||
response = self.prompt('enter filename', history='open')
|
||||
if response is not PromptResult.CANCELLED:
|
||||
opened = File(response, self.color_manager, self.hl_factories)
|
||||
self.files.append(opened)
|
||||
return EditResult.OPEN
|
||||
else:
|
||||
return None
|
||||
|
||||
def quit_save_modified(self) -> Optional[EditResult]:
|
||||
if self.file.modified:
|
||||
response = self.quick_prompt(
|
||||
'file is modified - save [y(es), n(o)]?', 'yn',
|
||||
'file is modified - save', ('yes', 'no'),
|
||||
)
|
||||
if response == 'y':
|
||||
if self.save_filename() is not PromptResult.CANCELLED:
|
||||
@@ -473,6 +501,7 @@ class Screen:
|
||||
b'^S': save,
|
||||
b'^O': save_filename,
|
||||
b'^X': quit_save_modified,
|
||||
b'^P': open_file,
|
||||
b'kLFT3': lambda screen: EditResult.PREV,
|
||||
b'kRIT3': lambda screen: EditResult.NEXT,
|
||||
b'^Z': background,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import functools
|
||||
import json
|
||||
import os.path
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
@@ -12,9 +11,6 @@ from babi._types import Protocol
|
||||
from babi.color import Color
|
||||
from babi.fdict import FDict
|
||||
|
||||
# yes I know this is wrong, but it's good enough for now
|
||||
UN_COMMENT = re.compile(r'^\s*//.*$', re.MULTILINE)
|
||||
|
||||
|
||||
class Style(NamedTuple):
|
||||
fg: Optional[Color]
|
||||
@@ -114,10 +110,14 @@ class Theme(NamedTuple):
|
||||
for rule in rules:
|
||||
if 'scope' not in rule:
|
||||
scopes = ['']
|
||||
elif rule['scope'] == '':
|
||||
scopes = ['']
|
||||
elif isinstance(rule['scope'], str):
|
||||
scopes = [
|
||||
# some themes have a buggy trailing comma
|
||||
s.strip() for s in rule['scope'].strip(',').split(',')
|
||||
s.strip()
|
||||
# some themes have a buggy trailing/leading comma
|
||||
for s in rule['scope'].strip().strip(',').split(',')
|
||||
if s.strip()
|
||||
]
|
||||
else:
|
||||
scopes = rule['scope']
|
||||
@@ -148,5 +148,4 @@ class Theme(NamedTuple):
|
||||
return cls.blank()
|
||||
else:
|
||||
with open(filename) as f:
|
||||
contents = UN_COMMENT.sub('', f.read())
|
||||
return cls.from_dct(json.loads(contents))
|
||||
return cls.from_dct(json.load(f))
|
||||
|
||||
@@ -46,17 +46,22 @@ class Syntax(NamedTuple):
|
||||
SYNTAXES = (
|
||||
Syntax('c', Ext.JSON, 'https://raw.githubusercontent.com/jeff-hykin/cpp-textmate-grammar/53e39b1c/syntaxes/c.tmLanguage.json'), # noqa: E501
|
||||
Syntax('css', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-css/9feb69c081308b63f78bb0d6a2af2ff5eb7d869b/grammars/css.cson'), # noqa: E501
|
||||
Syntax('docker', Ext.PLIST, 'https://raw.githubusercontent.com/moby/moby/c7ad2b866/contrib/syntax/textmate/Docker.tmbundle/Syntaxes/Dockerfile.tmLanguage'), # noqa: E501
|
||||
Syntax('diff', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/diff.tmbundle/0593bb77/Syntaxes/Diff.plist'), # noqa: E501
|
||||
Syntax('html', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee5/Syntaxes/HTML.plist'), # noqa: E501
|
||||
Syntax('html-derivative', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/html.tmbundle/0c3d5ee54de3a993f747f54186b73a4d2d3c44a2/Syntaxes/HTML%20(Derivative).tmLanguage'), # noqa: E501
|
||||
Syntax('ini', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/ini.tmbundle/7d8c7b55/Syntaxes/Ini.plist'), # noqa: E501
|
||||
Syntax('json', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-JSON.tmLanguage/d113e90937ed3ecc31ac54750aac2e8efa08d784/JSON.tmLanguage'), # noqa: E501
|
||||
Syntax('make', Ext.PLIST, 'https://raw.githubusercontent.com/fadeevab/make.tmbundle/fd57c0552/Syntaxes/Makefile.plist'), # noqa: E501
|
||||
Syntax('markdown', Ext.PLIST, 'https://raw.githubusercontent.com/microsoft/vscode-markdown-tm-grammar/59a5962/syntaxes/markdown.tmLanguage'), # noqa: E501
|
||||
Syntax('powershell', Ext.PLIST, 'https://raw.githubusercontent.com/PowerShell/EditorSyntax/4a0a0766/PowerShellSyntax.tmLanguage'), # noqa: E501
|
||||
Syntax('puppet', Ext.PLIST, 'https://raw.githubusercontent.com/lingua-pupuli/puppet-editor-syntax/dc414b8a/syntaxes/puppet.tmLanguage'), # noqa: E501
|
||||
Syntax('python', Ext.PLIST, 'https://raw.githubusercontent.com/MagicStack/MagicPython/c9b3409d/grammars/MagicPython.tmLanguage'), # noqa: E501
|
||||
# TODO: https://github.com/zargony/atom-language-rust/pull/149
|
||||
Syntax('rust', Ext.CSON, 'https://raw.githubusercontent.com/asottile/atom-language-rust/e113ca67/grammars/rust.cson'), # noqa: E501
|
||||
Syntax('shell', Ext.CSON, 'https://raw.githubusercontent.com/atom/language-shellscript/7008ea926867d8a231003e78094091471c4fccf8/grammars/shell-unix-bash.cson'), # noqa: E501
|
||||
# TODO: https://github.com/atom/language-xml/pull/99
|
||||
Syntax('xml', Ext.CSON, 'https://raw.githubusercontent.com/asottile/language-xml/2d76bc1f/grammars/xml.cson'), # noqa: E501
|
||||
Syntax('yaml', Ext.PLIST, 'https://raw.githubusercontent.com/textmate/yaml.tmbundle/e54ceae3/Syntaxes/YAML.tmLanguage'), # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
89
bin/download-theme
Executable file
89
bin/download-theme
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os.path
|
||||
import plistlib
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
import cson # pip install cson
|
||||
|
||||
TOKEN = re.compile(br'(\\\\|\\"|"|//|\n)')
|
||||
|
||||
|
||||
def json_with_comments(s: bytes) -> Any:
|
||||
bio = io.BytesIO()
|
||||
|
||||
idx = 0
|
||||
in_string = False
|
||||
in_comment = False
|
||||
|
||||
match = TOKEN.search(s, idx)
|
||||
while match:
|
||||
if not in_comment:
|
||||
bio.write(s[idx:match.start()])
|
||||
|
||||
tok = match[0]
|
||||
if not in_comment and tok == b'"':
|
||||
in_string = not in_string
|
||||
elif in_comment and tok == b'\n':
|
||||
in_comment = False
|
||||
elif not in_string and tok == b'//':
|
||||
in_comment = True
|
||||
|
||||
if not in_comment:
|
||||
bio.write(tok)
|
||||
|
||||
idx = match.end()
|
||||
match = TOKEN.search(s, idx)
|
||||
|
||||
print(bio.getvalue())
|
||||
bio.seek(0)
|
||||
return json.load(bio)
|
||||
|
||||
|
||||
STRATEGIES = (json.loads, plistlib.loads, cson.loads, json_with_comments)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('name')
|
||||
parser.add_argument('url')
|
||||
args = parser.parse_args()
|
||||
|
||||
if '/blob/' in args.url:
|
||||
url = args.url.replace('/blob/', '/raw/')
|
||||
else:
|
||||
url = args.url
|
||||
|
||||
contents = urllib.request.urlopen(url).read()
|
||||
|
||||
errors = []
|
||||
for strategy in STRATEGIES:
|
||||
try:
|
||||
loaded = strategy(contents)
|
||||
except Exception as e:
|
||||
errors.append((f'{strategy.__module__}.{strategy.__name__}', e))
|
||||
else:
|
||||
break
|
||||
else:
|
||||
errors_s = '\n'.join(f'\t{name}: {error}' for name, error in errors)
|
||||
raise AssertionError(f'could not load as json/plist/cson:\n{errors_s}')
|
||||
|
||||
config_dir = os.path.expanduser('~/.config/babi')
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
dest = os.path.join(config_dir, f'{args.name}.json')
|
||||
with open(dest, 'w') as f:
|
||||
json.dump(loaded, f)
|
||||
|
||||
theme_json = os.path.join(config_dir, 'theme.json')
|
||||
if os.path.lexists(theme_json):
|
||||
os.remove(theme_json)
|
||||
os.symlink(dest, theme_json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -1,5 +1,5 @@
|
||||
covdefaults
|
||||
coverage
|
||||
git+https://github.com/asottile/hecate@ebe6dfb
|
||||
git+https://github.com/asottile/hecate@875567f
|
||||
pytest
|
||||
remote-pdb
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = babi
|
||||
version = 0.0.1
|
||||
version = 0.0.2
|
||||
description = a text editor
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
@@ -22,7 +22,10 @@ classifiers =
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
identify
|
||||
onigurumacffi>=0.0.10
|
||||
importlib_metadata>=1;python_version<"3.8"
|
||||
windows-curses;sys_platform=="win32"
|
||||
python_requires = >=3.6.1
|
||||
|
||||
[options.entry_points]
|
||||
|
||||
16
tests/color_test.py
Normal file
16
tests/color_test.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
from babi.color import Color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('s', 'expected'),
|
||||
(
|
||||
('#1e77d3', Color(0x1e, 0x77, 0xd3)),
|
||||
('white', Color(0xff, 0xff, 0xff)),
|
||||
('black', Color(0x00, 0x00, 0x00)),
|
||||
('#ccc', Color(0xcc, 0xcc, 0xcc)),
|
||||
),
|
||||
)
|
||||
def test_color_parse(s, expected):
|
||||
assert Color.parse(s) == expected
|
||||
@@ -254,6 +254,7 @@ KEYS = [
|
||||
Key('^E', b'^E', '\x05'),
|
||||
Key('^J', b'^J', '\n'),
|
||||
Key('^O', b'^O', '\x0f'),
|
||||
Key('^P', b'^P', '\x10'),
|
||||
Key('^R', b'^R', '\x12'),
|
||||
Key('^S', b'^S', '\x13'),
|
||||
Key('^U', b'^U', '\x15'),
|
||||
@@ -266,7 +267,7 @@ KEYS = [
|
||||
Key('^\\', b'^\\', '\x1c'),
|
||||
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
|
||||
]
|
||||
KEYS_TMUX = {k.tmux: k.value for k in KEYS}
|
||||
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
|
||||
KEYS_CURSES = {k.value: k.curses for k in KEYS}
|
||||
|
||||
|
||||
@@ -297,7 +298,7 @@ class DeferredRunner:
|
||||
print(f'KEY: {keypress_event.wch!r}')
|
||||
return keypress_event.wch
|
||||
|
||||
def await_text(self, text):
|
||||
def await_text(self, text, timeout=1):
|
||||
self._ops.append(AwaitText(text))
|
||||
|
||||
def await_text_missing(self, text):
|
||||
|
||||
22
tests/features/key_debug_test.py
Normal file
22
tests/features/key_debug_test.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import curses
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
|
||||
|
||||
def test_key_debug(run):
|
||||
with run('--key-debug') as h:
|
||||
h.await_text(VERSION_STR, timeout=2)
|
||||
|
||||
h.await_text('press q to quit')
|
||||
|
||||
h.press('a')
|
||||
h.await_text("'a' 'STRING'")
|
||||
|
||||
h.press('^X')
|
||||
h.await_text(r"'\x18' '^X'")
|
||||
|
||||
with h.resize(width=20, height=20):
|
||||
h.await_text(f"{curses.KEY_RESIZE} 'KEY_RESIZE'")
|
||||
|
||||
h.press('q')
|
||||
h.await_exit()
|
||||
@@ -1,10 +1,19 @@
|
||||
def test_multiple_files(run, tmpdir):
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def abc(tmpdir):
|
||||
a = tmpdir.join('file_a')
|
||||
a.write('a text')
|
||||
b = tmpdir.join('file_b')
|
||||
b.write('b text')
|
||||
c = tmpdir.join('file_c')
|
||||
c.write('c text')
|
||||
yield a, b, c
|
||||
|
||||
|
||||
def test_multiple_files(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.await_text('file_a')
|
||||
@@ -44,9 +53,36 @@ def test_multiple_files(run, tmpdir):
|
||||
h.press('^J')
|
||||
h.await_text('unknown key')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.await_text('file_b')
|
||||
h.await_text_missing('unknown key')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_multiple_files_close_from_beginning(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.press('^X')
|
||||
h.await_text('file_b')
|
||||
h.press('^X')
|
||||
h.await_text('file_c')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
def test_multiple_files_close_from_end(run, abc):
|
||||
a, b, c = abc
|
||||
|
||||
with run(str(a), str(b), str(c)) as h:
|
||||
h.press('M-Right')
|
||||
h.await_text('file_b')
|
||||
|
||||
h.press('^X')
|
||||
h.await_text('file_c')
|
||||
h.press('^X')
|
||||
h.await_text('file_a')
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
|
||||
38
tests/features/open_test.py
Normal file
38
tests/features/open_test.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
def test_open_cancelled(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^P')
|
||||
h.await_text('enter filename:')
|
||||
h.press('^C')
|
||||
|
||||
h.await_text('cancelled')
|
||||
h.await_text('hello world')
|
||||
|
||||
|
||||
def test_open(run, tmpdir):
|
||||
f = tmpdir.join('f')
|
||||
f.write('hello world')
|
||||
g = tmpdir.join('g')
|
||||
g.write('goodbye world')
|
||||
|
||||
with run(str(f)) as h:
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^P')
|
||||
h.press_and_enter(str(g))
|
||||
|
||||
h.await_text('[2/2]')
|
||||
h.await_text('goodbye world')
|
||||
|
||||
h.press('^X')
|
||||
h.await_text('hello world')
|
||||
|
||||
h.press('^X')
|
||||
h.await_exit()
|
||||
@@ -37,7 +37,7 @@ def test_replace_actual_contents(run, ten_lines):
|
||||
h.press_and_enter('line_0')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_0')
|
||||
h.await_text('ohai')
|
||||
@@ -59,7 +59,7 @@ match me!
|
||||
h.press_and_enter('me!')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('youuuu')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_cursor_position(x=6, y=3)
|
||||
h.press('Up')
|
||||
@@ -74,7 +74,7 @@ def test_replace_cancel_at_individual_replace(run, ten_lines):
|
||||
h.press_and_enter(r'line_\d')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_replace_unknown_characters_at_individual_replace(run, ten_lines):
|
||||
h.press_and_enter(r'line_\d')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
@@ -99,7 +99,7 @@ def test_replace_say_no_to_individual_replace(run, ten_lines):
|
||||
h.press_and_enter('line_[135]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_1')
|
||||
h.press('n')
|
||||
@@ -116,7 +116,7 @@ def test_replace_all(run, ten_lines):
|
||||
h.press_and_enter(r'line_(\d)')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter(r'ohai+\1')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
h.await_text_missing('line')
|
||||
h.await_text('ohai+1')
|
||||
@@ -130,7 +130,7 @@ def test_replace_with_empty_string(run, ten_lines):
|
||||
h.press_and_enter('line_1')
|
||||
h.await_text('replace with:')
|
||||
h.press('Enter')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_1')
|
||||
|
||||
@@ -153,7 +153,7 @@ def test_replace_small_window_size(run, ten_lines):
|
||||
h.press_and_enter('line')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('wat')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
|
||||
with h.resize(width=8, height=24):
|
||||
h.await_text('replace…')
|
||||
@@ -170,7 +170,7 @@ def test_replace_height_1_highlight(run, tmpdir):
|
||||
h.press_and_enter('^x+$')
|
||||
h.await_text('replace with:')
|
||||
h.press('Enter')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
|
||||
with h.resize(width=80, height=1):
|
||||
h.await_text_missing('xxxxx')
|
||||
@@ -189,7 +189,7 @@ def test_replace_line_goes_off_screen(run):
|
||||
h.press_and_enter('b+')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('wat')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.await_text(f'{"a" * 20}{"b" * 59}»')
|
||||
h.press('y')
|
||||
h.await_text(f'{"a" * 20}wat')
|
||||
@@ -221,7 +221,7 @@ def test_replace_multiple_occurrences_in_line(run):
|
||||
h.press_and_enter('a+')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('q')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('a')
|
||||
h.await_text('bqbq')
|
||||
|
||||
@@ -234,7 +234,7 @@ def test_replace_after_wrapping(run, ten_lines):
|
||||
h.press_and_enter('line_[02]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('ohai')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_2')
|
||||
h.press('y')
|
||||
@@ -251,7 +251,7 @@ def test_replace_after_cursor_after_wrapping(run):
|
||||
h.press_and_enter('b')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('q')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('n')
|
||||
h.press('y')
|
||||
h.await_text('replaced 1 occurrence')
|
||||
@@ -267,7 +267,7 @@ def test_replace_separate_line_after_wrapping(run, ten_lines):
|
||||
h.press_and_enter('line_[01]')
|
||||
h.await_text('replace with:')
|
||||
h.press_and_enter('_')
|
||||
h.await_text('replace [y(es), n(o), a(ll)]?')
|
||||
h.await_text('replace [yes, no, all]?')
|
||||
h.press('y')
|
||||
h.await_text_missing('line_0')
|
||||
h.press('y')
|
||||
|
||||
@@ -148,7 +148,7 @@ def test_save_on_exit_cancel_yn(run):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
@@ -158,7 +158,7 @@ def test_save_on_exit_cancel_filename(run):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text('enter filename:')
|
||||
h.press('^C')
|
||||
@@ -171,7 +171,7 @@ def test_save_on_exit(run, tmpdir):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text(f'enter filename: {f}')
|
||||
h.press('Enter')
|
||||
@@ -183,9 +183,9 @@ def test_save_on_exit_resize(run, tmpdir):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
h.press('^X')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
with h.resize(width=10, height=24):
|
||||
h.await_text('file is m…')
|
||||
h.await_text('file is modified - save [y(es), n(o)]?')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('^C')
|
||||
h.await_text('cancelled')
|
||||
|
||||
22
tests/features/stdin_test.py
Normal file
22
tests/features/stdin_test.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from babi.screen import VERSION_STR
|
||||
from testing.runner import PrintsErrorRunner
|
||||
|
||||
|
||||
def test_open_from_stdin():
|
||||
with PrintsErrorRunner('env', 'TERM=screen', 'bash', '--norc') as h:
|
||||
cmd = (sys.executable, '-mcoverage', 'run', '-m', 'babi', '-')
|
||||
babi_cmd = ' '.join(shlex.quote(part) for part in cmd)
|
||||
h.press_and_enter(fr"echo $'hello\nworld' | {babi_cmd}")
|
||||
|
||||
h.await_text(VERSION_STR, timeout=2)
|
||||
h.await_text('<<new file>> *')
|
||||
h.await_text('hello\nworld')
|
||||
|
||||
h.press('^X')
|
||||
h.press('n')
|
||||
h.await_text_missing('<<new file>>')
|
||||
h.press_and_enter('exit')
|
||||
h.await_exit()
|
||||
@@ -78,3 +78,22 @@ def test_syntax_highlighting_does_not_highlight_arrows(run, tmpdir):
|
||||
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||
h.await_text('loooo')
|
||||
h.assert_screen_attr_equals(2, [(243, 40, 0)] * 19 + [(236, 40, 0)])
|
||||
|
||||
h.press('Down')
|
||||
h.press('^E')
|
||||
h.await_text_missing('loooo')
|
||||
expected = [(236, 40, 0)] + [(243, 40, 0)] * 15 + [(236, 40, 0)] * 4
|
||||
h.assert_screen_attr_equals(2, expected)
|
||||
|
||||
|
||||
def test_syntax_highlighting_off_screen_does_not_crash(run, tmpdir):
|
||||
f = tmpdir.join('f.demo')
|
||||
f.write(f'"""a"""{"x" * 40}"""b"""')
|
||||
|
||||
with run(str(f), term='screen-256color', width=20) as h, and_exit(h):
|
||||
h.await_text('"""a"""')
|
||||
h.assert_screen_attr_equals(1, [(17, 40, 0)] * 7 + [(236, 40, 0)] * 13)
|
||||
h.press('^E')
|
||||
h.await_text('"""b"""')
|
||||
expected = [(236, 40, 0)] * 11 + [(17, 40, 0)] * 7 + [(236, 40, 0)] * 2
|
||||
h.assert_screen_attr_equals(1, expected)
|
||||
|
||||
@@ -127,3 +127,16 @@ def test_undo_redo_causes_scroll(run):
|
||||
h.await_cursor_position(x=0, y=1)
|
||||
h.press('M-U')
|
||||
h.await_cursor_position(x=0, y=4)
|
||||
|
||||
|
||||
def test_undo_redo_clears_selection(run, ten_lines):
|
||||
# maintaining the selection across undo/redo is both difficult and not all
|
||||
# that useful. prior to this it was buggy anyway (a negative selection
|
||||
# indented and then undone would highlight out of bounds)
|
||||
with run(str(ten_lines), width=20) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
h.press('Tab')
|
||||
h.await_cursor_position(x=4, y=2)
|
||||
h.press('M-u')
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
h.assert_screen_attr_equals(1, [(-1, -1, 0)] * 20)
|
||||
|
||||
@@ -2,12 +2,13 @@ import io
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.file import File
|
||||
from babi.file import get_lines
|
||||
|
||||
|
||||
def test_position_repr():
|
||||
ret = repr(File('f.txt', ()))
|
||||
ret = repr(File('f.txt', ColorManager.make(), ()))
|
||||
assert ret == "<File 'f.txt'>"
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
from babi.highlight import Grammar
|
||||
from babi.highlight import Grammars
|
||||
from babi.highlight import highlight_line
|
||||
from babi.highlight import Region
|
||||
|
||||
|
||||
def _compiler_state(grammar_dct, *others):
|
||||
grammar = Grammar.from_data(grammar_dct)
|
||||
grammars = [grammar, *(Grammar.from_data(dct) for dct in others)]
|
||||
compiler = Grammars(grammars).compiler_for_scope(grammar.scope_name)
|
||||
def test_grammar_matches_extension_only_name():
|
||||
data = {'scopeName': 'shell', 'patterns': [], 'fileTypes': ['bashrc']}
|
||||
grammars = Grammars([data])
|
||||
compiler = grammars.compiler_for_file('.bashrc', 'alias nano=babi')
|
||||
assert compiler.root_state.entries[0].scope[0] == 'shell'
|
||||
|
||||
|
||||
def test_grammar_matches_via_identify_tag():
|
||||
data = {'scopeName': 'source.ini', 'patterns': []}
|
||||
grammars = Grammars([data])
|
||||
compiler = grammars.compiler_for_file('setup.cfg', '')
|
||||
assert compiler.root_state.entries[0].scope[0] == 'source.ini'
|
||||
|
||||
|
||||
def _compiler_state(*grammar_dcts):
|
||||
grammars = Grammars(grammar_dcts)
|
||||
compiler = grammars.compiler_for_scope(grammar_dcts[0]['scopeName'])
|
||||
return compiler, compiler.root_state
|
||||
|
||||
|
||||
@@ -528,3 +540,42 @@ def test_include_base():
|
||||
Region(2, 3, ('test', 'tick')),
|
||||
Region(3, 4, ('test',)),
|
||||
)
|
||||
|
||||
|
||||
def test_rule_with_begin_and_no_end():
|
||||
compiler, state = _compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [
|
||||
{
|
||||
'begin': '!', 'end': '!', 'name': 'bang',
|
||||
'patterns': [{'begin': '--', 'name': 'invalid'}],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, '!x! !--!', True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 1, ('test', 'bang')),
|
||||
Region(1, 2, ('test', 'bang')),
|
||||
Region(2, 3, ('test', 'bang')),
|
||||
Region(3, 4, ('test',)),
|
||||
Region(4, 5, ('test', 'bang')),
|
||||
Region(5, 7, ('test', 'bang', 'invalid')),
|
||||
Region(7, 8, ('test', 'bang', 'invalid')),
|
||||
)
|
||||
|
||||
|
||||
def test_begin_end_substitute_special_chars():
|
||||
compiler, state = _compiler_state({
|
||||
'scopeName': 'test',
|
||||
'patterns': [{'begin': r'(\*)', 'end': r'\1', 'name': 'italic'}],
|
||||
})
|
||||
|
||||
state, regions = highlight_line(compiler, state, '*italic*', True)
|
||||
|
||||
assert regions == (
|
||||
Region(0, 1, ('test', 'italic')),
|
||||
Region(1, 7, ('test', 'italic')),
|
||||
Region(7, 8, ('test', 'italic')),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from babi.color_manager import ColorManager
|
||||
from babi.highlight import Grammar
|
||||
from babi.highlight import Grammars
|
||||
from babi.hl.syntax import Syntax
|
||||
from babi.theme import Color
|
||||
@@ -72,18 +71,23 @@ THEME = Theme.from_dct({
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def syntax():
|
||||
return Syntax(Grammars([Grammar.blank()]), THEME, ColorManager.make())
|
||||
def syntax(tmpdir):
|
||||
return Syntax(Grammars.from_syntax_dir(tmpdir), THEME, ColorManager.make())
|
||||
|
||||
|
||||
def test_init_screen_low_color(stdscr, syntax):
|
||||
with FakeCurses.patch(n_colors=16, can_change_color=False) as fake_curses:
|
||||
syntax._init_screen(stdscr)
|
||||
assert syntax.color_manager.colors == {}
|
||||
assert syntax.color_manager.raw_pairs == {}
|
||||
assert syntax.color_manager.colors == {
|
||||
Color.parse('#cccccc'): -1,
|
||||
Color.parse('#333333'): -1,
|
||||
Color.parse('#000000'): -1,
|
||||
Color.parse('#009900'): -1,
|
||||
}
|
||||
assert syntax.color_manager.raw_pairs == {(-1, -1): 1}
|
||||
assert fake_curses.colors == {}
|
||||
assert fake_curses.pairs == {}
|
||||
assert stdscr.attr == 0
|
||||
assert fake_curses.pairs == {1: (-1, -1)}
|
||||
assert stdscr.attr == 1 << 8
|
||||
|
||||
|
||||
def test_init_screen_256_color(stdscr, syntax):
|
||||
@@ -131,7 +135,7 @@ def test_lazily_instantiated_pairs(stdscr, syntax):
|
||||
assert len(fake_curses.pairs) == 1
|
||||
|
||||
style = THEME.select(('string.python',))
|
||||
attr = syntax.get_blank_file_highlighter().attr(style)
|
||||
attr = syntax.blank_file_highlighter().attr(style)
|
||||
assert attr == 2 << 8
|
||||
|
||||
assert len(syntax.color_manager.raw_pairs) == 2
|
||||
@@ -143,5 +147,5 @@ def test_style_attributes_applied(stdscr, syntax):
|
||||
syntax._init_screen(stdscr)
|
||||
|
||||
style = THEME.select(('keyword.python',))
|
||||
attr = syntax.get_blank_file_highlighter().attr(style)
|
||||
attr = syntax.blank_file_highlighter().attr(style)
|
||||
assert attr == 2 << 8 | curses.A_BOLD
|
||||
|
||||
@@ -72,6 +72,31 @@ def test_theme_scope_split_by_commas():
|
||||
assert theme.select(('c',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_comma_at_beginning_and_end():
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
'tokenColors': [
|
||||
{'scope': '\n,a,b,\n', 'settings': {'fontStyle': 'italic'}},
|
||||
],
|
||||
})
|
||||
assert theme.select(('d',)).i is False
|
||||
assert theme.select(('a',)).i is True
|
||||
assert theme.select(('b',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_internal_newline_commas():
|
||||
# this is arguably malformed, but `cobalt2` in the wild has this issue
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
'tokenColors': [
|
||||
{'scope': '\n,a,\n,b,\n', 'settings': {'fontStyle': 'italic'}},
|
||||
],
|
||||
})
|
||||
assert theme.select(('d',)).i is False
|
||||
assert theme.select(('a',)).i is True
|
||||
assert theme.select(('b',)).i is True
|
||||
|
||||
|
||||
def test_theme_scope_as_A_list():
|
||||
theme = Theme.from_dct({
|
||||
'colors': {'foreground': '#cccccc', 'background': '#333333'},
|
||||
|
||||
Reference in New Issue
Block a user