Which model apply to GUI app

Hello :grinning:,
I got GUI application dcspy and I get to point I need serious refactor and move to MVC. I get some reading and research but I’m a bit lost and I do not what go to dead-end street.
I’m try find-out which model to use, so my application have bunch of settings like here:

This straightforward just use some dict() to save and load value in/from GUI.

But on another tab:

I need read 10-15MB of JSON and parse as Pydantic models to fill-up
Spec and Aircraft dropdowns
https:// ibb .co/hdzpZpk

Then values from Spec can be selected and assign to keys in table, like:
https:// ibb .co/3RH9GTr

However every selected item in table dropdown can be further tune and configure:
https:// ibb .co/1ryrr1J
and those settings will be store in disk as YAML file like:

G9_M3: HOT_MIC_SW DEC
G22_M1: IFF_M4_CODE_SW CYCLE 1 2
G23_M2: IFF_M4_REPLY_SW CYCLE 1 2

And of course load during app start

So whole app model (in terms of MVC) need store app settings and all Pydantic data/models read from JSON files.

I just thinking try to implement some QTreeView but not sure is good idea, or maybe stor everything in local sqlite db and use QSqlTableModel

Any guidance or tips would be appreciated.

Hi @Michal_Plichta welcome to the forum & congratulations on reaching that point in your project where you realize you should have started with MVC all along. It happens to us all :wink:

As you probably know, Qt comes with a bunch of built in “model views” which can be use to display data in lists, dropdowns and tables. But for general UI use you’re on your own. I’ll cover both cases below.

Standard UI MVC

Use a dictionary to store the actual data. You can derive this from UserDict to define some custom behaviors – for example, adding signals to fire any time a value is updated.


class DataModelSignals(QObject):
    # Emit an "updated" signal when a property changes.
    updated = pyqtSignal()


class DataModel(UserDict):

    def __init__(self, *args, **kwargs):
        self.signals = DataModelSignals()
        super().__init__(*args, **kwargs)

    def __setitem__(self, key, value):
        previous = self.get(key) # Get the existing value.
        super().__setitem__(key, value) # Set the value.
        if value != previous: # There is a change.
            self.signals.updated.emit() # Emit the signal.
            print(self) # Show the current state.

model = DataModel(
    name="Johnina Smith",
    age=10,
    favorite_icecream='Vanilla',
    disable_details=False,
)

Then in your UI you receive signals from widgets that indicate they are updated & and use model['name_of_value'] to set the new value. You can do clever things here, like using the objectName to link to the key in the dictionary if you’re so inclined.

The model will only emit the updated signal if the value has changed. You connect this .updated signal to your UI, through an def update_ui(self) method. This reads the values from the model & applies them to the UI – all of them, because setting the same value that is already shown has no cost.

That way you can hold all your UI update logic in a single place, making it easy to add complex logic (like disabling/enabling bits of hte UI depending on config state).

The more complicated stuff

To show the table I would go with a QTableView and use a combobox delegate as the editor component for the cell. That allows you to customize what is shown in the dropdown and still benefit from using a model to store the data.

Something like the following

import sys

from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QComboBox, QItemDelegate


class TableModel(QtCore.QAbstractTableModel):
    def __init__(self, data):
        super(TableModel, self).__init__()
        self._data = data

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            value = self._data[index.row()][index.column()]
            return str(value)

        if role == Qt.ItemDataRole.EditRole:
            value = self._data[index.row()][index.column()]
            return value

    def setData(self, index, value, role):
        if role == Qt.ItemDataRole.EditRole:
            self._data[index.row()][index.column()] = value
            return True
        return False

    def flags(self, index):
        flags = (
            Qt.ItemFlag.ItemIsEnabled
            | Qt.ItemFlag.ItemIsSelectable
            | Qt.ItemFlag.ItemIsEditable
        )
        return flags

    def rowCount(self, index):
        return len(self._data)

    def columnCount(self, index):
        return len(self._data[0])


