mirror of
https://github.com/kovidgoyal/kitty.git
synced 2024-09-23 04:29:26 +03:00
341a34ea9e
See .desktop files
432 lines
12 KiB
Python
432 lines
12 KiB
Python
#!/usr/bin/env python
|
|
# vim:fileencoding=utf-8
|
|
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
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:
|
|
|
|
__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, ignore_mark_indices):
|
|
for mark in reversed(all_marks):
|
|
if mark.index in ignore_mark_indices:
|
|
continue
|
|
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.ignore_mark_indices = set()
|
|
self.args = args
|
|
self.window_title = _('Choose URL') if args.type == 'url' else _('Choose text')
|
|
self.multiple = args.multiple
|
|
self.chosen = []
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.current_input = ''
|
|
self.current_text = 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 for idx, m in self.index_map.items()
|
|
if encode_hint(idx).startswith(self.current_input)
|
|
]
|
|
if len(matches) == 1:
|
|
self.chosen.append(matches[0].text)
|
|
if self.multiple:
|
|
self.ignore_mark_indices.add(matches[0].index)
|
|
self.reset()
|
|
else:
|
|
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.append(self.index_map[idx].text)
|
|
self.ignore_mark_indices.add(idx)
|
|
except Exception:
|
|
self.current_input = ''
|
|
self.current_text = None
|
|
self.draw_screen()
|
|
else:
|
|
if self.multiple:
|
|
self.reset()
|
|
self.draw_screen()
|
|
else:
|
|
self.quit_loop(0)
|
|
elif key_event.key is ESCAPE:
|
|
self.quit_loop(0 if self.multiple else 1)
|
|
|
|
def on_interrupt(self):
|
|
self.quit_loop(1)
|
|
|
|
def on_eot(self):
|
|
self.quit_loop(1)
|
|
|
|
def 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.ignore_mark_indices)
|
|
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,7})'
|
|
post_processors.extend((brackets, quotes))
|
|
elif args.type == 'line':
|
|
pattern = '(?m)^\\s*(.+)[\\s\0]*$'
|
|
elif args.type == 'hash':
|
|
pattern = '[0-9a-f]{7,128}'
|
|
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,hash,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.
|
|
|
|
|
|
--multiple
|
|
type=bool-set
|
|
Select multiple matches and perform the action on all of them together at the end.
|
|
In this mode, press :kbd:`Esc` to finish selecting.
|
|
'''.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']
|
|
matches = tuple(filter(None, data['match']))
|
|
if program == '-':
|
|
w = boss.window_id_map.get(target_window_id)
|
|
if w is not None:
|
|
for m in matches:
|
|
w.paste(m)
|
|
elif program == '@':
|
|
set_clipboard_string(matches[-1])
|
|
else:
|
|
cwd = None
|
|
w = boss.window_id_map.get(target_window_id)
|
|
if w is not None:
|
|
cwd = w.cwd_of_child
|
|
program = None if program == 'default' else program
|
|
for m in matches:
|
|
boss.open_url(m, 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
|
|
# }}}
|