1# ------------------------------------------------------------------------------ 2# Copyright (c) 2007, Riverbank Computing Limited 3# All rights reserved. 4# 5# This software is provided without warranty under the terms of the BSD license. 6# However, when used with the GPL version of PyQt the additional terms described 7# in the PyQt GPL exception also apply 8 9# 10# Author: Riverbank Computing Limited 11# ------------------------------------------------------------------------------ 12 13""" Defines the various list editors for the PyQt user interface toolkit. 14""" 15 16 17from pyface.qt import QtCore, QtGui 18 19from pyface.api import ImageResource 20 21from traits.api import Str, Any, Bool, Dict, Instance, List 22from traits.trait_base import user_name_for, xgetattr 23 24# FIXME: ToolkitEditorFactory is a proxy class defined here just for backward 25# compatibility. The class has been moved to the 26# traitsui.editors.list_editor file. 27from traitsui.editors.list_editor import ListItemProxy, ToolkitEditorFactory 28 29from .editor import Editor 30from .helper import IconButton 31from .menu import MakeMenu 32 33 34class SimpleEditor(Editor): 35 """ Simple style of editor for lists, which displays a list box with only 36 one item visible at a time. A icon next to the list box displays a menu of 37 operations on the list. 38 39 """ 40 41 # ------------------------------------------------------------------------- 42 # Trait definitions: 43 # ------------------------------------------------------------------------- 44 45 #: The kind of editor to create for each list item 46 kind = Str() 47 48 #: Is the list of items being edited mutable? 49 mutable = Bool(True) 50 51 #: Is the editor scrollable? 52 scrollable = Bool(True) 53 54 #: Signal mapper allowing to identify which icon button requested a context 55 #: menu 56 mapper = Instance(QtCore.QSignalMapper) 57 58 buttons = List([]) 59 60 _list_pane = Instance(QtGui.QWidget) 61 62 # ------------------------------------------------------------------------- 63 # Class constants: 64 # ------------------------------------------------------------------------- 65 66 #: Whether the list is displayed in a single row 67 single_row = True 68 69 # ------------------------------------------------------------------------- 70 # Normal list item menu: 71 # ------------------------------------------------------------------------- 72 73 #: Menu for modifying the list 74 list_menu = """ 75 Add &Before [_menu_before]: self.add_before() 76 Add &After [_menu_after]: self.add_after() 77 --- 78 &Delete [_menu_delete]: self.delete_item() 79 --- 80 Move &Up [_menu_up]: self.move_up() 81 Move &Down [_menu_down]: self.move_down() 82 Move to &Top [_menu_top]: self.move_top() 83 Move to &Bottom [_menu_bottom]: self.move_bottom() 84 """ 85 86 # ------------------------------------------------------------------------- 87 # Empty list item menu: 88 # ------------------------------------------------------------------------- 89 90 empty_list_menu = """ 91 Add: self.add_empty() 92 """ 93 94 def init(self, parent): 95 """ Finishes initializing the editor by creating the underlying toolkit 96 widget. 97 """ 98 # Initialize the trait handler to use: 99 trait_handler = self.factory.trait_handler 100 if trait_handler is None: 101 trait_handler = self.object.base_trait(self.name).handler 102 self._trait_handler = trait_handler 103 104 if self.scrollable: 105 # Create a scrolled window to hold all of the list item controls: 106 self.control = QtGui.QScrollArea() 107 self.control.setFrameShape(QtGui.QFrame.NoFrame) 108 self.control.setWidgetResizable(True) 109 self._list_pane = QtGui.QWidget() 110 else: 111 self.control = QtGui.QWidget() 112 self._list_pane = self.control 113 self._list_pane.setSizePolicy( 114 QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding 115 ) 116 117 # Create a mapper to identify which icon button requested a contextmenu 118 self.mapper = QtCore.QSignalMapper(self.control) 119 120 # Create a widget with a grid layout as the container. 121 layout = QtGui.QGridLayout(self._list_pane) 122 layout.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) 123 layout.setContentsMargins(0, 0, 0, 0) 124 layout.setSpacing(0) 125 126 # Remember the editor to use for each individual list item: 127 editor = self.factory.editor 128 if editor is None: 129 editor = trait_handler.item_trait.get_editor() 130 self._editor = getattr(editor, self.kind) 131 132 # Set up the additional 'list items changed' event handler needed for 133 # a list based trait. Note that we want to fire the update_editor_item 134 # only when the items in the list change and not when intermediate 135 # traits change. Therefore, replace "." by ":" in the extended_name 136 # when setting up the listener. 137 extended_name = self.extended_name.replace(".", ":") 138 self.context_object.on_trait_change( 139 self.update_editor_item, extended_name + "_items?", dispatch="ui" 140 ) 141 self.set_tooltip() 142 143 def dispose(self): 144 """ Disposes of the contents of an editor. 145 """ 146 self._dispose_items() 147 148 extended_name = self.extended_name.replace(".", ":") 149 self.context_object.on_trait_change( 150 self.update_editor_item, extended_name + "_items?", remove=True 151 ) 152 153 super(SimpleEditor, self).dispose() 154 155 def update_editor(self): 156 """ Updates the editor when the object trait changes externally to the 157 editor. 158 """ 159 self.mapper = QtCore.QSignalMapper(self.control) 160 # Disconnect the editor from any control about to be destroyed: 161 self._dispose_items() 162 163 list_pane = self._list_pane 164 layout = list_pane.layout() 165 166 # Create all of the list item trait editors: 167 trait_handler = self._trait_handler 168 resizable = ( 169 trait_handler.minlen != trait_handler.maxlen 170 ) and self.mutable 171 item_trait = trait_handler.item_trait 172 173 is_fake = resizable and (len(self.value) == 0) 174 if is_fake: 175 self.empty_list() 176 else: 177 self.buttons = [] 178 # Asking the mapper to send the sender to the callback method 179 self.mapper.mapped.connect(self.popup_menu) 180 181 editor = self._editor 182 for index, value in enumerate(self.value): 183 row, column = divmod(index, self.factory.columns) 184 185 # Account for the fact that we have <columns> number of 186 # pairs 187 column = column * 2 188 189 if resizable: 190 # Connecting the new button to the mapper 191 control = IconButton("list_editor.png", self.mapper.map) 192 self.buttons.append(control) 193 # Setting the mapping and asking it to send the index of the 194 # sender to the callback method. Unfortunately just sending 195 # the control does not work for PyQt (tested on 4.11) 196 self.mapper.setMapping(control, index) 197 198 layout.addWidget(control, row, column + 1) 199 200 proxy = ListItemProxy( 201 self.object, self.name, index, item_trait, value 202 ) 203 if resizable: 204 control.proxy = proxy 205 peditor = editor( 206 self.ui, proxy, "value", self.description, list_pane 207 ).trait_set(object_name="") 208 peditor.prepare(list_pane) 209 pcontrol = peditor.control 210 pcontrol.proxy = proxy 211 212 if isinstance(pcontrol, QtGui.QWidget): 213 layout.addWidget(pcontrol, row, column) 214 else: 215 layout.addLayout(pcontrol, row, column) 216 217 # QScrollArea can have problems if the widget being scrolled is set too 218 # early (ie. before it contains something). 219 if self.scrollable and self.control.widget() is None: 220 self.control.setWidget(list_pane) 221 222 def update_editor_item(self, event): 223 """ Updates the editor when an item in the object trait changes 224 externally to the editor. 225 """ 226 # If this is not a simple, single item update, rebuild entire editor: 227 if (len(event.removed) != 1) or (len(event.added) != 1): 228 self.update_editor() 229 return 230 231 # Otherwise, find the proxy for this index and update it with the 232 # changed value: 233 for control in self._list_pane.children(): 234 if isinstance(control, QtGui.QLayout): 235 continue 236 237 proxy = control.proxy 238 if proxy.index == event.index: 239 proxy.value = event.added[0] 240 break 241 242 def empty_list(self): 243 """ Creates an empty list entry (so the user can add a new item). 244 """ 245 # Connecting the new button to the mapper 246 control = IconButton("list_editor.png", self.mapper.map) 247 # Setting the mapping and asking it to send the index of the sender to 248 # callback method. Unfortunately just sending the control does not 249 # work for PyQt (tested on 4.11) 250 self.mapper.setMapping(control, 0) 251 self.mapper.mapped.connect(self.popup_empty_menu) 252 control.is_empty = True 253 self._cur_control = control 254 self.buttons = [control] 255 256 proxy = ListItemProxy(self.object, self.name, -1, None, None) 257 pcontrol = QtGui.QLabel(" (Empty List)") 258 pcontrol.proxy = control.proxy = proxy 259 260 layout = self._list_pane.layout() 261 layout.addWidget(control, 0, 1) 262 layout.addWidget(pcontrol, 0, 0) 263 264 def get_info(self): 265 """ Returns the associated object list and current item index. 266 """ 267 proxy = self._cur_control.proxy 268 return (proxy.list, proxy.index) 269 270 def popup_empty_menu(self, index): 271 """ Displays the empty list editor popup menu. 272 """ 273 self._cur_control = control = self.buttons[index] 274 menu = MakeMenu(self.empty_list_menu, self, True, control).menu 275 menu.exec_(control.mapToGlobal(QtCore.QPoint(4, 24))) 276 277 def popup_menu(self, index): 278 """ Displays the list editor popup menu. 279 """ 280 self._cur_control = sender = self.buttons[index] 281 282 proxy = sender.proxy 283 menu = MakeMenu(self.list_menu, self, True, sender).menu 284 len_list = len(proxy.list) 285 not_full = len_list < self._trait_handler.maxlen 286 287 self._menu_before.enabled(not_full) 288 self._menu_after.enabled(not_full) 289 self._menu_delete.enabled(len_list > self._trait_handler.minlen) 290 self._menu_up.enabled(index > 0) 291 self._menu_top.enabled(index > 0) 292 self._menu_down.enabled(index < (len_list - 1)) 293 self._menu_bottom.enabled(index < (len_list - 1)) 294 295 menu.exec_(sender.mapToGlobal(QtCore.QPoint(4, 24))) 296 297 def add_item(self, offset): 298 """ Adds a new value at the specified list index. 299 """ 300 list, index = self.get_info() 301 index += offset 302 item_trait = self._trait_handler.item_trait 303 value = item_trait.default_value_for(self.object, self.name) 304 self.value = list[:index] + [value] + list[index:] 305 self.update_editor() 306 307 def add_before(self): 308 """ Inserts a new item before the current item. 309 """ 310 self.add_item(0) 311 312 def add_after(self): 313 """ Inserts a new item after the current item. 314 """ 315 self.add_item(1) 316 317 def add_empty(self): 318 """ Adds a new item when the list is empty. 319 """ 320 list, index = self.get_info() 321 self.add_item(0) 322 323 def delete_item(self): 324 """ Delete the current item. 325 """ 326 list, index = self.get_info() 327 self.value = list[:index] + list[index + 1 :] 328 self.update_editor() 329 330 def move_up(self): 331 """ Move the current item up one in the list. 332 """ 333 list, index = self.get_info() 334 self.value = ( 335 list[: index - 1] 336 + [list[index], list[index - 1]] 337 + list[index + 1 :] 338 ) 339 self.update_editor() 340 341 def move_down(self): 342 """ Moves the current item down one in the list. 343 """ 344 list, index = self.get_info() 345 self.value = ( 346 list[:index] + [list[index + 1], list[index]] + list[index + 2 :] 347 ) 348 self.update_editor() 349 350 def move_top(self): 351 """ Moves the current item to the top of the list. 352 """ 353 list, index = self.get_info() 354 self.value = [list[index]] + list[:index] + list[index + 1 :] 355 self.update_editor() 356 357 def move_bottom(self): 358 """ Moves the current item to the bottom of the list. 359 """ 360 list, index = self.get_info() 361 self.value = list[:index] + list[index + 1 :] + [list[index]] 362 self.update_editor() 363 364 # -- Private Methods ------------------------------------------------------ 365 366 def _dispose_items(self): 367 """ Disposes of each current list item. 368 """ 369 layout = self._list_pane.layout() 370 child = layout.takeAt(0) 371 while child is not None: 372 control = child.widget() 373 if control is not None: 374 editor = getattr(control, "_editor", None) 375 if editor is not None: 376 editor.dispose() 377 editor.control = None 378 control.deleteLater() 379 child = layout.takeAt(0) 380 del child 381 382 # -- Trait initializers ---------------------------------------------------- 383 384 def _kind_default(self): 385 """ Returns a default value for the 'kind' trait. 386 """ 387 return self.factory.style + "_editor" 388 389 def _mutable_default(self): 390 """ Trait handler to set the mutable trait from the factory. 391 """ 392 return self.factory.mutable 393 394 def _scrollable_default(self): 395 return self.factory.scrollable 396 397class CustomEditor(SimpleEditor): 398 """ Custom style of editor for lists, which displays the items as a series 399 of text fields. If the list is editable, an icon next to each item displays 400 a menu of operations on the list. 401 """ 402 403 # ------------------------------------------------------------------------- 404 # Class constants: 405 # ------------------------------------------------------------------------- 406 407 #: Whether the list is displayed in a single row. This value overrides the 408 #: default. 409 single_row = False 410 411 412class TextEditor(CustomEditor): 413 414 #: The kind of editor to create for each list item. This value overrides the 415 #: default. 416 kind = "text_editor" 417 418 419class ReadonlyEditor(CustomEditor): 420 421 #: Is the list of items being edited mutable? This value overrides the 422 #: default. 423 mutable = False 424 425 426class NotebookEditor(Editor): 427 """ An editor for lists that displays the list as a "notebook" of tabbed 428 pages. 429 """ 430 431 #: The "Close Tab" button. 432 close_button = Any() 433 434 #: Maps tab names to QWidgets representing the tab contents 435 #: TODO: It would be nice to be able to reuse self._pages for this, but 436 #: its keys are not quite what we want. 437 _pagewidgets = Dict() 438 439 #: Maps names of tabs to their menu QAction instances; used to toggle 440 #: checkboxes 441 _action_dict = Dict() 442 443 # ------------------------------------------------------------------------- 444 # Trait definitions: 445 # ------------------------------------------------------------------------- 446 447 #: Is the notebook editor scrollable? This values overrides the default: 448 scrollable = True 449 450 #: The currently selected notebook page object: 451 selected = Any() 452 453 def init(self, parent): 454 """ Finishes initializing the editor by creating the underlying toolkit 455 widget. 456 """ 457 self._uis = [] 458 459 # Create a tab widget to hold each separate object's view: 460 self.control = QtGui.QTabWidget() 461 self.control.currentChanged.connect(self._tab_activated) 462 463 # minimal dock_style handling 464 if self.factory.dock_style == "tab": 465 self.control.setDocumentMode(True) 466 self.control.tabBar().setDocumentMode(True) 467 elif self.factory.dock_style == "vertical": 468 self.control.setTabPosition(QtGui.QTabWidget.West) 469 470 # Create the button to close tabs, if necessary: 471 if self.factory.deletable: 472 button = QtGui.QToolButton() 473 button.setAutoRaise(True) 474 button.setToolTip("Remove current tab ") 475 button.setIcon(ImageResource("closetab").create_icon()) 476 477 self.control.setCornerWidget(button, QtCore.Qt.TopRightCorner) 478 button.clicked.connect(self.close_current) 479 self.close_button = button 480 481 if self.factory.show_notebook_menu: 482 # Create the necessary attributes to manage hiding and revealing of 483 # tabs via a context menu 484 self._context_menu = QtGui.QMenu() 485 self.control.customContextMenuRequested.connect( 486 self._context_menu_requested 487 ) 488 self.control.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 489 490 # Set up the additional 'list items changed' event handler needed for 491 # a list based trait. Note that we want to fire the update_editor_item 492 # only when the items in the list change and not when intermediate 493 # traits change. Therefore, replace "." by ":" in the extended_name 494 # when setting up the listener. 495 extended_name = self.extended_name.replace(".", ":") 496 self.context_object.on_trait_change( 497 self.update_editor_item, extended_name + "_items?", dispatch="ui" 498 ) 499 500 # Set of selection synchronization: 501 self.sync_value(self.factory.selected, "selected") 502 503 def update_editor(self): 504 """ Updates the editor when the object trait changes externally to the 505 editor. 506 """ 507 # Destroy the views on each current notebook page: 508 self.close_all() 509 510 # Create a tab page for each object in the trait's value: 511 for object in self.value: 512 ui, view_object, monitoring = self._create_page(object) 513 514 # Remember the page for later deletion processing: 515 self._uis.append([ui.control, ui, view_object, monitoring]) 516 517 if self.selected: 518 self._selected_changed(self.selected) 519 520 def update_editor_item(self, event): 521 """ Handles an update to some subset of the trait's list. 522 """ 523 index = event.index 524 525 # Delete the page corresponding to each removed item: 526 page_name = self.factory.page_name[1:] 527 528 for i in event.removed: 529 page, ui, view_object, monitoring = self._uis[index] 530 if monitoring: 531 view_object.on_trait_change( 532 self.update_page_name, page_name, remove=True 533 ) 534 ui.dispose() 535 self.control.removeTab(self.control.indexOf(page)) 536 537 if self.factory.show_notebook_menu: 538 for name, tmp in self._pagewidgets.items(): 539 if tmp is page: 540 del self._pagewidgets[name] 541 self._context_menu.removeAction(self._action_dict[name]) 542 del self._action_dict[name] 543 544 del self._uis[index] 545 546 # Add a page for each added object: 547 first_page = None 548 for object in event.added: 549 ui, view_object, monitoring = self._create_page(object) 550 self._uis[index:index] = [ 551 [ui.control, ui, view_object, monitoring] 552 ] 553 index += 1 554 555 if first_page is None: 556 first_page = ui.control 557 558 if first_page is not None: 559 self.control.setCurrentWidget(first_page) 560 561 def close_current(self, force=False): 562 """ Closes the currently selected tab: 563 """ 564 widget = self.control.currentWidget() 565 for i in range(len(self._uis)): 566 page, ui, _, _ = self._uis[i] 567 if page is widget: 568 if force or ui.handler.close(ui.info, True): 569 del self.value[i] 570 break 571 572 if self.factory.show_notebook_menu: 573 # Find the name associated with this widget, so we can purge its action 574 # from the menu 575 for name, tmp in self._pagewidgets.items(): 576 if tmp is widget: 577 break 578 else: 579 # Hmm... couldn't find the widget, assume that we don't need to do 580 # anything. 581 return 582 583 action = self._action_dict[name] 584 self._context_menu.removeAction(action) 585 del self._action_dict[name] 586 del self._pagewidgets[name] 587 return 588 589 def close_all(self): 590 """ Closes all currently open notebook pages. 591 """ 592 page_name = self.factory.page_name[1:] 593 594 for _, ui, view_object, monitoring in self._uis: 595 if monitoring: 596 view_object.on_trait_change( 597 self.update_page_name, page_name, remove=True 598 ) 599 ui.dispose() 600 601 # Reset the list of ui's and dictionary of page name counts: 602 self._uis = [] 603 self._pages = {} 604 605 self.control.clear() 606 607 def dispose(self): 608 """ Disposes of the contents of an editor. 609 """ 610 self.context_object.on_trait_change( 611 self.update_editor_item, self.name + "_items?", remove=True 612 ) 613 self.close_all() 614 615 super(NotebookEditor, self).dispose() 616 617 def update_page_name(self, object, name, old, new): 618 """ Handles the trait defining a particular page's name being changed. 619 """ 620 for i, value in enumerate(self._uis): 621 page, ui, _, _ = value 622 if object is ui.info.object: 623 name = None 624 handler = getattr( 625 self.ui.handler, 626 "%s_%s_page_name" % (self.object_name, self.name), 627 None, 628 ) 629 630 if handler is not None: 631 name = handler(self.ui.info, object) 632 633 if name is None: 634 name = str( 635 xgetattr(object, self.factory.page_name[1:], "???") 636 ) 637 self.control.setTabText(self.control.indexOf(page), name) 638 break 639 640 def _create_page(self, object): 641 # Create the view for the object: 642 view_object = object 643 factory = self.factory 644 if factory.factory is not None: 645 view_object = factory.factory(object) 646 ui = view_object.edit_traits( 647 parent=self.control, view=factory.view, kind=factory.ui_kind 648 ).trait_set(parent=self.ui) 649 650 # Get the name of the page being added to the notebook: 651 name = "" 652 monitoring = False 653 prefix = "%s_%s_page_" % (self.object_name, self.name) 654 page_name = factory.page_name 655 if page_name[0:1] == ".": 656 name = xgetattr(view_object, page_name[1:], None) 657 monitoring = name is not None 658 if monitoring: 659 handler_name = None 660 method = getattr(self.ui.handler, prefix + "name", None) 661 if method is not None: 662 handler_name = method(self.ui.info, object) 663 if handler_name is not None: 664 name = handler_name 665 else: 666 name = str(name) or "???" 667 view_object.on_trait_change( 668 self.update_page_name, page_name[1:], dispatch="ui" 669 ) 670 else: 671 name = "" 672 elif page_name != "": 673 name = page_name 674 675 if name == "": 676 name = user_name_for(view_object.__class__.__name__) 677 678 # Make sure the name is not a duplicate: 679 if not monitoring: 680 self._pages[name] = count = self._pages.get(name, 0) + 1 681 if count > 1: 682 name += " %d" % count 683 684 # Return the control for the ui, and whether or not its name is being 685 # monitored: 686 image = None 687 method = getattr(self.ui.handler, prefix + "image", None) 688 if method is not None: 689 image = method(self.ui.info, object) 690 691 if image is None: 692 self.control.addTab(ui.control, name) 693 else: 694 self.control.addTab(ui.control, image, name) 695 696 if self.factory.show_notebook_menu: 697 newaction = self._context_menu.addAction(name) 698 newaction.setText(name) 699 newaction.setCheckable(True) 700 newaction.setChecked(True) 701 newaction.triggered.connect( 702 lambda e, name=name: self._menu_action(e, name=name) 703 ) 704 self._action_dict[name] = newaction 705 self._pagewidgets[name] = ui.control 706 707 return (ui, view_object, monitoring) 708 709 def _tab_activated(self, idx): 710 """ Handles a notebook tab being "activated" (i.e. clicked on) by the 711 user. 712 """ 713 widget = self.control.widget(idx) 714 for page, ui, _, _ in self._uis: 715 if page is widget: 716 self.selected = ui.info.object 717 break 718 719 def _selected_changed(self, selected): 720 """ Handles the **selected** trait being changed. 721 """ 722 for page, ui, _, _ in self._uis: 723 if ui.info and selected is ui.info.object: 724 self.control.setCurrentWidget(page) 725 break 726 deletable = self.factory.deletable 727 deletable_trait = self.factory.deletable_trait 728 if deletable and deletable_trait: 729 enabled = xgetattr(selected, deletable_trait, True) 730 self.close_button.setEnabled(enabled) 731 732 def _context_menu_requested(self, event): 733 self._context_menu.popup(self.control.mapToGlobal(event)) 734 735 def _menu_action(self, event, name=""): 736 """ Qt signal handler for when a item in a context menu is actually 737 selected. Not that we get this even after the underlying value has 738 already changed. 739 """ 740 action = self._action_dict[name] 741 checked = action.isChecked() 742 if not checked: 743 for ndx in range(self.control.count()): 744 if self.control.tabText(ndx) == name: 745 self.control.removeTab(ndx) 746 else: 747 # TODO: Fix tab order based on the context_object's list 748 self.control.addTab(self._pagewidgets[name], name) 749