Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement sections for instances and plug-ins #21

Open
mottosso opened this issue May 13, 2016 · 49 comments
Open

Implement sections for instances and plug-ins #21

mottosso opened this issue May 13, 2016 · 49 comments

Comments

@mottosso
Copy link
Member

Goal

In Pyblish QML, groups of items have a handy section label. Implement similar functionality for Lite.

Implementation

In QML, the built-in ListView has a section attribute that does exactly this. In the case of Widgets, we'll need to implement this ourselves.

One way would be to add null items to the actual models, make them inactive and they would appear in the proper positions and faded.

At this point, this should be fine. But a more proper method is to use a proxy model and add these "virtual" items there. In that way, the model contains real data and proxies provide necessary auxiliary data, such as sections.

We'll need proxy models regardless, for sorting and filtering, so it could be good idea to keep things uniform and hack less.

@mkolar
Copy link
Member

mkolar commented Jun 1, 2016

This is currently the only feature that's stopping us from trying this in production. Without the categories we can't tell what we are publishing at all, so I'd humbly say that this is the key for making pyblish-lite usable. Most of other issues are icing, this is core functionality.

@mottosso
Copy link
Member Author

mottosso commented Jun 1, 2016

I'm having trouble figuring this one out.

I'd rather not add "virtual" items to the model as it breaks the model-view-controller nature of the program - where view-related data overlap into the model.

The view itself is what is supposed to handle this functionality, in the same way it adds scrollbars. Scrollbars depend on the model, but are only in the view where they belong. I'd expect sections to be there as well.

But, I haven't yet figured out how to do it..

This is the closest I found on the subject.

Help!

@mkolar
Copy link
Member

mkolar commented Jun 1, 2016

@konstep could you look at this when you find a minute? You have more experience with Qt, maybe you'll have some elegant solution ;)

@mottosso
Copy link
Member Author

mottosso commented Jun 5, 2016

@tokejepsen
Copy link
Member

Adding the sections as in a QListView seems to be complicated. Why not use a QTreeView instead?

@mottosso
Copy link
Member Author

mottosso commented Aug 9, 2016

It's not necessarily complicated, it's just different.

I'd rather not use a treeview, as it would mean locking the data in to just being usable in a tree view, yet the data is flat; not hierarchical. I.e. it would be a hack and harm the longevity and extensibility of the project.

@tokejepsen
Copy link
Member

not hierarchical.

I guess you mean the results data, and not the instances?

@mottosso
Copy link
Member Author

mottosso commented Aug 9, 2016

I mean the nature of plug-ins and instances not having parents and children. Especially not in terms of which family they belong to. Families are properties of each object, not parents.

It sounds like what you are suggesting is that we make families parents of plug-ins and instances, to "cheat" the look we've established in pyblish-qml by mutilating the data in order to use it with a view that doesn't represent our actual data.

I'd rather do it right for as long as possible, and save the hacks till last.

@mkolar
Copy link
Member

mkolar commented Aug 10, 2016

Maybe I'm wrong, but isn't it splitting hairs a little bit?

Seems to be like a choice between different flavors of hacks.

@BigRoy
Copy link
Member

BigRoy commented Aug 10, 2016

Wouldn't a proxy model that represents the data in a different way (grouped by) suffice for this use case? The original data remains the structure as used internally by pyblish, there's just a proxy model layer on top of that reorganizing it by category.

# pseudo
model = InstanceModel()
proxy = GroupByProxyModel()
proxy.setSourceModel(model)
proxy.setGroupBy("order")
view = QtGui.QTreeView()
view.setModel(proxy)

The original model remains unaltered.

Theoretically the proxy could even allow the artist to group by different things (e.g. family, failed/success, order, etc.)

@mottosso
Copy link
Member Author

Yes, a proxy model can achieve this, I'm just not sure how.

Maybe I'm wrong, but isn't it splitting hairs a little bit?

It's a bit pedantic, yes. I just don't hink its worth working around and I'd rather learn something new and do it right this time. I've hacked around similar things in similar ways in the past and it always ends up a big hairy mess. Today, pyblish-lite is hack-free and as ideal as I can think to make it. I'd like to keep the bugs out for longer and make better software.

@BigRoy
Copy link
Member

BigRoy commented Aug 11, 2016

Yes, a proxy model can achieve this, I'm just not sure how.

Googling reveals there are some implementations out there. Probably worth investigating those for a bit.

http://qadvanceditemviews.sourceforge.net/class_q_grouping_proxy_model.html
http://www.qtcentre.org/threads/33048-QAbstractProxyModel-for-Group-By-works-but-arrow-keys-don-t-!

So it seems there is a way to do it like that :)

@tokejepsen
Copy link
Member

Yes, a proxy model can achieve this, I'm just not sure how.

So just to be clear. You aren't disputing the QTreeView, just that we don't mutilate the data?

@mottosso
Copy link
Member Author

So just to be clear. You aren't disputing the QTreeView, just that we don't mutilate the data?

Yes, that is fine. I think either are equally challenging to get right, it's adding these "virtual" items via the proxy that provide the challenge. Then whether you draw them in a list- or tree-view is pure cosmetics.

@mkolar
Copy link
Member

mkolar commented Aug 16, 2016

I was looking at this today, but quite frankly it's way above my skillset :). What @BigRoy posted with the QAbstractProxyModel, looks very promising as far as I can tell.

@tokejepsen
Copy link
Member

http://qadvanceditemviews.sourceforge.net/class_q_grouping_proxy_model.html

@BigRoy QGroupingProxyModel doesn't seem to be available for PySide

@tokejepsen
Copy link
Member

What I don't get about QSortFilterProxyModel is when mapFromSource gets called?
Unless you use the function yourself in data I can't get it to trigger.

@BigRoy
Copy link
Member

BigRoy commented Aug 16, 2016

QGroupingProxyModel doesn't seem to be available for PySide

That's probably correct. I pointed to these to showcase that it is actually possible to implement, wasn't sure whether it was actually implemented in PySide thus far.

What I don't get about QSortFilterProxyModel is when mapFromSource gets called?

