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 16from __future__ import annotations 17 18import functools 19import importlib.resources 20import inspect 21import logging 22from typing import TYPE_CHECKING 23 24from prompt_toolkit.document import Document 25from prompt_toolkit.filters import Condition 26from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent 27from prompt_toolkit.layout import ( 28 ConditionalContainer, 29 DynamicContainer, 30 FormattedTextControl, 31 HSplit, 32 VSplit, 33 Window, 34 WindowAlign, 35) 36from prompt_toolkit.layout.dimension import Dimension 37from prompt_toolkit.lexers import PygmentsLexer 38from prompt_toolkit.widgets import Box, TextArea 39 40from pygments.lexers.markup import RstLexer # type: ignore 41from pygments.lexers.data import YamlLexer # type: ignore 42 43from pw_console.style import ( 44 get_pane_indicator, 45) 46from pw_console.widgets import ( 47 mouse_handlers, 48 to_keybind_indicator, 49) 50 51if TYPE_CHECKING: 52 from pw_console.console_app import ConsoleApp 53 54_LOG = logging.getLogger(__package__) 55 56_PW_CONSOLE_MODULE = 'pw_console' 57 58 59def _longest_line_length(text): 60 """Return the longest line in the given text.""" 61 max_line_length = 0 62 for line in text.splitlines(): 63 if len(line) > max_line_length: 64 max_line_length = len(line) 65 return max_line_length 66 67 68class HelpWindow(ConditionalContainer): 69 """Help window container for displaying keybindings.""" 70 71 # pylint: disable=too-many-instance-attributes 72 73 def _create_help_text_area(self, **kwargs): 74 help_text_area = TextArea( 75 focusable=True, 76 focus_on_click=True, 77 scrollbar=True, 78 style='class:help_window_content', 79 wrap_lines=False, 80 **kwargs, 81 ) 82 83 # Additional keybindings for the text area. 84 key_bindings = KeyBindings() 85 register = self.application.prefs.register_keybinding 86 87 @register('help-window.close', key_bindings) 88 def _close_window(_event: KeyPressEvent) -> None: 89 """Close the current dialog window.""" 90 self.toggle_display() 91 92 if not self.disable_ctrl_c: 93 94 @register('help-window.copy-all', key_bindings) 95 def _copy_all(_event: KeyPressEvent) -> None: 96 """Close the current dialog window.""" 97 self.copy_all_text() 98 99 help_text_area.control.key_bindings = key_bindings 100 return help_text_area 101 102 def __init__( 103 self, 104 application: ConsoleApp, 105 preamble: str = '', 106 additional_help_text: str = '', 107 title: str = '', 108 disable_ctrl_c: bool = False, 109 ) -> None: 110 # Dict containing key = section title and value = list of key bindings. 111 self.application: ConsoleApp = application 112 self.show_window: bool = False 113 self.help_text_sections: dict[str, dict] = {} 114 self._pane_title: str = title 115 self.disable_ctrl_c = disable_ctrl_c 116 117 # Tracks the last focused container, to enable restoring focus after 118 # closing the dialog. 119 self.last_focused_pane = None 120 121 # Generated keybinding text 122 self.preamble: str = preamble 123 self.additional_help_text: str = additional_help_text 124 self.help_text: str = '' 125 126 self.max_additional_help_text_width: int = ( 127 _longest_line_length(self.additional_help_text) 128 if additional_help_text 129 else 0 130 ) 131 self.max_description_width: int = 0 132 self.max_key_list_width: int = 0 133 self.max_line_length: int = 0 134 135 self.help_text_area: TextArea = self._create_help_text_area() 136 137 close_mouse_handler = functools.partial( 138 mouse_handlers.on_click, self.toggle_display 139 ) 140 copy_mouse_handler = functools.partial( 141 mouse_handlers.on_click, self.copy_all_text 142 ) 143 144 toolbar_padding = 1 145 toolbar_title = ' ' * toolbar_padding 146 toolbar_title += self.pane_title() 147 148 buttons = [] 149 if not self.disable_ctrl_c: 150 buttons.extend( 151 to_keybind_indicator( 152 'Ctrl-c', 153 'Copy All', 154 copy_mouse_handler, 155 base_style='class:toolbar-button-active', 156 ) 157 ) 158 buttons.append(('', ' ')) 159 160 buttons.extend( 161 to_keybind_indicator( 162 'q', 163 'Close', 164 close_mouse_handler, 165 base_style='class:toolbar-button-active', 166 ) 167 ) 168 top_toolbar = VSplit( 169 [ 170 Window( 171 content=FormattedTextControl( 172 # [('', toolbar_title)] 173 functools.partial( 174 get_pane_indicator, 175 self, 176 toolbar_title, 177 ) 178 ), 179 align=WindowAlign.LEFT, 180 dont_extend_width=True, 181 ), 182 Window( 183 content=FormattedTextControl([]), 184 align=WindowAlign.LEFT, 185 dont_extend_width=False, 186 ), 187 Window( 188 content=FormattedTextControl(buttons), 189 align=WindowAlign.RIGHT, 190 dont_extend_width=True, 191 ), 192 ], 193 height=1, 194 style='class:toolbar_active', 195 ) 196 197 self.container = HSplit( 198 [ 199 top_toolbar, 200 Box( 201 body=DynamicContainer(lambda: self.help_text_area), 202 padding=Dimension(preferred=1, max=1), 203 padding_bottom=0, 204 padding_top=0, 205 char=' ', 206 style='class:frame.border', # Same style used for Frame. 207 ), 208 ] 209 ) 210 211 super().__init__( 212 self.container, 213 filter=Condition(lambda: self.show_window), 214 ) 215 216 def pane_title(self): 217 return self._pane_title 218 219 def menu_title(self): 220 """Return the title to display in the Window menu.""" 221 return self.pane_title() 222 223 def __pt_container__(self): 224 """Return the prompt_toolkit container for displaying this HelpWindow. 225 226 This allows self to be used wherever prompt_toolkit expects a container 227 object.""" 228 return self.container 229 230 def copy_all_text(self): 231 """Copy all text in the Python input to the system clipboard.""" 232 self.application.set_system_clipboard(self.help_text_area.buffer.text) 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: PygmentsLexer | None = 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