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 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)
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
class Screen: def __init__( 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, line, self.color_manager, self.hl_factories) for filename, line in zip(filenames, initial_lines) ] self.i = 0 self.history = History() self.perf = perf self.status = Status() self.margin = Margin.from_current_screen() self.cut_buffer: Tuple[str, ...] = () self.cut_selection = False self._buffered_input: Union[int, str, None] = None @property def file(self) -> File: return self.files[self.i] def _draw_header(self) -> None: filename = self.file.filename or '<<new file>>' if self.file.modified: filename += ' *' if len(self.files) > 1: files = f'[{self.i + 1}/{len(self.files)}] ' version_width = len(VERSION_STR) + 2 + len(files) else: files = '' version_width = len(VERSION_STR) + 2 centered = filename.center(self.margin.cols)[version_width:] s = f' {VERSION_STR} {files}{centered}{files}' self.stdscr.insstr(0, 0, s, curses.A_REVERSE) def _get_sequence_home_end(self, wch: str) -> str: try: c = self.stdscr.get_wch() except curses.error: return wch else: if isinstance(c, int) or c not in 'HF': self._buffered_input = c return wch else: return f'{wch}{c}' def _get_sequence_bracketed(self, wch: str) -> str: for _ in range(3): # [0-9]{1,2}; try: c = self.stdscr.get_wch() except curses.error: return wch else: if isinstance(c, int): self._buffered_input = c return wch else: wch += c if c == ';': break else: return wch # unexpected input while searching for `;` for _ in range(2): # [0-9]. try: c = self.stdscr.get_wch() except curses.error: return wch else: if isinstance(c, int): self._buffered_input = c return wch else: wch += c return wch def _get_sequence(self, wch: str) -> str: self.stdscr.nodelay(True) try: c = self.stdscr.get_wch() except curses.error: return wch else: if isinstance(c, int): # M-BSpace return f'{wch}({c})' # TODO elif c == 'O': return self._get_sequence_home_end(f'{wch}O') elif c == '[': return self._get_sequence_bracketed(f'{wch}[') else: return f'{wch}{c}' finally: self.stdscr.nodelay(False) def _get_string(self, wch: str) -> str: self.stdscr.nodelay(True) try: while True: try: c = self.stdscr.get_wch() if isinstance(c, str) and c.isprintable(): wch += c else: self._buffered_input = c break except curses.error: break finally: self.stdscr.nodelay(False) return wch def _get_char(self) -> Key: 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) if len(wch) == 2: return Key(wch, f'M-{wch[1]}'.encode()) elif len(wch) > 1: keyname = SEQUENCE_KEYNAME.get(wch, b'unknown') return Key(wch, keyname) elif isinstance(wch, str) and wch.isprintable(): wch = self._get_string(wch) return Key(wch, b'STRING') key = wch if isinstance(wch, int) else ord(wch) keyname = curses.keyname(key) keyname = KEYNAME_REWRITE.get(keyname, keyname) return Key(wch, keyname) def get_char(self) -> Key: self.perf.end() ret = self._get_char() self.perf.start(ret.keyname.decode()) return ret def draw(self) -> None: if self.margin.header: self._draw_header() self.file.draw(self.stdscr, self.margin) self.status.draw(self.stdscr, self.margin) def resize(self) -> None: curses.update_lines_cols() self.margin = Margin.from_current_screen() self.file.buf.scroll_screen_if_needed(self.margin) self.draw() def quick_prompt( self, prompt: str, opt_strs: Tuple[str, ...], ) -> Union[str, PromptResult]: opts = {opt[0] for opt in opt_strs} while True: x = 0 prompt_line = self.margin.lines - 1 def _write(s: str, *, attr: int = curses.A_REVERSE) -> None: nonlocal x if x >= self.margin.cols: return self.stdscr.insstr(prompt_line, x, s, attr) x += len(s) _write(prompt) _write(' [') for i, opt_str in enumerate(opt_strs): _write(opt_str[0], attr=curses.A_REVERSE | curses.A_BOLD) _write(opt_str[1:]) if i != len(opt_strs) - 1: _write(', ') _write(']?') if x < self.margin.cols - 1: s = ' ' * (self.margin.cols - x) self.stdscr.insstr(prompt_line, x, s, curses.A_REVERSE) x += 1 else: x = self.margin.cols - 1 self.stdscr.insstr(prompt_line, x, '…', curses.A_REVERSE) self.stdscr.move(prompt_line, x) key = self.get_char() if key.keyname == b'KEY_RESIZE': self.resize() elif key.keyname == b'^C': return self.status.cancelled() elif isinstance(key.wch, str) and key.wch.lower() in opts: return key.wch.lower() def prompt( self, prompt: str, *, allow_empty: bool = False, history: Optional[str] = None, default_prev: bool = False, default: Optional[str] = None, ) -> Union[str, PromptResult]: default = default or '' self.status.clear() if history is not None: history_data = [*self.history.data[history], default] if default_prev and history in self.history.prev: prompt = f'{prompt} [{self.history.prev[history]}]' else: history_data = [default] ret = Prompt(self, prompt, history_data).run() if ret is not PromptResult.CANCELLED and history is not None: if ret: # only put non-empty things in history history_lst = self.history.data[history] if not history_lst or history_lst[-1] != ret: history_lst.append(ret) self.history.prev[history] = ret elif default_prev and history in self.history.prev: return self.history.prev[history] if not allow_empty and not ret: return self.status.cancelled() else: return ret def go_to_line(self) -> None: response = self.prompt('enter line number') if response is not PromptResult.CANCELLED: try: lineno = int(response) except ValueError: self.status.update(f'not an integer: {response!r}') else: self.file.go_to_line(lineno, self.margin) def current_position(self) -> None: line = f'line {self.file.buf.y + 1}' col = f'col {self.file.buf.x + 1}' line_count = max(len(self.file.buf) - 1, 1) lines_word = 'line' if line_count == 1 else 'lines' self.status.update(f'{line}, {col} (of {line_count} {lines_word})') def cut(self) -> None: if self.file.selection.start: self.cut_buffer = self.file.cut_selection(self.margin) self.cut_selection = True else: self.cut_buffer = self.file.cut(self.cut_buffer) self.cut_selection = False def uncut(self) -> None: if self.cut_selection: self.file.uncut_selection(self.cut_buffer, self.margin) else: self.file.uncut(self.cut_buffer, self.margin) def _get_search_re(self, prompt: str) -> Union[Pattern[str], PromptResult]: response = self.prompt(prompt, history='search', default_prev=True) if response is PromptResult.CANCELLED: return response try: return re.compile(response) except re.error: self.status.update(f'invalid regex: {response!r}') return PromptResult.CANCELLED def _undo_redo( self, op: str, from_stack: List[Action], to_stack: List[Action], ) -> None: if not from_stack: self.status.update(f'nothing to {op}!') else: action = from_stack.pop() to_stack.append(action.apply(self.file)) self.file.buf.scroll_screen_if_needed(self.margin) self.status.update(f'{op}: {action.name}') self.file.selection.clear() def undo(self) -> None: self._undo_redo('undo', self.file.undo_stack, self.file.redo_stack) def redo(self) -> None: self._undo_redo('redo', self.file.redo_stack, self.file.undo_stack) def search(self) -> None: response = self._get_search_re('search') if response is not PromptResult.CANCELLED: self.file.search(response, self.status, self.margin) def replace(self) -> None: search_response = self._get_search_re('search (to replace)') if search_response is not PromptResult.CANCELLED: response = self.prompt( 'replace with', history='replace', allow_empty=True, ) if response is not PromptResult.CANCELLED: self.file.replace(self, search_response, response) def command(self) -> Optional[EditResult]: response = self.prompt('', history='command') if response is PromptResult.CANCELLED: pass elif response == ':q': return self.quit_save_modified() elif response == ':q!': return EditResult.EXIT elif response == ':w': self.save() elif response == ':wq': self.save() return EditResult.EXIT elif response == ':sort': if self.file.selection.start: self.file.sort_selection(self.margin) else: self.file.sort(self.margin) self.status.update('sorted!') 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!') elif response.startswith(':expandtabs'): for file in self.files: file.buf.expandtabs = True self.status.update('updated!') elif response.startswith(':noexpandtabs'): for file in self.files: file.buf.expandtabs = False self.status.update('updated!') elif response == ':comment' or response.startswith(':comment '): _, _, comment = response.partition(' ') comment = (comment or '#').strip() if self.file.selection.start: self.file.toggle_comment_selection(comment) else: self.file.toggle_comment(comment) else: self.status.update(f'invalid command: {response}') return None def save(self) -> Optional[PromptResult]: self.file.finalize_previous_action() # TODO: make directories if they don't exist # TODO: maybe use mtime / stat as a shortcut for hashing below # TODO: strip trailing whitespace? # TODO: save atomically? if self.file.filename is None: filename = self.prompt('enter filename') if filename is PromptResult.CANCELLED: return PromptResult.CANCELLED else: self.file.filename = filename if os.path.isfile(self.file.filename): with open(self.file.filename, encoding='UTF-8', newline='') as f: *_, sha256 = get_lines(f) else: sha256 = hashlib.sha256(b'').hexdigest() contents = self.file.nl.join(self.file.buf) sha256_to_save = hashlib.sha256(contents.encode()).hexdigest() # the file on disk is the same as when we opened it if sha256 not in (self.file.sha256, sha256_to_save): self.status.update('(file changed on disk, not implemented)') return PromptResult.CANCELLED try: with open( self.file.filename, 'w', encoding='UTF-8', newline='', ) as f: f.write(contents) except OSError as e: self.status.update(f'cannot save file: {e}') return PromptResult.CANCELLED self.file.modified = False self.file.sha256 = sha256_to_save num_lines = len(self.file.buf) - 1 lines = 'lines' if num_lines != 1 else 'line' self.status.update(f'saved! ({num_lines} {lines} written)') # fix up modified state in undo / redo stacks for stack in (self.file.undo_stack, self.file.redo_stack): first = True for action in reversed(stack): action.end_modified = not first action.start_modified = True first = False return None def save_filename(self) -> Optional[PromptResult]: response = self.prompt('enter filename', default=self.file.filename) if response is PromptResult.CANCELLED: return PromptResult.CANCELLED else: self.file.filename = response return self.save() def open_file(self) -> Optional[EditResult]: response = self.prompt('enter filename', history='open') if response is not PromptResult.CANCELLED: opened = File(response, 0, self.color_manager, self.hl_factories) self.files.append(opened) return EditResult.OPEN else: return None def quit_save_modified(self) -> Optional[EditResult]: if self.file.modified: response = self.quick_prompt( 'file is modified - save', ('yes', 'no'), ) if response == 'y': if self.save_filename() is not PromptResult.CANCELLED: return EditResult.EXIT else: return None elif response == 'n': return EditResult.EXIT else: assert response is PromptResult.CANCELLED return None 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() self.resize() DISPATCH = { b'KEY_RESIZE': resize, b'^_': go_to_line, b'^C': current_position, b'^K': cut, b'^U': uncut, b'M-u': undo, b'M-U': redo, b'M-e': redo, b'^W': search, b'^\\': replace, b'^[': command, b'^S': save, b'^O': save_filename, b'^X': quit_save_modified, b'^P': open_file, b'kLFT3': lambda screen: EditResult.PREV, b'kRIT3': lambda screen: EditResult.NEXT, b'^Z': background, }