diff --git a/kittens/unicode_input/main.py b/kittens/unicode_input/main.py index 01bbe3edc..d696c98fc 100644 --- a/kittens/unicode_input/main.py +++ b/kittens/unicode_input/main.py @@ -1,544 +1,11 @@ #!/usr/bin/env python3 # License: GPL v3 Copyright: 2018, Kovid Goyal -import os -import string -import subprocess -import sys -from contextlib import suppress -from functools import lru_cache -from gettext import gettext as _ -from typing import Any, Dict, FrozenSet, Generator, Iterable, List, Optional, Sequence, Tuple, Union -from kitty.cli import parse_args -from kitty.cli_stub import UnicodeCLIOptions -from kitty.config import cached_values_for -from kitty.constants import config_dir -from kitty.fast_data_types import is_emoji_presentation_base, wcswidth -from kitty.key_encoding import EventType, KeyEvent +from typing import List, Optional + from kitty.typing import BossType -from kitty.utils import ScreenSize, get_editor - -from ..tui.handler import Handler, result_handler -from ..tui.line_edit import LineEdit -from ..tui.loop import Loop -from ..tui.operations import clear_screen, colored, cursor, faint, set_line_wrapping, set_window_title, sgr, styled -from ..tui.utils import report_unhandled_error - -HEX, NAME, EMOTICONS, FAVORITES = 'HEX', 'NAME', 'EMOTICONS', 'FAVORITES' -favorites_path = os.path.join(config_dir, 'unicode-input-favorites.conf') -INDEX_CHAR = '.' -INDEX_BASE = 36 -DEFAULT_SET = tuple(map( - ord, - '‘’“”‹›«»‚„' '😀😛😇😈😉😍😎😮👍👎' '—–§¶†‡©®™' '→⇒•·°±−×÷¼½½¾' - '…µ¢£€¿¡¨´¸ˆ˜' 'ÀÁÂÃÄÅÆÇÈÉÊË' 'ÌÍÎÏÐÑÒÓÔÕÖØ' 'ŒŠÙÚÛÜÝŸÞßàá' 'âãäåæçèéêëìí' - 'îïðñòóôõöøœš' 'ùúûüýÿþªºαΩ∞' -)) -EMOTICONS_SET = tuple(range(0x1f600, 0x1f64f + 1)) -all_modes = ( - (_('Code'), 'F1', HEX), - (_('Name'), 'F2', NAME), - (_('Emoji'), 'F3', EMOTICONS), - (_('Favorites'), 'F4', FAVORITES), -) - - -def codepoint_ok(code: int) -> bool: - return not (code <= 32 or code == 127 or 128 <= code <= 159 or 0xd800 <= code <= 0xdbff or 0xDC00 <= code <= 0xDFFF) - - -@lru_cache(maxsize=256) -def points_for_word(w: str) -> FrozenSet[int]: - from .unicode_names import codepoints_for_word - return codepoints_for_word(w.lower()) - - -@lru_cache(maxsize=4096) -def name(cp: Union[int, str]) -> str: - from .unicode_names import name_for_codepoint - c = ord(cp[0]) if isinstance(cp, str) else cp - return (name_for_codepoint(c) or '').capitalize() - - -@lru_cache(maxsize=256) -def codepoints_matching_search(parts: Tuple[str, ...]) -> List[int]: - ans = [] - if parts and parts[0] and len(parts[0]) > 1: - codepoints = points_for_word(parts[0]) - for word in parts[1:]: - pts = points_for_word(word) - if pts: - intersection = codepoints & pts - if intersection: - codepoints = intersection - continue - codepoints = frozenset(c for c in codepoints if word in name(c).lower()) - if codepoints: - ans = list(sorted(codepoints)) - return ans - - -def parse_favorites(raw: str) -> Generator[int, None, None]: - for line in raw.splitlines(): - line = line.strip() - if line.startswith('#') or not line: - continue - idx = line.find('#') - if idx > -1: - line = line[:idx] - code_text = line.partition(' ')[0] - try: - code = int(code_text, 16) - except Exception: - pass - else: - if codepoint_ok(code): - yield code - - -def serialize_favorites(favorites: Iterable[int]) -> str: - ans = '''\ -# Favorite characters for unicode input -# Enter the hex code for each favorite character on a new line. Blank lines are -# ignored and anything after a # is considered a comment. - -'''.splitlines() - for cp in favorites: - ans.append(f'{cp:x} # {chr(cp)} {name(cp)}') - return '\n'.join(ans) - - -def load_favorites(refresh: bool = False) -> List[int]: - ans: Optional[List[int]] = getattr(load_favorites, 'ans', None) - if ans is None or refresh: - try: - with open(favorites_path, 'rb') as f: - raw = f.read().decode('utf-8') - ans = list(parse_favorites(raw)) or list(DEFAULT_SET) - except FileNotFoundError: - ans = list(DEFAULT_SET) - setattr(load_favorites, 'ans', ans) - return ans - - -def encode_hint(num: int, digits: str = string.digits + string.ascii_lowercase) -> str: - res = '' - d = len(digits) - while not res or num > 0: - num, i = divmod(num, d) - res = digits[i] + res - return res - - -def decode_hint(x: str) -> int: - return int(x, INDEX_BASE) - - -class Table: - - def __init__(self, emoji_variation: str) -> None: - self.emoji_variation = emoji_variation - self.layout_dirty: bool = True - self.last_rows = self.last_cols = -1 - self.codepoints: List[int] = [] - self.current_idx = 0 - self.scroll_rows = 0 - self.text = '' - self.num_cols = 0 - self.num_rows = 0 - self.mode = HEX - - @property - def current_codepoint(self) -> Optional[int]: - if self.codepoints: - return self.codepoints[self.current_idx] - return None - - def set_codepoints(self, codepoints: List[int], mode: str = HEX, current_idx: int = 0) -> None: - self.codepoints = codepoints - self.mode = mode - self.layout_dirty = True - self.current_idx = current_idx if current_idx < len(codepoints) else 0 - self.scroll_rows = 0 - - def codepoint_at_hint(self, hint: str) -> int: - return self.codepoints[decode_hint(hint)] - - def layout(self, rows: int, cols: int) -> Optional[str]: - if not self.layout_dirty and self.last_cols == cols and self.last_rows == rows: - return self.text - self.last_cols, self.last_rows = cols, rows - self.layout_dirty = False - - def safe_chr(codepoint: int) -> str: - ans = chr(codepoint).encode('utf-8', 'replace').decode('utf-8') - if self.emoji_variation and is_emoji_presentation_base(codepoint): - ans += self.emoji_variation - return ans - - if self.mode is NAME: - def as_parts(i: int, codepoint: int) -> Tuple[str, str, str]: - return encode_hint(i).ljust(idx_size), safe_chr(codepoint), name(codepoint) - - def cell(i: int, idx: str, c: str, desc: str) -> Generator[str, None, None]: - is_current = i == self.current_idx - text = colored(idx, 'green') + ' ' + sgr('49') + c + ' ' - w = wcswidth(c) - if w < 2: - text += ' ' * (2 - w) - if len(desc) > space_for_desc: - text += f'{desc[:space_for_desc - 1]}…' - else: - text += desc - extra = space_for_desc - len(desc) - if extra > 0: - text += ' ' * extra - - yield styled(text, reverse=True if is_current else None) - - else: - def as_parts(i: int, codepoint: int) -> Tuple[str, str, str]: - return encode_hint(i).ljust(idx_size), safe_chr(codepoint), '' - - def cell(i: int, idx: str, c: str, desc: str) -> Generator[str, None, None]: - yield colored(idx, 'green') + ' ' - yield colored(c, 'gray', True) - w = wcswidth(c) - if w < 2: - yield ' ' * (2 - w) - - num = len(self.codepoints) - if num < 1: - self.text = '' - self.num_cols = 0 - self.num_rows = 0 - return self.text - idx_size = len(encode_hint(num - 1)) - - parts = [as_parts(i, c) for i, c in enumerate(self.codepoints)] - if self.mode is NAME: - sizes = [idx_size + 2 + len(p[2]) + 2 for p in parts] - else: - sizes = [idx_size + 3] - longest = max(sizes) if sizes else 0 - col_width = longest + 2 - col_width = min(col_width, 40) - space_for_desc = col_width - 2 - idx_size - 4 - num_cols = self.num_cols = max(cols // col_width, 1) - buf: List[str] = [] - a = buf.append - rows_left = self.num_rows = rows - skip_scroll = self.scroll_rows * num_cols - - for i, (idx, c, desc) in enumerate(parts): - if skip_scroll > 0: - skip_scroll -= 1 - continue - buf.extend(cell(i, idx, c, desc)) - a(' ') - if i > 0 and (i+1) % num_cols == 0: - rows_left -= 1 - if rows_left == 0: - break - a('\r\n') - self.text = ''.join(buf) - return self.text - - def move_current(self, rows: int = 0, cols: int = 0) -> None: - if len(self.codepoints) == 0: - return - if cols: - self.current_idx = (self.current_idx + len(self.codepoints) + cols) % len(self.codepoints) - self.layout_dirty = True - if rows: - amt = rows * self.num_cols - self.current_idx += amt - self.current_idx = max(0, min(self.current_idx, len(self.codepoints) - 1)) - self.layout_dirty = True - first_visible = self.scroll_rows * self.num_cols - last_visible = first_visible + ((self.num_cols * self.num_rows) - 1) - scroll_amount = self.num_rows - if self.current_idx < first_visible: - self.scroll_rows = max(self.scroll_rows - scroll_amount, 0) - if self.current_idx > last_visible: - self.scroll_rows += scroll_amount - - -def is_index(w: str) -> bool: - if w[0] != INDEX_CHAR: - return False - try: - int(w.lstrip(INDEX_CHAR), INDEX_BASE) - return True - except Exception: - return False - - -class UnicodeInput(Handler): - - overlay_ready_report_needed = True - - def __init__(self, cached_values: Dict[str, Any], emoji_variation: str = 'none') -> None: - self.cached_values = cached_values - self.emoji_variation = '' - if emoji_variation == 'text': - self.emoji_variation = '\ufe0e' - elif emoji_variation == 'graphic': - self.emoji_variation = '\ufe0f' - self.line_edit = LineEdit() - self.recent = list(self.cached_values.get('recent', DEFAULT_SET)) - self.current_char: Optional[str] = None - self.prompt_template = '{}> ' - self.last_updated_code_point_at: Optional[Tuple[str, Union[Sequence[int], None, str]]] = None - self.choice_line = '' - self.mode = globals().get(cached_values.get('mode', 'HEX'), 'HEX') - self.table = Table(self.emoji_variation) - self.update_prompt() - - @property - def resolved_current_char(self) -> Optional[str]: - ans = self.current_char - if ans: - if self.emoji_variation and is_emoji_presentation_base(ord(ans[0])): - ans += self.emoji_variation - return ans - - def update_codepoints(self) -> None: - codepoints = None - iindex_word = 0 - if self.mode is HEX: - q: Tuple[str, Optional[Union[str, Sequence[int]]]] = (self.mode, None) - codepoints = self.recent - elif self.mode is EMOTICONS: - q = self.mode, None - codepoints = list(EMOTICONS_SET) - elif self.mode is FAVORITES: - codepoints = load_favorites() - q = self.mode, tuple(codepoints) - elif self.mode is NAME: - q = self.mode, self.line_edit.current_input - if q != self.last_updated_code_point_at: - words = self.line_edit.current_input.split() - words = [w for w in words if w != INDEX_CHAR] - index_words = [i for i, w in enumerate(words) if i > 0 and is_index(w)] - if index_words: - index_word = words[index_words[0]] - words = words[:index_words[0]] - iindex_word = int(index_word.lstrip(INDEX_CHAR), INDEX_BASE) - codepoints = codepoints_matching_search(tuple(words)) - if q != self.last_updated_code_point_at: - self.last_updated_code_point_at = q - self.table.set_codepoints(codepoints or [], self.mode, iindex_word) - - def update_current_char(self) -> None: - self.update_codepoints() - self.current_char = None - if self.mode is HEX: - with suppress(Exception): - if self.line_edit.current_input.startswith(INDEX_CHAR): - if len(self.line_edit.current_input) > 1: - self.current_char = chr(self.table.codepoint_at_hint(self.line_edit.current_input[1:])) - elif self.line_edit.current_input: - code = int(self.line_edit.current_input, 16) - self.current_char = chr(code) - elif self.mode is NAME: - cc = self.table.current_codepoint - if cc: - self.current_char = chr(cc) - else: - with suppress(Exception): - if self.line_edit.current_input: - self.current_char = chr(self.table.codepoint_at_hint(self.line_edit.current_input.lstrip(INDEX_CHAR))) - if self.current_char is not None: - code = ord(self.current_char) - if not codepoint_ok(code): - self.current_char = None - - def update_prompt(self) -> None: - self.update_current_char() - if self.current_char is None: - c, color = '??', 'red' - self.choice_line = '' - else: - c, color = self.current_char, 'green' - if self.emoji_variation and is_emoji_presentation_base(ord(c[0])): - c += self.emoji_variation - self.choice_line = _('Chosen:') + ' {} U+{} {}'.format( - colored(c, 'green'), hex(ord(c[0]))[2:], faint(styled(name(c) or '', italic=True))) - self.prompt = self.prompt_template.format(colored(c, color)) - - def init_terminal_state(self) -> None: - self.write(set_line_wrapping(False)) - self.write(set_window_title(_('Unicode input'))) - - def initialize(self) -> None: - self.init_terminal_state() - self.draw_screen() - - def draw_title_bar(self) -> None: - entries = [] - for name, key, mode in all_modes: - entry = f' {name} ({key}) ' - if mode is self.mode: - entry = styled(entry, reverse=False, bold=True) - entries.append(entry) - text = _('Search by:{}').format(' '.join(entries)) - extra = self.screen_size.cols - wcswidth(text) - if extra > 0: - text += ' ' * extra - self.print(styled(text, reverse=True)) - - @Handler.atomic_update - def draw_screen(self) -> None: - self.write(clear_screen()) - self.draw_title_bar() - y = 1 - - def writeln(text: str = '') -> None: - nonlocal y - self.print(text) - y += 1 - - if self.mode is NAME: - writeln(_('Enter words from the name of the character')) - elif self.mode is HEX: - writeln(_('Enter the hex code for the character')) - else: - writeln(_('Enter the index for the character you want from the list below')) - self.line_edit.write(self.write, self.prompt) - with cursor(self.write): - writeln() - writeln(self.choice_line) - if self.mode is HEX: - writeln(faint(_('Type {} followed by the index for the recent entries below').format(INDEX_CHAR))) - elif self.mode is NAME: - writeln(faint(_('Use Tab or arrow keys to choose a character. Type space and {} to select by index').format(INDEX_CHAR))) - elif self.mode is FAVORITES: - writeln(faint(_('Press F12 to edit the list of favorites'))) - self.table_at = y - q = self.table.layout(self.screen_size.rows - self.table_at, self.screen_size.cols) - if q: - self.write(q) - - def refresh(self) -> None: - self.update_prompt() - self.draw_screen() - - def on_text(self, text: str, in_bracketed_paste: bool = False) -> None: - self.line_edit.on_text(text, in_bracketed_paste) - self.refresh() - - def on_key(self, key_event: KeyEvent) -> None: - if self.mode is HEX and key_event.type is not EventType.RELEASE and not key_event.has_mods: - try: - val = int(self.line_edit.current_input, 16) - except Exception: - pass - else: - if key_event.matches('tab'): - self.line_edit.current_input = hex(val + 0x10)[2:] - self.refresh() - return - if key_event.matches('up'): - self.line_edit.current_input = hex(val + 1)[2:] - self.refresh() - return - if key_event.matches('down'): - self.line_edit.current_input = hex(val - 1)[2:] - self.refresh() - return - if self.mode is NAME: - if key_event.matches('shift+tab'): - self.table.move_current(cols=-1) - self.refresh() - return - if key_event.matches('tab'): - self.table.move_current(cols=1) - self.refresh() - return - if key_event.matches('left'): - self.table.move_current(cols=-1) - self.refresh() - return - if key_event.matches('right'): - self.table.move_current(cols=1) - self.refresh() - return - if key_event.matches('up'): - self.table.move_current(rows=-1) - self.refresh() - return - if key_event.matches('down'): - self.table.move_current(rows=1) - self.refresh() - return - - if self.line_edit.on_key(key_event): - self.refresh() - return - if key_event.matches('enter'): - self.quit_loop(0) - return - if key_event.matches('esc'): - self.quit_loop(1) - return - if key_event.matches_without_mods('f1') or key_event.matches('ctrl+1'): - self.switch_mode(HEX) - return - if key_event.matches_without_mods('f2') or key_event.matches('ctrl+2'): - self.switch_mode(NAME) - return - if key_event.matches_without_mods('f3') or key_event.matches('ctrl+3'): - self.switch_mode(EMOTICONS) - return - if key_event.matches_without_mods('f4') or key_event.matches('ctrl+4'): - self.switch_mode(FAVORITES) - return - if key_event.matches_without_mods('f12') and self.mode is FAVORITES: - self.edit_favorites() - return - if key_event.matches('ctrl+shift+tab'): - self.next_mode(-1) - return - for key in ('tab', '[', ']'): - if key_event.matches(f'ctrl+{key}'): - self.next_mode(-1 if key == '[' else 1) - return - - def edit_favorites(self) -> None: - if not os.path.exists(favorites_path): - with open(favorites_path, 'wb') as f: - f.write(serialize_favorites(load_favorites()).encode('utf-8')) - with self.suspend(): - p = subprocess.Popen(get_editor() + [favorites_path]) - if p.wait() == 0: - load_favorites(refresh=True) - self.init_terminal_state() - self.refresh() - - def switch_mode(self, mode: str) -> None: - if mode is not self.mode: - self.mode = mode - self.cached_values['mode'] = mode - self.line_edit.clear() - self.current_char = None - self.choice_line = '' - self.refresh() - - def next_mode(self, delta: int = 1) -> None: - modes = tuple(x[-1] for x in all_modes) - idx = (modes.index(self.mode) + delta + len(modes)) % len(modes) - self.switch_mode(modes[idx]) - - def on_interrupt(self) -> None: - self.quit_loop(1) - - def on_eot(self) -> None: - self.quit_loop(1) - - def on_resize(self, new_size: ScreenSize) -> None: - self.refresh() +from ..tui.handler import result_handler help_text = 'Input a Unicode character' usage = '' @@ -554,43 +21,19 @@ default form specified in the Unicode standard for the symbol is used. '''.format -def parse_unicode_input_args(args: List[str]) -> Tuple[UnicodeCLIOptions, List[str]]: - return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten unicode_input', result_class=UnicodeCLIOptions) - - -def main(args: List[str]) -> Optional[str]: - try: - cli_opts, items = parse_unicode_input_args(args[1:]) - except SystemExit as e: - if e.code != 0: - report_unhandled_error(e.args[0]) - return None - - loop = Loop() - with cached_values_for('unicode-input') as cached_values: - handler = UnicodeInput(cached_values, cli_opts.emoji_variation) - loop.loop(handler) - if handler.current_char and loop.return_code == 0: - with suppress(Exception): - handler.recent.remove(ord(handler.current_char)) - recent = [ord(handler.current_char)] + handler.recent - cached_values['recent'] = recent[:len(DEFAULT_SET)] - return handler.resolved_current_char - if loop.return_code != 0: - raise SystemExit(loop.return_code) - return None - - -@result_handler(has_ready_notification=UnicodeInput.overlay_ready_report_needed) +@result_handler(has_ready_notification=True) def handle_result(args: List[str], current_char: str, target_window_id: int, boss: BossType) -> None: w = boss.window_id_map.get(target_window_id) if w is not None: w.paste_text(current_char) +def main(args: List[str]) -> Optional[str]: + raise SystemExit('This should be run as kitten unicode_input') if __name__ == '__main__': - raise SystemExit('This should be run as kitten unicode_input') + main([]) elif __name__ == '__doc__': + import sys cd = sys.cli_docs # type: ignore cd['usage'] = usage cd['options'] = OPTIONS diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index 56717b9db..d2463e6a3 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -30,8 +30,7 @@ class TestBuild(BaseTest): from kittens.choose import subseq_matcher from kittens.diff import diff_speedup from kittens.transfer import rsync - from kittens.unicode_input import unicode_names - del fdt, unicode_names, subseq_matcher, diff_speedup, rsync + del fdt, subseq_matcher, diff_speedup, rsync def test_loading_shaders(self) -> None: from kitty.utils import load_shaders diff --git a/tools/cmd/unicode_input/main.go b/tools/cmd/unicode_input/main.go index c1ebb36d6..b1c15f10f 100644 --- a/tools/cmd/unicode_input/main.go +++ b/tools/cmd/unicode_input/main.go @@ -560,6 +560,7 @@ func run_loop(opts *Options) (lp *loop.Loop, err error) { lp.OnInitialize = func() (string, error) { h.initialize() + lp.SendOverlayReady() return "", nil } diff --git a/tools/tui/loop/api.go b/tools/tui/loop/api.go index 040f9eacc..ce83f83ea 100644 --- a/tools/tui/loop/api.go +++ b/tools/tui/loop/api.go @@ -352,6 +352,10 @@ func (self *Loop) ClearScreen() { self.QueueWriteString("\x1b[H\x1b[2J") } +func (self *Loop) SendOverlayReady() { + self.QueueWriteString("\x1bP@kitty-overlay-ready|\x1b\\") +} + func (self *Loop) Quit(exit_code int) { self.exit_code = exit_code self.keep_going = false diff --git a/tools/tui/ui_kitten.go b/tools/tui/ui_kitten.go index f8f10cf17..edc24335f 100644 --- a/tools/tui/ui_kitten.go +++ b/tools/tui/ui_kitten.go @@ -8,6 +8,8 @@ import ( "os" "kitty/tools/utils" + + "github.com/jamesruan/go-rfc1924/base85" ) var _ = fmt.Print @@ -21,7 +23,7 @@ func KittenOutputSerializer() func(any) (string, error) { if err != nil { return "", err } - return "\x1bP@kitty-kitten-result|" + utils.UnsafeBytesToString(data) + "\x1b\\", nil + return "\x1bP@kitty-kitten-result|" + base85.EncodeToString(data) + "\x1b\\", nil } } return func(what any) (string, error) {