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