diff --git a/docs/kittens/diff.rst b/docs/kittens/diff.rst index ffacb4bea..2e4cd7ed8 100644 --- a/docs/kittens/diff.rst +++ b/docs/kittens/diff.rst @@ -67,21 +67,25 @@ Keyboard controls ========================= =========================== Action Shortcut ========================= =========================== -Quit ``q, Ctrl+c`` -Scroll line up ``k, up`` -Scroll line down ``j, down`` -Scroll page up ``PgUp`` -Scroll page down ``PgDn`` -Scroll to top ``Home`` -Scroll to bottom ``End`` -Scroll to next page ``Space, PgDn`` -Scroll to previous page ``PgUp`` -Scroll to next change ``n`` -Scroll to previous change ``p`` -Increase lines of context ``+`` -Decrease lines of context ``-`` -All lines of context ``a`` -Restore default context ``=`` +Quit :kbd:`q, Ctrl+c` +Scroll line up :kbd:`k, up` +Scroll line down :kbd:`j, down` +Scroll page up :kbd:`PgUp` +Scroll page down :kbd:`PgDn` +Scroll to top :kbd:`Home` +Scroll to bottom :kbd:`End` +Scroll to next page :kbd:`Space, PgDn` +Scroll to previous page :kbd:`PgUp` +Scroll to next change :kbd:`n` +Scroll to previous change :kbd:`p` +Increase lines of context :kbd:`+` +Decrease lines of context :kbd:`-` +All lines of context :kbd:`a` +Restore default context :kbd:`=` +Search forwards :kbd:`/` +Search backwards :kbd:`?` +Scroll to next match :kbd:`>, .` +Scroll to previous match :kbd:`<, ,` ========================= =========================== diff --git a/kittens/diff/config.py b/kittens/diff/config.py index 8f24df7a2..3f9e85ef2 100644 --- a/kittens/diff/config.py +++ b/kittens/diff/config.py @@ -52,7 +52,7 @@ def parse_scroll_by(func, rest): @func_with_args('scroll_to') def parse_scroll_to(func, rest): rest = rest.lower() - if rest not in {'start', 'end', 'next-change', 'prev-change', 'next-page', 'prev-page'}: + if rest not in {'start', 'end', 'next-change', 'prev-change', 'next-page', 'prev-page', 'next-match', 'prev-match'}: rest = 'start' return func, rest @@ -69,6 +69,14 @@ def parse_change_context(func, rest): return func, amount +@func_with_args('start_search') +def parse_start_search(func, rest): + rest = rest.lower().split() + is_regex = rest and rest[0] == 'regex' + is_backward = len(rest) > 1 and rest[1] == 'backward' + return func, (is_regex, is_backward) + + def special_handling(key, val, ans): if key == 'map': action, *key_def = parse_kittens_key(val, args_funcs) diff --git a/kittens/diff/config_data.py b/kittens/diff/config_data.py index e6d61eeed..532b8a2ac 100644 --- a/kittens/diff/config_data.py +++ b/kittens/diff/config_data.py @@ -83,6 +83,8 @@ c('filler_bg', '#fafbfc', long_text=_('Filler (empty) line background')) c('hunk_margin_bg', '#dbedff', long_text=_('Hunk header colors')) c('hunk_bg', '#f1f8ff') +c('search_bg', '#444', long_text=_('Search highlighting')) +c('search_fg', 'white') g('shortcuts') k('quit', 'q', 'quit', _('Quit')) @@ -108,4 +110,11 @@ k('default_context', '=', 'change_context default', _('Show default context')) k('increase_context', '+', 'change_context 5', _('Increase context')) k('decrease_context', '-', 'change_context -5', _('Decrease context')) +k('search_forward', '/', 'start_search regex forward', _('Search forward')) +k('search_backward', '?', 'start_search regex backward', _('Search backward')) +k('next_match', '.', 'scroll_to next-match', _('Scroll to next search match')) +k('prev_match', ',', 'scroll_to prev-match', _('Scroll to previous search match')) +k('next_match', '>', 'scroll_to next-match', _('Scroll to next search match')) +k('prev_match', '<', 'scroll_to prev-match', _('Scroll to previous search match')) + type_map = {o.name: o.option_type for o in all_options.values() if hasattr(o, 'option_type')} diff --git a/kittens/diff/main.py b/kittens/diff/main.py index 6741b2e50..1ebb54b07 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -11,18 +11,21 @@ from gettext import gettext as _ from kitty.cli import CONFIG_HELP, appname, parse_args from kitty.fast_data_types import wcswidth -from kitty.key_encoding import RELEASE +from kitty.key_encoding import ESCAPE, RELEASE, enter_key from ..tui.handler import Handler from ..tui.images import ImageManager +from ..tui.line_edit import LineEdit from ..tui.loop import Loop from ..tui.operations import styled from .collect import ( - create_collection, data_for_path, lines_for_path, set_highlight_data + create_collection, data_for_path, lines_for_path, sanitize, + set_highlight_data ) from .config import init_config from .patch import Differ, set_diff_command from .render import ImageSupportWarning, LineRef, render_diff +from .search import BadRegex, Search try: from .highlight import initialize_highlighter, highlight_collection @@ -30,7 +33,7 @@ except ImportError: initialize_highlighter = None -INITIALIZING, COLLECTED, DIFFED = range(3) +INITIALIZING, COLLECTED, DIFFED, COMMAND, MESSAGE = range(5) def generate_diff(collection, context): @@ -51,6 +54,10 @@ class DiffHandler(Handler): def __init__(self, args, opts, left, right): self.state = INITIALIZING + self.message = '' + self.current_search_is_regex = True + self.current_search = None + self.line_edit = LineEdit() self.opts = opts self.left, self.right = left, right self.report_traceback_on_exit = None @@ -76,6 +83,8 @@ class DiffHandler(Handler): where = args[0] if 'change' in where: return self.scroll_to_next_change(backwards='prev' in where) + if 'match' in where: + return self.scroll_to_next_match(backwards='prev' in where) if 'page' in where: amt = self.num_lines * (1 if 'next' in where else -1) else: @@ -91,6 +100,9 @@ class DiffHandler(Handler): else: new_ctx += to return self.change_context_count(new_ctx) + if func == 'start_search': + self.start_search(*args) + return def create_collection(self): self.start_job('collect', create_collection, self.left, self.right) @@ -107,10 +119,13 @@ class DiffHandler(Handler): def render_diff(self): self.diff_lines = tuple(render_diff(self.collection, self.diff_map, self.args, self.screen_size.cols, self.image_manager)) + self.margin_size = render_diff.margin_size self.ref_path_map = defaultdict(list) for i, l in enumerate(self.diff_lines): self.ref_path_map[l.ref.path].append((i, l.ref)) self.max_scroll_pos = len(self.diff_lines) - self.num_lines + if self.current_search is not None: + self.current_search(self.diff_lines, self.margin_size, self.screen_size.cols) @property def current_position(self): @@ -152,6 +167,18 @@ class DiffHandler(Handler): return self.cmd.bell() + def scroll_to_next_match(self, backwards=False): + if self.current_search is not None: + if backwards: + r = range(self.scroll_pos - 1, -1, -1) + else: + r = range(self.scroll_pos + 1, len(self.diff_lines)) + for i in r: + if i in self.current_search: + self.scroll_lines(i - self.scroll_pos) + return + self.cmd.bell() + def set_scrolling_region(self): self.cmd.set_scrolling_region(self.screen_size, 0, self.num_lines - 2) @@ -178,7 +205,8 @@ class DiffHandler(Handler): def init_terminal_state(self): self.cmd.set_line_wrapping(False) self.cmd.set_window_title(main.title) - self.cmd.set_default_colors(self.opts.foreground, self.opts.background) + self.cmd.set_default_colors(self.opts.foreground, self.opts.background, self.opts.foreground) + self.cmd.set_cursor_shape('bar') def initialize(self): self.init_terminal_state() @@ -191,7 +219,6 @@ class DiffHandler(Handler): def finalize(self): self.cmd.set_cursor_visible(True) - self.cmd.set_default_colors() self.cmd.set_scrolling_region() def draw_lines(self, num, offset=0): @@ -208,6 +235,8 @@ class DiffHandler(Handler): if line.image_data is not None: image_involved = True self.write('\r\x1b[K' + text + '\x1b[0m') + if self.current_search is not None: + self.current_search.highlight_line(self.write, lpos) if i < num - 1: self.write('\n') if image_involved: @@ -262,19 +291,25 @@ class DiffHandler(Handler): def draw_status_line(self): if self.state < DIFFED: return + self.enforce_cursor_state() self.cmd.set_cursor_position(0, self.num_lines) self.cmd.clear_to_eol() - scroll_frac = styled('{:.0%}'.format(self.scroll_pos / (self.max_scroll_pos or 1)), fg=self.opts.margin_fg) - counts = '{}{}{}'.format( - styled(str(self.added_count), fg=self.opts.highlight_added_bg), - styled(',', fg=self.opts.margin_fg), - styled(str(self.removed_count), fg=self.opts.highlight_removed_bg) - ) - suffix = counts + ' ' + scroll_frac - prefix = styled(':', fg=self.opts.margin_fg) - filler = self.screen_size.cols - wcswidth(prefix) - wcswidth(suffix) - text = '{}{}{}'.format(prefix, ' ' * filler, suffix) - self.write(text) + if self.state is COMMAND: + self.line_edit.write(self.write) + elif self.state is MESSAGE: + self.write(self.message) + else: + scroll_frac = styled('{:.0%}'.format(self.scroll_pos / (self.max_scroll_pos or 1)), fg=self.opts.margin_fg) + counts = '{}{}{}'.format( + styled(str(self.added_count), fg=self.opts.highlight_added_bg), + styled(',', fg=self.opts.margin_fg), + styled(str(self.removed_count), fg=self.opts.highlight_removed_bg) + ) + suffix = counts + ' ' + scroll_frac + prefix = styled(':', fg=self.opts.margin_fg) + filler = self.screen_size.cols - wcswidth(prefix) - wcswidth(suffix) + text = '{}{}{}'.format(prefix, ' ' * filler, suffix) + self.write(text) def change_context_count(self, new_ctx): new_ctx = max(0, new_ctx) @@ -285,14 +320,69 @@ class DiffHandler(Handler): self.restore_position = self.current_position self.draw_screen() + def start_search(self, is_regex, is_backward): + if self.state != DIFFED: + self.cmd.bell() + return + self.state = COMMAND + self.line_edit.clear() + self.line_edit.add_text('?' if is_backward else '/') + self.current_search_is_regex = is_regex + self.draw_status_line() + + def do_search(self): + self.current_search = None + query = self.line_edit.current_input + if len(query) < 2: + return + try: + self.current_search = Search(self.opts, query[1:], self.current_search_is_regex, query[0] == '?') + except BadRegex: + self.state = MESSAGE + self.message = sanitize(_('Bad regex: {}').format(query)) + self.cmd.bell() + else: + if not self.current_search(self.diff_lines, self.margin_size, self.screen_size.cols): + self.state = MESSAGE + self.message = sanitize(_('No matches found')) + self.cmd.bell() + def on_text(self, text, in_bracketed_paste=False): + if self.state is COMMAND: + self.line_edit.on_text(text, in_bracketed_paste) + self.draw_status_line() + return + if self.state is MESSAGE: + self.state = DIFFED + return action = self.shortcut_action(text) if action is not None: return self.perform_action(action) def on_key(self, key_event): + if self.state is MESSAGE: + if key_event.type is not RELEASE: + self.state = DIFFED + return + if self.state is COMMAND: + if self.line_edit.on_key(key_event): + if not self.line_edit.current_input: + self.state = DIFFED + self.draw_status_line() + return if key_event.type is RELEASE: return + if self.state is COMMAND: + if key_event.key is ESCAPE: + self.state = DIFFED + self.draw_status_line() + return + if key_event is enter_key: + self.state = DIFFED + self.do_search() + self.line_edit.clear() + self.draw_screen() + return action = self.shortcut_action(key_event) if action is not None: return self.perform_action(action) diff --git a/kittens/diff/render.py b/kittens/diff/render.py index af4b0f2bc..1a8421592 100644 --- a/kittens/diff/render.py +++ b/kittens/diff/render.py @@ -447,7 +447,7 @@ def render_diff(collection, diff_map, args, columns, image_manager): if patch is not None: largest_line_number = max(largest_line_number, patch.largest_line_number) - margin_size = max(3, len(str(largest_line_number)) + 1) + margin_size = render_diff.margin_size = max(3, len(str(largest_line_number)) + 1) last_item_num = len(collection) - 1 for i, (path, item_type, other_path) in enumerate(collection): diff --git a/kittens/diff/search.py b/kittens/diff/search.py new file mode 100644 index 000000000..61c2bb7f3 --- /dev/null +++ b/kittens/diff/search.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2018, Kovid Goyal + +import re + +from kitty.fast_data_types import wcswidth + +from ..tui.operations import styled + + +class BadRegex(ValueError): + pass + + +class Search: + + def __init__(self, opts, query, is_regex, is_backward): + self.matches = {} + self.style = styled('|', fg=opts.search_fg, bg=opts.search_bg).split('|', 1)[0] + if not is_regex: + query = re.escape(query) + try: + self.pat = re.compile(query, flags=re.UNICODE | re.IGNORECASE) + except Exception: + raise BadRegex('Not a valid regex: {}'.format(query)) + + def __call__(self, diff_lines, margin_size, cols): + self.matches = {} + half_width = cols // 2 + strip_pat = re.compile('\033[[].*?m') + right_offset = half_width + 1 + margin_size + find = self.pat.finditer + for i, line in enumerate(diff_lines): + text = strip_pat.sub('', line.text) + left, right = text[margin_size:half_width + 1], text[right_offset:] + matches = [] + + def add(which, offset): + for m in find(which): + before = which[:m.start()] + matches.append((wcswidth(before) + offset, m.group())) + + add(left, margin_size) + add(right, right_offset) + if matches: + self.matches[i] = matches + return bool(self.matches) + + def __contains__(self, i): + return i in self.matches + + def __len__(self): + return len(self.matches) + + def highlight_line(self, write, line_num): + highlights = self.matches.get(line_num) + if not highlights: + return False + write(self.style) + for start, text in highlights: + write('\r\x1b[{}C{}'.format(start, text)) + write('\x1b[m') + return True diff --git a/kittens/tui/line_edit.py b/kittens/tui/line_edit.py index 9313731ac..37a53b8f7 100644 --- a/kittens/tui/line_edit.py +++ b/kittens/tui/line_edit.py @@ -30,7 +30,7 @@ class LineEdit: write(self.current_input) write('\r\x1b[{}C'.format(self.cursor_pos + wcswidth(prompt))) - def on_text(self, text, in_bracketed_paste): + def add_text(self, text): if self.current_input: x = truncate_point_for_length(self.current_input, self.cursor_pos) if self.cursor_pos else 0 self.current_input = self.current_input[:x] + text + self.current_input[x:] @@ -38,6 +38,9 @@ class LineEdit: self.current_input = text self.cursor_pos += wcswidth(text) + def on_text(self, text, in_bracketed_paste): + self.add_text(text) + def backspace(self, num=1): before, after = self.split_at_cursor() nbefore = before[:-num] diff --git a/kittens/tui/operations.py b/kittens/tui/operations.py index e77c2c28d..c7a6aaa9d 100644 --- a/kittens/tui/operations.py +++ b/kittens/tui/operations.py @@ -78,6 +78,13 @@ def set_cursor_position(x, y) -> str: # (0, 0) is top left return '\033[{};{}H'.format(y + 1, x + 1) +def set_cursor_shape(shape='block', blink=True) -> str: + val = {'block': 1, 'underline': 3, 'bar': 5}.get(shape, 1) + if not blink: + val += 1 + return '\033[{} q'.format(val) + + def set_scrolling_region(screen_size=None, top=None, bottom=None) -> str: if screen_size is None: return '\033[r' @@ -220,12 +227,16 @@ def alternate_screen(f=None): print(reset_mode('ALTERNATE_SCREEN'), end='', file=f) -def set_default_colors(fg=None, bg=None) -> str: +def set_default_colors(fg=None, bg=None, cursor=None) -> str: ans = '' if fg is None: ans += '\x1b]110\x1b\\' else: ans += '\x1b]10;{}\x1b\\'.format(color_as_sharp(fg if isinstance(fg, Color) else to_color(fg))) + if cursor is None: + ans += '\x1b]112\x1b\\' + else: + ans += '\x1b]12;{}\x1b\\'.format(color_as_sharp(cursor if isinstance(cursor, Color) else to_color(cursor))) if bg is None: ans += '\x1b]111\x1b\\' else: