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

feat: 2D config preset table #320

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
wip
tlambert03 committed Jul 14, 2024

Verified

This commit was signed with the committer’s verified signature.
tlambert03 Talley Lambert
commit 39afe8325545c1210c81f6e2826bba9deaa02328
71 changes: 53 additions & 18 deletions src/pymmcore_widgets/_config_preset_table.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from typing import DefaultDict, cast

Check warning on line 1 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L1

Added line #L1 was not covered by tests

from pymmcore_plus import CMMCorePlus
from PyQt6.QtCore import QEvent
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFont, QMouseEvent, QPainter
from qtpy.QtWidgets import (

Check warning on line 7 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L3-L7

Added lines #L3 - L7 were not covered by tests
QHeaderView,
QLineEdit,
QTableWidget,
@@ -12,222 +12,257 @@
QWidget,
)

from pymmcore_widgets import PropertyWidget

Check warning on line 15 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L15

Added line #L15 was not covered by tests


class ClickableHeaderView(QHeaderView):

Check warning on line 18 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L18

Added line #L18 was not covered by tests
# use `headerDataChanged` to detect changes in the header text

def __init__(

Check warning on line 21 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L21

Added line #L21 was not covered by tests
self, orientation: Qt.Orientation, parent: QWidget | None = None
) -> None:
super().__init__(orientation, parent)
self._is_editable: bool = True

Check warning on line 25 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L24-L25

Added lines #L24 - L25 were not covered by tests

# stores the mouse position on hover
self._mouse_pos = QPoint(-1, -1)

Check warning on line 28 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L28

Added line #L28 was not covered by tests
# stores the mouse position on press
self._press_pos = QPoint(-1, -1)

Check warning on line 30 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L30

Added line #L30 was not covered by tests
# stores the section currently being edited
self._sectionedit: int = 0

Check warning on line 32 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L32

Added line #L32 was not covered by tests
# whether the last column is reserved for adding new columns
self._last_column_adds = True
# whether the last column/row is reserved for adding a new column/row
self._last_section_adds = True

Check warning on line 34 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L34

Added line #L34 was not covered by tests

self._show_x: bool = True
self._x_on_right: bool = True

Check warning on line 37 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L36-L37

Added lines #L36 - L37 were not covered by tests

# line edit for editing header
self._line_edit = QLineEdit(parent=self.viewport())
self._line_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self._line_edit.setHidden(True)
self._line_edit.editingFinished.connect(self._on_header_edited)

Check warning on line 43 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L40-L43

Added lines #L40 - L43 were not covered by tests

# Connects to double click
self.sectionDoubleClicked.connect(self._on_header_double_clicked)

Check warning on line 46 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L46

Added line #L46 was not covered by tests

def _is_last_section(self, logicalIndex: int) -> bool:
if model := self.model():
if self.orientation() == Qt.Orientation.Horizontal:
return logicalIndex == model.columnCount() - 1
return logicalIndex == model.rowCount() - 1
return False

Check warning on line 53 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L48-L53

Added lines #L48 - L53 were not covered by tests

def _on_header_double_clicked(self, logicalIndex: int) -> None:

Check warning on line 55 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L55

Added line #L55 was not covered by tests
# This block sets up the geometry for the line edit
model = self.model()
if self._last_column_adds and model and logicalIndex == model.columnCount() - 1:
if not self._is_editable:
return

Check warning on line 58 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L57-L58

Added lines #L57 - L58 were not covered by tests

if self._last_section_adds and self._is_last_section(logicalIndex):
return

Check warning on line 61 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L60-L61

Added lines #L60 - L61 were not covered by tests

rect = self.geometry()
rect.setWidth(self.sectionSize(logicalIndex))
rect.moveLeft(self.sectionViewportPosition(logicalIndex))
if self.orientation() == Qt.Orientation.Horizontal:
rect.setWidth(self.sectionSize(logicalIndex))
rect.moveLeft(self.sectionViewportPosition(logicalIndex))

Check warning on line 66 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L63-L66

