Skip to content

Commit

Permalink
Adds Domains tab to project widget (#57)
Browse files Browse the repository at this point in the history
* added multiselect combobox

* added domains tab

* added tests

* fixed domain contrast display

* hid domain contrasts when not standard layers and moved tab

* review fixes
  • Loading branch information
alexhroom authored Nov 27, 2024
1 parent 770e48b commit d3fbbb6
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 10 deletions.
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,21 @@ mark-parentheses = false
# if overriding a PyQt method, please add it here!
# names should be in alphabetical order for readability
extend-ignore-names = ['allKeys',
'addItem',
'addItems',
'columnCount',
'createEditor',
'eventFilter',
'headerData',
'mergeWith',
'resizeEvent',
'rowCount',
'setData',
'setEditorData',
'setModelData',
'setValue',
'showEvent',
'sizeHint',
'stepBy',
'textFromValue',
'valueFromText',]
'valueFromText',]
11 changes: 9 additions & 2 deletions rascal2/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from rascal2.widgets.controls import ControlsWidget
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, get_validated_input
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, MultiSelectComboBox, get_validated_input
from rascal2.widgets.plot import PlotWidget
from rascal2.widgets.terminal import TerminalWidget

__all__ = ["ControlsWidget", "AdaptiveDoubleSpinBox", "get_validated_input", "PlotWidget", "TerminalWidget"]
__all__ = [
"ControlsWidget",
"AdaptiveDoubleSpinBox",
"get_validated_input",
"MultiSelectComboBox",
"PlotWidget",
"TerminalWidget",
]
30 changes: 29 additions & 1 deletion rascal2/widgets/delegates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from PyQt6 import QtCore, QtGui, QtWidgets

from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, get_validated_input
from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, MultiSelectComboBox, get_validated_input


class ValidatedInputDelegate(QtWidgets.QStyledItemDelegate):
Expand Down Expand Up @@ -101,3 +101,31 @@ def setEditorData(self, editor: QtWidgets.QWidget, index):
def setModelData(self, editor, model, index):
data = editor.currentText()
model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole)


class MultiSelectLayerDelegate(QtWidgets.QStyledItemDelegate):
"""Item delegate for multiselecting layers."""

def __init__(self, project_widget, parent):
super().__init__(parent)
self.project_widget = project_widget

def createEditor(self, parent, option, index):
widget = MultiSelectComboBox(parent)

layers = self.project_widget.draft_project["layers"]
widget.addItems([layer.name for layer in layers])

return widget

def setEditorData(self, editor: MultiSelectComboBox, index):
# index.data produces the display string rather than the underlying data,
# so we split it back into a list here
data = index.data(QtCore.Qt.ItemDataRole.DisplayRole).split(", ")
layers = self.project_widget.draft_project["layers"]

editor.select_indices([i for i, layer in enumerate(layers) if layer.name in data])

def setModelData(self, editor, model, index):
data = editor.selected_items()
model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole)
158 changes: 158 additions & 0 deletions rascal2/widgets/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,161 @@ def validate(self, input_text, pos) -> tuple[QtGui.QValidator.State, str, int]:
self.setDecimals(len(input_text.split(".")[-1]))
return (QtGui.QValidator.State.Acceptable, input_text, pos)
return super().validate(input_text, pos)


class MultiSelectComboBox(QtWidgets.QComboBox):
"""
A custom combo box widget that allows for multi-select functionality.
This widget provides the ability to select multiple items from a
dropdown list and display them in a comma-separated format in the
combo box's line edit area.
This is a simplified version of the combobox in
https://github.com/user0706/pyqt6-multiselect-combobox (MIT License)
"""

class Delegate(QtWidgets.QStyledItemDelegate):
def sizeHint(self, option, index):
size = super().sizeHint(option, index)
size.setHeight(20)
return size

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

self.setEditable(True)
self.lineEdit().setReadOnly(True)

self.setItemDelegate(MultiSelectComboBox.Delegate())

self.model().dataChanged.connect(self.update_text)
self.lineEdit().installEventFilter(self)
self.view().viewport().installEventFilter(self)

def resizeEvent(self, event) -> None:
"""Resize event handler.
Parameters
----------
event
The resize event.
"""
self.update_text()
super().resizeEvent(event)

def eventFilter(self, obj, event) -> bool:
"""Event filter to handle mouse button release events.
Parameters
----------
obj
The object emitting the event.
event
The event being emitted.
Returns
-------
bool
True if the event was handled, False otherwise.
"""
if obj == self.view().viewport() and event.type() == QtCore.QEvent.Type.MouseButtonRelease:
index = self.view().indexAt(event.position().toPoint())
item = self.model().itemFromIndex(index)
if item.checkState() == QtCore.Qt.CheckState.Checked:
item.setCheckState(QtCore.Qt.CheckState.Unchecked)
else:
item.setCheckState(QtCore.Qt.CheckState.Checked)
return True
return False

def update_text(self) -> None:
"""Update the displayed text based on selected items."""
items = self.selected_items()

if items:
text = ", ".join([str(i) for i in items])
else:
text = ""

metrics = QtGui.QFontMetrics(self.lineEdit().font())
elided_text = metrics.elidedText(text, QtCore.Qt.TextElideMode.ElideRight, self.lineEdit().width())
self.lineEdit().setText(elided_text)

