purxiz 85afda8832 fixes incorrect hint base
The bases for the hint were being decoded as base 16, despite being displayed as base 36 (0-9, a-z). This fix makes it so typing 'search string .index' returns the unicode char at the expected index, based on what is displayed by the program.

It also slightly changes the behavior. Before, only the selected result would appear. For example, musical note `musical note .18` would only show a single result, since there was only a single result at index 18. Now, searching by index doesn't remove codepoints/results, it simply highlights the result at the correct index for you.
2021-01-06 13:07:41 -06:00

594 lines
21 KiB
Python

#!/usr/bin/env python3
# vim:fileencoding=utf-8
# 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 (
CTRL, PRESS, RELEASE, SHIFT, KeyEvent, enter_key, key_defs as K
)
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
)
HEX, NAME, EMOTICONS, FAVORITES = 'HEX', 'NAME', 'EMOTICONS', 'FAVORITES'
UP = K['UP']
DOWN = K['DOWN']
LEFT = K['LEFT']
RIGHT = K['RIGHT']
RIGHT_BRACKET = K['RIGHT_BRACKET']
LEFT_BRACKET = K['LEFT_BRACKET']
TAB = K['TAB']
ESCAPE = K['ESCAPE']
F1 = K['F1']
F2 = K['F2']
F3 = K['F3']
F4 = K['F4']
F12 = K['F12']
favorites_path = os.path.join(config_dir, 'unicode-input-favorites.conf')
INDEX_CHAR = '.'
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: Sequence[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('{:x} # {} {}'.format(cp, 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, 36)
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.text = ''
self.num_cols = 0
self.mode = HEX
@property
def current_codepoint(self) -> Optional[int]:
if self.codepoints:
return self.codepoints[self.current_idx]
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
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 += 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
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 = rows
for i, (idx, c, desc) in enumerate(parts):
if i > 0 and i % num_cols == 0:
rows_left -= 1
if rows_left == 0:
break
a('\r\n')
buf.extend(cell(i, idx, c, desc))
a(' ')
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
def is_index(w: str) -> bool:
if w[0] != INDEX_CHAR:
return False
try:
int(w.lstrip(INDEX_CHAR), 36)
return True
except Exception:
return False
class UnicodeInput(Handler):
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), 36)
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 if iindex_word < len(codepoints) else 0)
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 = ' {} ({}) '.format(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))
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 RELEASE and not key_event.mods:
try:
val = int(self.line_edit.current_input, 16)
except Exception:
pass
else:
if key_event.key is TAB:
self.line_edit.current_input = hex(val + 0x10)[2:]
self.refresh()
return
if key_event.key is UP:
self.line_edit.current_input = hex(val + 1)[2:]
self.refresh()
return
if key_event.key is DOWN:
self.line_edit.current_input = hex(val - 1)[2:]
self.refresh()
return
if self.mode is NAME and key_event.type is not RELEASE and not key_event.mods:
if key_event.key is TAB:
if key_event.mods == SHIFT:
self.table.move_current(cols=-1)
self.refresh()
elif not key_event.mods:
self.table.move_current(cols=1)
self.refresh()
return
elif key_event.key is LEFT and not key_event.mods:
self.table.move_current(cols=-1)
self.refresh()
return
elif key_event.key is RIGHT and not key_event.mods:
self.table.move_current(cols=1)
self.refresh()
return
elif key_event.key is UP and not key_event.mods:
self.table.move_current(rows=-1)
self.refresh()
return
elif key_event.key is DOWN and not key_event.mods:
self.table.move_current(rows=1)
self.refresh()
return
if self.line_edit.on_key(key_event):
self.refresh()
return
if key_event is enter_key:
self.quit_loop(0)
elif key_event.type is PRESS:
if not key_event.mods:
if key_event.key is ESCAPE:
self.quit_loop(1)
elif key_event.key is F1:
self.switch_mode(HEX)
elif key_event.key is F2:
self.switch_mode(NAME)
elif key_event.key is F3:
self.switch_mode(EMOTICONS)
elif key_event.key is F4:
self.switch_mode(FAVORITES)
elif key_event.key is F12 and self.mode is FAVORITES:
self.edit_favorites()
elif key_event.mods == CTRL and key_event.key in (TAB, RIGHT_BRACKET, LEFT_BRACKET):
self.next_mode(-1 if key_event.key is LEFT_BRACKET else 1)
elif key_event.mods == CTRL | SHIFT and key_event.key is TAB:
self.next_mode(-1)
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:
print(e.args[0], file=sys.stderr)
input(_('Press Enter to quit'))
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()
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(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