When are you expecting it to be called?
Also I'm expecting that to implement something like the grouping proxy model it might be easier to start off from QAbstractProxyModel. Though again, I'm not sure. :)

@tokejepsen
Copy link
Member

When are you expecting it to be called?

I don't know :) Bu the documentation says that you have to reimplement the function, so I was confused about when it gets called.

@tokejepsen
Copy link
Member

Here is a wip that at least has the right order, just needs to fetch the objects rather than just text data. Also I'm iterating over the objects continously to get the right order, which might be slow with lots of objects;

import sys
from PySide import QtCore, QtGui


Label = QtCore.Qt.DisplayRole
Section = QtCore.Qt.UserRole + 1
IsSection = QtCore.Qt.UserRole + 2


class Item(object):
    @classmethod
    def paint(cls, painter, option, index):
        rect = QtCore.QRectF(option.rect)

        painter.save()

        if option.state & QtGui.QStyle.State_MouseOver:
            painter.fillRect(rect, QtGui.QColor("#DEE"))

        if option.state & QtGui.QStyle.State_Selected:
            painter.fillRect(rect, QtGui.QColor("#CDD"))

        painter.drawText(rect.adjusted(20, 0, 0, 0),
                         index.data(Label))

        painter.restore()

    @classmethod
    def sizeHint(cls, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Section(object):
    @classmethod
    def paint(self, painter, option, index):
        painter.save()
        painter.setPen(QtGui.QPen(QtGui.QColor("#666")))
        painter.drawText(QtCore.QRectF(option.rect), index.data(Label))
        painter.restore()

    @classmethod
    def sizeHint(self, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Delegate(QtGui.QStyledItemDelegate):
    def paint(self, painter, option, index):
        if index.data(IsSection):
            return Section.paint(painter, option, index)
        else:
            return Item.paint(painter, option, index)

    def sizeHint(self, option, index):
        if index.data(IsSection):
            return Section.sizeHint(option, index)
        else:
            return Item.sizeHint(option, index)


class Model(QtCore.QAbstractListModel):
    def __init__(self, parent=None):
        super(Model, self).__init__(parent)
        self.items = list()

    def data(self, index, role=QtCore.Qt.DisplayRole):

        return self.items[index.row()]

    def append(self, item):
        self.beginInsertRows(QtCore.QModelIndex(),
                             self.rowCount(),
                             self.rowCount())

        self.items.append(item)
        self.endInsertRows()

    def rowCount(self, parent=None):
        return len(self.items)


class Proxy(QtGui.QSortFilterProxyModel):

    def __init__(self):
        super(Proxy, self).__init__()
        self.items = [[]]

    def data(self, index, role=QtCore.Qt.DisplayRole):

        return self.mapFromSource(index)

    def rowCount(self, parent):
        sections = 0

        prev = None
        for item in self.sourceModel().items:
            cur = item["section"]

            if cur != prev:
                sections += 1

            prev = cur

        # Note: This includes 1 additional, duplicate, section
        # for the bottom item. Ordering of items in model is important.
        return self.sourceModel().rowCount() + sections

    def index(self, row, column, parent):

        return self.createIndex(row, column, parent)

    def mapFromSource(self, index):

        prev = None
        items = []
        for item in self.sourceModel().items:
            cur = item["section"]

            if cur == prev:
                items.append(item["label"])
            else:
                items.append(item["section"])
                items.append(item["label"])

            prev = cur

        return items[index.row()]

    def mapToSource(self, index):

        return self.createIndex(index.row(),
                                index.column(),
                                QtCore.QModelIndex())

    def parent(self, index):
        return QtCore.QModelIndex()


app = QtGui.QApplication(sys.argv)
model = Model()

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"}]

for item in data:
    model.append(item)

proxy = Proxy()
proxy.setSourceModel(model)

delegate = Delegate()

view = QtGui.QTreeView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setItemDelegate(delegate)
view.show()

app.exec_()

@BigRoy
Copy link
Member

BigRoy commented Aug 16, 2016

Hmm, this doesn't look right to me.

For example:

    def mapToSource(self, index):

        return self.createIndex(index.row(),
                                index.column(),
                                QtCore.QModelIndex())

This basically says that the index (row, column) in this proxy corresponds one-to-one with that on the source model. Which sounds odd to me, because what you're basically doing in your proxy model is trying to shift/bump over indices down a row (or more) to make room for the headers. As such this seems to map incorrectly. Shouldn't this return an invalid index if it's a header, because a header is not present in the source and subtract row numbers based on what N-th grouping its under to correct it back to the source model. Basically reversing that shift of rows.

Similarly it feels odd that mapFromSource just returns the Item instead of a QModelIndex(). Since this means you're moving away from the default Model - View implementations of Qt I'm expecting this not to work as flawlessly as you might hope.

Or that data is now returning an Item, instead of the actual data based on a role sounds like it won't work.

Even though I like the short amount of code you have there, it looks a lot like an oversimplification that is just wrong. I'll have to note that this is hardly my expertise so I could be looking at this in the wrong way myself (hopefully). Because to me it felt more complicated than what is shown here.

Am I misunderstanding how you're going about this?

@mottosso
Copy link
Member Author

Okay, I think it's a good step forward. A little over the top there, @BigRoy, I don't think we're completely off the beaten path.

Just so we're seeing the same thing, here's what I'm looking at with the above code.

image

Which, when swapping the TreeView with a ListView, looks like this.

image

Which is probably more close to our end result, and clearly a step forward from the original.

image

So I would say, keep experimenting!

@BigRoy
Copy link
Member

BigRoy commented Aug 16, 2016

A little over the top there, @BigRoy, I don't think we're completely off the beaten path.

As I was typing I felt it came across over as "harsh" and this puts my comment in a similar perspective. So let me just quickly chip in and say that was far from my intent. The whole comment was intended to be an explanation of how it felt off to me, not that it was off given as fact. Maybe even pointing out that I was myself looking at this completely the wrong way. As such I was probably looking for some more guidance or explanations in a way for me, instead of the other way around. Not sure if that makes sense, but the whole goal was to open it up into the discussion.

So I would say, keep experimenting!

+1.


Based on Stackoverflow question:

Update 2:
How do I sort them into place, above their "child" items?

Sorting them into place would mean setting their "row" index as you create the indices from source in-between the row indices of the headers. Right?

@tokejepsen
Copy link
Member

tokejepsen commented Aug 17, 2016

Similarly it feels odd that mapFromSource just returns the Item instead of a QModelIndex(). Since this means you're moving away from the default Model - View implementations of Qt I'm expecting this not to work as flawlessly as you might hope.

I agree completely :) In fact as I said earlier, I don't know when mapFromSource even gets called as you could have all the code from mapFromSource in data and the code would still run.

Or that data is now returning an Item, instead of the actual data based on a role sounds like it won't work.

This was my next step. I couldn't visualize the code without simplifying it first. I actually started by just trying to get the indices for the data;

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"}]

prev = None
row = 0
origRow = 0
for item in data:
    cur = item["section"]

    if cur == prev:
        print "\t" + item["label"] + "(%s, %s)" % (row, origRow)
        row += 1
        origRow += 1
    else:
        print item["section"] + "(%s)" % row
        row += 1
        print "\t" + item["label"] + "(%s, %s)" % (row, origRow)
        row += 1
        origRow += 1

    prev = cur

@tokejepsen
Copy link
Member

Which, when swapping the TreeView with a ListView, looks like this.

Forgot I'd changed that :) What I was thinking was, that we could use a TreeView for collapsing unwanted sections. For example we could have the collectors section collapsed by default, which would unclutter the view for end users.

@mkolar
Copy link
Member

mkolar commented Aug 17, 2016

For example we could have the collectors section collapsed by default,

THIS. We'd love that

@tokejepsen
Copy link
Member

Alright, I think I have a working version of the example. Again I don't know how this will scale up with more data, since we are rebuilding the data structure when ever the item data is called.

import sys
from PySide import QtCore, QtGui


Label = QtCore.Qt.DisplayRole
Section = QtCore.Qt.UserRole + 1
IsSection = QtCore.Qt.UserRole + 2


class Item(object):
    @classmethod
    def paint(cls, painter, option, index):
        rect = QtCore.QRectF(option.rect)

        painter.save()

        if option.state & QtGui.QStyle.State_MouseOver:
            painter.fillRect(rect, QtGui.QColor("#DEE"))

        if option.state & QtGui.QStyle.State_Selected:
            painter.fillRect(rect, QtGui.QColor("#CDD"))

        painter.drawText(rect.adjusted(20, 0, 0, 0),
                         index.data(Label))

        painter.restore()

    @classmethod
    def sizeHint(cls, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Section(object):
    @classmethod
    def paint(self, painter, option, index):
        painter.save()
        painter.setPen(QtGui.QPen(QtGui.QColor("#666")))
        painter.drawText(QtCore.QRectF(option.rect), index.data(Label))
        painter.restore()

    @classmethod
    def sizeHint(self, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Delegate(QtGui.QStyledItemDelegate):
    def paint(self, painter, option, index):
        if index.data(IsSection):
            return Section.paint(painter, option, index)
        else:
            return Item.paint(painter, option, index)

    def sizeHint(self, option, index):
        if index.data(IsSection):
            return Section.sizeHint(option, index)
        else:
            return Item.sizeHint(option, index)


class Model(QtCore.QAbstractListModel):
    def __init__(self, parent=None):
        super(Model, self).__init__(parent)
        self.items = list()

    def data(self, index, role):
        item = self.items[index.row()]

        return {
            Label: item["label"],
            Section: item["section"],
            IsSection: False
        }.get(role)

    def append(self, item):
        self.beginInsertRows(QtCore.QModelIndex(),
                             self.rowCount(),
                             self.rowCount())

        self.items.append(item)
        self.endInsertRows()

    def rowCount(self, parent=None):
        return len(self.items)


class Proxy(QtGui.QSortFilterProxyModel):

    def __init__(self):
        super(Proxy, self).__init__()
        self.items = [[]]

    def data(self, index, role):

        prev_section = None
        items = []
        for count in range(0, self.sourceModel().rowCount()):
            item_index = self.sourceModel().createIndex(count, 0)
            cur_section = self.sourceModel().data(item_index, Section)

            if cur_section == prev_section:
                items.append(self.sourceModel().data(item_index, role))
            else:
                items.append({Label: cur_section,
                              Section: "Virtual Section",
                              IsSection: True}.get(role))
                items.append(self.sourceModel().data(item_index, role))

            prev_section = cur_section

        return items[index.row()]

    def rowCount(self, parent):
        sections = 0

        prev = None
        for item in self.sourceModel().items:
            cur = item["section"]

            if cur != prev:
                sections += 1

            prev = cur

        # Note: This includes 1 additional, duplicate, section
        # for the bottom item. Ordering of items in model is important.
        return self.sourceModel().rowCount() + sections

    def index(self, row, column, parent):

        return self.createIndex(row, column, parent)

    def mapToSource(self, index):

        return self.createIndex(index.row(),
                                index.column(),
                                QtCore.QModelIndex())

    def parent(self, index):
        return QtCore.QModelIndex()


app = QtGui.QApplication(sys.argv)
model = Model()

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"}]

for item in data:
    model.append(item)

proxy = Proxy()
proxy.setSourceModel(model)

delegate = Delegate()

view = QtGui.QListView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setItemDelegate(delegate)
view.show()

app.exec_()

@BigRoy
Copy link
Member

BigRoy commented Aug 17, 2016

Ah, nice to see that work. :)

Here's a version of how I thought the proxy model would've had to be implemented. Basically the Proxy has its own indices, so mapToSource and mapFromSource are mapping methods between the proxy's own indices and those in the source model. This is especially important in our Proxy case, because the proxy has indices not present in the source, plus that indices are shifted in rows.

This doesn't have special painter delegate, so it s not as pretty. But it's functional (it should be) with any flat list model that Qt offers, e.g. here it is used with QStringListModel. And you can group it by a specific "role" by setting the group role.

import sys
from PySide import QtCore, QtGui
from itertools import groupby
import logging

log = logging.getLogger(__name__)


class Proxy(QtGui.QAbstractProxyModel):
    """Proxy that groups by based on a specific role

    This assumes the source data is a flat list and not a tree.

    """

    def __init__(self):
        super(Proxy, self).__init__()
        self.indices = list()
        self.mapping = dict()  # source row to proxy row
        self.headers = dict()  # header proxy indices to group role data
        self.index_mapping = dict() # proxy index to source index
        self.group_role = QtCore.Qt.DisplayRole

    def set_group_role(self, role):
        self.group_role = role

    def rebuild(self):
        """Update mappings and sections upon source model changes"""

        # Clear previous information
        self.mapping.clear()
        self.headers.clear()
        self.index_mapping.clear()

        # Get indices from source model
        source = self.sourceModel()
        source_rows = source.rowCount()
        source_indices = [source.index(i, 0) for i in range(source_rows)]

        # Group by sort role
        key_getter = lambda source_index: source.data(source_index,
                                                      self.group_role)

        # Collect rows
        row = 0
        for section, group in groupby(source_indices, key=key_getter):
            self.headers[row] = section     # header label
            header_index = self.createIndex(row, 0, None)
            self.indices.append(header_index)
            row += 1

            for index in group:
                proxy_index = self.createIndex(row, 0, index)
                self.mapping[index] = row
                self.index_mapping[proxy_index.row()] = index
                self.indices.append(proxy_index)
                row += 1

    def data(self, index, role=QtCore.Qt.DisplayRole):

        if not index.isValid():
            return

        # Override data methods for section headers because they can't
        # return data from the source model.
        if self.is_header(index):
            row = index.row()
            section = self.headers.get(row, "fallback")
            if role == QtCore.Qt.DisplayRole:
                return "Section: {0}".format(section)

            if role == QtCore.Qt.FontRole:
                font = QtGui.QFont()
                font.setBold(True)
                font.setPointSize(11)
                return font

            return None

        else:

            source_index = self.index_mapping.get(index.row(), None)
            if source_index is None:
                log.warning("No index mapping for non-header. "
                            "This would be a bug for index: {0}".format(index))
                return QtCore.QModelIndex()

            if not source_index.isValid():
                return

            if source_index is index:
                return

            source = self.sourceModel()
            data = source.data(source_index, role)
            return data

    def is_header(self, index):
        """Return whether index is a header"""
        if index.row() in self.index_mapping:
            return False
        else:
            return True

    def mapFromSource(self, index):
        if index.row() not in self.mapping:
            return QtCore.QModelIndex()

        return self.createIndex(self.mapping[index], index.column())

    def mapToSource(self, index):
        if index.row() not in self.index_mapping:
            return QtCore.QModelIndex()
        return self.index_mapping[index.row()]

    def columnCount(self, parent=QtCore.QModelIndex()):
        return 1

    def rowCount(self, parent):
        count = len(self.indices) if not parent.isValid() else 0
        return count

    def index(self, row, column, parent):
        if parent.isValid():
            return QtCore.QModelIndex()

        return self.createIndex(row, column)

    def parent(self, index):
        return QtCore.QModelIndex()     # no parent ever


app = QtGui.QApplication(sys.argv)

model = QtGui.QStringListModel()
model.setStringList(["a", "b", "c", "c", "d", "e", "e"])

proxy = Proxy()
proxy.set_group_role(QtCore.Qt.DisplayRole)
proxy.setSourceModel(model)
proxy.rebuild()

view = QtGui.QListView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setAlternatingRowColors(True)
view.show()

app.exec_()

The code groups by the display role, so by the actual string in the QStringListModel. But other roles should work when present. Theoretically you could group a flat file system structure by a "file size" or "file extension" role.

This grouping by display role results in:

screenshot 2016-08-17 12 08 22

Again, this is just a quick experiment. Hope it helps. :)

I think it'll actually be easier to have the proxy remap it to an actual "Tree" so things like collapsing and functions (like toggling) on all children become easier and more sensical.

@tokejepsen
Copy link
Member

I think it'll actually be easier to have the proxy remap it to an actual "Tree" so things like collapsing and functions (like toggling) on all children become easier and more sensical.

I agree. Since you can flatten a tree to look like a view (http://stackoverflow.com/questions/21564976/how-to-create-a-proxy-model-that-would-flatten-nodes-of-a-qabstractitemmodel-int)

@tokejepsen
Copy link
Member

tokejepsen commented Aug 17, 2016

def rebuild(self):

@BigRoy wouldn't this method need to be called every time the source model is updated? In which case you would need to call it from the source model, or connect a slot if one exists for when the source model changes.

@BigRoy
Copy link
Member

BigRoy commented Aug 17, 2016

@BigRoy wouldn't this method need to be called every time the source model is updated? In which case you would need to call it from the source model, or connect a slot if one exists for when the source model changes.

Exactly. I noticed the sourceModelChanged signal on the QAbstractProxyModel (see here) yet I couldn't get it to work in PySide. Seems like it wasn't there. Didn't investigate much further in this first experiment. My intent was just to see if I could get something working.

Even better would be if it could be implemented in a way where only actually changed indices are updated instead of the whole proxy, that way it could be somewhat optimized. With the downside of the code being a bit more complex.

@BigRoy
Copy link
Member

BigRoy commented Aug 17, 2016

Also, some more experimenting. Here's a similar implementation that remaps the flat list to a treeview grouping:

import sys
from PySide import QtCore, QtGui
from itertools import groupby
import logging

logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
log = logging.getLogger(__name__)


class Node(object):
    def __init__(self):
        self._parent = None
        self._children = list()

    def parent(self):
        return self._parent

    def addChild(self, node):
        node._parent = self
        self._children.append(node)

    def rowCount(self):
        return len(self._children)

    def row(self):

        parent = self.parent()
        if not parent:
            return 0
        else:
            return self.parent().children().index(self)

    def columnCount(self):
        return 1

    def child(self, row):
        return self._children[row]

    def children(self):
        return self._children

    def data(self, role=QtCore.Qt.DisplayRole):
        return None


class ProxyItem(Node):
    def __init__(self, source_index):
        super(ProxyItem, self).__init__()
        self.source_index = source_index

    def data(self, role=QtCore.Qt.DisplayRole):
        #print self.source_index.data(role)
        return self.source_index.data(role)


class ProxySectionItem(Node):
    def __init__(self, label):
        super(ProxySectionItem, self).__init__()
        self.label = "{0}".format(label)

    def data(self, role=QtCore.Qt.DisplayRole):

        if role == QtCore.Qt.DisplayRole:
            return self.label

        elif role == QtCore.Qt.FontRole:
            font = QtGui.QFont()
            font.setBold(True)
            font.setPointSize(15)
            return font


class Proxy(QtGui.QAbstractProxyModel):
    """Proxy that groups by based on a specific role

    This assumes the source data is a flat list and not a tree.

    """

    def __init__(self):
        super(Proxy, self).__init__()
        self.root = Node()

        # TODO: I think this could even do without storing such mapping?
        self.to_source = dict()     # proxy index to source index
        self.from_source = dict()   # from source row to proxy index

        self.group_role = QtCore.Qt.DisplayRole

    def set_group_role(self, role):
        self.group_role = role

    def rebuild(self):
        """Update mappings and sections upon source model changes"""

        # Clear previous information
        self.to_source.clear()
        self.from_source.clear()

        self.root = Node()

        # Get indices from source model
        source = self.sourceModel()
        source_rows = source.rowCount()
        source_indices = [source.index(i, 0) for i in range(source_rows)]

        # Group by sort role
        key_getter = lambda source_index: source.data(source_index,
                                                      self.group_role)

        # Collect rows
        section_num = 0
        for section, group in groupby(source_indices, key=key_getter):

            # section
            section_item = ProxySectionItem(section)
            section_num += 1

            self.root.addChild(section_item)

            # items in section
            for i, index in enumerate(group):

                proxy_item = ProxyItem(index)
                section_item.addChild(proxy_item)

                # TODO: Check if we can do without this code
                proxy_index = self.createIndex(i, 0, proxy_item)
                self.to_source[proxy_index] = index
                self.from_source[index] = proxy_index

    def data(self, index, role=QtCore.Qt.DisplayRole):

        if not index.isValid():
            return

        node = index.internalPointer()

        if not node:
            return

        return node.data(role)

    def is_header(self, index):
        """Return whether index is a header"""
        if index in self.to_source:
            return False
        else:
            return True

    def mapFromSource(self, index):

        if index not in self.from_source:
            return QtCore.QModelIndex()

        return self.createIndex(self.from_source[index].row(), index.column())

    def mapToSource(self, index):

        if index not in self.to_source:
            return QtCore.QModelIndex()

        return self.to_source[index]

    def columnCount(self, parent=QtCore.QModelIndex()):
        return 1

    def rowCount(self, parent):

        if not parent.isValid():
            node = self.root
        else:
            node = parent.internalPointer()

        if not node:
            return 0

        return node.rowCount()

    def index(self, row, column, parent):

        if parent.isValid():
            parent_node = parent.internalPointer()
        else:
            parent_node = self.root

        item = parent_node.child(row)
        if item:
            return self.createIndex(row, column, item)
        else:
            return QtCore.QModelIndex()

    def parent(self, index):

        if not index.isValid():
            return QtCore.QModelIndex()

        node = index.internalPointer()
        if not node:
            return QtCore.QModelIndex()
        else:
            parent = node.parent()
            if not parent:
                return QtCore.QModelIndex()

            row = parent.row()
            return self.createIndex(row, 0, parent)


app = QtGui.QApplication(sys.argv)

model = QtGui.QStringListModel()
model.setStringList(["a", "b", "c", "c", "d", "e", "e", "ff", "ff", "ff",
                     "g", "a"])

proxy = Proxy()
proxy.set_group_role(QtCore.Qt.DisplayRole)
proxy.setSourceModel(model)
proxy.rebuild()

view = QtGui.QTreeView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setHeaderHidden(True)
view.setAlternatingRowColors(True)
view.show()

app.exec_()

I'm assuming this can be simplified some more, and especially cleaned up a lot. But at least it is working! :)

Here's a preview grouping:
treeview_proxy

@mottosso
Copy link
Member Author

Hey Roy, how come you moved away from the data we've modeled? I'm having trouble seeing how this can be applied to the actual data, since they are all single values (strings) and sections seem based on that rather than some property of an actual object.

If you can, it'd be great to apply your idea to this data, just so we're sure it will actually work with the real problem.

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"}]

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

Actually, the idea is that as long as the source model holds true to Qt's Model/View implementations this should work. It doesn't filter just on "random" values but retrieves the data by a data role from the source model using its real original index (QModelIndex). As such if our custom model works like it should this should just work one to one. The tricky bit was that the 'test model' that was there to begin with didn't seem to be actually a fully valid model in Qt. I noticed that was mostly just me messing up in my proxy, but I found it easier to think more abstract with the simpler data plus wanted to make sure it wasn't messing up just because we had custom data.

Anyway, here's a quick working version with the model that was used before. Note that this is the exact same Proxy as shown before, but now grouping by a different role; SectionRole.

import sys
from PySide import QtCore, QtGui


import sys
from PySide import QtCore, QtGui
from itertools import groupby
import logging

logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
log = logging.getLogger(__name__)


LabelRole = QtCore.Qt.DisplayRole
SectionRole = QtCore.Qt.UserRole + 1
IsSectionRole = QtCore.Qt.UserRole + 2


class Item(object):
    @classmethod
    def paint(cls, painter, option, index):
        rect = QtCore.QRectF(option.rect)

        painter.save()

        if option.state & QtGui.QStyle.State_MouseOver:
            painter.fillRect(rect, QtGui.QColor("#DEE"))

        if option.state & QtGui.QStyle.State_Selected:
            painter.fillRect(rect, QtGui.QColor("#CDD"))

        painter.drawText(rect.adjusted(20, 0, 0, 0),
                         index.data(LabelRole))

        painter.restore()

    @classmethod
    def sizeHint(cls, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Section(object):
    @classmethod
    def paint(self, painter, option, index):
        painter.save()
        painter.setPen(QtGui.QPen(QtGui.QColor("#666")))
        painter.drawText(QtCore.QRectF(option.rect), index.data(LabelRole))
        painter.restore()

    @classmethod
    def sizeHint(self, option, index):
        return QtCore.QSize(option.rect.width(), 20)


class Delegate(QtGui.QStyledItemDelegate):
    def paint(self, painter, option, index):
        if index.data(IsSectionRole):
            return SectionRole.paint(painter, option, index)
        else:
            return Item.paint(painter, option, index)

    def sizeHint(self, option, index):
        if index.data(IsSectionRole):
            return SectionRole.sizeHint(option, index)
        else:
            return Item.sizeHint(option, index)


class Model(QtCore.QAbstractListModel):
    def __init__(self, parent=None):
        super(Model, self).__init__(parent)
        self.items = list()

    def data(self, index, role):
        item = self.items[index.row()]

        return {
            LabelRole: item["label"],
            SectionRole: item["section"],
            IsSectionRole: False
        }.get(role)

    def append(self, item):
        self.beginInsertRows(QtCore.QModelIndex(),
                             self.rowCount(),
                             self.rowCount())

        self.items.append(item)
        self.endInsertRows()

    def rowCount(self, parent=None):
        return len(self.items)


class Node(object):
    def __init__(self):
        self._parent = None
        self._children = list()

    def parent(self):
        return self._parent

    def addChild(self, node):
        node._parent = self
        self._children.append(node)

    def rowCount(self):
        return len(self._children)

    def row(self):

        parent = self.parent()
        if not parent:
            return 0
        else:
            return self.parent().children().index(self)

    def columnCount(self):
        return 1

    def child(self, row):
        return self._children[row]

    def children(self):
        return self._children

    def data(self, role=QtCore.Qt.DisplayRole):
        return None


class ProxyItem(Node):
    def __init__(self, source_index):
        super(ProxyItem, self).__init__()
        self.source_index = source_index

    def data(self, role=QtCore.Qt.DisplayRole):
        #print self.source_index.data(role)
        return self.source_index.data(role)


class ProxySectionItem(Node):
    def __init__(self, label):
        super(ProxySectionItem, self).__init__()
        self.label = "{0}".format(label)

    def data(self, role=QtCore.Qt.DisplayRole):

        if role == QtCore.Qt.DisplayRole:
            return self.label

        elif role == QtCore.Qt.FontRole:
            font = QtGui.QFont()
            font.setBold(True)
            font.setPointSize(15)
            return font


class Proxy(QtGui.QAbstractProxyModel):
    """Proxy that groups by based on a specific role

    This assumes the source data is a flat list and not a tree.

    """

    def __init__(self):
        super(Proxy, self).__init__()
        self.root = Node()

        # TODO: I think this could even do without storing such mapping?
        self.to_source = dict()     # proxy index to source index
        self.from_source = dict()   # from source row to proxy index

        self.group_role = QtCore.Qt.DisplayRole

    def set_group_role(self, role):
        self.group_role = role

    def rebuild(self):
        """Update mappings and sections upon source model changes"""

        # Clear previous information
        self.to_source.clear()
        self.from_source.clear()

        self.root = Node()

        # Get indices from source model
        source = self.sourceModel()
        source_rows = source.rowCount()
        source_indices = [source.index(i, 0) for i in range(source_rows)]

        # Group by sort role
        key_getter = lambda source_index: source.data(source_index,
                                                      self.group_role)

        # Collect rows
        section_num = 0
        for section, group in groupby(source_indices, key=key_getter):

            # section
            section_item = ProxySectionItem(section)
            section_num += 1

            self.root.addChild(section_item)

            # items in section
            for i, index in enumerate(group):

                proxy_item = ProxyItem(index)
                section_item.addChild(proxy_item)

                # TODO: Check if we can do without this code
                proxy_index = self.createIndex(i, 0, proxy_item)
                self.to_source[proxy_index] = index
                self.from_source[index] = proxy_index

    def data(self, index, role=QtCore.Qt.DisplayRole):

        if not index.isValid():
            return

        node = index.internalPointer()

        if not node:
            return

        return node.data(role)

    def is_header(self, index):
        """Return whether index is a header"""
        if index in self.to_source:
            return False
        else:
            return True

    def mapFromSource(self, index):

        if index not in self.from_source:
            return QtCore.QModelIndex()

        return self.createIndex(self.from_source[index].row(), index.column())

    def mapToSource(self, index):

        if index not in self.to_source:
            return QtCore.QModelIndex()

        return self.to_source[index]

    def columnCount(self, parent=QtCore.QModelIndex()):
        return 1

    def rowCount(self, parent):

        if not parent.isValid():
            node = self.root
        else:
            node = parent.internalPointer()

        if not node:
            return 0

        return node.rowCount()

    def index(self, row, column, parent):

        if parent.isValid():
            parent_node = parent.internalPointer()
        else:
            parent_node = self.root

        item = parent_node.child(row)
        if item:
            return self.createIndex(row, column, item)
        else:
            return QtCore.QModelIndex()

    def parent(self, index):

        if not index.isValid():
            return QtCore.QModelIndex()

        node = index.internalPointer()
        if not node:
            return QtCore.QModelIndex()
        else:
            parent = node.parent()
            if not parent:
                return QtCore.QModelIndex()

            row = parent.row()
            return self.createIndex(row, 0, parent)


app = QtGui.QApplication(sys.argv)

model = Model()

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Beta06", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"},
        {"label": "Jack", "section": "Human"},
        {"label": "Stella", "section": "Human"}]

for item in data:
    model.append(item)

proxy = Proxy()
proxy.set_group_role(SectionRole)
proxy.setSourceModel(model)
proxy.rebuild()

view = QtGui.QTreeView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setHeaderHidden(True)
view.setAlternatingRowColors(True)
view.show()

app.exec_()

Preview:
treeview_dict_proxy

The group by implemention doesn't sort the data upfront and works like python's groupby as such the data should be sorted upfront to actually be grouped together. That's why you see a Human group twice.

Does that help?

Note that the delegate doesn't work with this proxy currently, but that could be fixed of course. Consider the Proxy some 'pseudocode' as I didn't really clean it up yet. 😄

@mottosso
Copy link
Member Author

I think that's great!

The group by implemention doesn't sort the data upfront

Which is the right thing to do! Remember that we're using this proxy with instances and plug-ins, and their original order is important.

That's why you see a Human group twice

That's what I'd expect. What I wasn't expecting is Steve showing up twice, until I looked at the data where he's appearing twice. It's in all of our examples, not sure how that happened!

So again, I think this looks great! I say let's plug it in to see what the costs are (performance, accuracy, maintainability etc).

@tokejepsen
Copy link
Member

Exactly. I noticed the sourceModelChanged signal on the QAbstractProxyModel (see here) yet I couldn't get it to work in PySide. Seems like it wasn't there.

Yeah, it definitely isn't in there, although the qt version PySide uses should support it. Maybe they haven't exposed it?
It could also possibly be that its one of modelAboutToBeReset or modelReset signals, but there is just zero documention about it; https://srinikom.github.io/pyside-docs/PySide/QtCore/QAbstractItemModel.html?highlight=qabstractitemmodel#PySide.QtCore.PySide.QtCore.QAbstractItemModel.modelAboutToBeReset

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

I say let's plug it in to see what the costs are (performance, accuracy, maintainability etc).

Before that I'd say it's important to look at:

  1. Updating the model at the right time (callbacks for the proxy to know when to "re-section"), like when new orders appeared or new indices are added. If the Model of instances has a callback that fits that description we could hook it up to the rebuild method on the Proxy.
  2. Ensuring the mapToSource and mapFromSource methods are operating as they should.
    • I believe they are now (latest version) not mapping exactly correct. The mapToSource should be trivial by taking the source_index attribute from the node.
    • Yet mapFromSource might be slightly harder since I don't think actually having a dict with the QModelIndex as key performs correctly. During testing I had the feeling the same indices as QModelIndex don't hash the same, as such aren't the same key in the dictionary.
  3. Clean up the code. :)

