#!/usr/bin/env python # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2018, Kovid Goyal import os import re import string import sys from functools import lru_cache from gettext import gettext as _ from itertools import repeat from kitty.cli import parse_args from kitty.fast_data_types import set_clipboard_string from kitty.key_encoding import ESCAPE, backspace_key, enter_key from kitty.utils import screen_size_function from ..tui.handler import Handler from ..tui.loop import Loop from ..tui.operations import faint, styled URL_PREFIXES = 'http https file ftp'.split() HINT_ALPHABET = string.digits + string.ascii_lowercase screen_size = screen_size_function() class Mark(object): __slots__ = ('index', 'start', 'end', 'text') def __init__(self, index, start, end, text): self.index, self.start, self.end = index, start, end self.text = text @lru_cache(maxsize=2048) def encode_hint(num): res = '' d = len(HINT_ALPHABET) while not res or num > 0: num, i = divmod(num, d) res = HINT_ALPHABET[i] + res return res def decode_hint(x): return int(x, 36) def highlight_mark(m, text, current_input): hint = encode_hint(m.index) if current_input and not hint.startswith(current_input): return faint(text) hint = hint[len(current_input):] or ' ' text = text[len(hint):] return styled( hint, fg='black', bg='green', bold=True ) + styled( text, fg='gray', fg_intense=True, bold=True ) def render(text, current_input, all_marks): for mark in reversed(all_marks): mtext = highlight_mark(mark, text[mark.start:mark.end], current_input) text = text[:mark.start] + mtext + text[mark.end:] text = text.replace('\0', '') return text.replace('\n', '\r\n').rstrip() class Hints(Handler): def __init__(self, text, all_marks, index_map, args): self.text, self.index_map = text, index_map self.all_marks = all_marks self.current_input = '' self.current_text = None self.args = args self.window_title = _('Choose URL') if args.type == 'url' else _('Choose text') self.chosen = None def init_terminal_state(self): self.cmd.set_cursor_visible(False) self.cmd.set_window_title(self.window_title) self.cmd.set_line_wrapping(False) def initialize(self): self.init_terminal_state() self.draw_screen() def on_text(self, text, in_bracketed_paste): changed = False for c in text: if c in HINT_ALPHABET: self.current_input += c changed = True if changed: matches = [ m.text for idx, m in self.index_map.items() if encode_hint(idx).startswith(self.current_input) ] if len(matches) == 1: self.chosen = matches[0] self.quit_loop(0) return self.current_text = None self.draw_screen() def on_key(self, key_event): if key_event is backspace_key: self.current_input = self.current_input[:-1] self.current_text = None self.draw_screen() elif key_event is enter_key and self.current_input: try: idx = decode_hint(self.current_input) self.chosen = self.index_map[idx].text except Exception: self.current_input = '' self.current_text = None self.draw_screen() else: self.quit_loop(0) elif key_event.key is ESCAPE: self.quit_loop(1) def on_interrupt(self): self.quit_loop(1) def on_eot(self): self.quit_loop(1) def on_resize(self, new_size): Handler.on_resize(self, new_size) self.draw_screen() def draw_screen(self): if self.current_text is None: self.current_text = render(self.text, self.current_input, self.all_marks) self.cmd.clear_screen() self.write(self.current_text) def regex_finditer(pat, minimum_match_length, text): for m in pat.finditer(text): s, e = m.span(pat.groups) while e > s + 1 and text[e-1] == '\0': e -= 1 if e - s >= minimum_match_length: yield s, e closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>'} opening_brackets = ''.join(closing_bracket_map) postprocessor_map = {} def postprocessor(func): postprocessor_map[func.__name__] = func return func @postprocessor def url(text, s, e): if s > 4 and text[s - 5:s] == 'link:': # asciidoc URLs url = text[s:e] idx = url.rfind('[') if idx > -1: e -= len(url) - idx while text[e - 1] in '.,?!' and e > 1: # remove trailing punctuation e -= 1 # Restructured Text URLs if e > 3 and text[e-2:e] == '`_': e -= 2 # Remove trailing bracket if matched by leading bracket if s > 0 and e < len(text) and text[s-1] in opening_brackets and text[e-1] == closing_bracket_map[text[s-1]]: e -= 1 # Remove trailing quote if matched by leading quote if s > 0 and e < len(text) and text[s-1] in '\'"' and text[e-1] == text[s-1]: e -= 1 return s, e @postprocessor def brackets(text, s, e): # Remove matching brackets if e > s and e <= len(text): before = text[s] if before in '({[<' and text[e-1] == closing_bracket_map[before]: s += 1 e -= 1 return s, e @postprocessor def quotes(text, s, e): # Remove matching quotes if e > s and e <= len(text): before = text[s] if before in '\'"' and text[e-1] == before: s += 1 e -= 1 return s, e def mark(pattern, post_processors, text, args): pat = re.compile(pattern) for idx, (s, e) in enumerate(regex_finditer(pat, args.minimum_match_length, text)): for func in post_processors: s, e = func(text, s, e) mark_text = text[s:e].replace('\n', '').replace('\0', '') yield Mark(idx, s, e, mark_text) def run_loop(args, text, all_marks, index_map): loop = Loop() handler = Hints(text, all_marks, index_map, args) loop.loop(handler) if handler.chosen and loop.return_code == 0: return {'match': handler.chosen, 'program': args.program} raise SystemExit(loop.return_code) def escape(chars): return chars.replace('\\', '\\\\').replace('-', r'\-').replace(']', r'\]') def functions_for(args): post_processors = [] if args.type == 'url': from .url_regex import url_delimiters pattern = '(?:{})://[^{}]{{3,}}'.format( '|'.join(args.url_prefixes.split(',')), url_delimiters ) post_processors.append(url) elif args.type == 'path': pattern = r'(?:\S*/\S+)|(?:\S+[.][a-zA-Z0-9]{2,5})' post_processors.extend((brackets, quotes)) elif args.type == 'line': pattern = '(?m)^\\s*(.+)[\\s\0]*$' elif args.type == 'sha1': pattern = '[0-9a-f]{7,40}' elif args.type == 'word': chars = args.word_characters if chars is None: import json chars = json.loads(os.environ['KITTY_COMMON_OPTS'])['select_by_word_characters'] pattern = r'(?u)[{}\w]{{{},}}'.format(escape(chars), args.minimum_match_length) post_processors.extend((brackets, quotes)) else: pattern = args.regex return pattern, post_processors def convert_text(text, cols): lines = [] empty_line = '\0' * cols for full_line in text.split('\n'): if full_line: if not full_line.rstrip('\r'): # empty lines lines.extend(repeat(empty_line, len(full_line))) continue for line in full_line.split('\r'): if line: lines.append(line.ljust(cols, '\0')) return '\n'.join(lines) def parse_input(text): try: cols = int(os.environ['OVERLAID_WINDOW_COLS']) except KeyError: cols = screen_size().cols return convert_text(text, cols) def run(args, text): try: pattern, post_processors = functions_for(args) text = parse_input(text) all_marks = tuple(mark(pattern, post_processors, text, args)) if not all_marks: input(_('No {} found, press Enter to quit.').format( 'URLs' if args.type == 'url' else 'matches' )) return largest_index = all_marks[-1].index for m in all_marks: m.index = largest_index - m.index index_map = {m.index: m for m in all_marks} except Exception: import traceback traceback.print_exc() input('Press Enter to quit.') raise SystemExit(1) return run_loop(args, text, all_marks, index_map) # CLI {{{ OPTIONS = r''' --program default=default What program to use to open matched text. Defaults to the default open program for the operating system. Use a value of :file:`-` to paste the match into the terminal window instead. A value of :file:`@` will copy the match to the clipboard. --type default=url choices=url,regex,path,line,sha1,word The type of text to search for. --regex default=(?m)^\s*(.+)\s*$ The regular expression to use when :option:`kitty +kitten hints --type`=regex. If you specify a group in the regular expression only the group will be matched. This allow you to match text ignoring a prefix/suffix, as needed. The default expression matches lines. --url-prefixes default={0} Comma separated list of recognized URL prefixes. --word-characters Characters to consider as part of a word. In addition, all characters marked as alpha-numeric in the unicode database will be considered as word characters. Defaults to the select_by_word_characters setting from kitty.conf. --minimum-match-length default=3 type=int The minimum number of characters to consider a match. '''.format(','.join(sorted(URL_PREFIXES))).format help_text = 'Select text from the screen using the keyboard. Defaults to searching for URLs.' usage = '' def parse_hints_args(args): return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints') def main(args): text = '' if sys.stdin.isatty(): if '--help' not in args and '-h' not in args: print('You must pass the text to be hinted on STDIN', file=sys.stderr) input(_('Press Enter to quit')) return else: text = sys.stdin.buffer.read().decode('utf-8') sys.stdin = open('/dev/tty') try: args, items = parse_hints_args(args[1:]) except SystemExit as e: if e.code != 0: print(e.args[0], file=sys.stderr) input(_('Press Enter to quit')) return if items: print('Extra command line arguments present: {}'.format(' '.join(items)), file=sys.stderr) input(_('Press Enter to quit')) return return run(args, text) def handle_result(args, data, target_window_id, boss): program = data['program'] if program == '-': w = boss.window_id_map.get(target_window_id) if w is not None: w.paste(data['match']) elif program == '@': set_clipboard_string(data['match']) else: cwd = None w = boss.window_id_map.get(target_window_id) if w is not None: cwd = w.cwd_of_child boss.open_url(data['match'], None if program == 'default' else program, cwd=cwd) handle_result.type_of_input = 'screen' if __name__ == '__main__': # Run with kitty +kitten hints ans = main(sys.argv) if ans: print(ans) elif __name__ == '__doc__': sys.cli_docs['usage'] = usage sys.cli_docs['options'] = OPTIONS sys.cli_docs['help_text'] = help_text # }}}