1# -*- coding: utf-8 -*- 2"""This module defines a class to display widgets""" 3from __future__ import division 4from __future__ import absolute_import 5from __future__ import print_function 6from __future__ import unicode_literals 7from builtins import range 8from copy import copy, deepcopy 9from wcwidth import wcswidth 10from asciimatics.effects import Effect 11from asciimatics.event import KeyboardEvent, MouseEvent 12from asciimatics.exceptions import Highlander, InvalidFields 13from asciimatics.screen import Screen, Canvas 14from asciimatics.widgets.scrollbar import _ScrollBar 15from asciimatics.widgets.utilities import THEMES, logger 16 17class Frame(Effect): 18 """ 19 A Frame is a special Effect for controlling and displaying Widgets. 20 21 It is similar to a window as used in native GUI applications. Widgets are text UI elements 22 that can be used to create an interactive application within your Frame. 23 """ 24 25 #: Colour palette for the widgets within the Frame. Each entry should be 26 #: a 3-tuple of (foreground colour, attribute, background colour). 27 palette = {} 28 29 def __init__(self, screen, height, width, data=None, on_load=None, 30 has_border=True, hover_focus=False, name=None, title=None, 31 x=None, y=None, has_shadow=False, reduce_cpu=False, is_modal=False, 32 can_scroll=True): 33 """ 34 :param screen: The Screen that owns this Frame. 35 :param width: The desired width of the Frame. 36 :param height: The desired height of the Frame. 37 :param data: optional data dict to initialize any widgets in the frame. 38 :param on_load: optional function to call whenever the Frame reloads. 39 :param has_border: Whether the frame has a border box (and scroll bar). Defaults to True. 40 :param hover_focus: Whether hovering a mouse over a widget (i.e. mouse move events) 41 should change the input focus. Defaults to false. 42 :param name: Optional name to identify this Frame. This is used to reset data as needed 43 from on old copy after the screen resizes. 44 :param title: Optional title to display if has_border is True. 45 :param x: Optional x position for the top left corner of the Frame. 46 :param y: Optional y position for the top left corner of the Frame. 47 :param has_shadow: Optional flag to indicate if this Frame should have a shadow when 48 drawn. 49 :param reduce_cpu: Whether to minimize CPU usage (for use on low spec systems). 50 :param is_modal: Whether this Frame is "modal" - i.e. will stop all other Effects from 51 receiving input events. 52 :param can_scroll: Whether a scrollbar should be available on the border, or not. 53 (Only valid if `has_border=True`). 54 """ 55 super(Frame, self).__init__(screen) 56 self._focus = 0 57 self._max_height = 0 58 self._layouts = [] 59 self._effects = [] 60 self._canvas = Canvas(screen, height, width, x, y) 61 self._data = None 62 self._on_load = on_load 63 self._has_border = has_border 64 self._can_scroll = can_scroll 65 self._scroll_bar = _ScrollBar( 66 self._canvas, self.palette, self._canvas.width - 1, 2, self._canvas.height - 4, 67 self._get_pos, self._set_pos, absolute=True) if can_scroll else None 68 self._hover_focus = hover_focus 69 self._initial_data = data if data else {} 70 self._title = None 71 self.title = title # Use property to re-format text as required. 72 self._has_shadow = has_shadow 73 self._reduce_cpu = reduce_cpu 74 self._is_modal = is_modal 75 self._has_focus = False 76 77 # A unique name is needed for cloning. Try our best to get one! 78 self._name = title if name is None else name 79 80 # Flag to catch recursive calls inside the data setting. This is 81 # typically caused by callbacks subsequently trying to re-use functions. 82 self._in_call = False 83 84 # Now set up any passed data - use the public property to trigger any 85 # necessary updates. 86 self.data = deepcopy(self._initial_data) 87 88 # Optimization for non-unicode displays to avoid slow unicode calls. 89 self.string_len = wcswidth if self._canvas.unicode_aware else len 90 91 # Ensure that we have the default palette in place 92 self._theme = None 93 self.set_theme("default") 94 95 def _get_pos(self): 96 """ 97 Get current position for scroll bar. 98 """ 99 if self._canvas.height >= self._max_height: 100 return 0 101 return self._canvas.start_line / (self._max_height - self._canvas.height + 1) 102 103 def _set_pos(self, pos): 104 """ 105 Set current position for scroll bar. 106 """ 107 if self._canvas.height < self._max_height: 108 pos *= self._max_height - self._canvas.height + 1 109 pos = int(round(max(0, pos), 0)) 110 self._canvas.scroll_to(pos) 111 112 def add_layout(self, layout): 113 """ 114 Add a Layout to the Frame. 115 116 :param layout: The Layout to be added. 117 """ 118 layout.register_frame(self) 119 self._layouts.append(layout) 120 121 def add_effect(self, effect): 122 """ 123 Add an Effect to the Frame. 124 125 :param effect: The Effect to be added. 126 """ 127 effect.register_scene(self._scene) 128 self._effects.append(effect) 129 130 def fix(self): 131 """ 132 Fix the layouts and calculate the locations of all the widgets. 133 134 This function should be called once all Layouts have been added to the Frame and all 135 widgets added to the Layouts. 136 """ 137 # Do up to 2 passes in case we have a variable height Layout. 138 fill_layout = None 139 fill_height = y = 0 140 for _ in range(2): 141 # Pick starting point/height - varies for borders. 142 if self._has_border: 143 x = y = start_y = 1 144 height = self._canvas.height - 2 145 width = self._canvas.width - 2 146 else: 147 x = y = start_y = 0 148 height = self._canvas.height 149 width = self._canvas.width 150 151 # Process each Layout in the Frame - getting required height for 152 # each. 153 for layout in self._layouts: 154 if layout.fill_frame: 155 if fill_layout is None: 156 # First pass - remember it for now. 157 fill_layout = layout 158 elif fill_layout == layout: 159 # Second pass - pass in max height 160 y = layout.fix(x, y, width, fill_height) 161 else: 162 # A second filler - this is a bug in the application. 163 raise Highlander("Too many Layouts filling Frame") 164 else: 165 y = layout.fix(x, y, width, height) 166 167 # If we hit a variable height Layout - figure out the available 168 # space and reset everything to the new values. 169 if fill_layout is None: 170 break 171 else: 172 fill_height = max(1, start_y + height - y) 173 174 # Remember the resulting height of the underlying Layouts. 175 self._max_height = y 176 177 # Reset text 178 while self._focus < len(self._layouts): 179 try: 180 self._layouts[self._focus].focus(force_first=True) 181 break 182 except IndexError: 183 self._focus += 1 184 self._clear() 185 186 def _clear(self): 187 """ 188 Clear the current canvas. 189 """ 190 # It's orders of magnitude faster to reset with a print like this 191 # instead of recreating the screen buffers. 192 (colour, attr, bg) = self.palette["background"] 193 self._canvas.clear_buffer(colour, attr, bg) 194 195 def _update(self, frame_no): 196 # TODO: Should really be in a separate Desktop Manager class - wait for v2.0 197 if self.scene and self.scene.effects[-1] != self: 198 if self._focus < len(self._layouts): 199 self._layouts[self._focus].blur() 200 self._has_focus = False 201 202 # Reset the canvas to prepare for next round of updates. 203 self._clear() 204 205 # Update all the widgets first. 206 for layout in self._layouts: 207 layout.update(frame_no) 208 209 # Then update any effects as needed. 210 for effect in self._effects: 211 effect.update(frame_no) 212 213 # Draw any border if needed. 214 if self._has_border: 215 # Decide on box chars to use. 216 tl = u"┌" if self._canvas.unicode_aware else "+" 217 tr = u"┐" if self._canvas.unicode_aware else "+" 218 bl = u"└" if self._canvas.unicode_aware else "+" 219 br = u"┘" if self._canvas.unicode_aware else "+" 220 horiz = u"─" if self._canvas.unicode_aware else "-" 221 vert = u"│" if self._canvas.unicode_aware else "|" 222 223 # Draw the basic border first. 224 (colour, attr, bg) = self.palette["borders"] 225 for dy in range(self._canvas.height): 226 y = self._canvas.start_line + dy 227 if dy == 0: 228 self._canvas.print_at( 229 tl + (horiz * (self._canvas.width - 2)) + tr, 230 0, y, colour, attr, bg) 231 elif dy == self._canvas.height - 1: 232 self._canvas.print_at( 233 bl + (horiz * (self._canvas.width - 2)) + br, 234 0, y, colour, attr, bg) 235 else: 236 self._canvas.print_at(vert, 0, y, colour, attr, bg) 237 self._canvas.print_at(vert, self._canvas.width - 1, y, 238 colour, attr, bg) 239 240 # Now the title 241 (colour, attr, bg) = self.palette["title"] 242 title_width = self.string_len(self._title) 243 self._canvas.print_at( 244 self._title, 245 (self._canvas.width - title_width) // 2, 246 self._canvas.start_line, 247 colour, attr, bg) 248 249 # And now the scroll bar 250 if self._can_scroll and self._canvas.height > 5: 251 self._scroll_bar.update() 252 253 # Now push it all to screen. 254 self._canvas.refresh() 255 256 # And finally - draw the shadow 257 if self._has_shadow: 258 (colour, _, bg) = self.palette["shadow"] 259 self._screen.highlight( 260 self._canvas.origin[0] + 1, 261 self._canvas.origin[1] + self._canvas.height, 262 self._canvas.width - 1, 263 1, 264 fg=colour, bg=bg, blend=50) 265 self._screen.highlight( 266 self._canvas.origin[0] + self._canvas.width, 267 self._canvas.origin[1] + 1, 268 1, 269 self._canvas.height, 270 fg=colour, bg=bg, blend=50) 271 272 def set_theme(self, theme): 273 """ 274 Pick a palette from the list of supported THEMES. 275 276 :param theme: The name of the theme to set. 277 """ 278 if theme in THEMES: 279 self._theme = theme 280 self.palette = THEMES[theme] 281 if self._scroll_bar: 282 self._scroll_bar.palette = self.palette 283 284 @property 285 def title(self): 286 """ 287 Title for this Frame. 288 """ 289 return self._title 290 291 @title.setter 292 def title(self, new_value): 293 self._title = " " + new_value[0:self._canvas.width - 4] + " " if new_value else "" 294 295 @property 296 def data(self): 297 """ 298 Data dictionary containing values from the contained widgets. 299 """ 300 return self._data 301 302 @data.setter 303 def data(self, new_value): 304 # Don't allow this function to recurse. 305 if self._in_call: 306 return 307 self._in_call = True 308 309 # Do a key-by-key copy to allow for dictionary-like objects - e.g. 310 # sqlite3 Row class. 311 self._data = {} 312 if new_value is not None: 313 for key in list(new_value.keys()): 314 self._data[key] = new_value[key] 315 316 # Now update any widgets as needed. 317 for layout in self._layouts: 318 layout.update_widgets() 319 320 # All done - clear the recursion flag. 321 self._in_call = False 322 323 @property 324 def stop_frame(self): 325 # Widgets have no defined end - always return -1. 326 return -1 327 328 @property 329 def safe_to_default_unhandled_input(self): 330 # It is NOT safe to use the unhandled input handler on Frames as the 331 # default on space and enter is to go to the next Scene. 332 return False 333 334 @property 335 def canvas(self): 336 """ 337 The Canvas that backs this Frame. 338 """ 339 return self._canvas 340 341 @property 342 def focussed_widget(self): 343 """ 344 The widget that currently has the focus within this Frame. 345 """ 346 # If the frame has no focus, it can't have a focussed widget. 347 if not self._has_focus: 348 return None 349 350 try: 351 layout = self._layouts[self._focus] 352 return layout._columns[layout._live_col][layout._live_widget] 353 except IndexError: 354 # If the current indexing is invalid it's because no widget is selected. 355 return None 356 357 @property 358 def frame_update_count(self): 359 """ 360 The number of frames before this Effect should be updated. 361 """ 362 result = 1000000 363 for layout in self._layouts: 364 if layout.frame_update_count > 0: 365 result = min(result, layout.frame_update_count) 366 for effect in self._effects: 367 if effect.frame_update_count > 0: 368 result = min(result, effect.frame_update_count) 369 return result 370 371 @property 372 def reduce_cpu(self): 373 """ 374 Whether this Frame should try to optimize refreshes to reduce CPU. 375 """ 376 return self._reduce_cpu 377 378 def find_widget(self, name): 379 """ 380 Look for a widget with a specified name. 381 382 :param name: The name to search for. 383 384 :returns: The widget that matches or None if one couldn't be found. 385 """ 386 result = None 387 for layout in self._layouts: 388 result = layout.find_widget(name) 389 if result: 390 break 391 return result 392 393 def clone(self, _, scene): 394 """ 395 Create a clone of this Frame into a new Screen. 396 397 :param _: ignored. 398 :param scene: The new Scene object to clone into. 399 """ 400 # Assume that the application creates a new set of Frames and so we need to match up the 401 # data from the old object to the new (using the name). 402 if self._name is not None: 403 for effect in scene.effects: 404 if isinstance(effect, Frame): 405 logger.debug("Cloning: %s", effect._name) 406 if effect._name == self._name: 407 effect.set_theme(self._theme) 408 effect.data = self.data 409 for layout in self._layouts: 410 layout.update_widgets(new_frame=effect) 411 412 def reset(self): 413 # Reset form to default state. 414 self.data = deepcopy(self._initial_data) 415 416 # Now reset the individual widgets. 417 self._canvas.reset() 418 for layout in self._layouts: 419 layout.reset() 420 layout.blur() 421 422 # Then reset any effects as needed. 423 for effect in self._effects: 424 effect.reset() 425 426 # Set up active widget. 427 self._focus = 0 428 while self._focus < len(self._layouts): 429 try: 430 self._layouts[self._focus].focus(force_first=True) 431 break 432 except IndexError: 433 self._focus += 1 434 435 # Call the on_load function now if specified. 436 if self._on_load is not None: 437 self._on_load() 438 439 def save(self, validate=False): 440 """ 441 Save the current values in all the widgets back to the persistent data storage. 442 443 :param validate: Whether to validate the data before saving. 444 445 Calling this while setting the `data` field (e.g. in a widget callback) will have no 446 effect. 447 448 When validating data, it can throw an Exception for any 449 """ 450 # Don't allow this function to be called if we are already updating the 451 # data for the form. 452 if self._in_call: 453 return 454 455 # We're clear - pass on to all layouts/widgets. 456 invalid = [] 457 for layout in self._layouts: 458 try: 459 layout.save(validate=validate) 460 except InvalidFields as exc: 461 invalid.extend(exc.fields) 462 463 # Check for any bad data and raise exception if needed. 464 if len(invalid) > 0: 465 raise InvalidFields(invalid) 466 467 def switch_focus(self, layout, column, widget): 468 """ 469 Switch focus to the specified widget. 470 471 :param layout: The layout that owns the widget. 472 :param column: The column the widget is in. 473 :param widget: The index of the widget to take the focus. 474 """ 475 # Find the layout to own the focus. 476 for i, l in enumerate(self._layouts): 477 if l is layout: 478 break 479 else: 480 # No matching layout - give up now 481 return 482 483 self._layouts[self._focus].blur() 484 self._focus = i 485 self._layouts[self._focus].focus(force_column=column, 486 force_widget=widget) 487 488 def move_to(self, x, y, h): 489 """ 490 Make the specified location visible. This is typically used by a widget to scroll the 491 canvas such that it is visible. 492 493 :param x: The x location to make visible. 494 :param y: The y location to make visible. 495 :param h: The height of the location to make visible. 496 """ 497 if self._has_border: 498 start_x = 1 499 width = self.canvas.width - 2 500 start_y = self.canvas.start_line + 1 501 height = self.canvas.height - 2 502 else: 503 start_x = 0 504 width = self.canvas.width 505 start_y = self.canvas.start_line 506 height = self.canvas.height 507 508 if ((x >= start_x) and (x < start_x + width) and 509 (y >= start_y) and (y + h < start_y + height)): 510 # Already OK - quit now. 511 return 512 513 if y < start_y: 514 self.canvas.scroll_to(y - 1 if self._has_border else y) 515 else: 516 line = y + h - self.canvas.height + (1 if self._has_border else 0) 517 self.canvas.scroll_to(max(0, line)) 518 519 def rebase_event(self, event): 520 """ 521 Rebase the coordinates of the passed event to frame-relative coordinates. 522 523 :param event: The event to be rebased. 524 :returns: A new event object appropriately re-based. 525 """ 526 new_event = copy(event) 527 if isinstance(new_event, MouseEvent): 528 origin = self._canvas.origin 529 new_event.x -= origin[0] 530 new_event.y -= origin[1] - self._canvas.start_line 531 logger.debug("New event: %s", new_event) 532 return new_event 533 534 def _find_next_tab_stop(self, direction): 535 old_focus = self._focus 536 self._focus += direction 537 while self._focus != old_focus: 538 if self._focus < 0: 539 self._focus = len(self._layouts) - 1 540 if self._focus >= len(self._layouts): 541 self._focus = 0 542 try: 543 if direction > 0: 544 self._layouts[self._focus].focus(force_first=True) 545 else: 546 self._layouts[self._focus].focus(force_last=True) 547 break 548 except IndexError: 549 self._focus += direction 550 551 def _switch_to_nearest_vertical_widget(self, direction): 552 """ 553 Find the nearest widget above or below the current widget with the focus. 554 555 This should only be called by the Frame when normal Layout navigation fails and so this needs to find the 556 nearest widget in the next available Layout. It will not search the existing Layout for a closer match. 557 558 :param direction: The direction to move through the Layouts. 559 """ 560 current_widget = self._layouts[self._focus].get_current_widget() 561 focus = self._focus 562 focus += direction 563 while self._focus != focus: 564 if focus < 0: 565 focus = len(self._layouts) - 1 566 if focus >= len(self._layouts): 567 focus = 0 568 match = self._layouts[focus].get_nearest_widget(current_widget, direction) 569 if match: 570 self.switch_focus(self._layouts[focus], match[1], match[2]) 571 return 572 focus += direction 573 574 def process_event(self, event): 575 # Rebase any mouse events into Frame coordinates now. 576 old_event = event 577 event = self.rebase_event(event) 578 579 # Claim the input focus if a mouse clicked on this Frame. 580 claimed_focus = False 581 if isinstance(event, MouseEvent) and event.buttons > 0: 582 if (0 <= event.x < self._canvas.width and 583 0 <= event.y < self._canvas.height): 584 self._scene.remove_effect(self) 585 self._scene.add_effect(self, reset=False) 586 if not self._has_focus and self._focus < len(self._layouts): 587 self._layouts[self._focus].focus() 588 self._has_focus = claimed_focus = True 589 else: 590 if self._has_focus and self._focus < len(self._layouts): 591 self._layouts[self._focus].blur() 592 self._has_focus = False 593 elif isinstance(event, KeyboardEvent): 594 # TODO: Should have Desktop Manager handling this - wait for v2.0 595 # By this stage, if we're processing keys, we have the focus. 596 if not self._has_focus and self._focus < len(self._layouts): 597 self._layouts[self._focus].focus() 598 self._has_focus = True 599 600 # No need to do anything if this Frame has no Layouts - and hence no 601 # widgets. Swallow all Keyboard events while we have focus. 602 # 603 # Also don't bother trying to process widgets if there is no defined 604 # focus. This means there is no enabled widget in the Frame. 605 if (self._focus < 0 or self._focus >= len(self._layouts) or 606 not self._layouts): 607 if event is not None and isinstance(event, KeyboardEvent): 608 return None 609 else: 610 # Don't allow events to bubble down if this window owns the Screen - as already 611 # calculated when taking te focus - or is modal. 612 return None if claimed_focus or self._is_modal else old_event 613 614 # Give the current widget in focus first chance to process the event. 615 event = self._layouts[self._focus].process_event(event, self._hover_focus) 616 617 # If the underlying widgets did not process the event, try processing 618 # it now. 619 if event is not None: 620 if isinstance(event, KeyboardEvent): 621 if event.key_code == Screen.KEY_TAB: 622 # Move on to next widget. 623 self._layouts[self._focus].blur() 624 self._find_next_tab_stop(1) 625 self._layouts[self._focus].focus(force_first=True) 626 old_event = None 627 elif event.key_code == Screen.KEY_BACK_TAB: 628 # Move on to previous widget. 629 self._layouts[self._focus].blur() 630 self._find_next_tab_stop(-1) 631 self._layouts[self._focus].focus(force_last=True) 632 old_event = None 633 if event.key_code == Screen.KEY_DOWN: 634 # Move on to nearest vertical widget in the next Layout 635 self._switch_to_nearest_vertical_widget(1) 636 old_event = None 637 elif event.key_code == Screen.KEY_UP: 638 # Move on to nearest vertical widget in the next Layout 639 self._switch_to_nearest_vertical_widget(-1) 640 old_event = None 641 elif isinstance(event, MouseEvent): 642 # Give layouts/widgets first dibs on the mouse message. 643 for layout in self._layouts: 644 if layout.process_event(event, self._hover_focus) is None: 645 return None 646 647 # If no joy, check whether the scroll bar was clicked. 648 if self._has_border and self._can_scroll: 649 if self._scroll_bar.process_event(event): 650 return None 651 652 # Don't allow events to bubble down if this window owns the Screen (as already 653 # calculated when taking te focus) or if the Frame is modal or we handled the 654 # event. 655 return None if claimed_focus or self._is_modal or event is None else old_event 656 657