kitty/kittens/unicode_input/main.py
pagedown 510022c3c1
Docs: Improve the documentation for kittens
Add more text roles and links.
Add an example that broadcasts only to other windows in the current tab.
Initial capitalization of the key names in the kbd text role.
Add Python type hints for custom kittens.
Note about hyperlink support for ls on macOS.
Add description text for show_key.
2022-04-27 16:14:54 +08:00

606 lines
22 KiB
Python

#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
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 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 the arrow keys to choose a character from below')))
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()
help_text = 'Input a Unicode character'
usage = ''
OPTIONS = '''
--emoji-variation
type=choices
default=none
choices=none,graphic,text
Whether to use the textual or the graphical form for emoji. By default the
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)
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)
if __name__ == '__main__':
ans = main(sys.argv)
if ans:
print(ans)
elif __name__ == '__doc__':
cd = sys.cli_docs # type: ignore
cd['usage'] = usage
cd['options'] = OPTIONS
cd['help_text'] = help_text