source: sasview/src/sas/qtgui/Plotting/Plotter.py @ 4992ff2

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 4992ff2 was 4992ff2, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Initial, in-progress version. Not really working atm. SASVIEW-787

  • Property mode set to 100644
File size: 24.4 KB
Line 
1from PyQt5 import QtCore
2from PyQt5 import QtGui
3from PyQt5 import QtWidgets
4
5import functools
6import copy
7import matplotlib.pyplot as plt
8from matplotlib.font_manager import FontProperties
9from sas.qtgui.Plotting.PlotterData import Data1D
10from sas.qtgui.Plotting.PlotterBase import PlotterBase
11from sas.qtgui.Plotting.AddText import AddText
12from sas.qtgui.Plotting.SetGraphRange import SetGraphRange
13from sas.qtgui.Plotting.LinearFit import LinearFit
14from sas.qtgui.Plotting.PlotProperties import PlotProperties
15
16import sas.qtgui.Utilities.GuiUtils as GuiUtils
17import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
18
19class PlotterWidget(PlotterBase):
20    """
21    1D Plot widget for use with a QDialog
22    """
23    updatePlot = QtCore.pyqtSignal(tuple)
24    def __init__(self, parent=None, manager=None, quickplot=False):
25        super(PlotterWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
26
27        self.parent = parent
28
29        # Dictionary of {plot_id:Data1d}
30        self.plot_dict = {}
31
32        # Window for text add
33        self.addText = AddText(self)
34
35        # Log-ness of the axes
36        self.xLogLabel = "log10(x)"
37        self.yLogLabel = "log10(y)"
38
39        # Data container for the linear fit
40        self.fit_result = Data1D(x=[], y=[], dy=None)
41        self.fit_result.symbol = 13
42        self.fit_result.name = "Fit"
43
44        # Add a slot for receiving update signal from LinearFit
45        # NEW style signals
46        self.updatePlot.connect(self.onFitDisplay)
47        # OLD style signals
48        # QtCore.QObject.connect(self, QtCore.SIGNAL('updatePlot'), self.onFitDisplay)
49
50    @property
51    def data(self):
52        return self._data
53
54    @data.setter
55    def data(self, value):
56        """ data setter """
57        self._data = value
58        self.xLabel = "%s(%s)"%(value._xaxis, value._xunit)
59        self.yLabel = "%s(%s)"%(value._yaxis, value._yunit)
60        if value.isSesans:
61            self.xscale = 'linear'
62            self.yscale = 'linear'
63        self.title(title=value.name)
64
65    def plot(self, data=None, color=None, marker=None, hide_error=False):
66        """
67        Add a new plot of self._data to the chart.
68        """
69        # Data1D
70        if isinstance(data, Data1D):
71            self.data = data
72        assert(self._data)
73
74        is_fit = (self.data.id=="fit")
75
76        # Transform data if required.
77        # TODO: it properly!
78        #if data.xtransform is not None or data.ytransform is not None:
79        #    a, b, c, d = GuiUtils.xyTransform(self.data, self.data.xtransform, self.data.ytransform)
80
81        # Shortcuts
82        ax = self.ax
83        x = self._data.view.x
84        y = self._data.view.y
85
86        # Marker symbol. Passed marker is one of matplotlib.markers characters
87        # Alternatively, picked up from Data1D as an int index of PlotUtilities.SHAPES dict
88        if marker is None:
89            marker = self.data.symbol
90            # Try name first
91            try:
92                marker = PlotUtilities.SHAPES[marker]
93            except KeyError:
94                marker = list(PlotUtilities.SHAPES.values())[marker]
95
96        assert marker is not None
97        # Plot name
98        if self.data.title:
99            self.title(title=self.data.title)
100        else:
101            self.title(title=self.data.name)
102
103        # Error marker toggle
104        if hide_error is None:
105            hide_error = self.data.hide_error
106
107        # Plot color
108        if color is None:
109            color = self.data.custom_color
110
111        color = PlotUtilities.getValidColor(color)
112
113        markersize = self._data.markersize
114
115        # Draw non-standard markers
116        l_width = markersize * 0.4
117        if marker == '-' or marker == '--':
118            line = self.ax.plot(x, y, color=color, lw=l_width, marker='',
119                             linestyle=marker, label=self._title, zorder=10)[0]
120
121        elif marker == 'vline':
122            y_min = min(y)*9.0/10.0 if min(y) < 0 else 0.0
123            line = self.ax.vlines(x=x, ymin=y_min, ymax=y, color=color,
124                            linestyle='-', label=self._title, lw=l_width, zorder=1)
125
126        elif marker == 'step':
127            line = self.ax.step(x, y, color=color, marker='', linestyle='-',
128                                label=self._title, lw=l_width, zorder=1)[0]
129
130        else:
131            # plot data with/without errorbars
132            if hide_error:
133                line = ax.plot(x, y, marker=marker, color=color, markersize=markersize,
134                        linestyle='', label=self._title, picker=True)
135            else:
136                line = ax.errorbar(x, y,
137                            yerr=self._data.view.dy, xerr=None,
138                            capsize=2, linestyle='',
139                            barsabove=False,
140                            color=color,
141                            marker=marker,
142                            markersize=markersize,
143                            lolims=False, uplims=False,
144                            xlolims=False, xuplims=False,
145                            label=self._title,
146                            picker=True)
147
148        # Update the list of data sets (plots) in chart
149        self.plot_dict[self._data.id] = self.data
150
151        # Now add the legend with some customizations.
152
153        self.legend = ax.legend(loc='upper right', shadow=True)
154        if self.legend:
155            self.legend.set_picker(True)
156
157        # Current labels for axes
158        if self.y_label and not is_fit:
159            ax.set_ylabel(self.y_label)
160        if self.x_label and not is_fit:
161            ax.set_xlabel(self.x_label)
162
163        # Include scaling (log vs. linear)
164        ax.set_xscale(self.xscale)
165        ax.set_yscale(self.yscale)
166
167        # define the ranges
168        self.setRange = SetGraphRange(parent=self,
169            x_range=self.ax.get_xlim(), y_range=self.ax.get_ylim())
170
171        # refresh canvas
172        self.canvas.draw()
173
174    def createContextMenu(self):
175        """
176        Define common context menu and associated actions for the MPL widget
177        """
178        self.defaultContextMenu()
179
180        # Separate plots
181        self.addPlotsToContextMenu()
182
183        # Additional menu items
184        self.contextMenu.addSeparator()
185        self.actionAddText = self.contextMenu.addAction("Add Text")
186        self.actionRemoveText = self.contextMenu.addAction("Remove Text")
187        self.contextMenu.addSeparator()
188        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
189        self.contextMenu.addSeparator()
190        self.actionSetGraphRange = self.contextMenu.addAction("Set Graph Range")
191        self.actionResetGraphRange =\
192            self.contextMenu.addAction("Reset Graph Range")
193        # Add the title change for dialogs
194        #if self.parent:
195        self.contextMenu.addSeparator()
196        self.actionWindowTitle = self.contextMenu.addAction("Window Title")
197
198        # Define the callbacks
199        self.actionAddText.triggered.connect(self.onAddText)
200        self.actionRemoveText.triggered.connect(self.onRemoveText)
201        self.actionChangeScale.triggered.connect(self.onScaleChange)
202        self.actionSetGraphRange.triggered.connect(self.onSetGraphRange)
203        self.actionResetGraphRange.triggered.connect(self.onResetGraphRange)
204        self.actionWindowTitle.triggered.connect(self.onWindowsTitle)
205
206    def addPlotsToContextMenu(self):
207        """
208        Adds operations on all plotted sets of data to the context menu
209        """
210        for id in list(self.plot_dict.keys()):
211            plot = self.plot_dict[id]
212
213            name = plot.name if plot.name else plot.title
214            plot_menu = self.contextMenu.addMenu('&%s' % name)
215
216            self.actionDataInfo = plot_menu.addAction("&DataInfo")
217            self.actionDataInfo.triggered.connect(
218                                functools.partial(self.onDataInfo, plot))
219
220            self.actionSavePointsAsFile = plot_menu.addAction("&Save Points as a File")
221            self.actionSavePointsAsFile.triggered.connect(
222                                functools.partial(self.onSavePoints, plot))
223            plot_menu.addSeparator()
224
225            if plot.id != 'fit':
226                self.actionLinearFit = plot_menu.addAction('&Linear Fit')
227                self.actionLinearFit.triggered.connect(
228                                functools.partial(self.onLinearFit, id))
229                plot_menu.addSeparator()
230
231            self.actionRemovePlot = plot_menu.addAction("Remove")
232            self.actionRemovePlot.triggered.connect(
233                                functools.partial(self.onRemovePlot, id))
234
235            if not plot.is_data:
236                self.actionFreeze = plot_menu.addAction('&Freeze')
237                self.actionFreeze.triggered.connect(
238                                functools.partial(self.onFreeze, id))
239            plot_menu.addSeparator()
240
241            if plot.is_data:
242                self.actionHideError = plot_menu.addAction("Hide Error Bar")
243                if plot.dy is not None and plot.dy != []:
244                    if plot.hide_error:
245                        self.actionHideError.setText('Show Error Bar')
246                else:
247                    self.actionHideError.setEnabled(False)
248                self.actionHideError.triggered.connect(
249                                functools.partial(self.onToggleHideError, id))
250                plot_menu.addSeparator()
251
252            self.actionModifyPlot = plot_menu.addAction('&Modify Plot Property')
253            self.actionModifyPlot.triggered.connect(
254                                functools.partial(self.onModifyPlot, id))
255
256    def createContextMenuQuick(self):
257        """
258        Define context menu and associated actions for the quickplot MPL widget
259        """
260        # Default actions
261        self.defaultContextMenu()
262
263        # Additional actions
264        self.actionToggleGrid = self.contextMenu.addAction("Toggle Grid On/Off")
265        self.contextMenu.addSeparator()
266        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
267
268        # Define the callbacks
269        self.actionToggleGrid.triggered.connect(self.onGridToggle)
270        self.actionChangeScale.triggered.connect(self.onScaleChange)
271
272    def onScaleChange(self):
273        """
274        Show a dialog allowing axes rescaling
275        """
276        if self.properties.exec_() == QtWidgets.QDialog.Accepted:
277            self.xLogLabel, self.yLogLabel = self.properties.getValues()
278            self.xyTransform(self.xLogLabel, self.yLogLabel)
279
280    def onAddText(self):
281        """
282        Show a dialog allowing adding custom text to the chart
283        """
284        if self.addText.exec_() == QtWidgets.QDialog.Accepted:
285            # Retrieve the new text, its font and color
286            extra_text = self.addText.text()
287            extra_font = self.addText.font()
288            extra_color = self.addText.color()
289
290            # Place the text on the screen at (0,0)
291            pos_x = self.x_click
292            pos_y = self.y_click
293
294            # Map QFont onto MPL font
295            mpl_font = FontProperties()
296            mpl_font.set_size(int(extra_font.pointSize()))
297            mpl_font.set_family(str(extra_font.family()))
298            mpl_font.set_weight(int(extra_font.weight()))
299            # MPL style names
300            styles = ['normal', 'italic', 'oblique']
301            # QFont::Style maps directly onto the above
302            try:
303                mpl_font.set_style(styles[extra_font.style()])
304            except:
305                pass
306
307            if len(extra_text) > 0:
308                new_text = self.ax.text(str(pos_x),
309                                        str(pos_y),
310                                        extra_text,
311                                        color=extra_color,
312                                        fontproperties=mpl_font)
313                # Update the list of annotations
314                self.textList.append(new_text)
315                self.canvas.draw_idle()
316
317    def onRemoveText(self):
318        """
319        Remove the most recently added text
320        """
321        num_text = len(self.textList)
322        if num_text < 1:
323            return
324        txt = self.textList[num_text - 1]
325        text_remove = txt.get_text()
326        txt.remove()
327        self.textList.remove(txt)
328
329        self.canvas.draw_idle()
330
331    def onSetGraphRange(self):
332        """
333        Show a dialog allowing setting the chart ranges
334        """
335        # min and max of data
336        if self.setRange.exec_() == QtWidgets.QDialog.Accepted:
337            x_range = self.setRange.xrange()
338            y_range = self.setRange.yrange()
339            if x_range is not None and y_range is not None:
340                self.ax.set_xlim(x_range)
341                self.ax.set_ylim(y_range)
342                self.canvas.draw_idle()
343
344    def onResetGraphRange(self):
345        """
346        Resets the chart X and Y ranges to their original values
347        """
348        x_range = (self.data.x.min(), self.data.x.max())
349        y_range = (self.data.y.min(), self.data.y.max())
350        if x_range is not None and y_range is not None:
351            self.ax.set_xlim(x_range)
352            self.ax.set_ylim(y_range)
353            self.canvas.draw_idle()
354
355    def onLinearFit(self, id):
356        """
357        Creates and displays a simple linear fit for the selected plot
358        """
359        selected_plot = self.plot_dict[id]
360
361        maxrange = (min(selected_plot.x), max(selected_plot.x))
362        fitrange = self.ax.get_xlim()
363
364        fit_dialog = LinearFit(parent=self,
365                    data=selected_plot,
366                    max_range=maxrange,
367                    fit_range=fitrange,
368                    xlabel=self.xLogLabel,
369                    ylabel=self.yLogLabel)
370        if fit_dialog.exec_() == QtWidgets.QDialog.Accepted:
371            return
372
373    def replacePlot(self, id, new_plot):
374        """
375        Remove plot 'id' and add 'new_plot' to the chart.
376        This effectlvely refreshes the chart with changes to one of its plots
377        """
378        self.removePlot(id)
379        self.plot(data=new_plot)
380
381    def onRemovePlot(self, id):
382        """
383        Responds to the plot delete action
384        """
385        self.removePlot(id)
386
387        if len(self.plot_dict) == 0:
388            # last plot: graph is empty must be the panel must be destroyed
389                self.parent.close()
390
391    def removePlot(self, id):
392        """
393        Deletes the selected plot from the chart
394        """
395        if id not in list(self.plot_dict.keys()):
396            return
397
398        selected_plot = self.plot_dict[id]
399
400        plot_dict = copy.deepcopy(self.plot_dict)
401
402        # Labels might have been changed
403        xl = self.ax.xaxis.label.get_text()
404        yl = self.ax.yaxis.label.get_text()
405
406        self.plot_dict = {}
407
408        plt.cla()
409        self.ax.cla()
410
411        for ids in plot_dict:
412            if ids != id:
413                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)
414
415        # Reset the labels
416        self.ax.set_xlabel(xl)
417        self.ax.set_ylabel(yl)
418        self.canvas.draw()
419
420    def onFreeze(self, id):
421        """
422        Freezes the selected plot to a separate chart
423        """
424        plot = self.plot_dict[id]
425        self.manager.add_data(data_list=[plot])
426
427    def onModifyPlot(self, id):
428        """
429        Allows for MPL modifications to the selected plot
430        """
431        selected_plot = self.plot_dict[id]
432
433        # Old style color - single integer for enum color
434        # New style color - #hhhhhh
435        color = selected_plot.custom_color
436        # marker symbol and size
437        marker = selected_plot.symbol
438        marker_size = selected_plot.markersize
439        # plot name
440        legend = selected_plot.title
441
442        plotPropertiesWidget = PlotProperties(self,
443                                color=color,
444                                marker=marker,
445                                marker_size=marker_size,
446                                legend=legend)
447        if plotPropertiesWidget.exec_() == QtWidgets.QDialog.Accepted:
448            # Update Data1d
449            selected_plot.markersize = plotPropertiesWidget.markersize()
450            selected_plot.custom_color = plotPropertiesWidget.color()
451            selected_plot.symbol = plotPropertiesWidget.marker()
452            selected_plot.title = plotPropertiesWidget.legend()
453
454            # Redraw the plot
455            self.replacePlot(id, selected_plot)
456
457    def onToggleHideError(self, id):
458        """
459        Toggles hide error/show error menu item
460        """
461        selected_plot = self.plot_dict[id]
462        current = selected_plot.hide_error
463
464        # Flip the flag
465        selected_plot.hide_error = not current
466
467        plot_dict = copy.deepcopy(self.plot_dict)
468        self.plot_dict = {}
469
470        # Clean the canvas
471        plt.cla()
472        self.ax.cla()
473
474        # Recreate the plots but reverse the error flag for the current
475        for ids in plot_dict:
476            if ids == id:
477                self.plot(data=plot_dict[ids], hide_error=(not current))
478            else:
479                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)               
480
481    def xyTransform(self, xLabel="", yLabel=""):
482        """
483        Transforms x and y in View and set the scale
484        """
485        # Transform all the plots on the chart
486        for id in list(self.plot_dict.keys()):
487            current_plot = self.plot_dict[id]
488            if current_plot.id == "fit":
489                self.removePlot(id)
490                continue
491
492            new_xlabel, new_ylabel, xscale, yscale =\
493                GuiUtils.xyTransform(current_plot, xLabel, yLabel)
494            self.xscale = xscale
495            self.yscale = yscale
496
497            # Plot the updated chart
498            self.removePlot(id)
499
500            # This assignment will wrap the label in Latex "$"
501            self.xLabel = new_xlabel
502            self.yLabel = new_ylabel
503            # Directly overwrite the data to avoid label reassignment
504            self._data = current_plot
505            self.plot()
506
507        pass # debug hook
508
509    def onFitDisplay(self, fit_data):
510        """
511        Add a linear fitting line to the chart
512        """
513        # Create new data structure with fitting result
514        tempx = fit_data[0]
515        tempy = fit_data[1]
516        self.fit_result.x = []
517        self.fit_result.y = []
518        self.fit_result.x = tempx
519        self.fit_result.y = tempy
520        self.fit_result.dx = None
521        self.fit_result.dy = None
522
523        #Remove another Fit, if exists
524        self.removePlot("fit")
525
526        self.fit_result.reset_view()
527        #self.offset_graph()
528
529        # Set plot properties
530        self.fit_result.id = 'fit'
531        self.fit_result.title = 'Fit'
532        self.fit_result.name = 'Fit'
533
534        # Plot the line
535        self.plot(data=self.fit_result, marker='-', hide_error=True)
536
537    def onMplMouseDown(self, event):
538        """
539        Left button down and ready to drag
540        """
541        # Check that the LEFT button was pressed
542        if event.button != 1:
543            return
544
545        self.leftdown = True
546        for text in self.textList:
547            if text.contains(event)[0]: # If user has clicked on text
548                self.selectedText = text
549                return
550        if event.inaxes is None:
551            return
552        try:
553            self.x_click = float(event.xdata)  # / size_x
554            self.y_click = float(event.ydata)  # / size_y
555        except:
556            self.position = None
557
558    def onMplMouseUp(self, event):
559        """
560        Set the data coordinates of the click
561        """
562        self.x_click = event.xdata
563        self.y_click = event.ydata
564
565        # Check that the LEFT button was released
566        if event.button == 1:
567            self.leftdown = False
568            self.selectedText = None
569
570        #release the legend
571        if self.gotLegend == 1:
572            self.gotLegend = 0
573
574    def onMplMouseMotion(self, event):
575        """
576        Check if the left button is press and the mouse in moving.
577        Compute delta for x and y coordinates and then perform the drag
578        """
579        if self.gotLegend == 1 and self.leftdown:
580            self.onLegendMotion(event)
581            return
582
583        #if self.leftdown and self.selectedText is not None:
584        if not self.leftdown or self.selectedText is None:
585            return
586        # User has clicked on text and is dragging
587        if event.inaxes is None:
588            # User has dragged outside of axes
589            self.selectedText = None
590        else:
591            # Only move text if mouse is within axes
592            self.selectedText.set_position((event.xdata, event.ydata))
593            self.canvas.draw_idle()
594        return
595
596    def onMplPick(self, event):
597        """
598        On pick legend
599        """
600        legend = self.legend
601        if event.artist != legend:
602            return
603        # Get the box of the legend.
604        bbox = self.legend.get_window_extent()
605        # Get mouse coordinates at time of pick.
606        self.mouse_x = event.mouseevent.x
607        self.mouse_y = event.mouseevent.y
608        # Get legend coordinates at time of pick.
609        self.legend_x = bbox.xmin
610        self.legend_y = bbox.ymin
611        # Indicate we picked up the legend.
612        self.gotLegend = 1
613
614        #self.legend.legendPatch.set_alpha(0.5)
615
616    def onLegendMotion(self, event):
617        """
618        On legend in motion
619        """
620        ax = event.inaxes
621        if ax is None:
622            return
623        # Event occurred inside a plotting area
624        lo_x, hi_x = ax.get_xlim()
625        lo_y, hi_y = ax.get_ylim()
626        # How much the mouse moved.
627        x = mouse_diff_x = self.mouse_x - event.x
628        y = mouse_diff_y = self.mouse_y - event.y
629        # Put back inside
630        if x < lo_x:
631            x = lo_x
632        if x > hi_x:
633            x = hi_x
634        if y < lo_y:
635            y = lo_y
636        if y > hi_y:
637            y = hi_y
638        # Move the legend from its previous location by that same amount
639        loc_in_canvas = self.legend_x - mouse_diff_x, \
640                        self.legend_y - mouse_diff_y
641        # Transform into legend coordinate system
642        trans_axes = self.legend.parent.transAxes.inverted()
643        loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas)
644        self.legend_pos_loc = tuple(loc_in_norm_axes)
645        self.legend._loc = self.legend_pos_loc
646        # self.canvas.draw()
647        self.canvas.draw_idle()
648
649    def onMplWheel(self, event):
650        """
651        Process mouse wheel as zoom events
652        """
653        ax = event.inaxes
654        step = event.step
655
656        if ax is not None:
657            # Event occurred inside a plotting area
658            lo, hi = ax.get_xlim()
659            lo, hi = PlotUtilities.rescale(lo, hi, step,
660                              pt=event.xdata, scale=ax.get_xscale())
661            if not self.xscale == 'log' or lo > 0:
662                self._scale_xlo = lo
663                self._scale_xhi = hi
664                ax.set_xlim((lo, hi))
665
666            lo, hi = ax.get_ylim()
667            lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata,
668                              scale=ax.get_yscale())
669            if not self.yscale == 'log' or lo > 0:
670                self._scale_ylo = lo
671                self._scale_yhi = hi
672                ax.set_ylim((lo, hi))
673        else:
674            # Check if zoom happens in the axes
675            xdata, ydata = None, None
676            x, y = event.x, event.y
677
678            for ax in self.axes:
679                insidex, _ = ax.xaxis.contains(event)
680                if insidex:
681                    xdata, _ = ax.transAxes.inverted().transform_point((x, y))
682                insidey, _ = ax.yaxis.contains(event)
683                if insidey:
684                    _, ydata = ax.transAxes.inverted().transform_point((x, y))
685            if xdata is not None:
686                lo, hi = ax.get_xlim()
687                lo, hi = PlotUtilities.rescale(lo, hi, step,
688                                  bal=xdata, scale=ax.get_xscale())
689                if not self.xscale == 'log' or lo > 0:
690                    self._scale_xlo = lo
691                    self._scale_xhi = hi
692                    ax.set_xlim((lo, hi))
693            if ydata is not None:
694                lo, hi = ax.get_ylim()
695                lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata,
696                                  scale=ax.get_yscale())
697                if not self.yscale == 'log' or lo > 0:
698                    self._scale_ylo = lo
699                    self._scale_yhi = hi
700                    ax.set_ylim((lo, hi))
701        self.canvas.draw_idle()
702
703
704class Plotter(QtWidgets.QDialog, PlotterWidget):
705    def __init__(self, parent=None, quickplot=False):
706
707        QtWidgets.QDialog.__init__(self)
708        PlotterWidget.__init__(self, parent=self, manager=parent, quickplot=quickplot)
709        icon = QtGui.QIcon()
710        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
711        self.setWindowIcon(icon)
712
713
Note: See TracBrowser for help on using the repository browser.