source: orange/Orange/OrangeWidgets/Data/OWEditDomain.py @ 11096:cf7d2ae9d22b

Revision 11096:cf7d2ae9d22b, 21.8 KB checked in by Ales Erjavec <ales.erjavec@…>, 19 months ago (diff)

Added new svg icons for the widgets/categories.

RevLine 
[9320]1"""
2<name>Edit Domain</name>
3<description>Edit domain variables</description>
[11096]4<icon>icons/EditDomain.svg</icon>
[9320]5<contact>Ales Erjavec (ales.erjavec(@ at @)fri.uni-lj.si)</contact>
6<priority>3125</priority>
[9323]7<keywords>change,name,variable,domain</keywords>
[9320]8"""
9
10from OWWidget import *
11from OWItemModels import VariableListModel, PyListModel
12
13import OWGUI
14
15import Orange
16
17def is_discrete(var):
[10046]18    return isinstance(var, Orange.feature.Discrete)
[9320]19
20def is_continuous(var):
[10046]21    return isinstance(var, Orange.feature.Continuous)
[9320]22
23def get_qualified(module, name):
[10358]24    """Return a qualified module member ``name`` inside the named
[9323]25    ``module``.
26   
[10358]27    The module (or package) first gets imported and the name
[9323]28    is retrieved from the module's global namespace.
29     
30    """
[10358]31    # see __import__.__doc__ for why 'fromlist' is used
32    module = __import__(module, fromlist=[name])
[9320]33    return getattr(module, name)
34
35def variable_description(var):
[10358]36    """Return a variable descriptor.
[9323]37   
38    A descriptor is a hashable tuple which should uniquely define
39    the variable i.e. (module, type_name, variable_name,
[10358]40    any_kwargs, sorted-attributes-items).
[9323]41   
42    """
[9320]43    var_type = type(var)
44    if is_discrete(var):
45        return (var_type.__module__,
46                var_type.__name__,
47                var.name, 
48                (("values", tuple(var.values)),), 
49                tuple(sorted(var.attributes.items())))
50    else:
51        return (var_type.__module__,
52                var_type.__name__,
53                var.name, 
54                (), 
55                tuple(sorted(var.attributes.items())))
56
57def variable_from_description(description):
[10358]58    """Construct a variable from its description
[9323]59    (:ref:`variable_description`).
60   
61    """
[9320]62    module, type_name, name, kwargs, attrs = description
[10358]63    try:
64        type = get_qualified(module, type_name)
65    except (ImportError, AttributeError), ex:
66        raise ValueError("""Invalid descriptor type '{}.{}\
67        """.format(module, type_name))
68       
[9320]69    var = type(name, **dict(list(kwargs)))
70    var.attributes.update(attrs)
71    return var
72   
73from PyQt4 import QtCore, QtGui
74
75QtCore.Slot = QtCore.pyqtSlot
76QtCore.Signal = QtCore.pyqtSignal
77
78class PyStandardItem(QStandardItem):
79    def __lt__(self, other):
80        return id(self) < id(other)
81   
82class DictItemsModel(QStandardItemModel):
[10358]83    """A Qt Item Model class displaying the contents of a python
[9323]84    dictionary.
85   
86    """
87    # Implement a proper model with in-place editing.
88    # (Maybe it should be a TableModel with 2 columns)
[9320]89    def __init__(self, parent=None, dict={}):
90        QStandardItemModel.__init__(self, parent)
91        self.setHorizontalHeaderLabels(["Key", "Value"])
92        self.set_dict(dict)
93       
94    def set_dict(self, dict):
95        self._dict = dict
96        self.clear()
97        self.setHorizontalHeaderLabels(["Key", "Value"])
98        for key, value in sorted(dict.items()):
99            key_item = PyStandardItem(QString(key))
100            value_item = PyStandardItem(QString(value))
101            key_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
102            value_item.setFlags(value_item.flags() | Qt.ItemIsEditable)
103            self.appendRow([key_item, value_item])
104           
105    def get_dict(self):
106        dict = {}
107        for row in range(self.rowCount()):
108            key_item = self.item(row, 0)
109            value_item = self.item(row, 1)
110            dict[str(key_item.text())] = str(value_item.text())
111        return dict
112
113class VariableEditor(QWidget):
[10358]114    """An editor widget for a variable.
[9323]115   
116    Can edit the variable name, and its attributes dictionary.
117     
118    """
[9320]119    def __init__(self, parent=None):
120        QWidget.__init__(self, parent)
121        self.setup_gui()
122       
123    def setup_gui(self):
124        layout = QVBoxLayout()
125        self.setLayout(layout)
126       
127        self.main_form = QFormLayout()
128        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
129        layout.addLayout(self.main_form)
130       
131        self._setup_gui_name()
132        self._setup_gui_labels()
133       
134    def _setup_gui_name(self):
135        self.name_edit = QLineEdit()
136        self.main_form.addRow("Name", self.name_edit)
137        self.name_edit.editingFinished.connect(self.on_name_changed)
138       
139    def _setup_gui_labels(self):
[9323]140        vlayout = QVBoxLayout()
141        vlayout.setContentsMargins(0, 0, 0, 0)
142        vlayout.setSpacing(1)
143       
[9320]144        self.labels_edit = QTreeView()
[10726]145        self.labels_edit.setEditTriggers(QTreeView.CurrentChanged)
[9323]146        self.labels_edit.setRootIsDecorated(False)
147       
[9320]148        self.labels_model = DictItemsModel()
149        self.labels_edit.setModel(self.labels_model)
150       
[9323]151        self.labels_edit.selectionModel().selectionChanged.connect(\
152                                    self.on_label_selection_changed)
153       
154        # Necessary signals to know when the labels change
[9320]155        self.labels_model.dataChanged.connect(self.on_labels_changed)
[9323]156        self.labels_model.rowsInserted.connect(self.on_labels_changed)
157        self.labels_model.rowsRemoved.connect(self.on_labels_changed)
[9320]158       
[9323]159        vlayout.addWidget(self.labels_edit)
160        hlayout = QHBoxLayout()
161        hlayout.setContentsMargins(0, 0, 0, 0)
162        hlayout.setSpacing(1)
163        self.add_label_action = QAction("+", self,
164                        toolTip="Add a new label.",
165                        triggered=self.on_add_label,
166                        enabled=False,
167                        shortcut=QKeySequence(QKeySequence.New))
168
169        self.remove_label_action = QAction("-", self,
170                        toolTip="Remove selected label.",
171                        triggered=self.on_remove_label,
172                        enabled=False,
173                        shortcut=QKeySequence(QKeySequence.Delete))
[9325]174       
175        button_size = OWGUI.toolButtonSizeHint()
[9326]176        button_size = QSize(button_size, button_size)
[9325]177       
[9323]178        button = QToolButton(self)
[9325]179        button.setFixedSize(button_size)
[9323]180        button.setDefaultAction(self.add_label_action)
181        hlayout.addWidget(button)
182       
183        button = QToolButton(self)
[9325]184        button.setFixedSize(button_size)
[9323]185        button.setDefaultAction(self.remove_label_action)
186        hlayout.addWidget(button)
187        hlayout.addStretch(10)
188        vlayout.addLayout(hlayout)
189       
190        self.main_form.addRow("Labels", vlayout)
[9320]191       
192    def set_data(self, var):
[10358]193        """Set the variable to edit.
[9323]194        """
[9320]195        self.clear()
196        self.var = var
[9325]197       
[9320]198        if var is not None:
199            self.name_edit.setText(var.name)
200            self.labels_model.set_dict(dict(var.attributes))
[9323]201            self.add_label_action.setEnabled(True)
202        else:
203            self.add_label_action.setEnabled(False)
204            self.remove_label_action.setEnabled(False)
[9320]205           
206    def get_data(self):
[10358]207        """Retrieve the modified variable.
[9323]208        """
[9320]209        name = str(self.name_edit.text())
210        labels = self.labels_model.get_dict()
211       
212        # Is the variable actually changed.
213        if not self.is_same():
214            var = type(self.var)(name)
215            var.attributes.update(labels)
216            self.var = var
217        else:
218            var = self.var
219       
220        return var
221   
222    def is_same(self):
[10358]223        """Is the current model state the same as the input.
[9320]224        """
225        name = str(self.name_edit.text())
226        labels = self.labels_model.get_dict()
227       
228        return self.var and name == self.var.name and labels == self.var.attributes
229           
230    def clear(self):
[10358]231        """Clear the editor state.
[9323]232        """
[9320]233        self.var = None
234        self.name_edit.setText("")
235        self.labels_model.set_dict({})
236       
237    def maybe_commit(self):
238        if not self.is_same():
239            self.commit()
240           
241    def commit(self):
[10358]242        """Emit a ``variable_changed()`` signal.
[9323]243        """
[9320]244        self.emit(SIGNAL("variable_changed()"))
245       
246    @QtCore.Slot()
247    def on_name_changed(self):
248        self.maybe_commit()
249       
250    @QtCore.Slot()
[9323]251    def on_labels_changed(self, *args):
[9320]252        self.maybe_commit()
253       
[9323]254    @QtCore.Slot()
255    def on_add_label(self):
256        self.labels_model.appendRow([PyStandardItem(""), PyStandardItem("")])
257        row = self.labels_model.rowCount() - 1
258        index = self.labels_model.index(row, 0)
259        self.labels_edit.edit(index)
260       
261    @QtCore.Slot()
262    def on_remove_label(self):
263        rows = self.labels_edit.selectionModel().selectedRows()
264        if rows:
265            row = rows[0]
266            self.labels_model.removeRow(row.row())
267   
268    @QtCore.Slot()
269    def on_label_selection_changed(self):
270        selected = self.labels_edit.selectionModel().selectedRows()
271        self.remove_label_action.setEnabled(bool(len(selected)))
272       
[9320]273       
274class DiscreteVariableEditor(VariableEditor):
[10358]275    """An editor widget for editing a discrete variable.
[9323]276   
277    Extends the :class:`VariableEditor` to enable editing of
278    variables values.
279   
280    """
[9320]281    def setup_gui(self):
282        layout = QVBoxLayout()
283        self.setLayout(layout)
284       
285        self.main_form = QFormLayout()
286        self.main_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
287        layout.addLayout(self.main_form)
288       
289        self._setup_gui_name()
290        self._setup_gui_values()
291        self._setup_gui_labels()
292       
293    def _setup_gui_values(self):
294        self.values_edit = QListView()
[10726]295        self.values_edit.setEditTriggers(QTreeView.CurrentChanged)
[9320]296        self.values_model = PyListModel(flags=Qt.ItemIsSelectable | \
297                                        Qt.ItemIsEnabled | Qt.ItemIsEditable)
298        self.values_edit.setModel(self.values_model)
299       
300        self.values_model.dataChanged.connect(self.on_values_changed)
301        self.main_form.addRow("Values", self.values_edit)
302
303    def set_data(self, var):
[10358]304        """Set the variable to edit
[9323]305        """
[9320]306        VariableEditor.set_data(self, var)
307        self.values_model.wrap([])
308        if var is not None:
309            for v in var.values:
310                self.values_model.append(v)
311               
312    def get_data(self):
[10358]313        """Retrieve the modified variable
[9323]314        """
[9320]315        name = str(self.name_edit.text())
316        labels = self.labels_model.get_dict()
317        values = map(str, self.values_model)
318       
319        if not self.is_same():
320            var = type(self.var)(name, values=values)
321            var.attributes.update(labels)
322            self.var = var
323        else:
324            var = self.var
325           
326        return var
327           
328    def is_same(self):
[10358]329        """Is the current model state the same as the input.
[9320]330        """
331        values = map(str, self.values_model)
332        return VariableEditor.is_same(self) and self.var.values == values
333   
334    def clear(self):
[10358]335        """Clear the model state.
[9323]336        """
[9320]337        VariableEditor.clear(self)
338        self.values_model.wrap([])
339       
340    @QtCore.Slot()
341    def on_values_changed(self):
342        self.maybe_commit()
343       
344       
345class ContinuousVariableEditor(VariableEditor):
[9323]346    #TODO: enable editing of number_of_decimals, scientific format ...
347    pass 
[9320]348
349
350class OWEditDomain(OWWidget):
351    contextHandlers = {"": DomainContextHandler("", ["domain_change_hints", "selected_index"])}
[9323]352    settingsList = ["auto_commit"]
[9320]353   
354    def __init__(self, parent=None, signalManager=None, title="Edit Domain"):
355        OWWidget.__init__(self, parent, signalManager, title)
356       
[9546]357        self.inputs = [("Data", Orange.data.Table, self.set_data)]
358        self.outputs = [("Data", Orange.data.Table)]
[9320]359       
360        # Settings
361       
[9323]362        # Domain change hints maps from input variables description to
363        # the modified variables description as returned by
364        # `variable_description` function
[9320]365        self.domain_change_hints = {}
366        self.selected_index = 0
367        self.auto_commit = False
368        self.changed_flag = False
369       
370        self.loadSettings()
371       
372        #####
373        # GUI
374        #####
[9323]375   
376        # The list of domain's variables.
[9320]377        box = OWGUI.widgetBox(self.controlArea, "Domain Features")
378        self.domain_view = QListView()
379        self.domain_view.setSelectionMode(QListView.SingleSelection)
380       
381        self.domain_model = VariableListModel()
382       
383        self.domain_view.setModel(self.domain_model)
384       
385        self.connect(self.domain_view.selectionModel(),
386                     SIGNAL("selectionChanged(QItemSelection, QItemSelection)"),
387                     self.on_selection_changed)
388       
389        box.layout().addWidget(self.domain_view)
390       
[9323]391        # A stack for variable editor widgets.
[9320]392        box = OWGUI.widgetBox(self.mainArea, "Edit Feature")
393        self.editor_stack = QStackedWidget()
394        box.layout().addWidget(self.editor_stack)
395       
[9323]396       
[9320]397        box = OWGUI.widgetBox(self.controlArea, "Reset")
398       
399        OWGUI.button(box, self, "Reset selected",
400                     callback=self.reset_selected,
401                     tooltip="Reset changes made to the selected feature"
402                     )
403       
404        OWGUI.button(box, self, "Reset all",
405                     callback=self.reset_all,
406                     tooltip="Reset all changes made to the domain"
407                     )
408       
409        box = OWGUI.widgetBox(self.controlArea, "Commit")
410       
411        b = OWGUI.button(box, self, "&Commit",
412                         callback=self.commit,
413                         tooltip="Commit the data with the changed domain",
414                         )
415       
416        cb = OWGUI.checkBox(box, self, "auto_commit",
417                            label="Commit automatically",
418                            tooltip="Commit the changed domain on any change",
419                            callback=self.commit_if)
420       
421        OWGUI.setStopper(self, b, cb, "changed_flag",
422                         callback=self.commit)
423       
424        self._editor_cache = {}
425       
426        self.resize(600, 500)
427       
428    def clear(self):
[10358]429        """Clear the widget state.
[9323]430        """
[9320]431        self.data = None
432        self.domain_model[:] = []
433        self.domain_change_hints = {}
434        self.clear_editor()
435       
436    def clear_editor(self):
[10358]437        """Clear the current editor widget
[9323]438        """
[9320]439        current = self.editor_stack.currentWidget()
440        if current:
441            QObject.disconnect(current, SIGNAL("variable_changed()"),
442                               self.on_variable_changed)
443            current.set_data(None)
444       
445    def set_data(self, data=None):
446        self.closeContext("")
447        self.clear()
448        self.data = data
449        if data is not None:
450            input_domain = data.domain
451            all_vars = list(input_domain.variables) + \
452                       input_domain.getmetas().values()
453           
454            self.openContext("", data)
455           
456            edited_vars = []
[9323]457           
458            # Apply any saved transformations as listed in
459            # `domain_change_hints`
460             
[9320]461            for var in all_vars:
462                desc = variable_description(var)
463                changed = self.domain_change_hints.get(desc, None)
464                if changed is not None:
[10358]465                    try:
466                        new = variable_from_description(changed)
467                    except ValueError, ex:
468#                        print ex
469                        new = None
470                       
471                    if new is not None:
472                        # Make sure orange's domain transformations will work.
473                        new.source_variable = var
474                        new.get_value_from = Orange.core.ClassifierFromVar(whichVar=var)
475                        var = new
476                       
[9320]477                edited_vars.append(var)
478           
479            self.all_vars = all_vars
480            self.input_domain = input_domain
481           
[9323]482            # Sets the model to display in the 'Domain Features' view
[9320]483            self.domain_model[:] = edited_vars
484           
485            # Try to restore the variable selection
486            index = self.selected_index
487            if self.selected_index >= len(all_vars):
488                index = 0 if len(all_vars) else -1
489            if index >= 0:
490                self.select_variable(index)
491       
[9321]492            self.changed_flag = True
493            self.commit_if()
494        else:
495            # To force send None on output
496            self.commit()
[9320]497           
498    def on_selection_changed(self, *args):
[10358]499        """When selection in 'Domain Features' view changes.
[9323]500        """
[9320]501        i = self.selected_var_index()
502        if i is not None:
503            self.open_editor(i)
504            self.selected_index = i
505       
506    def selected_var_index(self):
[10358]507        """Return the selected row in 'Domain Features' view or None
[9323]508        if no row is selected.
509       
510        """
[9320]511        rows = self.domain_view.selectionModel().selectedRows()
512        if rows:
513            return rows[0].row()
514        else:
515            return None
516       
517    def select_variable(self, index):
[10358]518        """Select the variable with ``index`` in the 'Domain Features'
[9323]519        view.
520       
521        """
[9320]522        sel_model = self.domain_view.selectionModel()
523        sel_model.select(self.domain_model.index(index, 0),
524                         QItemSelectionModel.ClearAndSelect)
525       
526    def open_editor(self, index):
[10358]527        """Open the editor for variable at ``index`` and move it
[9323]528        to the top if the stack.
529       
530        """
[9320]531        # First remove and clear the current editor if any
532        self.clear_editor()
533           
534        var = self.domain_model[index]
535       
536        editor = self.editor_for_variable(var)
537        editor.set_data(var)
538        self.edited_variable_index = index
539       
540        QObject.connect(editor, SIGNAL("variable_changed()"),
541                        self.on_variable_changed)
542        self.editor_stack.setCurrentWidget(editor)
543   
544    def editor_for_variable(self, var):
[10358]545        """Return the editor for ``var``'s variable type.
[9323]546       
547        The editors are cached and reused by type.
548         
549        """
[9320]550        editor = None
551        if is_discrete(var):
552            editor = DiscreteVariableEditor
553        elif is_continuous(var):
554            editor = ContinuousVariableEditor
555        else:
556            editor = VariableEditor
557           
558        if type(var) not in self._editor_cache:
559            editor = editor()
560            self._editor_cache[type(var)] = editor
561            self.editor_stack.addWidget(editor)
562           
563        return self._editor_cache[type(var)]
564   
565    def on_variable_changed(self):
[10358]566        """When the user edited the current variable in editor.
[9323]567        """
[9320]568        var = self.domain_model[self.edited_variable_index]
569        editor = self.editor_stack.currentWidget()
570        new_var = editor.get_data()
571       
[9323]572        # Replace the variable in the 'Domain Features' view/model
[9320]573        self.domain_model[self.edited_variable_index] = new_var
574        old_var = self.all_vars[self.edited_variable_index]
575       
[9323]576        # Store the transformation hint.
[9320]577        self.domain_change_hints[variable_description(old_var)] = \
578                    variable_description(new_var)
[9323]579
580        # Make orange's domain transformation work.
[9320]581        new_var.source_variable = old_var
582        new_var.get_value_from = Orange.core.ClassifierFromVar(whichVar=old_var)
[9323]583       
[9320]584        self.commit_if()
585         
586    def reset_all(self):
[10358]587        """Reset all variables to the input state.
[9323]588        """
[9320]589        self.domain_change_hints = {}
590        if self.data is not None:
591            # To invalidate stored hints
592            self.closeContext("")
593            self.openContext("", self.data)
594            self.domain_model[:] = self.all_vars
595            self.select_variable(self.selected_index)
[9324]596            self.commit_if()
[9320]597           
598    def reset_selected(self):
[10358]599        """Reset the currently selected variable to its original
[9323]600        state.
601         
602        """
[9320]603        if self.data is not None:
604            var = self.all_vars[self.selected_index]
[9324]605            desc = variable_description(var)
606            if desc in self.domain_change_hints:
607                del self.domain_change_hints[desc]
608           
609            # To invalidate stored hints
610            self.closeContext("")
611            self.openContext("", self.data)
612           
[9320]613            self.domain_model[self.selected_index] = var
614            self.editor_stack.currentWidget().set_data(var)
[9324]615            self.commit_if()
[9320]616           
617    def commit_if(self):
618        if self.auto_commit:
619            self.commit()
620        else:
621            self.changed_flag = True
622       
623    def commit(self):
[10358]624        """Commit the changed data to output.
[9323]625        """
[9320]626        new_data = None
627        if self.data is not None:
628            new_vars = list(self.domain_model)
629            variables = new_vars[: len(self.input_domain.variables)]
630            class_var = None
631            if self.input_domain.class_var:
632                class_var = variables[-1]
633                variables = variables[:-1]
634           
635            new_metas = new_vars[len(self.input_domain.variables) :]
636            new_domain = Orange.data.Domain(variables, class_var)
637           
638            # Assumes getmetas().items() order has not changed.
[9323]639            # TODO: store metaids in set_data method
[9320]640            for (mid, _), new in zip(self.input_domain.getmetas().items(), 
641                                       new_metas):
642                new_domain.addmeta(mid, new)
643               
644            new_data = Orange.data.Table(new_domain, self.data)
645       
[9546]646        self.send("Data", new_data)
[9320]647        self.changed_flag = False
648           
649       
650def main():
651    import sys
652    app = QApplication(sys.argv)
653    w = OWEditDomain()
654    data = Orange.data.Table("iris")
655#    data = Orange.data.Table("rep:GDS636.tab")
656    w.set_data(data)
657    w.show()
658    rval = app.exec_()
659    w.set_data(None)
660    w.saveSettings()
661    return rval
662       
663if __name__ == "__main__":
664    import sys
665    sys.exit(main())
[10046]666   
Note: See TracBrowser for help on using the repository browser.