Added lines #L63 - L66 were not covered by tests
else:
rect.setHeight(self.sectionSize(logicalIndex))
rect.moveTop(self.sectionViewportPosition(logicalIndex))

Check warning on line 69 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L68-L69

Added lines #L68 - L69 were not covered by tests

if self._show_x and self._is_mouse_on(self._edge_rect(rect)):

Check warning on line 71 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L71

Added line #L71 was not covered by tests
# double click on the right edge of the header does nothing
# there will be an X button there to remove the column
return

Check warning on line 74 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L74

Added line #L74 was not covered by tests

self._line_edit.setGeometry(rect)
self._line_edit.setHidden(False)
self._line_edit.setFocus()
if m := self.model():
if txt := m.headerData(logicalIndex, Qt.Orientation.Horizontal):
if txt := m.headerData(logicalIndex, self.orientation()):
self._line_edit.setText(txt)
self._sectionedit = logicalIndex

Check warning on line 82 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L76-L82

Added lines #L76 - L82 were not covered by tests

def _on_header_edited(self) -> None:
if (new_text := self._line_edit.text()) and (model := self.model()):
model.setHeaderData(self._sectionedit, Qt.Orientation.Horizontal, new_text)
model.setHeaderData(self._sectionedit, self.orientation(), new_text)
self._line_edit.setHidden(True)

Check warning on line 87 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L84-L87

Added lines #L84 - L87 were not covered by tests

def mouseMoveEvent(self, event: QMouseEvent | None) -> None:
if event is not None:
self._mouse_pos = event.pos()
self.resizeEvent(None) # force repaint
super().mouseMoveEvent(event)

Check warning on line 93 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L89-L93

Added lines #L89 - L93 were not covered by tests

def mousePressEvent(self, event: QMouseEvent | None) -> None:
if event is not None:
self._press_pos = event.pos()
return super().mousePressEvent(event)

Check warning on line 98 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L95-L98

Added lines #L95 - L98 were not covered by tests

def _add_section(self) -> None:
if model := self.model():
if self.orientation() == Qt.Orientation.Horizontal:
model.insertColumn(model.columnCount())

Check warning on line 103 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L100-L103

Added lines #L100 - L103 were not covered by tests
else:
model.insertRow(model.rowCount())
self.resizeEvent(None)

Check warning on line 106 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L105-L106

Added lines #L105 - L106 were not covered by tests

def _remove_section(self, logicalIndex: int) -> None:
if model := self.model():
if self.orientation() == Qt.Orientation.Horizontal:
model.removeColumn(logicalIndex)

Check warning on line 111 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L108-L111

Added lines #L108 - L111 were not covered by tests
else:
model.removeRow(logicalIndex)
self.resizeEvent(None)

Check warning on line 114 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L113-L114

Added lines #L113 - L114 were not covered by tests

def mouseReleaseEvent(self, e: QMouseEvent | None) -> None:
if e and e.pos() == self._press_pos and (model := self.model()):

Check warning on line 117 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L116-L117

Added lines #L116 - L117 were not covered by tests
# click event

ncols = model.columnCount()
# check if the click was in the last column
# and if the last column is reserved for adding new columns
# then add a new column
logicalIndex = self.logicalIndexAt(e.pos())
if self._last_column_adds and logicalIndex == ncols - 1:
# add a new column
model.insertColumn(ncols)
self.resizeEvent(None)
if self._is_last_section(logicalIndex):
self._add_section()
return

Check warning on line 126 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L123-L126

Added lines #L123 - L126 were not covered by tests

# if the click was on the right edge of the header
# and if _show_x is True, then remove the column
if self._show_x:
rect = self.geometry()
rect.setWidth(self.sectionSize(logicalIndex))
rect.moveLeft(self.sectionViewportPosition(logicalIndex))
if self._is_mouse_on(self._edge_rect(rect)):
model.removeColumn(logicalIndex)
self.resizeEvent(None)
self._remove_section(logicalIndex)
return

Check warning on line 136 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L130-L136

Added lines #L130 - L136 were not covered by tests

return super().mouseReleaseEvent(e)