(About sourceModelChanged signal:) Yeah, it definitely isn't in there, although the qt version PySide uses should support it. Maybe they haven't exposed it?

As described in point 1 here it should work just as well if we know when its optimal to update for our model. Then at least we can connect the callback. Nevertheless if you want a generic Qt GroupProxy solution it could be interesting to investigate more into that.

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

Here's a cleaned up version of the proxy (this should mapFromSource and mapToSource correctly!)

import sys
from PySide import QtCore, QtGui
from itertools import groupby


LabelRole = QtCore.Qt.DisplayRole
SectionRole = QtCore.Qt.UserRole + 1
IsSectionRole = QtCore.Qt.UserRole + 2


class Model(QtCore.QAbstractListModel):
    """Simple Sample Model"""
    def __init__(self, parent=None):
        super(Model, self).__init__(parent)
        self.items = list()

    def data(self, index, role):
        item = self.items[index.row()]

        return {
            LabelRole: item["label"],
            SectionRole: item["section"],
            IsSectionRole: False
        }.get(role)

    def append(self, item):
        self.beginInsertRows(QtCore.QModelIndex(),
                             self.rowCount(),
                             self.rowCount())

        self.items.append(item)
        self.endInsertRows()

    def rowCount(self, parent=None):
        return len(self.items)


