def test_buf_set_value_idx_negative(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: buf[-1] = 'hello' assert lst == ['a', 'b', 'hello'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
def test_buf_insert_with_negative(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: buf.insert(-1, 'q') assert lst == ['a', 'b', 'q', 'c'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
def test_buf_del_with_negative(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: del buf[-1] assert lst == ['a', 'b'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
def test_buf_pop_idx(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: buf.pop(1) assert lst == ['a', 'c'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
def test_buf_append(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: buf.append('q') assert lst == ['a', 'b', 'c', 'q'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
def test_buf_multiple_modifications(): lst = ['a', 'b', 'c'] buf = Buf(lst) with buf.record() as modifications: buf[1] = 'hello' buf.insert(1, 'ohai') del buf[0] assert lst == ['ohai', 'hello', 'c'] buf.apply(modifications) assert lst == ['a', 'b', 'c']
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' self.sha256: Optional[str] = None self._in_edit_action = False self.undo_stack: List[Action] = [] self.redo_stack: List[Action] = [] self._hl_factories = hl_factories self._trailing_whitespace = TrailingWhitespace(color_manager) self._replace_hl = Replace() self.selection = Selection() self._file_hls: Tuple[FileHL, ...] = () def ensure_loaded( self, status: Status, margin: Margin, stdin: str, ) -> None: if self.buf: return if self.filename == '-': status.update('(from stdin)') self.filename = None self.modified = True sio = io.StringIO(stdin) lines, self.nl, mixed, self.sha256 = get_lines(sio) elif self.filename is not None and os.path.isfile(self.filename): with open(self.filename, newline='') as f: 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)') lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO('')) self.buf = Buf(lines, self.buf.tab_size) if mixed: status.update(f'mixed newlines will be converted to {self.nl!r}') self.modified = True file_hls = [] for factory in self._hl_factories: if self.filename is not None: hl = factory.file_highlighter(self.filename, self.buf[0]) file_hls.append(hl) else: file_hls.append(factory.blank_file_highlighter()) self._file_hls = ( *file_hls, self._trailing_whitespace, self._replace_hl, self.selection, ) 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}>' # movement @action def up(self, margin: Margin) -> None: self.buf.up(margin) @action def down(self, margin: Margin) -> None: self.buf.down(margin) @action def right(self, margin: Margin) -> None: self.buf.right(margin) @action def left(self, margin: Margin) -> None: self.buf.left(margin) @action def home(self, margin: Margin) -> None: self.buf.x = 0 @action def end(self, margin: Margin) -> None: self.buf.x = len(self.buf[self.buf.y]) @action def ctrl_up(self, margin: Margin) -> None: self.buf.file_up(margin) @action def ctrl_down(self, margin: Margin) -> None: self.buf.file_down(margin) @action def ctrl_right(self, margin: Margin) -> None: line = self.buf[self.buf.y] # if we're at the second to last character, jump to end of line if self.buf.x == len(line) - 1: self.buf.right(margin) # if we're at the end of the line, jump forward to the next non-ws elif self.buf.x == len(line): while (self.buf.y < len(self.buf) - 1 and (self.buf.x == len(self.buf[self.buf.y]) or self.buf[self.buf.y][self.buf.x].isspace())): self.buf.right(margin) # if we're inside the line, jump to next position that's not our type else: self.buf.right(margin) tp = line[self.buf.x].isalnum() while self.buf.x < len(line) and tp == line[self.buf.x].isalnum(): self.buf.right(margin) @action def ctrl_left(self, margin: Margin) -> None: line = self.buf[self.buf.y] # if we're at position 1 and it's not a space, go to the beginning if self.buf.x == 1 and not line[:self.buf.x].isspace(): self.buf.left(margin) # 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.buf.x == 0 or line[:self.buf.x].isspace(): self.buf.x = 0 while self.buf.y > 0 and self.buf.x == 0: self.buf.left(margin) else: self.buf.left(margin) tp = line[self.buf.x - 1].isalnum() while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum(): self.buf.left(margin) @action def ctrl_home(self, margin: Margin) -> None: self.buf.x = 0 self.buf.y = self.buf.file_y = 0 @action def ctrl_end(self, margin: Margin) -> None: self.buf.x = 0 self.buf.y = len(self.buf) - 1 self.buf.scroll_screen_if_needed(margin) @action def go_to_line(self, lineno: int, margin: Margin) -> None: self.buf.x = 0 if lineno == 0: self.buf.y = 0 elif lineno > len(self.buf): self.buf.y = len(self.buf) - 1 elif lineno < 0: self.buf.y = max(0, lineno + len(self.buf)) else: self.buf.y = lineno - 1 self.buf.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.buf.y and match.start() == self.buf.x: status.update('this is the only occurrence') else: if search.wrapped: status.update('search wrapped') self.buf.y = line_y self.buf.x = match.start() self.buf.scroll_screen_if_needed(margin) @clear_selection def replace( self, screen: 'Screen', reg: Pattern[str], replace: str, ) -> None: self.finalize_previous_action() count = 0 res: Union[str, PromptResult] = '' search = _SearchIter(self, reg, offset=0) for line_y, match in search: end = match.end() self.buf.y = line_y self.buf.x = match.start() self.buf.scroll_screen_if_needed(screen.margin) if res != 'a': # make `a` replace the rest of them with self._replace_hl.region(self.buf.y, self.buf.x, 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): replaced = match.expand(replace) line = screen.file.buf[line_y] if '\n' in replaced: replaced_lines = replaced.split('\n') self.buf[line_y] = ( f'{line[:match.start()]}{replaced_lines[0]}') for i, ins_line in enumerate(replaced_lines[1:-1], 1): self.buf.insert(line_y + i, ins_line) last_insert = line_y + len(replaced_lines) - 1 self.buf.insert( last_insert, f'{replaced_lines[-1]}{line[end:]}', ) self.buf.y = last_insert self.buf.x = 0 search.offset = len(replaced_lines[-1]) else: self.buf[line_y] = ( f'{line[:match.start()]}{replaced}{line[end:]}') 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.buf.y < margin.body_lines: self.buf.y = self.buf.file_y = 0 else: pos = max(self.buf.file_y - margin.page_size, 0) self.buf.y = self.buf.file_y = pos self.buf.x = 0 @action def page_down(self, margin: Margin) -> None: if self.buf.file_y + margin.body_lines >= len(self.buf): self.buf.y = len(self.buf) - 1 else: pos = self.buf.file_y + margin.page_size self.buf.y = self.buf.file_y = pos self.buf.x = 0 # 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.buf.y == 0 and self.buf.x == 0: pass # backspace at the end of the file does not change the contents elif self.buf.y == len(self.buf) - 1: self.buf.left(margin) # at the beginning of the line, we join the current line and # the previous line elif self.buf.x == 0: y, victim = self.buf.y, self.buf.pop(self.buf.y) self.buf.left(margin) self.buf[y - 1] += victim else: s = self.buf[self.buf.y] self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:] self.buf.left(margin) @edit_action('delete text', final=False) @clear_selection def delete(self, margin: Margin) -> None: if ( # noop at end of the file self.buf.y == len(self.buf) - 1 or # noop at end of last real line (self.buf.y == len(self.buf) - 2 and self.buf.x == len(self.buf[self.buf.y]))): pass # if we're at the end of the line, collapse the line afterwards elif self.buf.x == len(self.buf[self.buf.y]): victim = self.buf.pop(self.buf.y + 1) self.buf[self.buf.y] += victim else: s = self.buf[self.buf.y] self.buf[self.buf.y] = s[:self.buf.x] + s[self.buf.x + 1:] @edit_action('line break', final=False) @clear_selection def enter(self, margin: Margin) -> None: s = self.buf[self.buf.y] self.buf[self.buf.y] = s[:self.buf.x] self.buf.insert(self.buf.y + 1, s[self.buf.x:]) self.buf.down(margin) self.buf.x = 0 @edit_action('indent selection', final=True) def _indent_selection(self, margin: Margin) -> None: 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.buf[l_y]: self.buf[l_y] = ' ' * self.buf.tab_size + self.buf[l_y] if l_y == self.buf.y: self.buf.x += self.buf.tab_size if l_y == sel_y and sel_x != 0: 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 = 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 self.buf.restore_eof_invariant() def tab(self, margin: Margin) -> None: if self.selection.start is not None: self._indent_selection(margin) else: self._tab(margin) 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 return i @edit_action('dedent selection', final=True) def _dedent_selection(self, margin: Margin) -> None: 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.buf[l_y]) if n: self.buf[l_y] = self.buf[l_y][n:] if l_y == self.buf.y: self.buf.x = max(self.buf.x - n, 0) if l_y == sel_y: sel_x = max(sel_x - n, 0) self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x) @edit_action('dedent', final=True) def _dedent(self, margin: Margin) -> None: n = self._dedent_line(self.buf[self.buf.y]) if n: self.buf[self.buf.y] = self.buf[self.buf.y][n:] self.buf.x = max(self.buf.x - n, 0) def shift_tab(self, margin: Margin) -> None: if self.selection.start is not None: 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.selection.get() if s_y == e_y: ret.append(self.buf[s_y][s_x:e_x]) self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[s_y][e_x:] else: ret.append(self.buf[s_y][s_x:]) for l_y in range(s_y + 1, e_y): ret.append(self.buf[l_y]) ret.append(self.buf[e_y][:e_x]) self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[e_y][e_x:] for _ in range(s_y + 1, e_y + 1): self.buf.pop(s_y + 1) self.buf.y = s_y self.buf.x = s_x self.buf.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.buf.y == len(self.buf) - 1: return cut_buffer else: victim = self.buf.pop(self.buf.y) self.buf.x = 0 return cut_buffer + (victim, ) def _uncut(self, cut_buffer: Tuple[str, ...], margin: Margin) -> None: for cut_line in cut_buffer: line = self.buf[self.buf.y] before, after = line[:self.buf.x], line[self.buf.x:] self.buf[self.buf.y] = before + cut_line self.buf.insert(self.buf.y + 1, after) self.buf.down(margin) self.buf.x = 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.buf.up(margin) self.buf.x = len(self.buf[self.buf.y]) self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1) self.buf.restore_eof_invariant() def _sort(self, margin: Margin, s_y: int, e_y: int, reverse: bool) -> None: # self.buf intentionally does not support slicing so we use islice 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 self.buf.y = s_y self.buf.x = 0 self.buf.scroll_screen_if_needed(margin) @edit_action('sort', final=True) def sort(self, margin: Margin, reverse: bool = False) -> None: self._sort(margin, 0, len(self.buf) - 1, reverse=reverse) @edit_action('sort selection', final=True) @clear_selection def sort_selection(self, margin: Margin, reverse: bool = False) -> None: (s_y, _), (e_y, _) = self.selection.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, reverse=reverse) 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'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.buf[self.buf.y] self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:] self.buf.x += len(wch) self.buf.restore_eof_invariant() def finalize_previous_action(self) -> None: assert not self._in_edit_action, 'nested edit/movement' self.selection.clear() 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 not continue_last and self.undo_stack: self.undo_stack[-1].final = True before_x, before_line = self.buf.x, self.buf.y before_modified = self.modified assert not self._in_edit_action, f'recursive action? {name}' self._in_edit_action = True try: with self.buf.record() as modifications: yield finally: self._in_edit_action = False self.redo_stack.clear() if continue_last: self.undo_stack[-1].end_x = self.buf.x self.undo_stack[-1].end_y = self.buf.y self.undo_stack[-1].modifications.extend(modifications) elif modifications: self.modified = True action = Action( name=name, modifications=modifications, start_x=before_x, start_y=before_line, start_modified=before_modified, end_x=self.buf.x, end_y=self.buf.y, end_modified=True, final=final, ) self.undo_stack.append(action) @contextlib.contextmanager def select(self) -> Generator[None, None, None]: if self.selection.start is None: start = (self.buf.y, self.buf.x) else: start = self.selection.start try: yield finally: self.selection.set(*start, self.buf.y, self.buf.x) # positioning def move_cursor( self, stdscr: 'curses._CursesWindow', margin: Margin, ) -> None: stdscr.move(*self.buf.cursor_position(margin)) def draw(self, stdscr: 'curses._CursesWindow', margin: Margin) -> None: to_display = min(self.buf.displayable_count, margin.body_lines) for file_hl in self._file_hls: # XXX: this will go away? file_hl.highlight_until(self.buf, self.buf.file_y + to_display) for i in range(to_display): draw_y = i + margin.header l_y = self.buf.file_y + i stdscr.insstr(draw_y, 0, self.buf.rendered_line(l_y, margin)) l_x = self.buf.line_x(margin) if l_y == self.buf.y else 0 l_x_max = l_x + margin.cols for file_hl in self._file_hls: for region in file_hl.regions[l_y]: l_positions = self.buf.line_positions(l_y) r_x = l_positions[region.x] # the selection highlight intentionally extends one past # the end of the line, which won't have a position if region.end == len(l_positions): r_end = l_positions[-1] + 1 else: r_end = l_positions[region.end] if r_x >= l_x_max: break elif r_end <= l_x: continue if l_x and r_x <= l_x: if file_hl.include_edge: h_s_x = 0 else: h_s_x = 1 else: h_s_x = r_x - l_x if r_end >= l_x_max and l_x_max < l_positions[-1]: if file_hl.include_edge: h_e_x = margin.cols else: h_e_x = margin.cols - 1 else: h_e_x = r_end - l_x 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()