source: orange/Orange/OrangeCanvas/canvas/editlinksdialog.py @ 11277:3cb2c6af3ced

Revision 11277:3cb2c6af3ced, 23.7 KB checked in by Ales Erjavec <ales.erjavec@…>, 15 months ago (diff)

Set initial shown links in link edit dialog when creating a new link.

Line 
1"""
2An Dialog to edit links between two nodes in the scheme.
3
4"""
5
6from collections import namedtuple
7
8from xml.sax.saxutils import escape
9
10from PyQt4.QtGui import (
11    QApplication, QDialog, QVBoxLayout, QDialogButtonBox, QGraphicsScene,
12    QGraphicsView, QGraphicsWidget, QGraphicsRectItem,
13    QGraphicsLineItem, QGraphicsTextItem, QGraphicsLayoutItem,
14    QGraphicsLinearLayout, QGraphicsGridLayout, QGraphicsPixmapItem,
15    QGraphicsDropShadowEffect, QSizePolicy, QPalette, QPen,
16    QPainter
17)
18
19from PyQt4.QtCore import (
20    Qt, QObject, QSize, QSizeF, QPointF, QRectF, qVersion
21)
22
23from PyQt4.QtCore import pyqtSignal as Signal
24
25from ..scheme import SchemeNode, SchemeLink, compatible_channels
26from ..registry import InputSignal, OutputSignal
27
28from ..resources import icon_loader
29
30
31QWIDGETSIZE_MAX = ((1 << 24) - 1)
32
33
34class EditLinksDialog(QDialog):
35    """A dialog for editing links.
36
37    >>> dlg = EditLinksDialog()
38    >>> dlg.setNodes(file_node, test_learners_node)
39    >>> dlg.setLinks([(file_node.output_channel("Data"),
40    ...               (test_learners_node.input_channel("Data")])
41    >>> if dlg.exec_() == EditLinksDialog.Accpeted:
42    ...     new_links = dlg.links()
43    ...
44
45    """
46    def __init__(self, *args, **kwargs):
47        QDialog.__init__(self, *args, **kwargs)
48
49        self.setModal(True)
50
51        self.__setupUi()
52
53    def __setupUi(self):
54        layout = QVBoxLayout()
55
56        # Scene with the link editor.
57        self.scene = LinksEditScene()
58        self.view = QGraphicsView(self.scene)
59        self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
60        self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
61        self.view.setRenderHint(QPainter.Antialiasing)
62
63        self.scene.editWidget.geometryChanged.connect(self.__onGeometryChanged)
64
65        # Ok/Cancel/Clear All buttons.
66        buttons = QDialogButtonBox(QDialogButtonBox.Ok |
67                                   QDialogButtonBox.Cancel |
68                                   QDialogButtonBox.Reset,
69                                   Qt.Horizontal)
70
71        clear_button = buttons.button(QDialogButtonBox.Reset)
72        clear_button.setText(self.tr("Clear All"))
73
74        buttons.accepted.connect(self.accept)
75        buttons.rejected.connect(self.reject)
76        clear_button.clicked.connect(self.scene.editWidget.clearLinks)
77
78        layout.addWidget(self.view)
79        layout.addWidget(buttons)
80
81        self.setLayout(layout)
82        layout.setSizeConstraint(QVBoxLayout.SetFixedSize)
83
84        self.setSizeGripEnabled(False)
85
86    def setNodes(self, source_node, sink_node):
87        """Set the source/sink nodes (`SchemeNode` instances)
88        between which to edit the links.
89
90        """
91        self.scene.editWidget.setNodes(source_node, sink_node)
92
93    def setLinks(self, links):
94        """Set a list of links to display between the source and sink
95        nodes. The `links` is a list of (`OutputSignal`, `InputSignal`)
96        instances where the first element refers to the source node
97        and the second to the sink node.
98
99        """
100        self.scene.editWidget.setLinks(links)
101
102    def links(self):
103        """Return the links between the source and sink node.
104        """
105        return self.scene.editWidget.links()
106
107    def __onGeometryChanged(self):
108        size = self.scene.editWidget.size()
109        left, top, right, bottom = self.getContentsMargins()
110        self.view.setFixedSize(size.toSize() + \
111                               QSize(left + right + 4, top + bottom + 4))
112
113
114def find_item_at(scene, pos, order=Qt.DescendingOrder, type=None,
115                 name=None):
116    """Find an object in a :class:`QGraphicsScene` `scene` at `pos`.
117    If `type` is not `None` the it must specify  the type of the item.
118    I `name` is not `None` it must be a name of the object
119    (`QObject.objectName()`).
120
121    """
122    items = scene.items(pos, Qt.IntersectsItemShape, order)
123    for item in items:
124        if type is not None and \
125                not isinstance(item, type):
126            continue
127
128        if name is not None and isinstance(item, QObject) and \
129                item.objectName() != name:
130            continue
131        return item
132    else:
133        return None
134
135
136class LinksEditScene(QGraphicsScene):
137    """A :class:`QGraphicsScene` used by the :class:`LinkEditWidget`.
138
139    """
140    def __init__(self, *args, **kwargs):
141        QGraphicsScene.__init__(self, *args, **kwargs)
142
143        self.editWidget = LinksEditWidget()
144        self.addItem(self.editWidget)
145
146    findItemAt = find_item_at
147
148
149_Link = namedtuple(
150    "_Link",
151    ["output",    # OutputSignal
152     "input",     # InputSignal
153     "lineItem",  # QGraphicsLineItem
154     ])
155
156
157class LinksEditWidget(QGraphicsWidget):
158    """A Graphics Widget for editing the links between two nodes.
159    """
160    def __init__(self, *args, **kwargs):
161        QGraphicsWidget.__init__(self, *args, **kwargs)
162        self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
163
164        self.source = None
165        self.sink = None
166
167        # QGraphicsWidget/Items in the scene.
168        self.sourceNodeWidget = None
169        self.sourceNodeTitle = None
170        self.sinkNodeWidget = None
171        self.sinkNodeTitle = None
172
173        self.__links = []
174
175        self.__textItems = []
176        self.__iconItems = []
177        self.__tmpLine = None
178        self.__dragStartItem = None
179
180        self.setLayout(QGraphicsLinearLayout(Qt.Vertical))
181        self.layout().setContentsMargins(0, 0, 0, 0)
182
183    def removeItems(self, items):
184        """Remove child items form the widget and scene.
185
186        """
187        scene = self.scene()
188        for item in items:
189            item.setParentItem(None)
190            if scene is not None:
191                scene.removeItem(item)
192
193    def clear(self):
194        """Clear the editor state (source and sink nodes, channels ...).
195
196        """
197        if self.layout().count():
198            widget = self.layout().takeAt(0).graphicsItem()
199            self.removeItems([widget])
200
201        self.source = None
202        self.sink = None
203
204    def setNodes(self, source, sink):
205        """Set the source/sink nodes (`SchemeNode` instances)
206        between which to edit the links.
207
208        """
209        self.clear()
210
211        self.source = source
212        self.sink = sink
213
214        self.__updateState()
215
216    def setLinks(self, links):
217        """Set a list of links to display between the source and sink
218        nodes. The `links` is a list of (`OutputSignal`, `InputSignal`)
219        instances where the first element refers to the source node
220        and the second to the sink node.
221
222        """
223        for output, input in links:
224            self.addLink(output, input)
225
226    def links(self):
227        """Return the links between the source and sink node.
228        """
229        return [(link.output, link.input) for link in self.__links]
230
231    def mousePressEvent(self, event):
232        if event.button() == Qt.LeftButton:
233            startItem = find_item_at(self.scene(), event.pos(),
234                                     type=ChannelAnchor)
235            if startItem is not None:
236                # Start a connection line drag.
237                self.__dragStartItem = startItem
238                self.__tmpLine = None
239                event.accept()
240                return
241
242            lineItem = find_item_at(self.scene(), event.scenePos(),
243                                    type=QGraphicsLineItem)
244            if lineItem is not None:
245                # Remove a connection under the mouse
246                for link in self.__links:
247                    if link.lineItem == lineItem:
248                        self.removeLink(link.output, link.input)
249                event.accept()
250                return
251
252        QGraphicsWidget.mousePressEvent(self, event)
253
254    def mouseMoveEvent(self, event):
255        if event.buttons() & Qt.LeftButton:
256
257            downPos = event.buttonDownPos(Qt.LeftButton)
258            if not self.__tmpLine and self.__dragStartItem and \
259                    (downPos - event.pos()).manhattanLength() > \
260                        QApplication.instance().startDragDistance():
261                # Start a line drag
262                line = QGraphicsLineItem(self)
263                start = self.__dragStartItem.boundingRect().center()
264                start = self.mapFromItem(self.__dragStartItem, start)
265                line.setLine(start.x(), start.y(),
266                             event.pos().x(), event.pos().y())
267
268                pen = QPen(Qt.green, 4)
269                pen.setCapStyle(Qt.RoundCap)
270                line.setPen(pen)
271                line.show()
272
273                self.__tmpLine = line
274
275            if self.__tmpLine:
276                # Update the temp line
277                line = self.__tmpLine.line()
278                line.setP2(event.pos())
279                self.__tmpLine.setLine(line)
280
281        QGraphicsWidget.mouseMoveEvent(self, event)
282
283    def mouseReleaseEvent(self, event):
284        if event.button() == Qt.LeftButton and self.__tmpLine:
285            endItem = find_item_at(self.scene(), event.scenePos(),
286                                     type=ChannelAnchor)
287
288            if endItem is not None:
289                startItem = self.__dragStartItem
290                startChannel = startItem.channel()
291                endChannel = endItem.channel()
292                possible = False
293                if type(startChannel) != type(endChannel):
294                    if isinstance(startChannel, InputSignal):
295                        startChannel, endChannel = endChannel, startChannel
296
297                    possible = compatible_channels(startChannel, endChannel)
298
299                if possible:
300                    self.addLink(startChannel, endChannel)
301
302            self.scene().removeItem(self.__tmpLine)
303            self.__tmpLine = None
304            self.__dragStartItem = None
305
306        QGraphicsWidget.mouseReleaseEvent(self, event)
307
308    def addLink(self, output, input):
309        if not compatible_channels(output, input):
310            return
311
312        if output not in self.source.output_channels():
313            raise ValueError("%r is not an output channel of %r" % \
314                             (output, self.source))
315
316        if input not in self.sink.input_channels():
317            raise ValueError("%r is not an input channel of %r" % \
318                             (input, self.sink))
319
320        if input.single:
321            # Remove existing link
322            for s1, s2, _ in self.__links:
323                if s2 == input:
324                    self.removeLink(s1, s2)
325                break
326
327        line = QGraphicsLineItem(self)
328
329        source_anchor = self.sourceNodeWidget.anchor(output)
330        sink_anchor = self.sinkNodeWidget.anchor(input)
331
332        source_pos = source_anchor.boundingRect().center()
333        source_pos = self.mapFromItem(source_anchor, source_pos)
334
335        sink_pos = sink_anchor.boundingRect().center()
336        sink_pos = self.mapFromItem(sink_anchor, sink_pos)
337        line.setLine(source_pos.x(), source_pos.y(),
338                     sink_pos.x(), sink_pos.y())
339        pen = QPen(Qt.green, 4)
340        pen.setCapStyle(Qt.RoundCap)
341        line.setPen(pen)
342
343        self.__links.append(_Link(output, input, line))
344
345    def removeLink(self, output, input):
346        """Remove a link between the source and sink channels.
347        """
348        for link in list(self.__links):
349            if link.output == output and link.input == input:
350                self.scene().removeItem(link.lineItem)
351                self.__links.remove(link)
352                break
353        else:
354            raise ValueError("No such link {!0.name} -> {!1.name}." \
355                             .format(output, input))
356
357    def clearLinks(self):
358        """Clear (remove) all the links.
359        """
360        for output, input, _ in list(self.__links):
361            self.removeLink(output, input)
362
363    def __updateState(self):
364        """Update the widget with the new source/sink node signal descriptions.
365
366        """
367        widget = QGraphicsWidget()
368        widget.setLayout(QGraphicsGridLayout())
369
370        # Space between left and right anchors
371        widget.layout().setHorizontalSpacing(50)
372
373        left_node = EditLinksNode(self, direction=Qt.LeftToRight,
374                                  node=self.source)
375
376        left_node.setSizePolicy(QSizePolicy.MinimumExpanding,
377                                QSizePolicy.MinimumExpanding)
378
379        right_node = EditLinksNode(self, direction=Qt.RightToLeft,
380                                   node=self.sink)
381
382        right_node.setSizePolicy(QSizePolicy.MinimumExpanding,
383                                 QSizePolicy.MinimumExpanding)
384
385        left_node.setMinimumWidth(150)
386        right_node.setMinimumWidth(150)
387
388        widget.layout().addItem(left_node, 0, 0,)
389        widget.layout().addItem(right_node, 0, 1,)
390
391        title_template = "<center><b>{0}<b></center>"
392
393        left_title = GraphicsTextWidget(self)
394        left_title.setHtml(title_template.format(escape(self.source.title)))
395        left_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
396
397        right_title = GraphicsTextWidget(self)
398        right_title.setHtml(title_template.format(escape(self.sink.title)))
399        right_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
400
401        widget.layout().addItem(left_title, 1, 0,
402                                alignment=Qt.AlignHCenter | Qt.AlignTop)
403        widget.layout().addItem(right_title, 1, 1,
404                                alignment=Qt.AlignHCenter | Qt.AlignTop)
405
406        widget.setParentItem(self)
407
408        max_w = max(left_node.sizeHint(Qt.PreferredSize).width(),
409                    right_node.sizeHint(Qt.PreferredSize).width())
410
411        # fix same size
412        left_node.setMinimumWidth(max_w)
413        right_node.setMinimumWidth(max_w)
414        left_title.setMinimumWidth(max_w)
415        right_title.setMinimumWidth(max_w)
416
417        self.layout().addItem(widget)
418        self.layout().activate()
419
420        self.sourceNodeWidget = left_node
421        self.sinkNodeWidget = right_node
422        self.sourceNodeTitle = left_title
423        self.sinkNodeTitle = right_title
424
425    if qVersion() < "4.7":
426        geometryChanged = Signal()
427
428        def setGeometry(self, rect):
429            QGraphicsWidget.setGeometry(self, rect)
430            self.geometryChanged.emit()
431
432
433class EditLinksNode(QGraphicsWidget):
434    """A Node with channel anchors.
435
436    `direction` specifies the layout (default `Qt.LeftToRight` will
437    have icon on the left and channels on the right).
438
439    """
440
441    def __init__(self, parent=None, direction=Qt.LeftToRight,
442                 node=None, icon=None, iconSize=None, **args):
443        QGraphicsWidget.__init__(self, parent, **args)
444        self.setAcceptedMouseButtons(Qt.NoButton)
445        self.__direction = direction
446
447        self.setLayout(QGraphicsLinearLayout(Qt.Horizontal))
448
449        # Set the maximum size, otherwise the layout can't grow beyond its
450        # sizeHint (and we need it to grow so the widget can grow and keep the
451        # contents centered vertically.
452        self.layout().setMaximumSize(QSizeF(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX))
453
454        self.setSizePolicy(QSizePolicy.MinimumExpanding,
455                           QSizePolicy.MinimumExpanding)
456
457        self.__iconSize = iconSize or QSize(64, 64)
458        self.__icon = icon
459
460        self.__iconItem = QGraphicsPixmapItem(self)
461        self.__iconLayoutItem = GraphicsItemLayoutItem(item=self.__iconItem)
462
463        self.__channelLayout = QGraphicsGridLayout()
464        self.__channelAnchors = []
465
466        if self.__direction == Qt.LeftToRight:
467            self.layout().addItem(self.__iconLayoutItem)
468            self.layout().addItem(self.__channelLayout)
469            channel_alignemnt = Qt.AlignRight
470
471        else:
472            self.layout().addItem(self.__channelLayout)
473            self.layout().addItem(self.__iconLayoutItem)
474            channel_alignemnt = Qt.AlignLeft
475
476        self.layout().setAlignment(self.__iconLayoutItem, Qt.AlignCenter)
477        self.layout().setAlignment(self.__channelLayout,
478                                   Qt.AlignVCenter | channel_alignemnt)
479
480        if node is not None:
481            self.setSchemeNode(node)
482
483    def setIconSize(self, size):
484        """Set the icon size for the node.
485        """
486        if size != self.__iconSize:
487            self.__iconSize = size
488            if self.__icon:
489                self.__iconItem.setPixmap(self.__icon.pixmap(size))
490                self.__iconLayoutItem.updateGeometry()
491
492    def iconSize(self):
493        return self.__iconSize
494
495    def setIcon(self, icon):
496        """Set the icon to display.
497        """
498        if icon != self.__icon:
499            self.__icon = icon
500            self.__iconItem.setPixmap(icon.pixmap(self.iconSize()))
501            self.__iconLayoutItem.updateGeometry()
502
503    def icon(self):
504        return self.__icon
505
506    def setSchemeNode(self, node):
507        """Set an instance of `SchemeNode`. The widget will be
508        initialized with its icon and channels.
509
510        """
511        self.node = node
512
513        if self.__direction == Qt.LeftToRight:
514            channels = node.output_channels()
515        else:
516            channels = node.input_channels()
517        self.channels = channels
518
519        loader = icon_loader.from_description(node.description)
520        icon = loader.get(node.description.icon)
521
522        self.setIcon(icon)
523
524        label_template = ('<div align="{align}">'
525                          '<b class="channelname">{name}</b><br/>'
526                          '<span class="typename">{typename}</span>'
527                          '</div>')
528
529        if self.__direction == Qt.LeftToRight:
530            align = "right"
531            label_alignment = Qt.AlignVCenter | Qt.AlignRight
532            anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft
533            label_row = 0
534            anchor_row = 1
535        else:
536            align = "left"
537            label_alignment = Qt.AlignVCenter | Qt.AlignLeft
538            anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft
539            label_row = 1
540            anchor_row = 0
541
542        self.__channelAnchors = []
543        grid = self.__channelLayout
544
545        for i, channel in enumerate(channels):
546            text = label_template.format(align=align,
547                                         name=escape(channel.name),
548                                         typename=escape(channel.type))
549
550            text_item = GraphicsTextWidget(self)
551            text_item.setHtml(text)
552            text_item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
553
554            grid.addItem(text_item, i, label_row,
555                         alignment=label_alignment)
556
557            anchor = ChannelAnchor(self, channel=channel,
558                                   rect=QRectF(0, 0, 20, 20))
559
560            anchor.setBrush(self.palette().brush(QPalette.Mid))
561
562            layout_item = GraphicsItemLayoutItem(grid, item=anchor)
563            grid.addItem(layout_item, i, anchor_row,
564                         alignment=anchor_alignment)
565
566            if hasattr(channel, "description"):
567                text_item.setToolTip((channel.description))
568
569            self.__channelAnchors.append(anchor)
570
571    def anchor(self, channel):
572        """Return the anchor item for the `channel` name.
573
574        """
575        for anchor in self.__channelAnchors:
576            if anchor.channel() == channel:
577                return anchor
578
579        raise ValueError(channel.name)
580
581    def paint(self, painter, option, widget=None):
582        painter.save()
583        palette = self.palette()
584        border = palette.brush(QPalette.Mid)
585        pen = QPen(border, 1)
586        pen.setCosmetic(True)
587        painter.setPen(pen)
588        painter.setBrush(palette.brush(QPalette.Window))
589        brect = self.boundingRect()
590        painter.drawRoundedRect(brect, 4, 4)
591        painter.restore()
592
593
594class GraphicsItemLayoutItem(QGraphicsLayoutItem):
595    """A graphics layout that handles the position of a general QGraphicsItem
596    in a QGraphicsLayout. The items boundingRect is used as this items fixed
597    sizeHint and the item is positioned at the top left corner of the this
598    items geometry.
599
600    """
601
602    def __init__(self, parent=None, item=None, ):
603        self.__item = None
604
605        QGraphicsLayoutItem.__init__(self, parent, isLayout=False)
606
607        self.setOwnedByLayout(True)
608        self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
609
610        if item is not None:
611            self.setItem(item)
612
613    def setItem(self, item):
614        self.__item = item
615        self.setGraphicsItem(item)
616
617    def setGeometry(self, rect):
618        # TODO: specifiy if the geometry should be set relative to the
619        # bounding rect top left corner
620        if self.__item:
621            self.__item.setPos(rect.topLeft())
622
623        QGraphicsLayoutItem.setGeometry(self, rect)
624
625    def sizeHint(self, which, constraint):
626        if self.__item:
627            return self.__item.boundingRect().size()
628        else:
629            return QGraphicsLayoutItem.sizeHint(self, which, constraint)
630
631
632class ChannelAnchor(QGraphicsRectItem):
633    def __init__(self, parent=None, channel=None, rect=None, **kwargs):
634        QGraphicsRectItem.__init__(self, **kwargs)
635        self.setAcceptHoverEvents(True)
636        self.setAcceptedMouseButtons(Qt.NoButton)
637        self.__channel = None
638
639        if rect is None:
640            rect = QRectF(0, 0, 20, 20)
641
642        self.setRect(rect)
643
644        if channel:
645            self.setChannel(channel)
646
647        self.__shadow = QGraphicsDropShadowEffect(blurRadius=5,
648                                                  offset=QPointF(0, 0))
649        self.setGraphicsEffect(self.__shadow)
650        self.__shadow.setEnabled(False)
651
652    def setChannel(self, channel):
653        if channel != self.__channel:
654            self.__channel = channel
655            if hasattr(channel, "description"):
656                self.setToolTip(channel.description)
657            # TODO: Should also include name, type, flags, dynamic in the
658            #       tool tip as well as add visual clues to the anchor
659
660    def channel(self):
661        return self.__channel
662
663    def hoverEnterEvent(self, event):
664        self.__shadow.setEnabled(True)
665        QGraphicsRectItem.hoverEnterEvent(self, event)
666
667    def hoverLeaveEvent(self, event):
668        self.__shadow.setEnabled(False)
669        QGraphicsRectItem.hoverLeaveEvent(self, event)
670
671
672class GraphicsTextWidget(QGraphicsWidget):
673    """A QGraphicsWidget subclass that manages a QGraphicsTextItem
674
675    """
676
677    def __init__(self, parent=None, textItem=None):
678        QGraphicsLayoutItem.__init__(self, parent)
679        if textItem is None:
680            textItem = QGraphicsTextItem()
681
682        self.__textItem = textItem
683        self.__textItem.setParentItem(self)
684        self.__textItem.setPos(0, 0)
685
686        doc_layout = self.document().documentLayout()
687        doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged)
688
689    def sizeHint(self, which, constraint=QSizeF()):
690        # TODO: More sensible size hints.
691        # If the text is a plain text or html
692        # Check how QLabel.sizeHint works.
693
694        if which == Qt.PreferredSize:
695            return self.__textItem.boundingRect().size()
696        else:
697            return QGraphicsWidget.sizeHint(self, which, constraint)
698
699    def setGeometry(self, rect):
700        QGraphicsWidget.setGeometry(self, rect)
701        self.__textItem.setTextWidth(rect.width())
702
703    def setPlainText(self, text):
704        self.__textItem.setPlainText(text)
705        self.updateGeometry()
706
707    def setHtml(self, text):
708        self.__textItem.setHtml(text)
709
710    def adjustSize(self):
711        self.__textItem.adjustSize()
712        self.updateGeometry()
713
714    def setDefaultTextColor(self, color):
715        self.__textItem.setDefaultTextColor(color)
716
717    def document(self):
718        return self.__textItem.document()
719
720    def setDocument(self, doc):
721        doc_layout = self.document().documentLayout()
722        doc_layout.documentSizeChanged.disconnect(self._onDocumentSizeChanged)
723
724        self.__textItem.setDocument(doc)
725
726        doc_layout = self.document().documentLayout()
727        doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged)
728
729        self.updateGeometry()
730
731    def _onDocumentSizeChanged(self, size):
732        """The doc size has changed"""
733        self.updateGeometry()
Note: See TracBrowser for help on using the repository browser.