class Item(object):
    """Base class for an Item in the Group By Proxy"""
    def __init__(self):
        self._parent = None
        self._children = list()

    def parent(self):
        return self._parent

    def addChild(self, node):
        node._parent = self
        self._children.append(node)

    def rowCount(self):
        return len(self._children)

    def row(self):

        parent = self.parent()
        if not parent:
            return 0
        else:
            return self.parent().children().index(self)

    def columnCount(self):
        return 1

    def child(self, row):
        return self._children[row]

    def children(self):
        return self._children

    def data(self, role=QtCore.Qt.DisplayRole):
        return None


class ProxyItem(Item):
    def __init__(self, source_index):
        super(ProxyItem, self).__init__()
        self.source_index = source_index

    def data(self, role=QtCore.Qt.DisplayRole):
        return self.source_index.data(role)


class ProxySectionItem(Item):
    def __init__(self, label):
        super(ProxySectionItem, self).__init__()
        self.label = "{0}".format(label)

    def data(self, role=QtCore.Qt.DisplayRole):

        if role == QtCore.Qt.DisplayRole:
            return self.label

        elif role == QtCore.Qt.FontRole:
            font = QtGui.QFont()
            font.setPointSize(10)
            font.setWeight(900)
            return font

        elif role == QtCore.Qt.TextColorRole:
            return QtGui.QColor(50, 20, 20)

        elif role == QtCore.Qt.BackgroundColorRole:
            return QtGui.QColor(220, 220, 220)


