blob: 4f4c98a69b8707bf018bbc1a711e7b4784f8b6e2 [file] [log] [blame]
# Copyright 2022 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""CommandRunner dialog classes."""
from __future__ import annotations
import functools
import logging
import re
from typing import (
Callable,
Iterable,
Iterator,
List,
Optional,
TYPE_CHECKING,
Tuple,
)
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.filters import Condition
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.formatted_text.utils import fragment_list_to_text
from prompt_toolkit.layout.utils import explode_text_fragments
from prompt_toolkit.history import InMemoryHistory
from prompt_toolkit.key_binding import (
KeyBindings,
KeyBindingsBase,
KeyPressEvent,
)
from prompt_toolkit.layout import (
AnyContainer,
ConditionalContainer,
DynamicContainer,
FormattedTextControl,
HSplit,
VSplit,
Window,
WindowAlign,
)
from prompt_toolkit.widgets import MenuItem
from prompt_toolkit.widgets import TextArea
import pw_console.widgets.border
import pw_console.widgets.checkbox
import pw_console.widgets.mouse_handlers
if TYPE_CHECKING:
from pw_console.console_app import ConsoleApp
_LOG = logging.getLogger(__package__)
def flatten_menu_items(items: List[MenuItem],
prefix: str = '') -> Iterator[Tuple[str, Callable]]:
"""Flatten nested prompt_toolkit MenuItems into text and callable tuples."""
for item in items:
new_text = []
if prefix:
new_text.append(prefix)
new_text.append(item.text)
new_prefix = ' > '.join(new_text)
if item.children:
yield from flatten_menu_items(item.children, new_prefix)
elif item.handler:
# Skip this item if it's a separator or disabled.
if item.text == '-' or item.disabled:
continue
yield (new_prefix, item.handler)
def highlight_matches(
regexes: Iterable[re.Pattern],
line_fragments: StyleAndTextTuples) -> StyleAndTextTuples:
"""Highlight regex matches in prompt_toolkit FormattedTextTuples."""
line_text = fragment_list_to_text(line_fragments)
exploded_fragments = explode_text_fragments(line_fragments)
def apply_highlighting(fragments: StyleAndTextTuples,
index: int,
matching_regex_index: int = 0) -> None:
# Expand all fragments and apply the highlighting style.
old_style, _text, *_ = fragments[index]
# There are 6 fuzzy-highlight styles defined in style.py. Get an index
# from 0-5 to use one style after the other in turn.
style_index = matching_regex_index % 6
fragments[index] = (
old_style +
f' class:command-runner-fuzzy-highlight-{style_index} ',
fragments[index][1],
)
# Highlight each non-overlapping search match.
for regex_i, regex in enumerate(regexes):
for match in regex.finditer(line_text):
for fragment_i in range(match.start(), match.end()):
apply_highlighting(exploded_fragments, fragment_i, regex_i)
return exploded_fragments
class CommandRunner:
"""CommandRunner dialog box."""
# pylint: disable=too-many-instance-attributes
def __init__(
self,
application: ConsoleApp,
window_title: str = None,
load_completions: Optional[Callable[[],
List[Tuple[str,
Callable]]]] = None,
width: int = 80,
height: int = 10):
# Parent pw_console application
self.application = application
# Visibility toggle
self.show_dialog = False
# Tracks the last focused container, to enable restoring focus after
# closing the dialog.
self.last_focused_pane = None
# List of all possible completion items
self.completions: List[Tuple[str, Callable]] = []
# Formatted text fragments of matched items
self.completion_fragments: List[StyleAndTextTuples] = []
# Current selected item tracking variables
self.selected_item: int = 0
self.selected_item_text: str = ''
self.selected_item_handler: Optional[Callable] = None
# Previous input text
self.last_input_field_text: str = 'EMPTY'
# Previous selected item
self.last_selected_item: int = 0
# Dialog width, height and title
self.width = width
self.height = height
self.window_title: str
# Callable to fetch completion items
self.load_completions: Callable[[], List[Tuple[str, Callable]]]
# Command runner text input field
self.input_field = TextArea(
prompt=[
('class:command-runner-setting', '> ',
functools.partial(pw_console.widgets.mouse_handlers.on_click,
self.focus_self))
],
focusable=True,
focus_on_click=True,
scrollbar=False,
multiline=False,
height=1,
dont_extend_height=True,
dont_extend_width=False,
accept_handler=self._command_accept_handler,
history=InMemoryHistory(),
)
# Set additional keybindings for the input field
self.input_field.control.key_bindings = self._create_key_bindings()
# Container for the Cancel and Run buttons
input_field_buttons_container = ConditionalContainer(
Window(
content=FormattedTextControl(
self._get_input_field_button_fragments,
focusable=False,
show_cursor=False,
),
height=1,
align=WindowAlign.RIGHT,
dont_extend_width=True,
),
filter=Condition(lambda: self.content_width() > 40),
)
# Container for completion matches
command_items_window = Window(
content=FormattedTextControl(
self.render_completion_items,
show_cursor=False,
focusable=False,
),
align=WindowAlign.LEFT,
dont_extend_width=False,
height=self.height,
)
# Main content HSplit
self.command_runner_content = HSplit(
[
# Input field and buttons on the same line
VSplit([
self.input_field,
input_field_buttons_container,
]),
# Completion items below
command_items_window,
],
style='class:command-runner class:theme-fg-default',
)
# Set completions if passed in.
self.set_completions(window_title, load_completions)
# bordered_content wraps the above command_runner_content in a border.
self.bordered_content: AnyContainer
# Root prompt_toolkit container
self.container = ConditionalContainer(
DynamicContainer(lambda: self.bordered_content),
filter=Condition(lambda: self.show_dialog),
)
def _create_bordered_content(self) -> None:
"""Wrap self.command_runner_content in a border."""
# This should be called whenever the window_title changes.
self.bordered_content = pw_console.widgets.border.create_border(
self.command_runner_content,
title=self.window_title,
border_style='class:command-runner-border',
left_margin_columns=1,
right_margin_columns=1,
)
def __pt_container__(self) -> AnyContainer:
"""Return the prompt_toolkit root container for this dialog."""
return self.container
def _create_key_bindings(self) -> KeyBindingsBase:
"""Create additional key bindings for the command input field."""
key_bindings = KeyBindings()
register = self.application.prefs.register_keybinding
@register('command-runner.cancel', key_bindings)
def _cancel(_event: KeyPressEvent) -> None:
"""Clear input or close command."""
if self._get_input_field_text() != '':
self._reset_selected_item()
return
self.close_dialog()
@register('command-runner.select-previous-item', key_bindings)
def _select_previous_item(_event: KeyPressEvent) -> None:
"""Select previous completion item."""
self._previous_item()
@register('command-runner.select-next-item', key_bindings)
def _select_next_item(_event: KeyPressEvent) -> None:
"""Select next completion item."""
self._next_item()
return key_bindings
def content_width(self) -> int:
"""Return the smaller value of self.width and the available width."""
window_manager_width = (
self.application.window_manager.current_window_manager_width)
if not window_manager_width:
window_manager_width = self.width
return min(self.width, window_manager_width)
def focus_self(self) -> None:
self.application.layout.focus(self)
def close_dialog(self) -> None:
"""Close command runner dialog box."""
self.show_dialog = False
self._reset_selected_item()
# Restore original focus if possible.
if self.last_focused_pane:
self.application.focus_on_container(self.last_focused_pane)
else:
# Fallback to focusing on the main menu.
self.application.focus_main_menu()
def open_dialog(self) -> None:
self.show_dialog = True
self.last_focused_pane = self.application.focused_window()
self.focus_self()
self.application.redraw_ui()
def set_completions(
self,
window_title: str = None,
load_completions: Optional[Callable[[], List[Tuple[str,
Callable]]]] = None,
) -> None:
"""Set window title and callable to fetch possible completions.
Call this function whenever new completion items need to be loaded.
"""
self.window_title = window_title if window_title else 'Menu Items'
self.load_completions = (load_completions
if load_completions else self.load_menu_items)
self._reset_selected_item()
self.completions = []
self.completion_fragments = []
# Load and filter completions
self.filter_completions()
# (Re)create the bordered content with the window_title set.
self._create_bordered_content()
def reload_completions(self) -> None:
self.completions = self.load_completions()
def load_menu_items(self) -> List[Tuple[str, Callable]]:
# pylint: disable=no-self-use
return list(flatten_menu_items(self.application.menu_items))
def _get_input_field_text(self) -> str:
return self.input_field.buffer.text
def _make_regexes(self, input_text) -> List[re.Pattern]:
# pylint: disable=no-self-use
regexes: List[re.Pattern] = []
if not input_text:
return regexes
text_tokens = input_text.split(' ')
if len(text_tokens) > 0:
regexes = [
re.compile(re.escape(text), re.IGNORECASE)
for text in text_tokens
]
return regexes
def _matches_orderless(self, regexes: List[re.Pattern], text) -> bool:
"""Check if all supplied regexs match the input text."""
# pylint: disable=no-self-use
return all(regex.search(text) for regex in regexes)
def filter_completions(self) -> None:
"""Filter completion items if new user input detected."""
if not self.input_text_changed() and not self.selected_item_changed():
return
self.reload_completions()
input_text = self._get_input_field_text()
self.completion_fragments = []
regexes = self._make_regexes(input_text)
check_match = self._matches_orderless
i = 0
for text, handler in self.completions:
if not (input_text == '' or check_match(regexes, text)):
continue
style = ''
if i == self.selected_item:
style = 'class:command-runner-selected-item'
self.selected_item_text = text
self.selected_item_handler = handler
text = text.ljust(self.content_width())
fragments: StyleAndTextTuples = highlight_matches(
regexes, [(style, text + '\n')])
self.completion_fragments.append(fragments)
i += 1
def input_text_changed(self) -> bool:
"""Return True if text in the input field has changed."""
input_text = self._get_input_field_text()
if input_text != self.last_input_field_text:
self.last_input_field_text = input_text
self.selected_item = 0
return True
return False
def selected_item_changed(self) -> bool:
"""Check if the user pressed up or down to select a different item."""
return self.last_selected_item != self.selected_item
def _next_item(self) -> None:
self.last_selected_item = self.selected_item
self.selected_item = min(
# Don't move past the height of the window or the length of possible
# items.
min(self.height, len(self.completion_fragments)) - 1,
self.selected_item + 1)
self.application.redraw_ui()
def _previous_item(self) -> None:
self.last_selected_item = self.selected_item
self.selected_item = max(0, self.selected_item - 1)
self.application.redraw_ui()
def _get_input_field_button_fragments(self) -> StyleAndTextTuples:
# Mouse handlers
focus = functools.partial(pw_console.widgets.mouse_handlers.on_click,
self.focus_self)
cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click,
self.close_dialog)
select_item = functools.partial(
pw_console.widgets.mouse_handlers.on_click,
self._run_selected_item)
separator_text = ('', ' ', focus)
# Default button style
button_style = 'class:toolbar-button-inactive'
fragments: StyleAndTextTuples = []
# Cancel button
fragments.extend(
pw_console.widgets.checkbox.to_keybind_indicator(
key='Ctrl-c',
description='Cancel',
mouse_handler=cancel,
base_style=button_style,
))
fragments.append(separator_text)
# Run button
fragments.extend(
pw_console.widgets.checkbox.to_keybind_indicator(
'Enter', 'Run', select_item, base_style=button_style))
return fragments
def render_completion_items(self) -> StyleAndTextTuples:
"""Render completion items."""
fragments: StyleAndTextTuples = []
# Update completions if any state change since the last render (new text
# entered or arrow keys pressed).
self.filter_completions()
for completion_item in self.completion_fragments:
fragments.extend(completion_item)
return fragments
def _reset_selected_item(self) -> None:
self.selected_item = 0
self.last_selected_item = 0
self.selected_item_text = ''
self.selected_item_handler = None
self.last_input_field_text = 'EMPTY'
self.input_field.buffer.reset()
def _run_selected_item(self) -> None:
"""Run the selected action."""
if not self.selected_item_handler:
return
# Save the selected item handler. This is reset by self.close_dialog()
handler = self.selected_item_handler
# Depending on what action is run, the command runner dialog may need to
# be closed, left open, or closed before running the selected action.
close_dialog = True
close_dialog_first = False
# Actions that launch new command runners, close_dialog should not run.
for command_text in [
'[File] > Insert Repl Snippet',
'[File] > Insert Repl History',
'[File] > Open Logger',
]:
if command_text in self.selected_item_text:
close_dialog = False
break
# Actions that change what is in focus should be run after closing the
# command runner dialog.
for command_text in [
'[File] > Games > ',
'[View] > Focus Next Window/Tab',
'[View] > Focus Prev Window/Tab',
# All help menu entries open popup windows.
'[Help] > ',
# This focuses on a save dialog bor.
'Save/Export a copy',
'[Windows] > Floating ',
]:
if command_text in self.selected_item_text:
close_dialog_first = True
break
# Close first if needed
if close_dialog and close_dialog_first:
self.close_dialog()
# Run the selected item handler
handler()
# If not already closed earlier.
if close_dialog and not close_dialog_first:
self.close_dialog()
def _command_accept_handler(self, _buff: Buffer) -> bool:
"""Function run when pressing Enter in the command runner input box."""
# If at least one match is available
if len(self.completion_fragments) > 0:
self._run_selected_item()
# Erase input text
return False
# Keep input text
return True