Check warning on line 138 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L138

Added line #L138 was not covered by tests

def leaveEvent(self, event: QEvent | None) -> None:
self._mouse_pos = QPoint(-1, -1)
self.resizeEvent(None) # force repaint
return super().leaveEvent(event)

Check warning on line 143 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L140-L143

Added lines #L140 - L143 were not covered by tests

def paintSection(

Check warning on line 145 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L145

Added line #L145 was not covered by tests
self, painter: QPainter | None, rect: QRect, logicalIndex: int
) -> None:
if not painter:
return

Check warning on line 149 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L148-L149

Added lines #L148 - L149 were not covered by tests

pen = painter.pen()
is_hovering = self._is_mouse_on(rect)
big_font = QFont("Arial", 20)
big_font.setBold(True)

Check warning on line 154 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L151-L154

Added lines #L151 - L154 were not covered by tests

# Draw a plus sign on the last column, instead of the usual header text
if (

Check warning on line 157 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L157

Added line #L157 was not covered by tests
self._last_column_adds
self._last_section_adds
and (model := self.model())
and logicalIndex == model.columnCount() - 1
):
painter.setFont(big_font)
pen.setColor(

Check warning on line 163 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L162-L163

Added lines #L162 - L163 were not covered by tests
Qt.GlobalColor.green if is_hovering else Qt.GlobalColor.darkGreen
)
painter.setPen(pen)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "+")
return

Check warning on line 168 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L166-L168

Added lines #L166 - L168 were not covered by tests

painter.save()
super().paintSection(painter, rect, logicalIndex)
painter.restore()

Check warning on line 172 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L170-L172

Added lines #L170 - L172 were not covered by tests

if self._show_x:

Check warning on line 174 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L174

Added line #L174 was not covered by tests
# Draw a cross on the right side of the header
# only if we're currently hovering over the header
edge_rect = self._edge_rect(rect)
if edge_rect.contains(self._mouse_pos):
pen.setColor(Qt.GlobalColor.red)
elif is_hovering:
pen.setColor(Qt.GlobalColor.gray)

Check warning on line 181 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L177-L181

Added lines #L177 - L181 were not covered by tests
else:
pen.setColor(Qt.GlobalColor.lightGray)
painter.setFont(big_font)
painter.setPen(pen)
painter.drawText(edge_rect, Qt.AlignmentFlag.AlignCenter, "\u00d7")

Check warning on line 186 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L183-L186

Added lines #L183 - L186 were not covered by tests

def _is_mouse_on(self, rect: QRect) -> bool:
return rect.contains(self._mouse_pos)

Check warning on line 189 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L188-L189

Added lines #L188 - L189 were not covered by tests

def _edge_rect(self, rect: QRect) -> QRect:
if self._x_on_right:
return rect.adjusted(rect.width() - 40, 0, 0, 0)
return rect.adjusted(0, 0, 40 - rect.width(), 0)

Check warning on line 194 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L191-L194

Added lines #L191 - L194 were not covered by tests


class ConfigPresetTable(QTableWidget):
def __init__(

Check warning on line 198 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L197-L198

Added lines #L197 - L198 were not covered by tests
self, parent: QWidget | None = None, core: CMMCorePlus | None = None
) -> None:
super().__init__(parent)
self._core = core or CMMCorePlus.instance()
self.setHorizontalHeader(ClickableHeaderView(Qt.Orientation.Horizontal, self))
vh = ClickableHeaderView(Qt.Orientation.Vertical, self)
vh._show_x = False
vh._is_editable = False
self.setVerticalHeader(vh)
self.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
hh = self.horizontalHeader()
hh.setSectionResizeMode(hh.ResizeMode.Stretch)
if model := self.model():
model.columnsInserted.connect(self._on_columns_changed)
model.columnsRemoved.connect(self._on_columns_changed)

Check warning on line 213 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L201-L213

Added lines #L201 - L213 were not covered by tests