def addItem(self, text: str, data: str = None) -> None:
"""Add an item to the combo box.
Parameters
----------
text : str
The text to display.
data : str
The associated data. Default is None.
"""
item = QtGui.QStandardItem()
item.setText(text)
item.setData(data or text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
item.setData(QtCore.Qt.CheckState.Unchecked, QtCore.Qt.ItemDataRole.CheckStateRole)
self.model().appendRow(item)

def addItems(self, texts: list, data_list: list = None) -> None:
"""Add multiple items to the combo box.
Parameters
----------
texts : list
A list of items to add.
data_list : list
A list of associated data. Default is None.
"""
data_list = data_list or [None] * len(texts)
for text, data in zip(texts, data_list):
self.addItem(text, data)

def selected_items(self) -> list:
"""Get the currently selected data.
Returns
-------
list
A list of currently selected data.
"""
return [
self.model().item(i).data()
for i in range(self.model().rowCount())
if self.model().item(i).checkState() == QtCore.Qt.CheckState.Checked
]

def select_indices(self, indices: list) -> None:
"""Set the selected items based on the provided indices.
Parameters
----------
indexes : list
A list of indexes to select.
"""
for i in range(self.model().rowCount()):
self.model().item(i).setCheckState(
QtCore.Qt.CheckState.Checked if i in indices else QtCore.Qt.CheckState.Unchecked
)
self.update_text()

def showEvent(self, event) -> None:
"""Show event handler.
Parameters
----------
event
The show event.
"""
super().showEvent(event)
self.update_text()
52 changes: 51 additions & 1 deletion rascal2/widgets/project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
from RATapi.utils.enums import Procedures

from rascal2.config import path_for
from rascal2.widgets.delegates import ParametersDelegate, ValidatedInputDelegate, ValueSpinBoxDelegate
from rascal2.widgets.delegates import (
MultiSelectLayerDelegate,
ParametersDelegate,
ValidatedInputDelegate,
ValueSpinBoxDelegate,
)


class ClassListModel(QtCore.QAbstractTableModel):
Expand Down Expand Up @@ -63,6 +68,8 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
# pyqt can't automatically coerce enums to strings...
if isinstance(data, Enum):
return str(data)
if isinstance(data, list):
return ", ".join(data)
return data
elif role == QtCore.Qt.ItemDataRole.CheckStateRole and self.index_header(index) == "fit":
return QtCore.Qt.CheckState.Checked if data else QtCore.Qt.CheckState.Unchecked
Expand Down Expand Up @@ -380,6 +387,16 @@ def set_absorption(self, absorption: bool):
self.endResetModel()


class ContrastsModel(ClassListModel):
"""Classlist model for Contrasts."""

def flags(self, index):
flags = super().flags(index)
if self.edit_mode:
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
return flags


class LayerFieldWidget(ProjectFieldWidget):
"""Project field widget for Layer objects."""

Expand Down Expand Up @@ -411,3 +428,36 @@ def set_absorption(self, absorption: bool):
self.model.set_absorption(absorption)
if self.model.edit_mode:
self.edit()


class DomainsModel(ClassListModel):
"""Classlist model for domain contrasts."""

def flags(self, index):
flags = super().flags(index)
if self.edit_mode:
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
return flags


class DomainContrastWidget(ProjectFieldWidget):
"""Subclass of field widgets for domain contrasts."""

classlist_model = DomainsModel

def __init__(self, field, parent):
super().__init__(field, parent)
self.project_widget = parent.parent

def update_model(self, classlist):
super().update_model(classlist)

header = self.table.horizontalHeader()
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch)

def set_item_delegates(self):
self.table.setItemDelegateForColumn(
1, ValidatedInputDelegate(self.model.item_type.model_fields["name"], self.table)
)
self.table.setItemDelegateForColumn(2, MultiSelectLayerDelegate(self.project_widget, self.table))
15 changes: 12 additions & 3 deletions rascal2/widgets/project/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
from RATapi.utils.enums import Calculations, Geometries, LayerModels

from rascal2.config import path_for
from rascal2.widgets.project.models import LayerFieldWidget, ParameterFieldWidget, ProjectFieldWidget
from rascal2.widgets.project.models import (
DomainContrastWidget,
LayerFieldWidget,
ParameterFieldWidget,
ProjectFieldWidget,
)


class ProjectWidget(QtWidgets.QWidget):
Expand Down Expand Up @@ -37,8 +42,8 @@ def __init__(self, parent):
"Layers": ["layers"],
"Data": [],
"Backgrounds": [],
"Domains": ["domain_ratios", "domain_contrasts"],
"Contrasts": [],
"Domains": [],
}

self.view_tabs = {}
Expand Down Expand Up @@ -257,11 +262,13 @@ def handle_tabs(self) -> None:
self.project_tab.setTabVisible(domain_tab_index, is_domains)
self.edit_project_tab.setTabVisible(domain_tab_index, is_domains)

# the layers tab should only be visible in standard layers
# the layers tab and domain contrasts table should only be visible in standard layers
layers_tab_index = list(self.view_tabs).index("Layers")
is_layers = self.model_combobox.currentText() == LayerModels.StandardLayers
self.project_tab.setTabVisible(layers_tab_index, is_layers)
self.edit_project_tab.setTabVisible(layers_tab_index, is_layers)
self.view_tabs["Domains"].tables["domain_contrasts"].setVisible(is_layers)
self.edit_tabs["Domains"].tables["domain_contrasts"].setVisible(is_layers)

def handle_controls_update(self):
"""Handle updates to Controls that need to be reflected in the project."""
Expand Down Expand Up @@ -362,6 +369,8 @@ def __init__(self, fields: list[str], parent, edit_mode: bool = False):
self.tables[field] = ParameterFieldWidget(field, self)
elif field == "layers":
self.tables[field] = LayerFieldWidget(field, self)
elif field == "domain_contrasts":
self.tables[field] = DomainContrastWidget(field, self)
else:
self.tables[field] = ProjectFieldWidget(field, self)
layout.addWidget(self.tables[field])
Expand Down
Loading

0 comments on commit d3fbbb6

Please sign in to comment.