getavalon / core

The safe post-production pipeline - https://getavalon.github.io/2.0
MIT License
214 stars 48 forks source link

Refactor tools/ #436

Open mottosso opened 4 years ago

mottosso commented 4 years ago

Goal

Reduce duplication, simplify implementations of tools.

Motivation

Our "tools" are GUIs written in Qt, and GUIs are a hard problem to solve. There is no PEP 008 for writing graphical applications, and the result is a lack of consistency and foresight.

Without appropriate answers to these questions, extending or editing a graphical application is a problem made even harder.

Implementation

Let's see if we can run through these and establish a foundation upon which to build GUIs effectively. I'll write these as unambious as I can, but remember these are guidelines and though some are tried and true, some are not (I'll tag these appropriately).

Architecture

You generally won't need to divide an application into model, view and controller unless it is of a certain size. But as this is an article on consistency, let's move the goal posts to say "No application is small enough to not require MVC".

With MVC as our baseline, here's how to implement it.

Module Description
view.py The paintbrush. The "main" of our Window, the cortex of where all widgets come together. This is responsible for drawing to the screen and for converting user interaction to interactions with the "controller"
control.py The brain. The "main" of our program, independent of a display, independent of the data that it operates on.
model.py The memory. The container of data.

These three are all that is required to implement an application. Additional modules are for convenience and organisation only, such as..

Module Description
widgets.py Independent widgets too large to fit in view.py, yet specific to an application (i.e. not shared with anything else)
util.py Like widgets, but non-graphical. Standalone timers, text processing, threading or delay functions.
delegates.py If relevant; these create a dependency between model and view, which typically only communicate through the controller.

Open Questions

Structure I - View

This involves the def __init__() in view.py.

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)
        self.setWindowTitle(self.title)
        self.setWindowIcon(QtGui.QIcon(...))
        self.setAttribute(QtCore.Qt.WA_StyledBackground)

        pages = {
            "home": QtWidgets.QWidget(),
        }

        panels = {
            "header": QtWidgets.QWidget(),
            "body": QtWidgets.QWidget(),
            "footer": QtWidgets.QWidget(),
            "sidebar": QtWidgets.QWidget(),
        }

        widgets = {
            "pages": QtWidgets.QStackedWidget(),
            "logo": QtWidgets.QWidget(),
            "okBtn": QtWidgets.QPushButton("Ok"),
            "cancelBtn": QtWidgets.QPushButton("Cancel"),
            "resetBtn": QtWidgets.QPushButton("Reset"),
        }

        icons = {
            "cancelBtn": resources.icon("cancelBtn"),
        }

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

        for name, widget in chain(panels.items(),
                                  widgets.items(),
                                  pages.items()):
            # Expose to CSS
            widget.setObjectName(name)

            # Support for CSS
            widget.setAttribute(QtCore.Qt.WA_StyledBackground)

        self.setCentralWidget(widgets["pages"])
        widgets["pages"].addWidget(pages["home"])

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

        layout = QtWidgets.QHBoxLayout(panels["header"])
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setMargin(0)
        layout.addWidget(widgets["logo"])

        layout = QtWidgets.QHBoxLayout(panels["body"])
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setMargin(0)
        layout.addWidget(widgets["okBtn"])
        layout.addWidget(widgets["cancelBtn"])
        layout.addWidget(widgets["resetBtn"])

        layout = QtWidgets.QHBoxLayout(panels["body"])
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setMargin(0)
        layout.addWidget(QtWidgets.QLabel("My Footer"))

        #  ___________________
        # |           |       |
        # |___________|       |
        # |           |       |
        # |___________|       |
        # |           |       |
        # |___________|_______|
        #
        layout = QtWidgets.QGridLayout(pages["home"])
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(panels["header"], 0, 0)
        layout.addWidget(panels["body"], 1, 0)
        layout.addWidget(panels["footer"], 2, 0)
        layout.addWidget(panels["sidebar"], 0, 1, 0, 2)

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

        widgets["logo"].setCursor(QtCore.Qt.PointingHandCursor)
        widgets["okBtn"].setTooltip("Press me")
        widgets["cancelBtn"].setTooltip("Don't press me")
        widgets["cancelBtn"].setIcon(icons["cancelIcon"])
        widgets["someView"].setModel(model.SomeModel())

        widgets["okBtn"].clicked.connect(self.on_okbtn_clicked)
        widgets["someView"].selectionChanged.connect(self.on_someview_selection_changed)

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

        self._pages = pages
        self._panels = panels
        self._widgets = widgets
        self._ctrl = ctrl

        self.setup_a()
        self.setup_b()
        self.update_c()

        # Misc
        # ...

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

    def on_this(...):
    def on_that(...):
    def on_this_changed(...):
    def on_that_changed(...):
    ...

Notes

class Window(QtWidgets.QMainWindow):
    title = "My Window"

    def __init__(self, ctrl, parent=None):
        super(Window, self).__init__(parent)

        ...

class SmallHelperWidget(...):
    pass

class SpecialityButton(...):
    pass

Notes


Structure II - Controller

A view merely interprets what the user wants, the controller is what actually makes it happen. When a button is pressed, a signal handler calls on the controller to take action.

class View(Widget):
    ...

    def on_copy_clicked(self):
        self._ctrl.copy()

Likewise, when the controller does something, the view is what tells the user about it.

class Controller(QtCore.QObject):
    state_changed = QtCore.Signal(str)

class View(Widget):
    def __init__(self, ctrl, parent=None):
        super(View, self).__init__(parent)

        ...

        ctrl.state_changed.connect(self.on_state_changed)

    def on_state_changed(self, state):
        self._widgets["statusLabel"].setText(state)


Structure III - Model

...


Responsiveness

Always develop your application synchronously at first, blocking at every turn. Once a series of operations run stable but cause too much delay (200 ms+), consider parallelising and what should be happening from the users point of view.

  1. What does the user need to know? E.g. progress bar, status message, email on completion. Your safest course of action is switching page to a "Loading Screen", then consider balancing between interactivity and information.
  2. What is the user allowed to do? E.g. some buttons disabled, some actions able to operate in parallel. Your simplest course of action is to keep the GUI responsive, but disable all widgets. Then look for a balance between enabled and safe.

Heads up

This got long. I'll pause here and update as I go. Potentially turn it into a more formal document, like CONTRIBUTING.md. Is this a good idea? Does this kind of thing help? Does it limit more than it liberates? Let me know.

davidlatwe commented 4 years ago

Awesome post !

But I though the model was the brain ? The thing that handles the business logic, and the control is the nerves. Controls receive the user inputs and the model changes data by it's logic so the view represent model process result visually.

mottosso commented 4 years ago

But I though the model was the brain ?

I've honestly never considered that. Whenever I read up on the topic, I typically conclude with "nobody knows" because there are so many variations on MVC (MVV, MVP, MV, what else?) and I've never fully understood how they differ. I've always turned to whatever Qt does, as it's at least documented and wouldn't result in a framework-on-framework.

But a quick search just now took me to a post I've read once before.

Except today it makes sense.

One of the things I've always struggled with is where to store the model.

So what ends up happening is the model is updated from the view, with data from the controller, which means it cannot be used/tested without a view, and that's bad.

Nuking the controller, and making the model the controller instead would make life a whole lot easier. The Qt docs even says so, which I honestly haven't noticed until just now; there is no C in MVC. I don't like the idea of mixing data with business logic; to me the model has always been just dumb "structured" data. Something facilitating a call to data() and setData(). And it feels a little backwards also having e.g. launchProcess() and printSummary() in there.

But let's try that, I think you're right.