Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions kittens/hints/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,43 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
current_input = ""
current_text = ""
}
select_keys := make(map[int]string, len(index_map))
if o.CustomizeProcessing == "::import::kitty.choose_entry" {
for _, m := range index_map {
text := strings.TrimPrefix(m.Text, ": ")
if text == m.Text {
continue
}
key, _, ok := strings.Cut(text, " - ")
if !ok || key == "" {
continue
}
select_keys[m.Index] = key
}
}
handle_select_key := func(ev *loop.KeyEvent) bool {
if len(select_keys) == 0 {
return false
}
for idx, spec := range select_keys {
if spec == "" {
continue
}
if ev.MatchesPressOrRepeat(spec) {
if m := index_map[idx]; m != nil {
chosen = append(chosen, m)
if o.Multiple {
ignore_mark_indices.Add(m.Index)
reset()
} else {
lp.Quit(0)
}
}
return true
}
}
return false
}

lp.OnInitialize = func() (string, error) {
lp.SetCursorVisible(false)
Expand Down Expand Up @@ -330,6 +367,10 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
}

lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
if len(select_keys) > 0 && handle_select_key(ev) {
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("backspace") {
ev.Handled = true
r := []rune(current_input)
Expand Down
3 changes: 3 additions & 0 deletions kitty/boss.py
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,9 @@ def push_keyboard_mode(self, new_mode: str) -> None:
self.mappings.push_keyboard_mode(new_mode)

def dispatch_possible_special_key(self, ev: KeyEvent) -> bool:
w = self.active_window
if w is not None and w.overlay_parent is not None:
return False
return self.mappings.dispatch_possible_special_key(ev)

def cancel_current_visual_select(self) -> None:
Expand Down
144 changes: 143 additions & 1 deletion kitty/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
GLFW_MOD_SUPER,
KeyEvent,
SingleKey,
add_timer,
get_boss,
get_options,
glfw_get_key_name,
grab_keyboard,
is_modifier_key,
remove_timer,
ring_bell,
set_ignore_os_keyboard_processing,
)
from .options.types import Options
from .options.utils import KeyboardMode, KeyDefinition, KeyMap
from .types import Shortcut, human_repr_of_single_key
from .typing_compat import ScreenType

if TYPE_CHECKING:
Expand Down Expand Up @@ -87,6 +91,8 @@ def update_keymap(self, global_shortcuts: dict[str, SingleKey] | None = None) ->

def clear_keyboard_modes(self) -> None:
had_mode = bool(self.keyboard_mode_stack)
for mode in self.keyboard_mode_stack:
self.cancel_sequence_hint(mode)
self.keyboard_mode_stack = []
self.set_ignore_os_keyboard_processing(False)
if had_mode:
Expand All @@ -95,7 +101,8 @@ def clear_keyboard_modes(self) -> None:
def pop_keyboard_mode(self) -> bool:
passthrough = True
if self.keyboard_mode_stack:
self.keyboard_mode_stack.pop()
mode = self.keyboard_mode_stack.pop()
self.cancel_sequence_hint(mode)
if not self.keyboard_mode_stack:
self.set_ignore_os_keyboard_processing(False)
passthrough = False
Expand Down Expand Up @@ -153,6 +160,135 @@ def matching_key_actions(self, candidates: Iterable[KeyDefinition]) -> list[KeyD
matches = [matches[-1]]
return matches

def single_key_from_event(self, ev: KeyEvent) -> SingleKey:
mods = ev.mods & mod_mask
if ev.key:
return SingleKey(mods, False, ev.key)
return SingleKey(mods, True, ev.native_key)

def cancel_sequence_hint(self, mode: KeyboardMode) -> None:
timer_id = getattr(mode, 'sequence_hint_timer_id', 0)
if timer_id:
remove_timer(timer_id)
mode.sequence_hint_timer_id = 0

def schedule_sequence_hint(self, mode: KeyboardMode, actions: list[KeyDefinition]) -> None:
delay_ms = self.get_options().multi_key_hint_delay
if delay_ms < 0:
return
delay = delay_ms / 1000.0
self.cancel_sequence_hint(mode)
if delay <= 0:
self.clear_keyboard_modes()
self.show_sequence_choices(actions, mode.sequence_hint_prefix)
return

def show_hint(timer_id: int | None) -> None:
if timer_id is None or getattr(mode, 'sequence_hint_timer_id', 0) != timer_id:
return
if not self.keyboard_mode_stack or self.keyboard_mode_stack[-1] is not mode:
return
mode.sequence_hint_timer_id = 0
self.clear_keyboard_modes()
self.show_sequence_choices(actions, mode.sequence_hint_prefix)

mode.sequence_hint_timer_id = add_timer(show_hint, delay, False)

def show_sequence_choices(self, actions: list[KeyDefinition], prefix: tuple[SingleKey, ...]) -> None:
boss = get_boss()
opts = self.get_options()
kitty_mod = opts.kitty_mod
grouped: dict[SingleKey, list[KeyDefinition]] = {}
order: list[SingleKey] = []
for fa in actions:
if not fa.rest:
continue
k = fa.rest[0]
if k not in grouped:
grouped[k] = []
order.append(k)
grouped[k].append(fa)

if not order:
return

entries: list[tuple[int, str]] = []
entry_data: list[tuple[SingleKey, list[KeyDefinition]]] = []
alphabet_chars: list[str] = []
used_hint_chars: set[str] = set()
fallback_chars = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

def hint_char_for_key(k: SingleKey) -> str | None:
resolved = k.resolve_kitty_mod(kitty_mod)
name = (glfw_get_key_name(0, resolved.key) if resolved.is_native else glfw_get_key_name(resolved.key, 0)) or ''
if name == ' ' or len(name) != 1:
return None
if resolved.mods & GLFW_MOD_SHIFT and name.isalpha():
name = name.upper()
if not name.isprintable() or ord(name) >= 128:
return None
return name

entry_infos: list[tuple[int, SingleKey, list[KeyDefinition], str | None, int]] = []

def mod_weight(mods: int) -> int:
return bin(mods).count('1')

for idx, k in enumerate(order):
group = grouped[k]
key_name = human_repr_of_single_key(k, kitty_mod)
desc = group[0].human_repr()
if len(group) > 1 or len(group[0].rest) > 1:
desc = f'{desc} (more)'
entries.append((idx, f'{key_name} - {desc}'))
entry_data.append((k, group))
resolved = k.resolve_kitty_mod(kitty_mod)
entry_infos.append((idx, k, group, hint_char_for_key(k), mod_weight(resolved.mods)))

preferred_owner: dict[str, int] = {}
for idx, _k, _group, hint_char, weight in sorted(entry_infos, key=lambda x: (x[4], x[0])):
if hint_char and hint_char not in preferred_owner:
preferred_owner[hint_char] = idx

for idx, _k, _group, hint_char, _weight in entry_infos:
if hint_char is not None and preferred_owner.get(hint_char) == idx and hint_char not in used_hint_chars:
alphabet_chars.append(hint_char)
used_hint_chars.add(hint_char)
continue
for fc in fallback_chars:
if fc not in used_hint_chars:
alphabet_chars.append(fc)
used_hint_chars.add(fc)
break

hints_args_list: list[str] = []
if len(alphabet_chars) == len(order):
hints_args_list.extend(['--alphabet', ''.join(alphabet_chars), '--hints-offset=0'])
hints_args: tuple[str, ...] | None = tuple(hints_args_list) if hints_args_list else None

title = f'Key sequence: {Shortcut(prefix).human_repr(kitty_mod)}'

chooser_window: Window | None = None

def chosen(ans: None | str | int) -> None:
if chooser_window is not None:
chooser_window.close()
if not isinstance(ans, int):
return
if ans < 0 or ans >= len(entry_data):
return
next_key, group = entry_data[ans]
if len(group) == 1 and len(group[0].rest) == 1:
self.combine(group[0].definition)
return
next_actions = [fa.shift_sequence_and_copy() for fa in group if len(fa.rest) > 1]
if not next_actions:
self.combine(group[0].definition)
return
self.show_sequence_choices(next_actions, prefix + (next_key,))

chooser_window = boss.choose_entry(title, entries, chosen, hints_args=hints_args)

def dispatch_possible_special_key(self, ev: KeyEvent) -> bool:
# Handles shortcuts, return True if the key was consumed
is_root_mode = not self.keyboard_mode_stack
Expand All @@ -169,6 +305,7 @@ def dispatch_possible_special_key(self, ev: KeyEvent) -> bool:
return False
if not is_root_mode:
if mode.sequence_keys is not None:
self.cancel_sequence_hint(mode)
self.pop_keyboard_mode()
w = self.get_active_window()
if w is not None:
Expand All @@ -192,11 +329,14 @@ def dispatch_possible_special_key(self, ev: KeyEvent) -> bool:
sm = KeyboardMode('__sequence__')
sm.on_action = 'end'
sm.sequence_keys = [ev]
sm.sequence_hint_prefix = (self.single_key_from_event(ev),)
for fa in final_actions:
sm.keymap[fa.rest[0]].append(fa.shift_sequence_and_copy())
self._push_keyboard_mode(sm)
self.debug_print('\n\x1b[35mKeyPress\x1b[m matched sequence prefix, ', end='')
self.schedule_sequence_hint(sm, final_actions)
else:
self.cancel_sequence_hint(mode)
if len(final_actions) == 1 and not final_actions[0].rest:
self.pop_keyboard_mode()
consumed = self.combine(final_actions[0].definition)
Expand All @@ -206,10 +346,12 @@ def dispatch_possible_special_key(self, ev: KeyEvent) -> bool:
w.send_key_sequence(*mode.sequence_keys)
return consumed
mode.sequence_keys.append(ev)
mode.sequence_hint_prefix = tuple(self.single_key_from_event(x) for x in mode.sequence_keys)
self.debug_print('\n\x1b[35mKeyPress\x1b[m matched sequence prefix, ', end='')
mode.keymap.clear()
for fa in final_actions:
mode.keymap[fa.rest[0]].append(fa.shift_sequence_and_copy())
self.schedule_sequence_hint(mode, final_actions)
return True
final_action = final_actions[0]
consumed = self.combine(final_action.definition)
Expand Down
9 changes: 9 additions & 0 deletions kitty/options/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -3785,6 +3785,15 @@
'''
)

opt('multi_key_hint_delay', '-1',
option_type='int', ctype='time-ms',
long_text='''
Delay before showing the multi-key hint popup (in milliseconds). Set to a
negative value to disable the popup entirely. A value of zero shows it
immediately.
'''
)

opt('+action_alias', 'launch_tab launch --type=tab --cwd=current',
option_type='action_alias',
add_to_default=False,
Expand Down
3 changes: 3 additions & 0 deletions kitty/options/parse.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions kitty/options/types.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.