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 importlib.resources 18import inspect 19import logging 20from typing import Dict, Optional, 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 40 41from pw_console.style import ( 42 get_pane_indicator, 43) 44from pw_console.widgets import ( 45 mouse_handlers, 46 to_keybind_indicator, 47) 48 49if TYPE_CHECKING: 50 from pw_console.console_app import ConsoleApp 51 52_LOG = logging.getLogger(__package__) 53 54_PW_CONSOLE_MODULE = 'pw_console' 55 56 57def _longest_line_length(text): 58 """Return the longest line in the given text.""" 59 max_line_length = 0 60 for line in text.splitlines(): 61 if len(line) > max_line_length: 62 max_line_length = len(line) 63 return max_line_length 64 65 66class HelpWindow(ConditionalContainer): 67 """Help window container for displaying keybindings.""" 68 69 # pylint: disable=too-many-instance-attributes 70 71 def _create_help_text_area(self, **kwargs): 72 help_text_area = TextArea( 73 focusable=True, 74 focus_on_click=True, 75 scrollbar=True, 76 style='class:help_window_content', 77 wrap_lines=False, 78 **kwargs, 79 ) 80 81 # Additional keybindings for the text area. 82 key_bindings = KeyBindings() 83 register = self.application.prefs.register_keybinding 84 85 @register('help-window.close', key_bindings) 86 def _close_window(_event: KeyPressEvent) -> None: 87 """Close the current dialog window.""" 88 self.toggle_display() 89 90 if not self.disable_ctrl_c: 91 92 @register('help-window.copy-all', key_bindings) 93 def _copy_all(_event: KeyPressEvent) -> None: 94 """Close the current dialog window.""" 95 self.copy_all_text() 96 97 help_text_area.control.key_bindings = key_bindings 98 return help_text_area 99 100 def __init__( 101 self, 102 application: 'ConsoleApp', 103 preamble: str = '', 104 additional_help_text: str = '', 105 title: str = '', 106 disable_ctrl_c: bool = False, 107 ) -> None: 108 # Dict containing key = section title and value = list of key bindings. 109 self.application: 'ConsoleApp' = application 110 self.show_window: bool = False 111 self.help_text_sections: Dict[str, Dict] = {} 112 self._pane_title: str = title 113 self.disable_ctrl_c = disable_ctrl_c 114 115 # Tracks the last focused container, to enable restoring focus after 116 # closing the dialog. 117 self.last_focused_pane = None 118 119 # Generated keybinding text 120 self.preamble: str = preamble 121 self.additional_help_text: str = additional_help_text 122 self.help_text: str = '' 123 124 self.max_additional_help_text_width: int = ( 125 _longest_line_length(self.additional_help_text) 126 if additional_help_text 127 else 0 128 ) 129 self.max_description_width: int = 0 130 self.max_key_list_width: int = 0 131 self.max_line_length: int = 0 132 133 self.help_text_area: TextArea = self._create_help_text_area() 134 135 close_mouse_handler = functools.partial( 136 mouse_handlers.on_click, self.toggle_display 137 ) 138 copy_mouse_handler = functools.partial( 139 mouse_handlers.on_click, self.copy_all_text 140 ) 141 142 toolbar_padding = 1 143 toolbar_title = ' ' * toolbar_padding 144 toolbar_title += self.pane_title() 145 146 buttons = [] 147 if not self.disable_ctrl_c: 148 buttons.extend( 149 to_keybind_indicator( 150 'Ctrl-c', 151 'Copy All', 152 copy_mouse_handler, 153 base_style='class:toolbar-button-active', 154 ) 155 ) 156 buttons.append(('', ' ')) 157 158 buttons.extend( 159 to_keybind_indicator( 160 'q', 161 'Close', 162 close_mouse_handler, 163 base_style='class:toolbar-button-active', 164 ) 165 ) 166 top_toolbar = VSplit( 167 [ 168 Window( 169 content=FormattedTextControl( 170 # [('', toolbar_title)] 171 functools.partial( 172 get_pane_indicator, 173 self, 174 toolbar_title, 175 ) 176 ), 177 align=WindowAlign.LEFT, 178 dont_extend_width=True, 179 ), 180 Window( 181 content=FormattedTextControl([]), 182 align=WindowAlign.LEFT, 183 dont_extend_width=False, 184 ), 185 Window( 186 content=FormattedTextControl(buttons), 187 align=WindowAlign.RIGHT, 188 dont_extend_width=True, 189 ), 190 ], 191 height=1, 192 style='class:toolbar_active', 193 ) 194 195 self.container = HSplit( 196 [ 197 top_toolbar, 198 Box( 199 body=DynamicContainer(lambda: self.help_text_area), 200 padding=Dimension(preferred=1, max=1), 201 padding_bottom=0, 202 padding_top=0, 203 char=' ', 204 style='class:frame.border', # Same style used for Frame. 205 ), 206 ] 207 ) 208 209 super().__init__( 210 self.container, 211 filter=Condition(lambda: self.show_window), 212 ) 213 214 def pane_title(self): 215 return self._pane_title 216 217 def menu_title(self): 218 """Return the title to display in the Window menu.""" 219 return self.pane_title() 220 221 def __pt_container__(self): 222 """Return the prompt_toolkit container for displaying this HelpWindow. 223 224 This allows self to be used wherever prompt_toolkit expects a container 225 object.""" 226 return self.container 227 228 def copy_all_text(self): 229 """Copy all text in the Python input to the system clipboard.""" 230 self.application.application.clipboard.set_text( 231 self.help_text_area.buffer.text 232 ) 233 234 def toggle_display(self): 235 """Toggle visibility of this help window.""" 236 # Toggle state variable. 237 self.show_window = not self.show_window 238 239 if self.show_window: 240 # Save previous focus 241 self.last_focused_pane = self.application.focused_window() 242 # Set the help window in focus. 243 self.application.layout.focus(self.help_text_area) 244 else: 245 # Restore original focus if possible. 246 if self.last_focused_pane: 247 self.application.layout.focus(self.last_focused_pane) 248 else: 249 # Fallback to focusing on the first window pane. 250 self.application.focus_main_menu() 251 252 def content_width(self) -> int: 253 """Return total width of help window.""" 254 # Widths of UI elements 255 frame_width = 1 256 padding_width = 1 257 left_side_frame_and_padding_width = frame_width + padding_width 258 right_side_frame_and_padding_width = frame_width + padding_width 259 scrollbar_padding = 1 260 scrollbar_width = 1 261 262 desired_width = self.max_line_length + ( 263 left_side_frame_and_padding_width 264 + right_side_frame_and_padding_width 265 + scrollbar_padding 266 + scrollbar_width 267 ) 268 desired_width = max(60, desired_width) 269 270 window_manager_width = ( 271 self.application.window_manager.current_window_manager_width 272 ) 273 if not window_manager_width: 274 window_manager_width = 80 275 return min(desired_width, window_manager_width) 276 277 def load_user_guide(self): 278 rstdoc_text = importlib.resources.read_text( 279 f'{_PW_CONSOLE_MODULE}.docs', 'user_guide.rst' 280 ) 281 max_line_length = 0 282 rst_text = '' 283 for line in rstdoc_text.splitlines(): 284 if 'https://' not in line and len(line) > max_line_length: 285 max_line_length = len(line) 286 rst_text += line + '\n' 287 self.max_line_length = max_line_length 288 289 self.help_text_area = self._create_help_text_area( 290 lexer=PygmentsLexer(RstLexer), 291 text=rst_text, 292 ) 293 294 def load_yaml_text(self, content: str): 295 max_line_length = 0 296 for line in content.splitlines(): 297 if 'https://' not in line and len(line) > max_line_length: 298 max_line_length = len(line) 299 self.max_line_length = max_line_length 300 301 self.help_text_area = self._create_help_text_area( 302 lexer=PygmentsLexer(YamlLexer), 303 text=content, 304 ) 305 306 def set_help_text( 307 self, text: str, lexer: Optional[PygmentsLexer] = None 308 ) -> None: 309 self.help_text_area = self._create_help_text_area( 310 lexer=lexer, 311 text=text, 312 ) 313 self._update_help_text_area(text) 314 315 def generate_keybind_help_text(self) -> str: 316 """Generate help text based on added key bindings.""" 317 318 template = self.application.get_template('keybind_list.jinja') 319 320 text = template.render( 321 sections=self.help_text_sections, 322 max_additional_help_text_width=self.max_additional_help_text_width, 323 max_description_width=self.max_description_width, 324 max_key_list_width=self.max_key_list_width, 325 preamble=self.preamble, 326 additional_help_text=self.additional_help_text, 327 ) 328 329 self._update_help_text_area(text) 330 return text 331 332 def _update_help_text_area(self, text: str) -> None: 333 self.help_text = text 334 335 # Find the longest line in the rendered template. 336 self.max_line_length = _longest_line_length(self.help_text) 337 338 # Replace the TextArea content. 339 self.help_text_area.buffer.document = Document( 340 text=self.help_text, cursor_position=0 341 ) 342 343 def add_custom_keybinds_help_text(self, section_name, key_bindings: Dict): 344 """Add hand written key_bindings.""" 345 self.help_text_sections[section_name] = key_bindings 346 347 def add_keybind_help_text(self, section_name, key_bindings: KeyBindings): 348 """Append formatted key binding text to this help window.""" 349 350 # Create a new keybind section, erasing any old section with thesame 351 # title. 352 self.help_text_sections[section_name] = {} 353 354 # Loop through passed in prompt_toolkit key_bindings. 355 for binding in key_bindings.bindings: 356 # Skip this keybind if the method name ends in _hidden. 357 if binding.handler.__name__.endswith('_hidden'): 358 continue 359 360 # Get the key binding description from the function doctstring. 361 docstring = binding.handler.__doc__ 362 if not docstring: 363 docstring = '' 364 description = inspect.cleandoc(docstring) 365 description = description.replace('\n', ' ') 366 367 # Save the length of the description. 368 if len(description) > self.max_description_width: 369 self.max_description_width = len(description) 370 371 # Get the existing list of keys for this function or make a new one. 372 key_list = self.help_text_sections[section_name].get( 373 description, list() 374 ) 375 376 # Save the name of the key e.g. F1, q, ControlQ, ControlUp 377 key_name = ' '.join( 378 [getattr(key, 'name', str(key)) for key in binding.keys] 379 ) 380 key_name = key_name.replace('Control', 'Ctrl-') 381 key_name = key_name.replace('Shift', 'Shift-') 382 key_name = key_name.replace('Escape ', 'Alt-') 383 key_name = key_name.replace('Alt-Ctrl-', 'Ctrl-Alt-') 384 key_name = key_name.replace('BackTab', 'Shift-Tab') 385 key_list.append(key_name) 386 387 key_list_width = len(', '.join(key_list)) 388 # Save the length of the key list. 389 if key_list_width > self.max_key_list_width: 390 self.max_key_list_width = key_list_width 391 392 # Update this functions key_list 393 self.help_text_sections[section_name][description] = key_list 394