move File into its own file
This commit is contained in:
873
babi/file.py
Normal file
873
babi/file.py
Normal file
@@ -0,0 +1,873 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import os.path
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
from typing import Generator
|
||||
from typing import IO
|
||||
from typing import List
|
||||
from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Pattern
|
||||
from typing import Tuple
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.list_spy import ListSpy
|
||||
from babi.list_spy import MutableSequenceNoSlice
|
||||
from babi.margin import Margin
|
||||
from babi.prompt import PromptResult
|
||||
from babi.status import Status
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babi.main import Screen # XXX: circular
|
||||
|
||||
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
|
||||
HIGHLIGHT = curses.A_REVERSE | curses.A_DIM
|
||||
|
||||
|
||||
def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None:
|
||||
"""The file lines will always contain a blank empty string at the end to
|
||||
simplify rendering. This should be called whenever the end of the file
|
||||
might change.
|
||||
"""
|
||||
if not lines or lines[-1] != '':
|
||||
lines.append('')
|
||||
|
||||
|
||||
def get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
|
||||
sha256 = hashlib.sha256()
|
||||
lines = []
|
||||
newlines = collections.Counter({'\n': 0}) # default to `\n`
|
||||
for line in sio:
|
||||
sha256.update(line.encode())
|
||||
for ending in ('\r\n', '\n'):
|
||||
if line.endswith(ending):
|
||||
lines.append(line[:-1 * len(ending)])
|
||||
newlines[ending] += 1
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
_restore_lines_eof_invariant(lines)
|
||||
(nl, _), = newlines.most_common(1)
|
||||
mixed = len({k for k, v in newlines.items() if v}) > 1
|
||||
return lines, nl, mixed, sha256.hexdigest()
|
||||
|
||||
|
||||
class Action:
|
||||
def __init__(
|
||||
self, *, name: str, spy: ListSpy,
|
||||
start_x: int, start_y: int, start_modified: bool,
|
||||
end_x: int, end_y: int, end_modified: bool,
|
||||
final: bool,
|
||||
):
|
||||
self.name = name
|
||||
self.spy = spy
|
||||
self.start_x = start_x
|
||||
self.start_y = start_y
|
||||
self.start_modified = start_modified
|
||||
self.end_x = end_x
|
||||
self.end_y = end_y
|
||||
self.end_modified = end_modified
|
||||
self.final = final
|
||||
|
||||
def apply(self, file: 'File') -> 'Action':
|
||||
spy = ListSpy(file.lines)
|
||||
action = Action(
|
||||
name=self.name, spy=spy,
|
||||
start_x=self.end_x, start_y=self.end_y,
|
||||
start_modified=self.end_modified,
|
||||
end_x=self.start_x, end_y=self.start_y,
|
||||
end_modified=self.start_modified,
|
||||
final=True,
|
||||
)
|
||||
|
||||
self.spy.undo(spy)
|
||||
file.x = self.start_x
|
||||
file.y = self.start_y
|
||||
file.modified = self.start_modified
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def action(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
self.finalize_previous_action()
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, action_inner)
|
||||
|
||||
|
||||
def edit_action(
|
||||
name: str,
|
||||
*,
|
||||
final: bool,
|
||||
) -> Callable[[TCallable], TCallable]:
|
||||
def edit_action_decorator(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.edit_action_context(name, final=final):
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, edit_action_inner)
|
||||
return edit_action_decorator
|
||||
|
||||
|
||||
def keep_selection(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.select():
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, keep_selection_inner)
|
||||
|
||||
|
||||
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
|
||||
return ret
|
||||
return cast(TCallable, clear_selection_inner)
|
||||
|
||||
|
||||
class Found(NamedTuple):
|
||||
y: int
|
||||
match: Match[str]
|
||||
|
||||
|
||||
class _SearchIter:
|
||||
def __init__(
|
||||
self,
|
||||
file: 'File',
|
||||
reg: Pattern[str],
|
||||
*,
|
||||
offset: int,
|
||||
) -> None:
|
||||
self.file = file
|
||||
self.reg = reg
|
||||
self.offset = offset
|
||||
self.wrapped = False
|
||||
self._start_x = file.x + offset
|
||||
self._start_y = file.y
|
||||
|
||||
def __iter__(self) -> '_SearchIter':
|
||||
return self
|
||||
|
||||
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
|
||||
if (
|
||||
self.wrapped and (
|
||||
y > self._start_y or
|
||||
y == self._start_y and match.start() >= self._start_x
|
||||
)
|
||||
):
|
||||
raise StopIteration()
|
||||
return Found(y, match)
|
||||
|
||||
def __next__(self) -> Tuple[int, Match[str]]:
|
||||
x = self.file.x + self.offset
|
||||
y = self.file.y
|
||||
|
||||
match = self.reg.search(self.file.lines[y], x)
|
||||
if match:
|
||||
return self._stop_if_past_original(y, match)
|
||||
|
||||
if self.wrapped:
|
||||
for line_y in range(y + 1, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
else:
|
||||
for line_y in range(y + 1, len(self.file.lines)):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
self.wrapped = True
|
||||
|
||||
for line_y in range(0, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
raise StopIteration()
|
||||
|
||||
|
||||
class File:
|
||||
def __init__(self, filename: Optional[str]) -> None:
|
||||
self.filename = filename
|
||||
self.modified = False
|
||||
self.lines: MutableSequenceNoSlice = []
|
||||
self.nl = '\n'
|
||||
self.file_y = self.y = self.x = self.x_hint = 0
|
||||
self.sha256: Optional[str] = None
|
||||
self.undo_stack: List[Action] = []
|
||||
self.redo_stack: List[Action] = []
|
||||
self.select_start: Optional[Tuple[int, int]] = None
|
||||
|
||||
def ensure_loaded(self, status: Status) -> None:
|
||||
if self.lines:
|
||||
return
|
||||
|
||||
if 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:
|
||||
if self.filename is not None:
|
||||
if os.path.lexists(self.filename):
|
||||
status.update(f'{self.filename!r} is not a file')
|
||||
self.filename = None
|
||||
else:
|
||||
status.update('(new file)')
|
||||
sio = io.StringIO('')
|
||||
self.lines, self.nl, mixed, self.sha256 = get_lines(sio)
|
||||
|
||||
if mixed:
|
||||
status.update(f'mixed newlines will be converted to {self.nl!r}')
|
||||
self.modified = True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
|
||||
return f'{type(self).__name__}(\n {attrs},\n)'
|
||||
|
||||
# movement
|
||||
|
||||
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||
# if the `y` is not on screen, make it so
|
||||
if self.file_y <= self.y < self.file_y + margin.body_lines:
|
||||
return
|
||||
|
||||
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||
|
||||
def _scroll_amount(self) -> int:
|
||||
return int(curses.LINES / 2 + .5)
|
||||
|
||||
def _set_x_after_vertical_movement(self) -> None:
|
||||
self.x = min(len(self.lines[self.y]), self.x_hint)
|
||||
|
||||
def _increment_y(self, margin: Margin) -> None:
|
||||
self.y += 1
|
||||
if self.y >= self.file_y + margin.body_lines:
|
||||
self.file_y += self._scroll_amount()
|
||||
|
||||
def _decrement_y(self, margin: Margin) -> None:
|
||||
self.y -= 1
|
||||
if self.y < self.file_y:
|
||||
self.file_y -= self._scroll_amount()
|
||||
self.file_y = max(self.file_y, 0)
|
||||
|
||||
@action
|
||||
def up(self, margin: Margin) -> None:
|
||||
if self.y > 0:
|
||||
self._decrement_y(margin)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def down(self, margin: Margin) -> None:
|
||||
if self.y < len(self.lines) - 1:
|
||||
self._increment_y(margin)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def right(self, margin: Margin) -> None:
|
||||
if self.x >= len(self.lines[self.y]):
|
||||
if self.y < len(self.lines) - 1:
|
||||
self.x = 0
|
||||
self._increment_y(margin)
|
||||
else:
|
||||
self.x += 1
|
||||
self.x_hint = self.x
|
||||
|
||||
@action
|
||||
def left(self, margin: Margin) -> None:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self._decrement_y(margin)
|
||||
self.x = len(self.lines[self.y])
|
||||
else:
|
||||
self.x -= 1
|
||||
self.x_hint = self.x
|
||||
|
||||
@action
|
||||
def home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@action
|
||||
def end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
|
||||
@action
|
||||
def ctrl_up(self, margin: Margin) -> None:
|
||||
self.file_y = max(0, self.file_y - 1)
|
||||
self.y = min(self.y, self.file_y + margin.body_lines - 1)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def ctrl_down(self, margin: Margin) -> None:
|
||||
self.file_y = min(len(self.lines) - 1, self.file_y + 1)
|
||||
self.y = max(self.y, self.file_y)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def ctrl_right(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
# if we're at the second to last character, jump to end of line
|
||||
if self.x == len(line) - 1:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
# if we're at the end of the line, jump forward to the next non-ws
|
||||
elif self.x == len(line):
|
||||
while (
|
||||
self.y < len(self.lines) - 1 and (
|
||||
self.x == len(self.lines[self.y]) or
|
||||
self.lines[self.y][self.x].isspace()
|
||||
)
|
||||
):
|
||||
if self.x == len(self.lines[self.y]):
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
# if we're inside the line, jump to next position that's not our type
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
tp = line[self.x].isalnum()
|
||||
while self.x < len(line) and tp == line[self.x].isalnum():
|
||||
self.x = self.x_hint = self.x + 1
|
||||
|
||||
@action
|
||||
def ctrl_left(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
# if we're at position 1 and it's not a space, go to the beginning
|
||||
if self.x == 1 and not line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
# if we're at the beginning or it's all space up to here jump to the
|
||||
# end of the previous non-space line
|
||||
elif self.x == 0 or line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
while self.y > 0 and (self.x == 0 or not self.lines[self.y]):
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
else:
|
||||
self.x = self.x_hint = self.x - 1
|
||||
tp = line[self.x - 1].isalnum()
|
||||
while self.x > 0 and tp == line[self.x - 1].isalnum():
|
||||
self.x = self.x_hint = self.x - 1
|
||||
|
||||
@action
|
||||
def ctrl_home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = self.file_y = 0
|
||||
|
||||
@action
|
||||
def ctrl_end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = len(self.lines) - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def go_to_line(self, lineno: int, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
if lineno == 0:
|
||||
self.y = 0
|
||||
elif lineno > len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
elif lineno < 0:
|
||||
self.y = max(0, lineno + len(self.lines))
|
||||
else:
|
||||
self.y = lineno - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def search(
|
||||
self,
|
||||
reg: Pattern[str],
|
||||
status: Status,
|
||||
margin: Margin,
|
||||
) -> None:
|
||||
search = _SearchIter(self, reg, offset=1)
|
||||
try:
|
||||
line_y, match = next(iter(search))
|
||||
except StopIteration:
|
||||
status.update('no matches')
|
||||
else:
|
||||
if line_y == self.y and match.start() == self.x:
|
||||
status.update('this is the only occurrence')
|
||||
else:
|
||||
if search.wrapped:
|
||||
status.update('search wrapped')
|
||||
self.y = line_y
|
||||
self.x = self.x_hint = match.start()
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@clear_selection
|
||||
def replace(
|
||||
self,
|
||||
screen: 'Screen',
|
||||
reg: Pattern[str],
|
||||
replace: str,
|
||||
) -> 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)
|
||||
for line_y, match in search:
|
||||
self.y = line_y
|
||||
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',
|
||||
)
|
||||
if res in {'y', 'a'}:
|
||||
count += 1
|
||||
with self.edit_action_context('replace', final=True):
|
||||
replaced = match.expand(replace)
|
||||
line = screen.file.lines[line_y]
|
||||
line = line[:match.start()] + replaced + line[match.end():]
|
||||
screen.file.lines[line_y] = line
|
||||
search.offset = len(replaced)
|
||||
elif res == 'n':
|
||||
search.offset = 1
|
||||
else:
|
||||
assert res is PromptResult.CANCELLED
|
||||
return
|
||||
|
||||
if res == '': # we never went through the loop
|
||||
screen.status.update('no matches')
|
||||
else:
|
||||
occurrences = 'occurrence' if count == 1 else 'occurrences'
|
||||
screen.status.update(f'replaced {count} {occurrences}')
|
||||
|
||||
@action
|
||||
def page_up(self, margin: Margin) -> None:
|
||||
if self.y < margin.body_lines:
|
||||
self.y = self.file_y = 0
|
||||
else:
|
||||
pos = max(self.file_y - margin.page_size, 0)
|
||||
self.y = self.file_y = pos
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def page_down(self, margin: Margin) -> None:
|
||||
if self.file_y + margin.body_lines >= len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
else:
|
||||
pos = self.file_y + margin.page_size
|
||||
self.y = self.file_y = pos
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
# editing
|
||||
|
||||
@edit_action('backspace text', final=False)
|
||||
@clear_selection
|
||||
def backspace(self, margin: Margin) -> None:
|
||||
# backspace at the beginning of the file does nothing
|
||||
if self.y == 0 and self.x == 0:
|
||||
pass
|
||||
# backspace at the end of the file does not change the contents
|
||||
elif self.y == len(self.lines) - 1:
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
elif self.x == 0:
|
||||
victim = self.lines.pop(self.y)
|
||||
new_x = len(self.lines[self.y - 1])
|
||||
self.lines[self.y - 1] += victim
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = new_x
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x - 1] + s[self.x:]
|
||||
self.x = self.x_hint = self.x - 1
|
||||
|
||||
@edit_action('delete text', final=False)
|
||||
@clear_selection
|
||||
def delete(self, margin: Margin) -> None:
|
||||
# noop at end of the file
|
||||
if self.y == len(self.lines) - 1:
|
||||
pass
|
||||
# if we're at the end of the line, collapse the line afterwards
|
||||
elif self.x == len(self.lines[self.y]):
|
||||
victim = self.lines.pop(self.y + 1)
|
||||
self.lines[self.y] += victim
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + s[self.x + 1:]
|
||||
|
||||
@edit_action('line break', final=False)
|
||||
@clear_selection
|
||||
def enter(self, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x]
|
||||
self.lines.insert(self.y + 1, s[self.x:])
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
n = 4 - self.x % 4
|
||||
line = self.lines[self.y]
|
||||
self.lines[self.y] = line[:self.x] + n * ' ' + line[self.x:]
|
||||
self.x = self.x_hint = self.x + n
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
def tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
self._indent_selection(margin)
|
||||
else:
|
||||
self._tab(margin)
|
||||
|
||||
@staticmethod
|
||||
def _dedent_line(s: str) -> int:
|
||||
bound = min(len(s), 4)
|
||||
i = 0
|
||||
while i < bound and s[i] == ' ':
|
||||
i += 1
|
||||
return i
|
||||
|
||||
@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()
|
||||
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)
|
||||
|
||||
@edit_action('dedent', final=True)
|
||||
def _dedent(self, margin: Margin) -> None:
|
||||
n = self._dedent_line(self.lines[self.y])
|
||||
if n:
|
||||
self.lines[self.y] = self.lines[self.y][n:]
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
|
||||
def shift_tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
self._dedent_selection(margin)
|
||||
else:
|
||||
self._dedent(margin)
|
||||
|
||||
@edit_action('cut selection', final=True)
|
||||
@clear_selection
|
||||
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
|
||||
ret = []
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
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:]
|
||||
else:
|
||||
ret.append(self.lines[s_y][s_x:])
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
ret.append(self.lines[l_y])
|
||||
ret.append(self.lines[e_y][:e_x])
|
||||
|
||||
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[e_y][e_x:]
|
||||
for _ in range(s_y + 1, e_y + 1):
|
||||
self.lines.pop(s_y + 1)
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = s_x
|
||||
self.scroll_screen_if_needed(margin)
|
||||
return tuple(ret)
|
||||
|
||||
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
|
||||
# only continue a cut if the last action is a non-final cut
|
||||
if not self._continue_last_action('cut'):
|
||||
cut_buffer = ()
|
||||
|
||||
with self.edit_action_context('cut', final=False):
|
||||
if self.y == len(self.lines) - 1:
|
||||
return ()
|
||||
else:
|
||||
victim = self.lines.pop(self.y)
|
||||
self.x = self.x_hint = 0
|
||||
return cut_buffer + (victim,)
|
||||
|
||||
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
for cut_line in cut_buffer:
|
||||
line = self.lines[self.y]
|
||||
before, after = line[:self.x], line[self.x:]
|
||||
self.lines[self.y] = before + cut_line
|
||||
self.lines.insert(self.y + 1, after)
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@edit_action('uncut', final=True)
|
||||
@clear_selection
|
||||
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
|
||||
@edit_action('uncut selection', final=True)
|
||||
@clear_selection
|
||||
def uncut_selection(
|
||||
self,
|
||||
cut_buffer: Tuple[str, ...], margin: Margin,
|
||||
) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
self.lines[self.y] += self.lines.pop(self.y + 1)
|
||||
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
|
||||
# self.lines intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.lines, s_y, e_y))
|
||||
for i, line in zip(range(s_y, e_y), lines):
|
||||
self.lines[i] = line
|
||||
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = 0
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin) -> None:
|
||||
self._sort(margin, 0, len(self.lines) - 1)
|
||||
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin) -> None:
|
||||
(s_y, _), (e_y, _) = self._get_selection()
|
||||
e_y = min(e_y + 1, len(self.lines) - 1)
|
||||
if self.lines[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
self._sort(margin, s_y, e_y)
|
||||
|
||||
DISPATCH = {
|
||||
# movement
|
||||
b'KEY_UP': up,
|
||||
b'KEY_DOWN': down,
|
||||
b'KEY_RIGHT': right,
|
||||
b'KEY_LEFT': left,
|
||||
b'KEY_HOME': home,
|
||||
b'^A': home,
|
||||
b'KEY_END': end,
|
||||
b'^E': end,
|
||||
b'KEY_PPAGE': page_up,
|
||||
b'^Y': page_up,
|
||||
b'KEY_NPAGE': page_down,
|
||||
b'^V': page_down,
|
||||
b'kUP5': ctrl_up,
|
||||
b'kDN5': ctrl_down,
|
||||
b'kRIT5': ctrl_right,
|
||||
b'kLFT5': ctrl_left,
|
||||
b'kHOM5': ctrl_home,
|
||||
b'kEND5': ctrl_end,
|
||||
# editing
|
||||
b'KEY_BACKSPACE': backspace,
|
||||
b'^H': backspace, # ^Backspace
|
||||
b'KEY_DC': delete,
|
||||
b'^M': enter,
|
||||
b'^I': tab,
|
||||
b'KEY_BTAB': shift_tab,
|
||||
# selection (shift + movement)
|
||||
b'KEY_SR': keep_selection(up),
|
||||
b'KEY_SF': keep_selection(down),
|
||||
b'KEY_SLEFT': keep_selection(left),
|
||||
b'KEY_SRIGHT': keep_selection(right),
|
||||
b'KEY_SHOME': keep_selection(home),
|
||||
b'KEY_SEND': keep_selection(end),
|
||||
b'KEY_SPREVIOUS': keep_selection(page_up),
|
||||
b'KEY_SNEXT': keep_selection(page_down),
|
||||
b'kRIT6': keep_selection(ctrl_right),
|
||||
b'kLFT6': keep_selection(ctrl_left),
|
||||
b'kHOM6': keep_selection(ctrl_home),
|
||||
b'kEND6': keep_selection(ctrl_end),
|
||||
}
|
||||
|
||||
@edit_action('text', final=False)
|
||||
@clear_selection
|
||||
def c(self, wch: str, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + wch + s[self.x:]
|
||||
self.x = self.x_hint = self.x + 1
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
def finalize_previous_action(self) -> None:
|
||||
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
|
||||
self.select_start = None
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
|
||||
def _continue_last_action(self, name: str) -> bool:
|
||||
return (
|
||||
bool(self.undo_stack) and
|
||||
self.undo_stack[-1].name == name and
|
||||
not self.undo_stack[-1].final
|
||||
)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def edit_action_context(
|
||||
self, name: str,
|
||||
*,
|
||||
final: bool,
|
||||
) -> Generator[None, None, None]:
|
||||
continue_last = self._continue_last_action(name)
|
||||
if continue_last:
|
||||
spy = self.undo_stack[-1].spy
|
||||
else:
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
spy = ListSpy(self.lines)
|
||||
|
||||
before_x, before_line = self.x, self.y
|
||||
before_modified = self.modified
|
||||
assert not isinstance(self.lines, ListSpy), 'recursive action?'
|
||||
orig, self.lines = self.lines, spy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.lines = orig
|
||||
self.redo_stack.clear()
|
||||
if continue_last:
|
||||
self.undo_stack[-1].end_x = self.x
|
||||
self.undo_stack[-1].end_y = self.y
|
||||
elif spy.has_modifications:
|
||||
self.modified = True
|
||||
action = Action(
|
||||
name=name, spy=spy,
|
||||
start_x=before_x, start_y=before_line,
|
||||
start_modified=before_modified,
|
||||
end_x=self.x, end_y=self.y,
|
||||
end_modified=True,
|
||||
final=final,
|
||||
)
|
||||
self.undo_stack.append(action)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def select(self) -> Generator[None, None, None]:
|
||||
if self.select_start is None:
|
||||
select_start = (self.y, self.x)
|
||||
else:
|
||||
select_start = self.select_start
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.select_start = select_start
|
||||
|
||||
# positioning
|
||||
|
||||
def rendered_y(self, margin: Margin) -> int:
|
||||
return self.y - self.file_y + margin.header
|
||||
|
||||
def rendered_x(self) -> int:
|
||||
return self.x - line_x(self.x, curses.COLS)
|
||||
|
||||
def move_cursor(
|
||||
self,
|
||||
stdscr: 'curses._CursesWindow',
|
||||
margin: Margin,
|
||||
) -> 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 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)
|
||||
blankline = ' ' * curses.COLS
|
||||
for i in range(to_display, margin.body_lines):
|
||||
stdscr.insstr(i + margin.header, 0, blankline)
|
||||
|
||||
if self.select_start is not None:
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
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:
|
||||
raise NotImplementedError('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)
|
||||
857
babi/main.py
857
babi/main.py
@@ -1,36 +1,26 @@
|
||||
import argparse
|
||||
import collections
|
||||
import contextlib
|
||||
import curses
|
||||
import enum
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
from typing import Generator
|
||||
from typing import IO
|
||||
from typing import List
|
||||
from typing import Match
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Pattern
|
||||
from typing import Sequence
|
||||
from typing import Tuple
|
||||
from typing import TypeVar
|
||||
from typing import Union
|
||||
|
||||
from babi.file import Action
|
||||
from babi.file import File
|
||||
from babi.file import get_lines
|
||||
from babi.history import History
|
||||
from babi.horizontal_scrolling import line_x
|
||||
from babi.horizontal_scrolling import scrolled_line
|
||||
from babi.list_spy import ListSpy
|
||||
from babi.list_spy import MutableSequenceNoSlice
|
||||
from babi.margin import Margin
|
||||
from babi.perf import Perf
|
||||
from babi.prompt import Prompt
|
||||
@@ -38,9 +28,7 @@ from babi.prompt import PromptResult
|
||||
from babi.status import Status
|
||||
|
||||
VERSION_STR = 'babi v0'
|
||||
TCallable = TypeVar('TCallable', bound=Callable[..., Any])
|
||||
EditResult = enum.Enum('EditResult', 'EXIT NEXT PREV')
|
||||
HIGHLIGHT = curses.A_REVERSE | curses.A_DIM
|
||||
|
||||
# TODO: find a place to populate these, surely there's a database somewhere
|
||||
SEQUENCE_KEYNAME = {
|
||||
@@ -76,843 +64,6 @@ class Key(NamedTuple):
|
||||
keyname: bytes
|
||||
|
||||
|
||||
def _restore_lines_eof_invariant(lines: MutableSequenceNoSlice) -> None:
|
||||
"""The file lines will always contain a blank empty string at the end to
|
||||
simplify rendering. This should be called whenever the end of the file
|
||||
might change.
|
||||
"""
|
||||
if not lines or lines[-1] != '':
|
||||
lines.append('')
|
||||
|
||||
|
||||
def _get_lines(sio: IO[str]) -> Tuple[List[str], str, bool, str]:
|
||||
sha256 = hashlib.sha256()
|
||||
lines = []
|
||||
newlines = collections.Counter({'\n': 0}) # default to `\n`
|
||||
for line in sio:
|
||||
sha256.update(line.encode())
|
||||
for ending in ('\r\n', '\n'):
|
||||
if line.endswith(ending):
|
||||
lines.append(line[:-1 * len(ending)])
|
||||
newlines[ending] += 1
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
_restore_lines_eof_invariant(lines)
|
||||
(nl, _), = newlines.most_common(1)
|
||||
mixed = len({k for k, v in newlines.items() if v}) > 1
|
||||
return lines, nl, mixed, sha256.hexdigest()
|
||||
|
||||
|
||||
class Action:
|
||||
def __init__(
|
||||
self, *, name: str, spy: ListSpy,
|
||||
start_x: int, start_y: int, start_modified: bool,
|
||||
end_x: int, end_y: int, end_modified: bool,
|
||||
final: bool,
|
||||
):
|
||||
self.name = name
|
||||
self.spy = spy
|
||||
self.start_x = start_x
|
||||
self.start_y = start_y
|
||||
self.start_modified = start_modified
|
||||
self.end_x = end_x
|
||||
self.end_y = end_y
|
||||
self.end_modified = end_modified
|
||||
self.final = final
|
||||
|
||||
def apply(self, file: 'File') -> 'Action':
|
||||
spy = ListSpy(file.lines)
|
||||
action = Action(
|
||||
name=self.name, spy=spy,
|
||||
start_x=self.end_x, start_y=self.end_y,
|
||||
start_modified=self.end_modified,
|
||||
end_x=self.start_x, end_y=self.start_y,
|
||||
end_modified=self.start_modified,
|
||||
final=True,
|
||||
)
|
||||
|
||||
self.spy.undo(spy)
|
||||
file.x = self.start_x
|
||||
file.y = self.start_y
|
||||
file.modified = self.start_modified
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def action(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
self.finalize_previous_action()
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, action_inner)
|
||||
|
||||
|
||||
def edit_action(
|
||||
name: str,
|
||||
*,
|
||||
final: bool,
|
||||
) -> Callable[[TCallable], TCallable]:
|
||||
def edit_action_decorator(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def edit_action_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.edit_action_context(name, final=final):
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, edit_action_inner)
|
||||
return edit_action_decorator
|
||||
|
||||
|
||||
def keep_selection(func: TCallable) -> TCallable:
|
||||
@functools.wraps(func)
|
||||
def keep_selection_inner(self: 'File', *args: Any, **kwargs: Any) -> Any:
|
||||
with self.select():
|
||||
return func(self, *args, **kwargs)
|
||||
return cast(TCallable, keep_selection_inner)
|
||||
|
||||
|
||||
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
|
||||
return ret
|
||||
return cast(TCallable, clear_selection_inner)
|
||||
|
||||
|
||||
class Found(NamedTuple):
|
||||
y: int
|
||||
match: Match[str]
|
||||
|
||||
|
||||
class _SearchIter:
|
||||
def __init__(
|
||||
self,
|
||||
file: 'File',
|
||||
reg: Pattern[str],
|
||||
*,
|
||||
offset: int,
|
||||
) -> None:
|
||||
self.file = file
|
||||
self.reg = reg
|
||||
self.offset = offset
|
||||
self.wrapped = False
|
||||
self._start_x = file.x + offset
|
||||
self._start_y = file.y
|
||||
|
||||
def __iter__(self) -> '_SearchIter':
|
||||
return self
|
||||
|
||||
def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
|
||||
if (
|
||||
self.wrapped and (
|
||||
y > self._start_y or
|
||||
y == self._start_y and match.start() >= self._start_x
|
||||
)
|
||||
):
|
||||
raise StopIteration()
|
||||
return Found(y, match)
|
||||
|
||||
def __next__(self) -> Tuple[int, Match[str]]:
|
||||
x = self.file.x + self.offset
|
||||
y = self.file.y
|
||||
|
||||
match = self.reg.search(self.file.lines[y], x)
|
||||
if match:
|
||||
return self._stop_if_past_original(y, match)
|
||||
|
||||
if self.wrapped:
|
||||
for line_y in range(y + 1, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
else:
|
||||
for line_y in range(y + 1, len(self.file.lines)):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
self.wrapped = True
|
||||
|
||||
for line_y in range(0, self._start_y + 1):
|
||||
match = self.reg.search(self.file.lines[line_y])
|
||||
if match:
|
||||
return self._stop_if_past_original(line_y, match)
|
||||
|
||||
raise StopIteration()
|
||||
|
||||
|
||||
class File:
|
||||
def __init__(self, filename: Optional[str]) -> None:
|
||||
self.filename = filename
|
||||
self.modified = False
|
||||
self.lines: MutableSequenceNoSlice = []
|
||||
self.nl = '\n'
|
||||
self.file_y = self.y = self.x = self.x_hint = 0
|
||||
self.sha256: Optional[str] = None
|
||||
self.undo_stack: List[Action] = []
|
||||
self.redo_stack: List[Action] = []
|
||||
self.select_start: Optional[Tuple[int, int]] = None
|
||||
|
||||
def ensure_loaded(self, status: Status) -> None:
|
||||
if self.lines:
|
||||
return
|
||||
|
||||
if 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:
|
||||
if self.filename is not None:
|
||||
if os.path.lexists(self.filename):
|
||||
status.update(f'{self.filename!r} is not a file')
|
||||
self.filename = None
|
||||
else:
|
||||
status.update('(new file)')
|
||||
sio = io.StringIO('')
|
||||
self.lines, self.nl, mixed, self.sha256 = _get_lines(sio)
|
||||
|
||||
if mixed:
|
||||
status.update(f'mixed newlines will be converted to {self.nl!r}')
|
||||
self.modified = True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
attrs = ',\n '.join(f'{k}={v!r}' for k, v in self.__dict__.items())
|
||||
return f'{type(self).__name__}(\n {attrs},\n)'
|
||||
|
||||
# movement
|
||||
|
||||
def scroll_screen_if_needed(self, margin: Margin) -> None:
|
||||
# if the `y` is not on screen, make it so
|
||||
if self.file_y <= self.y < self.file_y + margin.body_lines:
|
||||
return
|
||||
|
||||
self.file_y = max(self.y - margin.body_lines // 2, 0)
|
||||
|
||||
def _scroll_amount(self) -> int:
|
||||
return int(curses.LINES / 2 + .5)
|
||||
|
||||
def _set_x_after_vertical_movement(self) -> None:
|
||||
self.x = min(len(self.lines[self.y]), self.x_hint)
|
||||
|
||||
def _increment_y(self, margin: Margin) -> None:
|
||||
self.y += 1
|
||||
if self.y >= self.file_y + margin.body_lines:
|
||||
self.file_y += self._scroll_amount()
|
||||
|
||||
def _decrement_y(self, margin: Margin) -> None:
|
||||
self.y -= 1
|
||||
if self.y < self.file_y:
|
||||
self.file_y -= self._scroll_amount()
|
||||
self.file_y = max(self.file_y, 0)
|
||||
|
||||
@action
|
||||
def up(self, margin: Margin) -> None:
|
||||
if self.y > 0:
|
||||
self._decrement_y(margin)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def down(self, margin: Margin) -> None:
|
||||
if self.y < len(self.lines) - 1:
|
||||
self._increment_y(margin)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def right(self, margin: Margin) -> None:
|
||||
if self.x >= len(self.lines[self.y]):
|
||||
if self.y < len(self.lines) - 1:
|
||||
self.x = 0
|
||||
self._increment_y(margin)
|
||||
else:
|
||||
self.x += 1
|
||||
self.x_hint = self.x
|
||||
|
||||
@action
|
||||
def left(self, margin: Margin) -> None:
|
||||
if self.x == 0:
|
||||
if self.y > 0:
|
||||
self._decrement_y(margin)
|
||||
self.x = len(self.lines[self.y])
|
||||
else:
|
||||
self.x -= 1
|
||||
self.x_hint = self.x
|
||||
|
||||
@action
|
||||
def home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@action
|
||||
def end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
|
||||
@action
|
||||
def ctrl_up(self, margin: Margin) -> None:
|
||||
self.file_y = max(0, self.file_y - 1)
|
||||
self.y = min(self.y, self.file_y + margin.body_lines - 1)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def ctrl_down(self, margin: Margin) -> None:
|
||||
self.file_y = min(len(self.lines) - 1, self.file_y + 1)
|
||||
self.y = max(self.y, self.file_y)
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def ctrl_right(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
# if we're at the second to last character, jump to end of line
|
||||
if self.x == len(line) - 1:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
# if we're at the end of the line, jump forward to the next non-ws
|
||||
elif self.x == len(line):
|
||||
while (
|
||||
self.y < len(self.lines) - 1 and (
|
||||
self.x == len(self.lines[self.y]) or
|
||||
self.lines[self.y][self.x].isspace()
|
||||
)
|
||||
):
|
||||
if self.x == len(self.lines[self.y]):
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
# if we're inside the line, jump to next position that's not our type
|
||||
else:
|
||||
self.x = self.x_hint = self.x + 1
|
||||
tp = line[self.x].isalnum()
|
||||
while self.x < len(line) and tp == line[self.x].isalnum():
|
||||
self.x = self.x_hint = self.x + 1
|
||||
|
||||
@action
|
||||
def ctrl_left(self, margin: Margin) -> None:
|
||||
line = self.lines[self.y]
|
||||
# if we're at position 1 and it's not a space, go to the beginning
|
||||
if self.x == 1 and not line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
# if we're at the beginning or it's all space up to here jump to the
|
||||
# end of the previous non-space line
|
||||
elif self.x == 0 or line[:self.x].isspace():
|
||||
self.x = self.x_hint = 0
|
||||
while self.y > 0 and (self.x == 0 or not self.lines[self.y]):
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
else:
|
||||
self.x = self.x_hint = self.x - 1
|
||||
tp = line[self.x - 1].isalnum()
|
||||
while self.x > 0 and tp == line[self.x - 1].isalnum():
|
||||
self.x = self.x_hint = self.x - 1
|
||||
|
||||
@action
|
||||
def ctrl_home(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = self.file_y = 0
|
||||
|
||||
@action
|
||||
def ctrl_end(self, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
self.y = len(self.lines) - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def go_to_line(self, lineno: int, margin: Margin) -> None:
|
||||
self.x = self.x_hint = 0
|
||||
if lineno == 0:
|
||||
self.y = 0
|
||||
elif lineno > len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
elif lineno < 0:
|
||||
self.y = max(0, lineno + len(self.lines))
|
||||
else:
|
||||
self.y = lineno - 1
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@action
|
||||
def search(
|
||||
self,
|
||||
reg: Pattern[str],
|
||||
status: Status,
|
||||
margin: Margin,
|
||||
) -> None:
|
||||
search = _SearchIter(self, reg, offset=1)
|
||||
try:
|
||||
line_y, match = next(iter(search))
|
||||
except StopIteration:
|
||||
status.update('no matches')
|
||||
else:
|
||||
if line_y == self.y and match.start() == self.x:
|
||||
status.update('this is the only occurrence')
|
||||
else:
|
||||
if search.wrapped:
|
||||
status.update('search wrapped')
|
||||
self.y = line_y
|
||||
self.x = self.x_hint = match.start()
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@clear_selection
|
||||
def replace(
|
||||
self,
|
||||
screen: 'Screen',
|
||||
reg: Pattern[str],
|
||||
replace: str,
|
||||
) -> 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)
|
||||
for line_y, match in search:
|
||||
self.y = line_y
|
||||
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',
|
||||
)
|
||||
if res in {'y', 'a'}:
|
||||
count += 1
|
||||
with self.edit_action_context('replace', final=True):
|
||||
replaced = match.expand(replace)
|
||||
line = screen.file.lines[line_y]
|
||||
line = line[:match.start()] + replaced + line[match.end():]
|
||||
screen.file.lines[line_y] = line
|
||||
search.offset = len(replaced)
|
||||
elif res == 'n':
|
||||
search.offset = 1
|
||||
else:
|
||||
assert res is PromptResult.CANCELLED
|
||||
return
|
||||
|
||||
if res == '': # we never went through the loop
|
||||
screen.status.update('no matches')
|
||||
else:
|
||||
occurrences = 'occurrence' if count == 1 else 'occurrences'
|
||||
screen.status.update(f'replaced {count} {occurrences}')
|
||||
|
||||
@action
|
||||
def page_up(self, margin: Margin) -> None:
|
||||
if self.y < margin.body_lines:
|
||||
self.y = self.file_y = 0
|
||||
else:
|
||||
pos = max(self.file_y - margin.page_size, 0)
|
||||
self.y = self.file_y = pos
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
@action
|
||||
def page_down(self, margin: Margin) -> None:
|
||||
if self.file_y + margin.body_lines >= len(self.lines):
|
||||
self.y = len(self.lines) - 1
|
||||
else:
|
||||
pos = self.file_y + margin.page_size
|
||||
self.y = self.file_y = pos
|
||||
self._set_x_after_vertical_movement()
|
||||
|
||||
# editing
|
||||
|
||||
@edit_action('backspace text', final=False)
|
||||
@clear_selection
|
||||
def backspace(self, margin: Margin) -> None:
|
||||
# backspace at the beginning of the file does nothing
|
||||
if self.y == 0 and self.x == 0:
|
||||
pass
|
||||
# backspace at the end of the file does not change the contents
|
||||
elif self.y == len(self.lines) - 1:
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
# at the beginning of the line, we join the current line and
|
||||
# the previous line
|
||||
elif self.x == 0:
|
||||
victim = self.lines.pop(self.y)
|
||||
new_x = len(self.lines[self.y - 1])
|
||||
self.lines[self.y - 1] += victim
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = new_x
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x - 1] + s[self.x:]
|
||||
self.x = self.x_hint = self.x - 1
|
||||
|
||||
@edit_action('delete text', final=False)
|
||||
@clear_selection
|
||||
def delete(self, margin: Margin) -> None:
|
||||
# noop at end of the file
|
||||
if self.y == len(self.lines) - 1:
|
||||
pass
|
||||
# if we're at the end of the line, collapse the line afterwards
|
||||
elif self.x == len(self.lines[self.y]):
|
||||
victim = self.lines.pop(self.y + 1)
|
||||
self.lines[self.y] += victim
|
||||
else:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + s[self.x + 1:]
|
||||
|
||||
@edit_action('line break', final=False)
|
||||
@clear_selection
|
||||
def enter(self, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x]
|
||||
self.lines.insert(self.y + 1, s[self.x:])
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
@edit_action('insert tab', final=False)
|
||||
def _tab(self, margin: Margin) -> None:
|
||||
n = 4 - self.x % 4
|
||||
line = self.lines[self.y]
|
||||
self.lines[self.y] = line[:self.x] + n * ' ' + line[self.x:]
|
||||
self.x = self.x_hint = self.x + n
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
def tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
self._indent_selection(margin)
|
||||
else:
|
||||
self._tab(margin)
|
||||
|
||||
@staticmethod
|
||||
def _dedent_line(s: str) -> int:
|
||||
bound = min(len(s), 4)
|
||||
i = 0
|
||||
while i < bound and s[i] == ' ':
|
||||
i += 1
|
||||
return i
|
||||
|
||||
@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()
|
||||
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)
|
||||
|
||||
@edit_action('dedent', final=True)
|
||||
def _dedent(self, margin: Margin) -> None:
|
||||
n = self._dedent_line(self.lines[self.y])
|
||||
if n:
|
||||
self.lines[self.y] = self.lines[self.y][n:]
|
||||
self.x = self.x_hint = max(self.x - n, 0)
|
||||
|
||||
def shift_tab(self, margin: Margin) -> None:
|
||||
if self.select_start:
|
||||
self._dedent_selection(margin)
|
||||
else:
|
||||
self._dedent(margin)
|
||||
|
||||
@edit_action('cut selection', final=True)
|
||||
@clear_selection
|
||||
def cut_selection(self, margin: Margin) -> Tuple[str, ...]:
|
||||
ret = []
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
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:]
|
||||
else:
|
||||
ret.append(self.lines[s_y][s_x:])
|
||||
for l_y in range(s_y + 1, e_y):
|
||||
ret.append(self.lines[l_y])
|
||||
ret.append(self.lines[e_y][:e_x])
|
||||
|
||||
self.lines[s_y] = self.lines[s_y][:s_x] + self.lines[e_y][e_x:]
|
||||
for _ in range(s_y + 1, e_y + 1):
|
||||
self.lines.pop(s_y + 1)
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = s_x
|
||||
self.scroll_screen_if_needed(margin)
|
||||
return tuple(ret)
|
||||
|
||||
def cut(self, cut_buffer: Tuple[str, ...]) -> Tuple[str, ...]:
|
||||
# only continue a cut if the last action is a non-final cut
|
||||
if not self._continue_last_action('cut'):
|
||||
cut_buffer = ()
|
||||
|
||||
with self.edit_action_context('cut', final=False):
|
||||
if self.y == len(self.lines) - 1:
|
||||
return ()
|
||||
else:
|
||||
victim = self.lines.pop(self.y)
|
||||
self.x = self.x_hint = 0
|
||||
return cut_buffer + (victim,)
|
||||
|
||||
def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
for cut_line in cut_buffer:
|
||||
line = self.lines[self.y]
|
||||
before, after = line[:self.x], line[self.x:]
|
||||
self.lines[self.y] = before + cut_line
|
||||
self.lines.insert(self.y + 1, after)
|
||||
self._increment_y(margin)
|
||||
self.x = self.x_hint = 0
|
||||
|
||||
@edit_action('uncut', final=True)
|
||||
@clear_selection
|
||||
def uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
|
||||
@edit_action('uncut selection', final=True)
|
||||
@clear_selection
|
||||
def uncut_selection(
|
||||
self,
|
||||
cut_buffer: Tuple[str, ...], margin: Margin,
|
||||
) -> None:
|
||||
self._uncut(cut_buffer, margin)
|
||||
self._decrement_y(margin)
|
||||
self.x = self.x_hint = len(self.lines[self.y])
|
||||
self.lines[self.y] += self.lines.pop(self.y + 1)
|
||||
|
||||
def _sort(self, margin: Margin, s_y: int, e_y: int) -> None:
|
||||
# self.lines intentionally does not support slicing so we use islice
|
||||
lines = sorted(itertools.islice(self.lines, s_y, e_y))
|
||||
for i, line in zip(range(s_y, e_y), lines):
|
||||
self.lines[i] = line
|
||||
|
||||
self.y = s_y
|
||||
self.x = self.x_hint = 0
|
||||
self.scroll_screen_if_needed(margin)
|
||||
|
||||
@edit_action('sort', final=True)
|
||||
def sort(self, margin: Margin) -> None:
|
||||
self._sort(margin, 0, len(self.lines) - 1)
|
||||
|
||||
@edit_action('sort selection', final=True)
|
||||
@clear_selection
|
||||
def sort_selection(self, margin: Margin) -> None:
|
||||
(s_y, _), (e_y, _) = self._get_selection()
|
||||
e_y = min(e_y + 1, len(self.lines) - 1)
|
||||
if self.lines[e_y - 1] == '':
|
||||
e_y -= 1
|
||||
self._sort(margin, s_y, e_y)
|
||||
|
||||
DISPATCH = {
|
||||
# movement
|
||||
b'KEY_UP': up,
|
||||
b'KEY_DOWN': down,
|
||||
b'KEY_RIGHT': right,
|
||||
b'KEY_LEFT': left,
|
||||
b'KEY_HOME': home,
|
||||
b'^A': home,
|
||||
b'KEY_END': end,
|
||||
b'^E': end,
|
||||
b'KEY_PPAGE': page_up,
|
||||
b'^Y': page_up,
|
||||
b'KEY_NPAGE': page_down,
|
||||
b'^V': page_down,
|
||||
b'kUP5': ctrl_up,
|
||||
b'kDN5': ctrl_down,
|
||||
b'kRIT5': ctrl_right,
|
||||
b'kLFT5': ctrl_left,
|
||||
b'kHOM5': ctrl_home,
|
||||
b'kEND5': ctrl_end,
|
||||
# editing
|
||||
b'KEY_BACKSPACE': backspace,
|
||||
b'^H': backspace, # ^Backspace
|
||||
b'KEY_DC': delete,
|
||||
b'^M': enter,
|
||||
b'^I': tab,
|
||||
b'KEY_BTAB': shift_tab,
|
||||
# selection (shift + movement)
|
||||
b'KEY_SR': keep_selection(up),
|
||||
b'KEY_SF': keep_selection(down),
|
||||
b'KEY_SLEFT': keep_selection(left),
|
||||
b'KEY_SRIGHT': keep_selection(right),
|
||||
b'KEY_SHOME': keep_selection(home),
|
||||
b'KEY_SEND': keep_selection(end),
|
||||
b'KEY_SPREVIOUS': keep_selection(page_up),
|
||||
b'KEY_SNEXT': keep_selection(page_down),
|
||||
b'kRIT6': keep_selection(ctrl_right),
|
||||
b'kLFT6': keep_selection(ctrl_left),
|
||||
b'kHOM6': keep_selection(ctrl_home),
|
||||
b'kEND6': keep_selection(ctrl_end),
|
||||
}
|
||||
|
||||
@edit_action('text', final=False)
|
||||
@clear_selection
|
||||
def c(self, wch: str, margin: Margin) -> None:
|
||||
s = self.lines[self.y]
|
||||
self.lines[self.y] = s[:self.x] + wch + s[self.x:]
|
||||
self.x = self.x_hint = self.x + 1
|
||||
_restore_lines_eof_invariant(self.lines)
|
||||
|
||||
def finalize_previous_action(self) -> None:
|
||||
assert not isinstance(self.lines, ListSpy), 'nested edit/movement'
|
||||
self.select_start = None
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
|
||||
def _continue_last_action(self, name: str) -> bool:
|
||||
return (
|
||||
bool(self.undo_stack) and
|
||||
self.undo_stack[-1].name == name and
|
||||
not self.undo_stack[-1].final
|
||||
)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def edit_action_context(
|
||||
self, name: str,
|
||||
*,
|
||||
final: bool,
|
||||
) -> Generator[None, None, None]:
|
||||
continue_last = self._continue_last_action(name)
|
||||
if continue_last:
|
||||
spy = self.undo_stack[-1].spy
|
||||
else:
|
||||
if self.undo_stack:
|
||||
self.undo_stack[-1].final = True
|
||||
spy = ListSpy(self.lines)
|
||||
|
||||
before_x, before_line = self.x, self.y
|
||||
before_modified = self.modified
|
||||
assert not isinstance(self.lines, ListSpy), 'recursive action?'
|
||||
orig, self.lines = self.lines, spy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.lines = orig
|
||||
self.redo_stack.clear()
|
||||
if continue_last:
|
||||
self.undo_stack[-1].end_x = self.x
|
||||
self.undo_stack[-1].end_y = self.y
|
||||
elif spy.has_modifications:
|
||||
self.modified = True
|
||||
action = Action(
|
||||
name=name, spy=spy,
|
||||
start_x=before_x, start_y=before_line,
|
||||
start_modified=before_modified,
|
||||
end_x=self.x, end_y=self.y,
|
||||
end_modified=True,
|
||||
final=final,
|
||||
)
|
||||
self.undo_stack.append(action)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def select(self) -> Generator[None, None, None]:
|
||||
if self.select_start is None:
|
||||
select_start = (self.y, self.x)
|
||||
else:
|
||||
select_start = self.select_start
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.select_start = select_start
|
||||
|
||||
# positioning
|
||||
|
||||
def rendered_y(self, margin: Margin) -> int:
|
||||
return self.y - self.file_y + margin.header
|
||||
|
||||
def rendered_x(self) -> int:
|
||||
return self.x - line_x(self.x, curses.COLS)
|
||||
|
||||
def move_cursor(
|
||||
self,
|
||||
stdscr: 'curses._CursesWindow',
|
||||
margin: Margin,
|
||||
) -> 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 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)
|
||||
blankline = ' ' * curses.COLS
|
||||
for i in range(to_display, margin.body_lines):
|
||||
stdscr.insstr(i + margin.header, 0, blankline)
|
||||
|
||||
if self.select_start is not None:
|
||||
(s_y, s_x), (e_y, e_x) = self._get_selection()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
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:
|
||||
raise NotImplementedError('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)
|
||||
|
||||
|
||||
class Screen:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1169,7 +320,7 @@ class Screen:
|
||||
|
||||
if os.path.isfile(self.file.filename):
|
||||
with open(self.file.filename) as f:
|
||||
*_, sha256 = _get_lines(f)
|
||||
*_, sha256 = get_lines(f)
|
||||
else:
|
||||
sha256 = hashlib.sha256(b'').hexdigest()
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from babi.main import File
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.file import File
|
||||
from babi.file import get_lines
|
||||
|
||||
|
||||
def test_position_repr():
|
||||
@@ -19,3 +24,25 @@ def test_position_repr():
|
||||
' select_start=None,\n'
|
||||
')'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('s', 'lines', 'nl', 'mixed'),
|
||||
(
|
||||
pytest.param('', [''], '\n', False, id='trivial'),
|
||||
pytest.param('1\n2\n', ['1', '2', ''], '\n', False, id='lf'),
|
||||
pytest.param('1\r\n2\r\n', ['1', '2', ''], '\r\n', False, id='crlf'),
|
||||
pytest.param('1\r\n2\n', ['1', '2', ''], '\n', True, id='mixed'),
|
||||
pytest.param('1\n2', ['1', '2', ''], '\n', False, id='noeol'),
|
||||
),
|
||||
)
|
||||
def test_get_lines(s, lines, nl, mixed):
|
||||
# sha256 tested below
|
||||
ret_lines, ret_nl, ret_mixed, _ = get_lines(io.StringIO(s))
|
||||
assert (ret_lines, ret_nl, ret_mixed) == (lines, nl, mixed)
|
||||
|
||||
|
||||
def test_get_lines_sha256_checksum():
|
||||
ret = get_lines(io.StringIO(''))
|
||||
sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
assert ret == ([''], '\n', False, sha256)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from babi.main import _get_lines
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('s', 'lines', 'nl', 'mixed'),
|
||||
(
|
||||
pytest.param('', [''], '\n', False, id='trivial'),
|
||||
pytest.param('1\n2\n', ['1', '2', ''], '\n', False, id='lf'),
|
||||
pytest.param('1\r\n2\r\n', ['1', '2', ''], '\r\n', False, id='crlf'),
|
||||
pytest.param('1\r\n2\n', ['1', '2', ''], '\n', True, id='mixed'),
|
||||
pytest.param('1\n2', ['1', '2', ''], '\n', False, id='noeol'),
|
||||
),
|
||||
)
|
||||
def test_get_lines(s, lines, nl, mixed):
|
||||
# sha256 tested below
|
||||
ret_lines, ret_nl, ret_mixed, _ = _get_lines(io.StringIO(s))
|
||||
assert (ret_lines, ret_nl, ret_mixed) == (lines, nl, mixed)
|
||||
|
||||
|
||||
def test_get_lines_sha256_checksum():
|
||||
ret = _get_lines(io.StringIO(''))
|
||||
sha256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
assert ret == ([''], '\n', False, sha256)
|
||||
Reference in New Issue
Block a user