class Proxy(QtGui.QAbstractProxyModel):
    """Proxy that groups by based on a specific role

    This assumes the source data is a flat list and not a tree.

    """

    def __init__(self):
        super(Proxy, self).__init__()
        self.root = Item()
        self.group_role = QtCore.Qt.DisplayRole

    def set_group_role(self, role):
        self.group_role = role

    def rebuild(self):
        """Update proxy sections and items

        This should be called after changes in the source model that require
        changes in this list (for example new indices, less indices or update
        sections)

        """

        # Start with new root node
        self.root = Item()

        # Get indices from source model
        source = self.sourceModel()
        source_rows = source.rowCount()
        source_indices = [source.index(i, 0) for i in range(source_rows)]

        def key_getter(source_index):
            """Return group role data for source index"""
            return source.data(source_index, self.group_role)

        for section, group in groupby(source_indices, key=key_getter):

            # section
            section_item = ProxySectionItem(section)
            self.root.addChild(section_item)

            #  items in section
            for i, index in enumerate(group):
                proxy_item = ProxyItem(index)
                section_item.addChild(proxy_item)

    def data(self, index, role=QtCore.Qt.DisplayRole):

        if not index.isValid():
            return

        node = index.internalPointer()

        if not node:
            return

        return node.data(role)

    def is_header(self, index):
        """Return whether index is a header"""
        if index in self.to_source:
            return False
        else:
            return True

    def mapFromSource(self, index):

        for section_item in self.root.children():
            for item in section_item.children():
                if item.source_index == index:
                    return self.createIndex(item.row(),
                                            index.column(),
                                            item)

        return QtCore.QModelIndex()

    def mapToSource(self, index):

        if not index.isValid():
            return QtCore.QModelIndex()

        node = index.internalPointer()
        if not node:
            return QtCore.QModelIndex()

        if not hasattr(node, "source_index"):
            return QtCore.QModelIndex()

        return node.source_index

    def columnCount(self, parent=QtCore.QModelIndex()):
        return 1

    def rowCount(self, parent):

        if not parent.isValid():
            node = self.root
        else:
            node = parent.internalPointer()

        if not node:
            return 0

        return node.rowCount()

    def index(self, row, column, parent):

        if parent and parent.isValid():
            parent_node = parent.internalPointer()
        else:
            parent_node = self.root

        item = parent_node.child(row)
        if item:
            return self.createIndex(row, column, item)
        else:
            return QtCore.QModelIndex()

    def parent(self, index):

        if not index.isValid():
            return QtCore.QModelIndex()

        node = index.internalPointer()
        if not node:
            return QtCore.QModelIndex()
        else:
            parent = node.parent()
            if not parent:
                return QtCore.QModelIndex()

            row = parent.row()
            return self.createIndex(row, 0, parent)