class ComboDelegate(QItemDelegate):
    """
    Add a QComboBox in every cell of the table.
    """

    def __init__(self, parent, values):
        super(ComboDelegate, self).__init__(parent)
        self.model = None
        self.values = list(set(values))  # Unique values.

    def createEditor(self, parent, option, index):
        combobox = QComboBox(parent)
        combobox.addItems(self.values)
        return combobox

    def setEditorData(self, editor, index):
        text = index.model().data(index, Qt.EditRole)
        print("Update the editor from the model:", text)
        if index:
            editor.setCurrentText(text)

    def setModelData(self, editor, model, index):
        print("Updating the model model:", editor, model, index, editor.currentText())
        model.setData(index, editor.currentText(), Qt.ItemDataRole.EditRole)


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        combovalues = ["", "HOT_MIC_SW", "IFF_M4_CODE_SW", "IFF_M4_REPLY_SW"]

        self.table = QtWidgets.QTableView()

        self.setCentralWidget(self.table)

        combodelegate = ComboDelegate(self, combovalues)        
        self.table.setItemDelegate(combodelegate)

        self.model = TableModel(
            [
                ["", "", "", "", ""],
                ["", "", "", "", ""],
                ["", "", "", "", ""],
                ["", "", "", "", ""],
            ]
        )
        self.table.setModel(self.model)


app = QtWidgets.QApplication(sys.argv)

window = MainWindow()
window.show()
app.exec()

Which gives you a window like this, where each cell can be selected using a combo dropdown.

image

For the additional details I would add a sidebar, with the options etc. shown in it. This can react to the change in currently selected cell in the view & the current value in the dropdown.

In case it isn’t obvious you can store this information in the same model as that used for the table view — in the example above the model is a list of lists containing strings (from the selected values). But the model can contain anything, as the “values” e.g. dictionaries, objects, etc. and you can store your data however you like inside that. All you need to ensure is that the model can read/write to the value to display in the table, handled through the data/setData methods.

I hope this helps. Let me know if you have any questions, I realise it’s pretty complicated stuff.

1 Like

Thx for reply I will study your answer and back with question and comments. BTW… I kind of love your book!

1 Like

Ok Standard UI MVC is nice and clear, I will incorporate in my codebase. Pretty straightforward.

With TableModel() is quite clear. I like solution with ComboDelegate() never knew such technic in Qt. But of course got few questions/ideas:

  • to activate cell I need double click on cell, is there any way to overcome this, single click or start rolling mouse wheel to scroll options in combobox. Just curiosity this is a not big deal.
  • In my real world example some of table cells are disabled, how can I do it. See screenshoot.

Can you extend your example to:

  • add QDockWidget() with ie. QButtonGroup() with 3 QRadioButton()
# pseudo code
rb_values = {"HOT_MIC_SW": [1, 2, 3], "IFF_M4_CODE_SW": [2, 3], "IFF_M4_REPLY_SW": [1, 3]}
self.bg_rb = QButtonGroup(self)
self.rb_1 = QRadioButton()
self.rb_2 = QRadioButton()
self.rb_3 = QRadioButton()
self.bg_rb.addButton(self.rb_1)
self.bg_rb.addButton(self.rb_2)
self.bg_rb.addButton(self.rb_3)

# at start disable all radiobuttons
for b in self.bg_rb.buttons():
    b.setDisabled(True)

for combo_txt, rbs in rb_values:
    if <combo_widget> == combo_txt:
        for rb in rbs:
            getattr(self, f'rb_{rb}').setEnabled(True)
  • when current value in table combobox is changed(or enter pressed) it should enable correct radio buttons at dock
  • user can select any of enabled radio button and it should be keep (I use dict of dicts), any table cell can have some value assign (from dropdown) and this value can have (here one of 1, 2 , 3 value) assign.
  • later when this cell will be selected it should set radio buttons accordantly.

It would be a real help when you could extend your example with this user case!

Anyway do you see Aircraft dropdown list (at screenshoot) select some values it will chnage / rebuild combo_values and rb_values

And another note, I need build table based on user profile, just some snippet of code ( to get idea):

def _generate_table(self) -> None:
    ctrl_list_without_sep = [item for item in self.ctrl_list if item and CTRL_LIST_SEPARATOR not in item]
    for row in range(0, self.device.rows.total):
        for col in range(0, self.device.cols):
            self._make_combo_with_completer_at(row, col, ctrl_list_without_sep)

