Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ff8d9726 | ||
|
|
f0b2af9a9f | ||
|
|
fc21a144aa | ||
|
|
973b4c3cf8 | ||
|
|
bd60977438 | ||
|
|
144bbb9daf | ||
|
|
7c16cd966e | ||
|
|
dd19b26fa2 | ||
|
|
dca410dd44 | ||
|
|
ed51b6e6dc | ||
|
|
18b5e258f6 | ||
|
|
e7108f843b | ||
|
|
ff8d3f10fb | ||
|
|
8f603b8e14 | ||
|
|
c184468843 | ||
|
|
c5653976c7 | ||
|
|
d81bb12ff7 | ||
|
|
afe461372e | ||
|
|
b486047e90 | ||
|
|
f3401a46c7 | ||
|
|
fbf5fc6ba2 | ||
|
|
60b0a77f05 | ||
|
|
28a73a1a8c | ||
|
|
432640eaf1 | ||
|
|
71e67a6349 | ||
|
|
a5caa9d746 | ||
|
|
599dfa1d0e | ||
|
|
3f259403fe | ||
|
|
4b27a18c0f | ||
|
|
58bc4780ca | ||
|
|
4812daf300 |
@@ -11,16 +11,16 @@ repos:
|
||||
- id: name-tests-test
|
||||
- id: requirements-txt-fixer
|
||||
- repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.7.9
|
||||
rev: 3.8.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-typing-imports==1.7.0]
|
||||
- repo: https://github.com/pre-commit/mirrors-autopep8
|
||||
rev: v1.5
|
||||
rev: v1.5.2
|
||||
hooks:
|
||||
- id: autopep8
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v2.1.0
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: [--py3-plus]
|
||||
@@ -30,12 +30,12 @@ repos:
|
||||
- id: add-trailing-comma
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.1.0
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.7.0
|
||||
rev: v1.9.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
|
||||
@@ -45,7 +45,7 @@ these are all of the current key bindings in babi
|
||||
- <kbd>tab</kbd> / <kbd>shift-tab</kbd>: indent or dedent current line (or
|
||||
selection)
|
||||
- <kbd>^K</kbd> / <kbd>^U</kbd>: cut and uncut the current line (or selection)
|
||||
- <kbd>M-u</kbd> / <kbd>M-U</kbd>: undo / redo
|
||||
- <kbd>M-u</kbd> / <kbd>M-U</kbd> or <kbd>M-e</kbd>: undo / redo
|
||||
- <kbd>^W</kbd>: search
|
||||
- <kbd>^\\</kbd>: search and replace
|
||||
- <kbd>^C</kbd>: show the current position in the file
|
||||
@@ -87,7 +87,7 @@ this opens the file, displays it, and can be edited and can save! unknown keys
|
||||
are displayed as errors in the status bar. babi will scroll if the cursor
|
||||
goes off screen either from resize events or from movement. babi can edit
|
||||
multiple files. babi has a command mode (so you can quit it like vim
|
||||
<kbd>:q</kbd>!). babi also support syntax highlighting
|
||||
<kbd>:q</kbd>!). babi also supports syntax highlighting
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ resources:
|
||||
type: github
|
||||
endpoint: github
|
||||
name: asottile/azure-pipeline-templates
|
||||
ref: refs/tags/v1.0.0
|
||||
ref: refs/tags/v2.0.0
|
||||
|
||||
jobs:
|
||||
- template: job--pre-commit.yml@asottile
|
||||
- template: job--python-tox.yml@asottile
|
||||
parameters:
|
||||
toxenvs: [py36, py37, py38]
|
||||
toxenvs: [pypy3, py36, py37, py38, py39]
|
||||
os: linux
|
||||
|
||||
17
babi/buf.py
17
babi/buf.py
@@ -19,11 +19,11 @@ DelCallback = Callable[['Buf', int, str], None]
|
||||
InsCallback = Callable[['Buf', int], None]
|
||||
|
||||
|
||||
def _offsets(s: str) -> Tuple[int, ...]:
|
||||
def _offsets(s: str, tab_size: int) -> Tuple[int, ...]:
|
||||
ret = [0]
|
||||
for c in s:
|
||||
if c == '\t':
|
||||
ret.append(ret[-1] + (4 - ret[-1] % 4))
|
||||
ret.append(ret[-1] + (tab_size - ret[-1] % tab_size))
|
||||
else:
|
||||
ret.append(ret[-1] + wcwidth(c))
|
||||
return tuple(ret)
|
||||
@@ -57,8 +57,9 @@ class DelModification(NamedTuple):
|
||||
|
||||
|
||||
class Buf:
|
||||
def __init__(self, lines: List[str]) -> None:
|
||||
def __init__(self, lines: List[str], tab_size: int = 4) -> None:
|
||||
self._lines = lines
|
||||
self.tab_size = tab_size
|
||||
self.file_y = self.y = self._x = self._x_hint = 0
|
||||
|
||||
self._set_callbacks: List[SetCallback] = [self._set_cb]
|
||||
@@ -136,6 +137,10 @@ class Buf:
|
||||
if self[-1] != '':
|
||||
self.append('')
|
||||
|
||||
def set_tab_size(self, tab_size: int) -> None:
|
||||
self.tab_size = tab_size
|
||||
self._positions = [None]
|
||||
|
||||
# event handling
|
||||
|
||||
def add_set_callback(self, cb: SetCallback) -> None:
|
||||
@@ -219,7 +224,8 @@ class Buf:
|
||||
self._extend_positions(idx)
|
||||
value = self._positions[idx]
|
||||
if value is None:
|
||||
value = self._positions[idx] = _offsets(self._lines[idx])
|
||||
value = _offsets(self._lines[idx], self.tab_size)
|
||||
self._positions[idx] = value
|
||||
return value
|
||||
|
||||
def line_x(self, margin: Margin) -> int:
|
||||
@@ -238,7 +244,8 @@ class Buf:
|
||||
|
||||
def rendered_line(self, idx: int, margin: Margin) -> str:
|
||||
x = self._cursor_x if idx == self.y else 0
|
||||
return scrolled_line(self._lines[idx].expandtabs(4), x, margin.cols)
|
||||
expanded = self._lines[idx].expandtabs(self.tab_size)
|
||||
return scrolled_line(expanded, x, margin.cols)
|
||||
|
||||
# movement
|
||||
|
||||
|
||||
38
babi/file.py
38
babi/file.py
@@ -198,10 +198,12 @@ class File:
|
||||
def __init__(
|
||||
self,
|
||||
filename: Optional[str],
|
||||
initial_line: int,
|
||||
color_manager: ColorManager,
|
||||
hl_factories: Tuple[HLFactory, ...],
|
||||
) -> None:
|
||||
self.filename = filename
|
||||
self.initial_line = initial_line
|
||||
self.modified = False
|
||||
self.buf = Buf([])
|
||||
self.nl = '\n'
|
||||
@@ -215,7 +217,12 @@ class File:
|
||||
self.selection = Selection()
|
||||
self._file_hls: Tuple[FileHL, ...] = ()
|
||||
|
||||
def ensure_loaded(self, status: Status, stdin: str) -> None:
|
||||
def ensure_loaded(
|
||||
self,
|
||||
status: Status,
|
||||
margin: Margin,
|
||||
stdin: str,
|
||||
) -> None:
|
||||
if self.buf:
|
||||
return
|
||||
|
||||
@@ -237,7 +244,7 @@ class File:
|
||||
status.update('(new file)')
|
||||
lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
|
||||
|
||||
self.buf = Buf(lines)
|
||||
self.buf = Buf(lines, self.buf.tab_size)
|
||||
|
||||
if mixed:
|
||||
status.update(f'mixed newlines will be converted to {self.nl!r}')
|
||||
@@ -257,6 +264,8 @@ class File:
|
||||
for file_hl in self._file_hls:
|
||||
file_hl.register_callbacks(self.buf)
|
||||
|
||||
self.go_to_line(self.initial_line, margin)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<{type(self).__name__} {self.filename!r}>'
|
||||
|
||||
@@ -514,16 +523,16 @@ class File:
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
for l_y in range(s_y, e_y + 1):
|
||||
if self.buf[l_y]:
|
||||
self.buf[l_y] = ' ' * 4 + self.buf[l_y]
|
||||
self.buf[l_y] = ' ' * self.buf.tab_size + self.buf[l_y]
|
||||
if l_y == self.buf.y:
|
||||
self.buf.x += 4
|
||||
self.buf.x += self.buf.tab_size
|
||||
if l_y == sel_y and sel_x != 0:
|
||||
sel_x += 4
|
||||
sel_x += self.buf.tab_size
|
||||
self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
n = 4 - self.buf.x % 4
|
||||
n = self.buf.tab_size - self.buf.x % self.buf.tab_size
|
||||
line = self.buf[self.buf.y]
|
||||
self.buf[self.buf.y] = line[:self.buf.x] + n * ' ' + line[self.buf.x:]
|
||||
self.buf.x += n
|
||||
@@ -535,9 +544,8 @@ class File:
|
||||
else:
|
||||
self._tab(margin)
|
||||
|
||||
@staticmethod
|
||||
def _dedent_line(s: str) -> int:
|
||||
bound = min(len(s), 4)
|
||||
def _dedent_line(self, s: str) -> int:
|
||||
bound = min(len(s), self.buf.tab_size)
|
||||
i = 0
|
||||
while i < bound and s[i] == ' ':
|
||||
i += 1
|
||||
@@ -632,9 +640,9 @@ class File:
|
||||
self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
|
||||
self.buf.restore_eof_invariant()
|
||||
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int, reverse: bool) -> None:
|
||||
# self.buf intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.buf, s_y, e_y))
|
||||
lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
|
||||
for i, line in zip(range(s_y, e_y), lines):
|
||||
self.buf[i] = line
|
||||
|
||||
@@ -643,17 +651,17 @@ class File:
|
||||
self.buf.scroll_screen_if_needed(margin)
|
||||
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin) -> None:
|
||||
self._sort(margin, 0, len(self.buf) - 1)
|
||||
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) -> None:
|
||||
def sort_selection(self, margin: Margin, reverse: bool = False) -> None:
|
||||
(s_y, _), (e_y, _) = self.selection.get()
|
||||
e_y = min(e_y + 1, len(self.buf) - 1)
|
||||
if self.buf[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
self._sort(margin, s_y, e_y)
|
||||
self._sort(margin, s_y, e_y, reverse=reverse)
|
||||
|
||||
DISPATCH = {
|
||||
# movement
|
||||
|
||||
61
babi/main.py
61
babi/main.py
@@ -1,9 +1,13 @@
|
||||
import argparse
|
||||
import curses
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Tuple
|
||||
|
||||
from babi.buf import Buf
|
||||
from babi.file import File
|
||||
@@ -14,10 +18,11 @@ from babi.screen import make_stdscr
|
||||
from babi.screen import Screen
|
||||
|
||||
CONSOLE = 'CONIN$' if sys.platform == 'win32' else '/dev/tty'
|
||||
POSITION_RE = re.compile(r'^\+-?\d+$')
|
||||
|
||||
|
||||
def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
screen.file.ensure_loaded(screen.status, stdin)
|
||||
screen.file.ensure_loaded(screen.status, screen.margin, stdin)
|
||||
|
||||
while True:
|
||||
screen.status.tick(screen.margin)
|
||||
@@ -40,11 +45,12 @@ def _edit(screen: Screen, stdin: str) -> EditResult:
|
||||
|
||||
def c_main(
|
||||
stdscr: 'curses._CursesWindow',
|
||||
args: argparse.Namespace,
|
||||
filenames: List[Optional[str]],
|
||||
positions: List[int],
|
||||
stdin: str,
|
||||
perf: Perf,
|
||||
) -> int:
|
||||
with perf_log(args.perf_log) as perf:
|
||||
screen = Screen(stdscr, args.filenames or [None], perf)
|
||||
screen = Screen(stdscr, filenames, positions, perf)
|
||||
with screen.history.save():
|
||||
while screen.files:
|
||||
screen.i = screen.i % len(screen.files)
|
||||
@@ -67,8 +73,8 @@ def c_main(
|
||||
return 0
|
||||
|
||||
|
||||
def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], Perf())
|
||||
def _key_debug(stdscr: 'curses._CursesWindow', perf: Perf) -> int:
|
||||
screen = Screen(stdscr, ['<<key debug>>'], [0], perf)
|
||||
screen.file.buf = Buf([''])
|
||||
|
||||
while True:
|
||||
@@ -85,6 +91,37 @@ def _key_debug(stdscr: 'curses._CursesWindow') -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _filenames(filenames: List[str]) -> Tuple[List[Optional[str]], List[int]]:
|
||||
if not filenames:
|
||||
return [None], [0]
|
||||
|
||||
ret_filenames: List[Optional[str]] = []
|
||||
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: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('filenames', metavar='filename', nargs='*')
|
||||
@@ -102,11 +139,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
else:
|
||||
stdin = ''
|
||||
|
||||
with make_stdscr() as stdscr:
|
||||
# ignore backgrounding signals, we'll handle those in curses
|
||||
# fixes a problem with ^Z on termination which would break the terminal
|
||||
if sys.platform != 'win32': # pragma: win32 no cover # pragma: no branch
|
||||
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
|
||||
|
||||
with perf_log(args.perf_log) as perf, make_stdscr() as stdscr:
|
||||
if args.key_debug:
|
||||
return _key_debug(stdscr)
|
||||
return _key_debug(stdscr, perf)
|
||||
else:
|
||||
return c_main(stdscr, args, stdin)
|
||||
filenames, positions = _filenames(args.filenames)
|
||||
return c_main(stdscr, filenames, positions, stdin, perf)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
117
babi/reg.py
117
babi/reg.py
@@ -6,80 +6,36 @@ from typing import Tuple
|
||||
|
||||
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"""
|
||||
for c in chars:
|
||||
if f'\\{c}' in s:
|
||||
break
|
||||
else:
|
||||
return s
|
||||
|
||||
b = []
|
||||
i = 0
|
||||
length = len(s)
|
||||
while i < length:
|
||||
try:
|
||||
sbi = s.index('\\', i)
|
||||
except ValueError:
|
||||
b.append(s[i:])
|
||||
break
|
||||
if sbi > i:
|
||||
b.append(s[i:sbi])
|
||||
b.append('\\')
|
||||
i = sbi + 1
|
||||
if i < length:
|
||||
if s[i] in chars:
|
||||
b.append('\uffff')
|
||||
else:
|
||||
b.append(s[i])
|
||||
i += 1
|
||||
return ''.join(b)
|
||||
_FLAGS = {
|
||||
# (first_line, boundary)
|
||||
(False, False): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
|
||||
),
|
||||
(False, True): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_STRING
|
||||
),
|
||||
(True, False): (
|
||||
onigurumacffi.OnigSearchOption.NOT_END_STRING |
|
||||
onigurumacffi.OnigSearchOption.NOT_BEGIN_POSITION
|
||||
),
|
||||
(True, True): onigurumacffi.OnigSearchOption.NOT_END_STRING,
|
||||
}
|
||||
|
||||
|
||||
class _Reg:
|
||||
def __init__(self, s: str) -> None:
|
||||
self._pattern = s
|
||||
self._reg = onigurumacffi.compile(self._pattern)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'{type(self).__name__}({self._pattern!r})'
|
||||
|
||||
@cached_property
|
||||
def _reg(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'z'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Az'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'Gz'))
|
||||
|
||||
@cached_property
|
||||
def _reg_no_A_no_G(self) -> onigurumacffi._Pattern:
|
||||
return onigurumacffi.compile(_replace_esc(self._pattern, 'AGz'))
|
||||
|
||||
def _get_reg(
|
||||
self,
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> onigurumacffi._Pattern:
|
||||
if boundary:
|
||||
if first_line:
|
||||
return self._reg
|
||||
else:
|
||||
return self._reg_no_A
|
||||
else:
|
||||
if first_line:
|
||||
return self._reg_no_G
|
||||
else:
|
||||
return self._reg_no_A_no_G
|
||||
|
||||
def search(
|
||||
self,
|
||||
line: str,
|
||||
@@ -87,7 +43,7 @@ class _Reg:
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> Optional[Match[str]]:
|
||||
return self._get_reg(first_line, boundary).search(line, pos)
|
||||
return self._reg.search(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
|
||||
def match(
|
||||
self,
|
||||
@@ -96,36 +52,18 @@ class _Reg:
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> Optional[Match[str]]:
|
||||
return self._get_reg(first_line, boundary).match(line, pos)
|
||||
return self._reg.match(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
|
||||
|
||||
class _RegSet:
|
||||
def __init__(self, *s: str) -> None:
|
||||
self._patterns = s
|
||||
self._set = onigurumacffi.compile_regset(*self._patterns)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
args = ', '.join(repr(s) for s in self._patterns)
|
||||
return f'{type(self).__name__}({args})'
|
||||
|
||||
@cached_property
|
||||
def _set(self) -> onigurumacffi._RegSet:
|
||||
return onigurumacffi.compile_regset(*self._patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_A(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'A') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_G(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'G') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
@cached_property
|
||||
def _set_no_A_no_G(self) -> onigurumacffi._RegSet:
|
||||
patterns = (_replace_esc(p, 'AG') for p in self._patterns)
|
||||
return onigurumacffi.compile_regset(*patterns)
|
||||
|
||||
def search(
|
||||
self,
|
||||
line: str,
|
||||
@@ -133,16 +71,7 @@ class _RegSet:
|
||||
first_line: bool,
|
||||
boundary: bool,
|
||||
) -> Tuple[int, Optional[Match[str]]]:
|
||||
if boundary:
|
||||
if first_line:
|
||||
return self._set.search(line, pos)
|
||||
else:
|
||||
return self._set_no_A.search(line, pos)
|
||||
else:
|
||||
if first_line:
|
||||
return self._set_no_G.search(line, pos)
|
||||
else:
|
||||
return self._set_no_A_no_G.search(line, pos)
|
||||
return self._set.search(line, pos, flags=_FLAGS[first_line, boundary])
|
||||
|
||||
|
||||
def expand_escaped(match: Match[str], s: str) -> str:
|
||||
@@ -151,4 +80,4 @@ def expand_escaped(match: Match[str], s: str) -> str:
|
||||
|
||||
make_reg = functools.lru_cache(maxsize=None)(_Reg)
|
||||
make_regset = functools.lru_cache(maxsize=None)(_RegSet)
|
||||
ERR_REG = make_reg(')this pattern always triggers an error when used(')
|
||||
ERR_REG = make_reg('$ ^')
|
||||
|
||||
@@ -81,8 +81,11 @@ KEYNAME_REWRITE = {
|
||||
b'CTL_DOWN': b'kDN5',
|
||||
b'CTL_RIGHT': b'kRIT5',
|
||||
b'CTL_LEFT': b'kLFT5',
|
||||
b'CTL_HOME': b'kHOM5',
|
||||
b'CTL_END': b'kEND5',
|
||||
b'ALT_RIGHT': b'kRIT3',
|
||||
b'ALT_LEFT': b'kLFT3',
|
||||
b'ALT_E': b'M-e',
|
||||
# windows-curses: idk why these are different
|
||||
b'KEY_SUP': b'KEY_SR',
|
||||
b'KEY_SDOWN': b'KEY_SF',
|
||||
@@ -90,6 +93,7 @@ KEYNAME_REWRITE = {
|
||||
b'^?': b'KEY_BACKSPACE',
|
||||
# linux, perhaps others
|
||||
b'^H': b'KEY_BACKSPACE', # ^Backspace on my keyboard
|
||||
b'PADENTER': b'^M', # Enter on numpad
|
||||
}
|
||||
|
||||
|
||||
@@ -103,14 +107,15 @@ class Screen:
|
||||
self,
|
||||
stdscr: 'curses._CursesWindow',
|
||||
filenames: List[Optional[str]],
|
||||
initial_lines: List[int],
|
||||
perf: Perf,
|
||||
) -> None:
|
||||
self.stdscr = stdscr
|
||||
self.color_manager = ColorManager.make()
|
||||
self.hl_factories = (Syntax.from_screen(stdscr, self.color_manager),)
|
||||
self.files = [
|
||||
File(filename, self.color_manager, self.hl_factories)
|
||||
for filename in filenames
|
||||
File(filename, line, self.color_manager, self.hl_factories)
|
||||
for filename, line in zip(filenames, initial_lines)
|
||||
]
|
||||
self.i = 0
|
||||
self.history = History()
|
||||
@@ -221,6 +226,9 @@ class Screen:
|
||||
if self._buffered_input is not None:
|
||||
wch, self._buffered_input = self._buffered_input, None
|
||||
else:
|
||||
try:
|
||||
wch = self.stdscr.get_wch()
|
||||
except curses.error: # pragma: no cover (macos bug?)
|
||||
wch = self.stdscr.get_wch()
|
||||
if isinstance(wch, str) and wch == '\x1b':
|
||||
wch = self._get_sequence(wch)
|
||||
@@ -413,7 +421,9 @@ class Screen:
|
||||
|
||||
def command(self) -> Optional[EditResult]:
|
||||
response = self.prompt('', history='command')
|
||||
if response == ':q':
|
||||
if response is PromptResult.CANCELLED:
|
||||
pass
|
||||
elif response == ':q':
|
||||
return self.quit_save_modified()
|
||||
elif response == ':q!':
|
||||
return EditResult.EXIT
|
||||
@@ -428,7 +438,26 @@ class Screen:
|
||||
else:
|
||||
self.file.sort(self.margin)
|
||||
self.status.update('sorted!')
|
||||
elif response is not PromptResult.CANCELLED:
|
||||
elif response == ':sort!':
|
||||
if self.file.selection.start:
|
||||
self.file.sort_selection(self.margin, reverse=True)
|
||||
else:
|
||||
self.file.sort(self.margin, reverse=True)
|
||||
self.status.update('sorted!')
|
||||
elif response.startswith((':tabstop ', ':tabsize ')):
|
||||
_, _, tab_size = response.partition(' ')
|
||||
try:
|
||||
parsed_tab_size = int(tab_size)
|
||||
except ValueError:
|
||||
self.status.update(f'invalid size: {tab_size}')
|
||||
else:
|
||||
if parsed_tab_size <= 0:
|
||||
self.status.update(f'invalid size: {parsed_tab_size}')
|
||||
else:
|
||||
for file in self.files:
|
||||
file.buf.set_tab_size(parsed_tab_size)
|
||||
self.status.update('updated!')
|
||||
else:
|
||||
self.status.update(f'invalid command: {response}')
|
||||
return None
|
||||
|
||||
@@ -489,7 +518,7 @@ class Screen:
|
||||
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)
|
||||
opened = File(response, 0, self.color_manager, self.hl_factories)
|
||||
self.files.append(opened)
|
||||
return EditResult.OPEN
|
||||
else:
|
||||
@@ -513,6 +542,9 @@ class Screen:
|
||||
return EditResult.EXIT
|
||||
|
||||
def background(self) -> None:
|
||||
if sys.platform == 'win32': # pragma: win32 cover
|
||||
self.status.update('cannot run babi in background on Windows')
|
||||
else: # pragma: win32 no cover
|
||||
curses.endwin()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
self.stdscr = _init_screen()
|
||||
@@ -526,6 +558,7 @@ class Screen:
|
||||
b'^U': uncut,
|
||||
b'M-u': undo,
|
||||
b'M-U': redo,
|
||||
b'M-e': redo,
|
||||
b'^W': search,
|
||||
b'^\\': replace,
|
||||
b'^[': command,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[metadata]
|
||||
name = babi
|
||||
version = 0.0.7
|
||||
version = 0.0.12
|
||||
description = a text editor
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
@@ -24,7 +24,7 @@ packages = find:
|
||||
install_requires =
|
||||
babi-grammars
|
||||
identify
|
||||
onigurumacffi>=0.0.10
|
||||
onigurumacffi>=0.0.18
|
||||
importlib_metadata>=1;python_version<"3.8"
|
||||
windows-curses;sys_platform=="win32"
|
||||
python_requires = >=3.6.1
|
||||
|
||||
@@ -391,6 +391,7 @@ class DeferredRunner:
|
||||
|
||||
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
|
||||
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
|
||||
_curses_set_escdelay = _curses__noop
|
||||
|
||||
_curses_error = curses.error # so we don't mock the exception
|
||||
|
||||
|
||||
28
tests/features/initial_position_test.py
Normal file
28
tests/features/initial_position_test.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
@@ -133,7 +133,7 @@ def test_save_via_ctrl_o(run, tmpdir):
|
||||
with run(str(f)) as h, and_exit(h):
|
||||
h.press('hello world')
|
||||
h.press('^O')
|
||||
h.await_text(f'enter filename: ')
|
||||
h.await_text('enter filename: ')
|
||||
h.press('Enter')
|
||||
h.await_text('saved! (1 line written)')
|
||||
assert f.read() == 'hello world\n'
|
||||
@@ -237,7 +237,7 @@ def test_vim_save_on_exit(run, tmpdir):
|
||||
h.press_and_enter(':q')
|
||||
h.await_text('file is modified - save [yes, no]?')
|
||||
h.press('y')
|
||||
h.await_text(f'enter filename: ')
|
||||
h.await_text('enter filename: ')
|
||||
h.press('Enter')
|
||||
h.await_exit()
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@ def test_sort_entire_file(run, unsorted):
|
||||
assert unsorted.read() == 'a\nb\nc\nd\n'
|
||||
|
||||
|
||||
def test_reverse_sort_entire_file(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':sort!')
|
||||
h.await_text('sorted!')
|
||||
h.await_cursor_position(x=0, y=1)
|
||||
h.press('^S')
|
||||
assert unsorted.read() == 'd\nc\nb\na\n'
|
||||
|
||||
|
||||
def test_sort_selection(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
h.press('S-Down')
|
||||
@@ -32,6 +42,18 @@ def test_sort_selection(run, unsorted):
|
||||
assert unsorted.read() == 'b\nd\nc\na\n'
|
||||
|
||||
|
||||
def test_reverse_sort_selection(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
h.press('Down')
|
||||
h.press('S-Down')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(':sort!')
|
||||
h.await_text('sorted!')
|
||||
h.await_cursor_position(x=0, y=2)
|
||||
h.press('^S')
|
||||
assert unsorted.read() == 'd\nc\nb\na\n'
|
||||
|
||||
|
||||
def test_sort_selection_does_not_include_eof(run, unsorted):
|
||||
with run(str(unsorted)) as h, and_exit(h):
|
||||
for _ in range(5):
|
||||
|
||||
30
tests/features/tabsize_test.py
Normal file
30
tests/features/tabsize_test.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
from testing.runner import trigger_command_mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize('setting', ('tabsize', 'tabstop'))
|
||||
def test_set_tabstop(run, setting):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(f':{setting} 2')
|
||||
h.await_text('updated!')
|
||||
h.press('Tab')
|
||||
h.await_text('\n a')
|
||||
h.await_cursor_position(x=2, y=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tabsize', ('-1', '0', 'wat'))
|
||||
def test_set_invalid_tabstop(run, tabsize):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('a')
|
||||
h.press('Left')
|
||||
trigger_command_mode(h)
|
||||
h.press_and_enter(f':tabstop {tabsize}')
|
||||
h.await_text(f'invalid size: {tabsize}')
|
||||
h.press('Tab')
|
||||
h.await_text(' a')
|
||||
h.await_cursor_position(x=4, y=1)
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from testing.runner import and_exit
|
||||
|
||||
|
||||
@@ -9,7 +11,8 @@ def test_nothing_to_undo_redo(run):
|
||||
h.await_text('nothing to redo!')
|
||||
|
||||
|
||||
def test_undo_redo(run):
|
||||
@pytest.mark.parametrize('r', ('M-U', 'M-e'))
|
||||
def test_undo_redo(run, r):
|
||||
with run() as h, and_exit(h):
|
||||
h.press('hello')
|
||||
h.await_text('hello')
|
||||
@@ -17,7 +20,7 @@ def test_undo_redo(run):
|
||||
h.await_text('undo: text')
|
||||
h.await_text_missing('hello')
|
||||
h.await_text_missing(' *')
|
||||
h.press('M-U')
|
||||
h.press(r)
|
||||
h.await_text('redo: text')
|
||||
h.await_text('hello')
|
||||
h.await_text(' *')
|
||||
|
||||
@@ -8,7 +8,7 @@ from babi.file import get_lines
|
||||
|
||||
|
||||
def test_position_repr():
|
||||
ret = repr(File('f.txt', ColorManager.make(), ()))
|
||||
ret = repr(File('f.txt', 0, ColorManager.make(), ()))
|
||||
assert ret == "<File 'f.txt'>"
|
||||
|
||||
|
||||
|
||||
19
tests/main_test.py
Normal file
19
tests/main_test.py
Normal file
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
@@ -35,9 +35,8 @@ def test_reg_other_escapes_left_untouched():
|
||||
def test_reg_not_out_of_bounds_at_end():
|
||||
# the only way this is triggerable is with an illegal regex, we'd rather
|
||||
# produce an error about the regex being wrong than an IndexError
|
||||
reg = _Reg('\\A\\')
|
||||
with pytest.raises(onigurumacffi.OnigError) as excinfo:
|
||||
reg.search('\\', 0, first_line=False, boundary=False)
|
||||
_Reg('\\A\\')
|
||||
msg, = excinfo.value.args
|
||||
assert msg == 'end pattern at escape'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user