app = QtGui.QApplication(sys.argv)

model = Model()

data = [{"label": "Ben", "section": "Human"},
        {"label": "Steve", "section": "Human"},
        {"label": "Alpha12", "section": "Robot"},
        {"label": "Beta06", "section": "Robot"},
        {"label": "Mike", "section": "Toaster"},
        {"label": "Steve", "section": "Human"},
        {"label": "Jack", "section": "Human"},
        {"label": "Stella", "section": "Human"}]

for item in data:
    model.append(item)

proxy = Proxy()
proxy.set_group_role(SectionRole)
proxy.setSourceModel(model)
proxy.rebuild()

view = QtGui.QTreeView()
view.setWindowTitle("My View")
view.setModel(proxy)
view.setHeaderHidden(True)
view.setRootIsDecorated(False)
view.setIndentation(10)
view.setItemsExpandable(False)
view.expandAll()
view.show()

app.exec_()

This result:

treeview_dict_proxy2

At this point I think the bigger step is to know when to update the proxy model and hook it up to a callback. And basically "plugging it in"

@mottosso
Copy link
Member Author

Plug it in and try. Doo iiit! :D

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

Now I just need to remap it to Qt.py so it seems! The horror! Haha. Working on it!

@tokejepsen
Copy link
Member

Go Go @BigRoy :)

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