def _make_combo_with_completer_at(self, row: int, col: int, ctrl_list_no_sep: list[str]) -> None:
    key = self.device.get_key_at(row=row, col=col)
    if col == 0 or row < self.device.no_g_keys:
        completer = QCompleter(ctrl_list_no_sep)
        completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
        completer.setFilterMode(Qt.MatchFlag.MatchContains)
        completer.setMaxVisibleItems(self._completer_items)
        completer.setModelSorting(QCompleter.ModelSorting.CaseInsensitivelySortedModel)

        combo = QComboBox()
        combo.setEditable(True)
        combo.addItems(self.ctrl_list)
        combo.setCompleter(completer)
        self._disable_items_with(text=CTRL_LIST_SEPARATOR, widget=combo)
        self.tw_gkeys.setCellWidget(row, col, combo)
        try:
            identifier = self.input_reqs[self.current_plane][str(key)].identifier
        except KeyError:
            identifier = ''
        combo.setCurrentText(identifier)
        combo.editTextChanged.connect(partial(self._cell_ctrl_content_changed, widget=combo, row=row, col=col))
    else:
        combo = QComboBox()
        combo.setDisabled(True)
        self.tw_gkeys.setCellWidget(row, col, combo)
    combo.setStyleSheet(self._get_style_for_combobox(key=key, fg='black'))

    @staticmethod
    def _get_style_for_combobox(key: AnyButton, fg: str) -> str:
        bg = ''
        if isinstance(key, Gkey):
            bg = 'lightgreen'
        elif isinstance(key, MouseButton):
            bg = 'lightyellow'
        elif isinstance(key, LcdButton):
            bg = 'lightblue'
        return f'QComboBox{{color: {fg};background-color: {bg};}} QComboBox QAbstractItemView {{background-color: {bg};}}'

Big thanks in advanced!

PS. I would love to get even deeper knowledge of Qt beyond your book, If you consider write part 2 "Advanced Qt/Pyside6 stuff… " count on me as customer :slight_smile:

For this I think you’re looking for edit triggers which you can set on the view.

view.setEditTriggers(QAbstractItemView.EditTriggers.CurrentChanged)

There are other edit triggers too (see the docs) and you can set multiple by oring them together with | or using the all edit triggers setting.

Enabling and disabling cells is handled by returning (or rather not returning) flags from the model’s flags method. There is a Qt.ItemIsEnabled flag, which if you don’t return this then the item will be disabled.

Something like the following (you’ll need to fill in your own disabled logic).

def flags(self, index):
    # Use the index to determine if the item should be disabled or not.
    is_disabled = <your enabled/disabled logic>
    if is_disabled:
        return Qt.ItemFlag.NoItemFlags
    return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEditable
1 Like

The way to achieve this is either through view.currentChanged() signal. When the current item changes you will receive the index (and the previous item index) through a signal. Here you can retrieve the radio button state from your model and update the current state.

def handle_current_changed(self, index, prev_index):
    data = # get the data from the model for the radio inputs.
    for rb in rbuttons:
         value = data.get(...)
         # ...update the radio buttons.

Note that you can add any custom method onto the model you want, so you could implement a get_data_for_radiobuttons that returns the data in exact format you want for updating the UI.

To handle the editing, you’ll have a few options:

  1. use the prev_index passed to the method to store the current state before setting, again you can create a custom method on the model set_data_from_radiobuttons to handle this. You’ll need to call this manually at shutdown, or you’ll miss the final edit.
  2. add a button to “apply” the changes, which calls the set_data_from_radiobuttons method with the current index.
  3. apply changes to the model using the valueChanged signals from the radiobutton to call a handler. In this case, I would still store data from all radio buttons just to keep things simple.

Of these I prefer 2, but depends on how often you need to make edits in user.

1 Like

Ok perfect this is clear!

Now, when I see my code is not only spaghetti code but rather half-cooked spaghetti :laughing:
I will try write PoC and refactor a lot of code, so it take a while. Probably I will got some more question, but man I get a feeling my Qt knowledge is sky rocketing.

Take care!

1 Like