def _on_columns_changed(self) -> None:
ncols = self.columnCount()
self.setColumnWidth(ncols - 1, 40)
self.horizontalHeader().setSectionResizeMode(

Check warning on line 218 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L215-L218

Added lines #L215 - L218 were not covered by tests
ncols - 1, QHeaderView.ResizeMode.Fixed
)

def _on_rows_changed(self) -> None:
nrows = self.rowCount()
self.verticalHeader().setSectionResizeMode(

Check warning on line 224 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L222-L224

Added lines #L222 - L224 were not covered by tests
nrows - 1, QHeaderView.ResizeMode.Fixed
)

def sizeHint(self) -> QSize:
return QSize(1000, 200)

Check warning on line 229 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L228-L229

Added lines #L228 - L229 were not covered by tests

def loadGroup(self, group: str) -> None:
self._rebuild_table(group)

Check warning on line 232 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L231-L232

Added lines #L231 - L232 were not covered by tests

def _rebuild_table(self, group: str) -> None:

Check warning on line 234 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L234

Added line #L234 was not covered by tests
# Get all presets and their properties
# Mapping {preset -> {(dev, prop) -> val}}
preset2props: DefaultDict[str, dict[tuple[str, str], str]] = DefaultDict(dict)
for preset in self._core.getAvailableConfigs(group):
for dev, prop, _val in self._core.getConfigData(group, preset):
preset2props[preset][(dev, prop)] = _val

Check warning on line 240 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L237-L240

Added lines #L237 - L240 were not covered by tests

all_props = set.union(*[set(props.keys()) for props in preset2props.values()])
ncols = len(preset2props) + 1
self.setColumnCount(ncols)

self.setRowCount(len(all_props))
self.setRowCount(len(all_props) + 1)

Check warning on line 245 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L242-L245

Added lines #L242 - L245 were not covered by tests

# store which device/property is in which row
ROWS: dict[tuple[str, str], int] = {}

Check warning on line 248 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L248

Added line #L248 was not covered by tests

for row, (dev, prop) in enumerate(sorted(all_props)):
ROWS[(dev, prop)] = row
name = "Active" if dev == "Core" else dev
self.setVerticalHeaderItem(row, QTableWidgetItem(f"{name}-{prop}"))

Check warning on line 253 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L250-L253

Added lines #L250 - L253 were not covered by tests

for col, (preset, props) in enumerate(preset2props.items()):
item = QTableWidgetItem(preset)
item.setFlags(Qt.ItemFlag.ItemIsEditable | Qt.ItemFlag.ItemIsEnabled)
self.setHorizontalHeaderItem(col, item)
for (dev, prop), val in props.items():
wdg = PropertyWidget(dev, prop, mmcore=self._core, connect_core=False)
wdg._preset = preset
wdg.setValue(val)
wdg.valueChanged.connect(self._on_value_changed)
self.setCellWidget(ROWS[(dev, prop)], col, wdg)

Check warning on line 264 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L255-L264

Added lines #L255 - L264 were not covered by tests

def _on_value_changed(self, val) -> None:
wdg = cast("PropertyWidget", self.sender())
print("preset", wdg._preset, "changed", wdg._dp, "to", val)

Check warning on line 268 in src/pymmcore_widgets/_config_preset_table.py

Codecov / codecov/patch

src/pymmcore_widgets/_config_preset_table.py#L266-L268

Added lines #L266 - L268 were not covered by tests

Unchanged files with check annotations Beta

if self._updates_core:
try:
self._mmc.setProperty(self._device_label, self._prop_name, value)
except (RuntimeError, ValueError):

Check warning on line 361 in src/pymmcore_widgets/_property_widget.py

Codecov / codecov/patch

src/pymmcore_widgets/_property_widget.py#L361

Added line #L361 was not covered by tests
# if there's an error when updating mmcore, reset widget value to mmcore
self._try_update_from_core()

Check warning on line 363 in src/pymmcore_widgets/_property_widget.py

Codecov / codecov/patch

src/pymmcore_widgets/_property_widget.py#L363

Added line #L363 was not covered by tests
self.valueChanged.emit(value)
def _disconnect(self) -> None: