1# Copyright 2021 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Help window container class.""" 15 16import functools 17import inspect 18import logging 19from pathlib import Path 20from typing import Dict, TYPE_CHECKING 21 22from prompt_toolkit.document import Document 23from prompt_toolkit.filters import Condition 24from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent 25from prompt_toolkit.layout import ( 26 ConditionalContainer, 27 DynamicContainer, 28 FormattedTextControl, 29 HSplit, 30 VSplit, 31 Window, 32 WindowAlign, 33) 34from prompt_toolkit.layout.dimension import Dimension 35from prompt_toolkit.lexers import PygmentsLexer 36from prompt_toolkit.widgets import Box, TextArea 37 38from pygments.lexers.markup import RstLexer # type: ignore 39from pygments.lexers.data import YamlLexer # type: ignore 40import pw_console.widgets.mouse_handlers 41 42if TYPE_CHECKING: 43 from pw_console.console_app import ConsoleApp 44 45_LOG = logging.getLogger(__package__) 46 47 48def _longest_line_length(text): 49 """Return the longest line in the given text.""" 50 max_line_length = 0 51 for line in text.splitlines(): 52 if len(line) > max_line_length: 53 max_line_length = len(line) 54 return max_line_length 55 56 57class HelpWindow(ConditionalContainer): 58 """Help window container for displaying keybindings.""" 59 60 # pylint: disable=too-many-instance-attributes 61 62 def _create_help_text_area(self, **kwargs): 63 help_text_area = TextArea( 64 focusable=True, 65 focus_on_click=True, 66 scrollbar=True, 67 style='class:help_window_content', 68 wrap_lines=False, 69 **kwargs, 70 ) 71 72 # Additional keybindings for the text area. 73 key_bindings = KeyBindings() 74 register = self.application.prefs.register_keybinding 75 76 @register('help-window.close', key_bindings) 77 def _close_window(_event: KeyPressEvent) -> None: 78 """Close the current dialog window.""" 79 self.toggle_display() 80 81 @register('help-window.copy-all', key_bindings) 82 def _copy_all(_event: KeyPressEvent) -> None: 83 """Close the current dialog window.""" 84 self.copy_all_text() 85 86 help_text_area.control.key_bindings = key_bindings 87 return help_text_area 88 89 def __init__(self, 90 application: 'ConsoleApp', 91 preamble: str = '', 92 additional_help_text: str = '', 93 title: str = '') -> None: 94 # Dict containing key = section title and value = list of key bindings. 95 self.application: 'ConsoleApp' = application 96 self.show_window: bool = False 97 self.help_text_sections: Dict[str, Dict] = {} 98 self._pane_title: str = title 99 100 # Tracks the last focused container, to enable restoring focus after 101 # closing the dialog. 102 self.last_focused_pane = None 103 104 # Generated keybinding text 105 self.preamble: str = preamble 106 self.additional_help_text: str = additional_help_text 107 self.help_text: str = '' 108 109 self.max_additional_help_text_width: int = (_longest_line_length( 110 self.additional_help_text) if additional_help_text else 0) 111 self.max_description_width: int = 0 112 self.max_key_list_width: int = 0 113 self.max_line_length: int = 0 114 115 self.help_text_area: TextArea = self._create_help_text_area() 116 117 close_mouse_handler = functools.partial( 118 pw_console.widgets.mouse_handlers.on_click, self.toggle_display) 119 copy_mouse_handler = functools.partial( 120 pw_console.widgets.mouse_handlers.on_click, self.copy_all_text) 121 122 toolbar_padding = 1 123 toolbar_title = ' ' * toolbar_padding 124 toolbar_title += self.pane_title() 125 126 buttons = [] 127 buttons.extend( 128 pw_console.widgets.checkbox.to_keybind_indicator( 129 'Ctrl-c', 130 'Copy All', 131 copy_mouse_handler, 132 base_style='class:toolbar-button-active')) 133 buttons.append(('', ' ')) 134 buttons.extend( 135 pw_console.widgets.checkbox.to_keybind_indicator( 136 'q', 137 'Close', 138 close_mouse_handler, 139 base_style='class:toolbar-button-active')) 140 top_toolbar = VSplit( 141 [ 142 Window( 143 content=FormattedTextControl( 144 # [('', toolbar_title)] 145 functools.partial(pw_console.style.get_pane_indicator, 146 self, toolbar_title)), 147 align=WindowAlign.LEFT, 148 dont_extend_width=True, 149 ), 150 Window( 151 content=FormattedTextControl([]), 152 align=WindowAlign.LEFT, 153 dont_extend_width=False, 154 ), 155 Window( 156 content=FormattedTextControl(buttons), 157 align=WindowAlign.RIGHT, 158 dont_extend_width=True, 159 ), 160 ], 161 height=1, 162 style='class:toolbar_active', 163 ) 164 165 self.container = HSplit([ 166 top_toolbar, 167 Box( 168 body=DynamicContainer(lambda: self.help_text_area), 169 padding=Dimension(preferred=1, max=1), 170 padding_bottom=0, 171 padding_top=0, 172 char=' ', 173 style='class:frame.border', # Same style used for Frame. 174 ), 175 ]) 176 177 super().__init__( 178 self.container, 179 filter=Condition(lambda: self.show_window), 180 ) 181 182 def pane_title(self): 183 return self._pane_title 184 185 def menu_title(self): 186 """Return the title to display in the Window menu.""" 187 return self.pane_title() 188 189 def __pt_container__(self): 190 """Return the prompt_toolkit container for displaying this HelpWindow. 191 192 This allows self to be used wherever prompt_toolkit expects a container 193 object.""" 194 return self.container 195 196 def copy_all_text(self): 197 """Copy all text in the Python input to the system clipboard.""" 198 self.application.application.clipboard.set_text( 199 self.help_text_area.buffer.text) 200 201 def toggle_display(self): 202 """Toggle visibility of this help window.""" 203 # Toggle state variable. 204 self.show_window = not self.show_window 205 206 if self.show_window: 207 # Save previous focus 208 self.last_focused_pane = self.application.focused_window() 209 # Set the help window in focus. 210 self.application.layout.focus(self.help_text_area) 211 else: 212 # Restore original focus if possible. 213 if self.last_focused_pane: 214 self.application.layout.focus(self.last_focused_pane) 215 else: 216 # Fallback to focusing on the first window pane. 217 self.application.focus_main_menu() 218 219 def content_width(self) -> int: 220 """Return total width of help window.""" 221 # Widths of UI elements 222 frame_width = 1 223 padding_width = 1 224 left_side_frame_and_padding_width = frame_width + padding_width 225 right_side_frame_and_padding_width = frame_width + padding_width 226 scrollbar_padding = 1 227 scrollbar_width = 1 228 229 desired_width = self.max_line_length + ( 230 left_side_frame_and_padding_width + 231 right_side_frame_and_padding_width + scrollbar_padding + 232 scrollbar_width) 233 desired_width = max(60, desired_width) 234 235 window_manager_width = ( 236 self.application.window_manager.current_window_manager_width) 237 if not window_manager_width: 238 window_manager_width = 80 239 return min(desired_width, window_manager_width) 240 241 def load_user_guide(self): 242 rstdoc = Path(__file__).parent / 'docs/user_guide.rst' 243 max_line_length = 0 244 rst_text = '' 245 with rstdoc.open() as rstfile: 246 for line in rstfile.readlines(): 247 if 'https://' not in line and len(line) > max_line_length: 248 max_line_length = len(line) 249 rst_text += line 250 self.max_line_length = max_line_length 251 252 self.help_text_area = self._create_help_text_area( 253 lexer=PygmentsLexer(RstLexer), 254 text=rst_text, 255 ) 256 257 def load_yaml_text(self, content: str): 258 max_line_length = 0 259 for line in content.splitlines(): 260 if 'https://' not in line and len(line) > max_line_length: 261 max_line_length = len(line) 262 self.max_line_length = max_line_length 263 264 self.help_text_area = self._create_help_text_area( 265 lexer=PygmentsLexer(YamlLexer), 266 text=content, 267 ) 268 269 def generate_help_text(self): 270 """Generate help text based on added key bindings.""" 271 272 template = self.application.get_template('keybind_list.jinja') 273 274 self.help_text = template.render( 275 sections=self.help_text_sections, 276 max_additional_help_text_width=self.max_additional_help_text_width, 277 max_description_width=self.max_description_width, 278 max_key_list_width=self.max_key_list_width, 279 preamble=self.preamble, 280 additional_help_text=self.additional_help_text, 281 ) 282 283 # Find the longest line in the rendered template. 284 self.max_line_length = _longest_line_length(self.help_text) 285 286 # Replace the TextArea content. 287 self.help_text_area.buffer.document = Document(text=self.help_text, 288 cursor_position=0) 289 290 return self.help_text 291 292 def add_custom_keybinds_help_text(self, section_name, key_bindings: Dict): 293 """Add hand written key_bindings.""" 294 self.help_text_sections[section_name] = key_bindings 295 296 def add_keybind_help_text(self, section_name, key_bindings: KeyBindings): 297 """Append formatted key binding text to this help window.""" 298 299 # Create a new keybind section, erasing any old section with thesame 300 # title. 301 self.help_text_sections[section_name] = {} 302 303 # Loop through passed in prompt_toolkit key_bindings. 304 for binding in key_bindings.bindings: 305 # Skip this keybind if the method name ends in _hidden. 306 if binding.handler.__name__.endswith('_hidden'): 307 continue 308 309 # Get the key binding description from the function doctstring. 310 docstring = binding.handler.__doc__ 311 if not docstring: 312 docstring = '' 313 description = inspect.cleandoc(docstring) 314 description = description.replace('\n', ' ') 315 316 # Save the length of the description. 317 if len(description) > self.max_description_width: 318 self.max_description_width = len(description) 319 320 # Get the existing list of keys for this function or make a new one. 321 key_list = self.help_text_sections[section_name].get( 322 description, list()) 323 324 # Save the name of the key e.g. F1, q, ControlQ, ControlUp 325 key_name = ' '.join( 326 [getattr(key, 'name', str(key)) for key in binding.keys]) 327 key_name = key_name.replace('Control', 'Ctrl-') 328 key_name = key_name.replace('Shift', 'Shift-') 329 key_name = key_name.replace('Escape ', 'Alt-') 330 key_name = key_name.replace('Alt-Ctrl-', 'Ctrl-Alt-') 331 key_name = key_name.replace('BackTab', 'Shift-Tab') 332 key_list.append(key_name) 333 334 key_list_width = len(', '.join(key_list)) 335 # Save the length of the key list. 336 if key_list_width > self.max_key_list_width: 337 self.max_key_list_width = key_list_width 338 339 # Update this functions key_list 340 self.help_text_sections[section_name][description] = key_list 341