Not exactly what I was expecting as first result. :)

pyblish_proxy

I couldn't find the exact callbacks to connect to so I hooked it up to all the controller's signal for now, just to ensure it updates whenever.

Only left side has a proxy there. Just for testing purposes. Also because I couldn't find an "OrderRule" that I could retrieve using the data method for the right list.

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

For sake of reference (and little time to experiment, clean up and improve on this) I've pushed the state above into a branch on my fork so you can play around with it: https://github.com/BigRoy/pyblish-lite/tree/sections

Whenever I'll have some time on my hands I'll try to experiment some more of course. :)

Also you'll see some of the signals on the TreeView (currently in tree.py, even though I'm assuming it shouldn't end up there) that is as much as possible a copy of the list view will need some reimplementing to work on the actual tree view. E.g. you won't be able to toggle instances yet.

@BigRoy
Copy link
Member

BigRoy commented Aug 18, 2016

Here's a preview of the current state:

screenshot 2016-08-18 22 40 34

I bumped up the indentation (bit extreme) for debugging purposes to have a clear view of what is going on. This has some of the sections collapsed. Pretty experimental and not usable other than visually getting closer to what we are looking for.

Playing around with it a bit it's good to see the coloring (success/failure) seems to be working without changes to the proxy, also the middle-mouse click on items still show their info.

@mottosso
Copy link
Member Author

This looks great. For the family, have a look at how qml does it.

Other than that, I think it's a good time to make a it a PR and talk code and design.

@BigRoy
Copy link
Member

BigRoy commented Aug 19, 2016

For the family, have a look at how qml does it.

Basically I would like the source model to give me the necessary information through a data role. There doesn't seem to be a way to retrieve the family information, since all revolves around the families instead. Looking at QML it just seems to use the family data on the instance. This way the proxy doesn't secretly get additional information to that retrieved by the source model.

Other than that, I think it's a good time to make a it a PR and talk code and design.

Sure. Would be great if this could "be played with" even though it's hardly functioning like the list view version, because toggling/actions don't work yet. Also would love to see how much this still works with @tokejepsen 's work on Action icons, etc. The idea of course is to have these kind of things work with at least re-work done when swapping to a TreeView.

@mottosso
Copy link
Member Author

Pull-request please.

@BigRoy BigRoy mentioned this issue Aug 19, 2016
@BigRoy
Copy link
Member

BigRoy commented Aug 19, 2016

Pull-request please.

Done.

tokejepsen pushed a commit to tokejepsen/pyblish-lite that referenced this issue May 22, 2020
…after_processing

Feature/PYPE-413 buttons after processing
@hannesdelbeke
Copy link
Contributor

hannesdelbeke commented Jan 31, 2022

has this ever gone into lite? or been disabled afterwards?
edit: nvm PR close to done but never merged in #58

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants