From 9ffd604e995419b5361c2ee6a61865f499d2913c Mon Sep 17 00:00:00 2001 From: compucell3d Date: Sat, 27 Jun 2026 12:19:18 -0600 Subject: [PATCH 1/4] Added expand/collapse all context menu to Model editor (parameter steering) --- cc3d/player5/UI/ModelEditor.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cc3d/player5/UI/ModelEditor.py b/cc3d/player5/UI/ModelEditor.py index 216195f..b3185ec 100644 --- a/cc3d/player5/UI/ModelEditor.py +++ b/cc3d/player5/UI/ModelEditor.py @@ -11,10 +11,29 @@ def __init__(self, parent): QTreeView.__init__(self, parent) self.setFrameStyle(QFrame.NoFrame) self.parent = parent + self.__setup_actions() def getParent(self): return self.parent + def __setup_actions(self): + self.expand_all_action = QAction("Expand All", self) + self.expand_all_action.setToolTip("Expand all model parameters") + self.expand_all_action.triggered.connect(self.expandAll) + + self.collapse_all_action = QAction("Collapse All", self) + self.collapse_all_action.setToolTip("Collapse all model parameters") + self.collapse_all_action.triggered.connect(self.collapseAll) + + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.__show_context_menu) + + def __show_context_menu(self, position): + menu = QMenu(self) + menu.addAction(self.expand_all_action) + menu.addAction(self.collapse_all_action) + menu.exec_(self.viewport().mapToGlobal(position)) + def setParams(self): # Column widths should be set after setting the model! # Fixme: Before setting the column sizes, make sure that @@ -42,4 +61,4 @@ def setParams(self): self.setColumnWidth(0, 180) selectionModel = QItemSelectionModel(model) self.setSelectionModel(selectionModel) - """ \ No newline at end of file + """ From e4f48fec692304fd4f87a2db2338321c9c06cea7 Mon Sep 17 00:00:00 2001 From: compucell3d Date: Sat, 27 Jun 2026 13:00:47 -0600 Subject: [PATCH 2/4] Improved model editor visualization --- cc3d/player5/Utilities/SimModel.py | 46 ++++++++++++++++++++++++++-- cc3d/player5/Utilities/TreeMapper.py | 3 ++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/cc3d/player5/Utilities/SimModel.py b/cc3d/player5/Utilities/SimModel.py index 034f871..529ce22 100644 --- a/cc3d/player5/Utilities/SimModel.py +++ b/cc3d/player5/Utilities/SimModel.py @@ -28,6 +28,15 @@ def __init__(self, domDoc, parent=None): self.__isDirty = False self.__dirtyModules = {} self.__headers = ["Property", "Value"] + self.__moduleStyleColors = { + "Plugin": QColor("#2457A6"), + "Steppable": QColor("#0F766E"), + "Potts": QColor("#7C3AED"), + "Metadata": QColor("#B45309") + } + self.__editableValueColor = QColor("#166534") + self.__editableValueBackgroundColor = QColor("#ECFDF3") + self.__attributeColor = QColor("#64748B") def setPrintFlag(self, _flag): self.__printFlag = _flag @@ -55,10 +64,43 @@ def treeItemFromIndex(self, _itemIndex): return self.__rootItem def data(self, index, role=Qt.DisplayRole): # interface: done - if role != Qt.DisplayRole or not index.isValid(): + if not index.isValid(): return QVariant() node = index.internalPointer() + module_color = self.__moduleStyleColors.get(node.name()) + is_editable_value = index.column() == VALUE and node.type() is not None + is_attribute = node.elementType() == "attribute" + + if role == Qt.FontRole: + font = QFont() + if module_color is not None: + font.setBold(True) + return QVariant(font) + if is_editable_value: + if is_attribute: + font.setItalic(True) + else: + font.setBold(True) + return QVariant(font) + if is_attribute: + font.setItalic(True) + return QVariant(font) + + if role == Qt.ForegroundRole: + if module_color is not None: + return QVariant(QBrush(module_color)) + if is_editable_value: + return QVariant(QBrush(self.__editableValueColor)) + if is_attribute: + return QVariant(QBrush(self.__attributeColor)) + + if role == Qt.BackgroundRole and is_editable_value: + return QVariant(QBrush(self.__editableValueBackgroundColor)) + + if role != Qt.DisplayRole: + return QVariant() + rowdata = [node.name(), node.value()] # Specify which data to display in each column! @@ -195,5 +237,3 @@ def checkSanity(self): cc3d_xml_2_obj_converter = CompuCellSetup.parseXML(file_name) sim_model = SimModel(domDoc=cc3d_xml_2_obj_converter) - - diff --git a/cc3d/player5/Utilities/TreeMapper.py b/cc3d/player5/Utilities/TreeMapper.py index eb931bc..fdc1edb 100644 --- a/cc3d/player5/Utilities/TreeMapper.py +++ b/cc3d/player5/Utilities/TreeMapper.py @@ -47,6 +47,9 @@ def setCC3DXMLElement(self, _cc3dXMLElement): def setElementType(self, _elementType): self.__elementType = _elementType + def elementType(self): + return self.__elementType + def setSuperParent(self, _superParent): self.__superParent = _superParent From 02cf0ec2aa1aed1e2b3494d597eeafb71e3c0a63 Mon Sep 17 00:00:00 2001 From: compucell3d Date: Sat, 27 Jun 2026 15:32:36 -0600 Subject: [PATCH 3/4] Added tile/cascade functionality to floating windows --- .../Plugins/ViewManagerPlugins/MainArea.py | 133 +++++++++++++++++- .../ViewManagerPlugins/SimpleTabView.py | 4 +- cc3d/player5/ViewManager/SimpleViewManager.py | 9 ++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py b/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py index 5564cb2..a95edb2 100644 --- a/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py +++ b/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py @@ -6,6 +6,7 @@ from cc3d.player5 import Graphics from .WindowInventory import WindowInventory import sys +from math import ceil, sqrt from weakref import ref from gc import collect @@ -281,19 +282,143 @@ def addSteeringSubWindow(self, widget): def tileSubWindows(self): """ - dummy function to make conform to QMdiArea API + Tiles floating Player windows across the available screen area. :return: None """ - pass + windows = self.__arrangeable_windows() + if not windows: + return + + available_rect = self.__available_arrangement_rect(reserve_main_window=True) + margin = 12 + spacing = 8 + window_count = len(windows) + column_count = int(ceil(sqrt(window_count))) + row_count = int(ceil(float(window_count) / column_count)) + tile_width = max(240, int((available_rect.width() - 2 * margin - spacing * (column_count - 1)) / column_count)) + tile_height = max(200, int((available_rect.height() - 2 * margin - spacing * (row_count - 1)) / row_count)) + + for idx, win in enumerate(windows): + row = int(idx / column_count) + column = idx % column_count + x = available_rect.x() + margin + column * (tile_width + spacing) + y = available_rect.y() + margin + row * (tile_height + spacing) + win.showNormal() + win.setGeometry(x, y, tile_width, tile_height) + win.raise_() + + self.setActiveSubWindow(windows[-1]) def cascadeSubWindows(self): """ - dummy function to make conform to QMdiArea API + Cascades floating Player windows across the available screen area. :return: None """ - pass + windows = self.__arrangeable_windows() + if not windows: + return + + available_rect = self.__available_arrangement_rect(reserve_main_window=True) + margin = 24 + offset = 32 + cascade_width = min(900, max(360, int(available_rect.width() * 0.68))) + cascade_height = min(700, max(300, int(available_rect.height() * 0.68))) + max_x = available_rect.right() - cascade_width - margin + max_y = available_rect.bottom() - cascade_height - margin + x = available_rect.x() + margin + y = available_rect.y() + margin + + for win in windows: + if x > max_x or y > max_y: + x = available_rect.x() + margin + y = available_rect.y() + margin + win.showNormal() + win.setGeometry(x, y, cascade_width, cascade_height) + win.raise_() + x += offset + y += offset + + self.setActiveSubWindow(windows[-1]) + + def __arrangeable_windows(self): + """ + Returns visible floating subwindows that should participate in layout operations. + + :return: list of SubWindow objects + """ + windows = [] + for win in self.subWindowList(): + widget = win.widget() + if widget is not None and getattr(widget, 'is_screenshot_widget', False): + continue + if not win.isVisible(): + continue + windows.append(win) + + return windows + + def __available_arrangement_rect(self, reserve_main_window=False): + """ + Returns the available geometry of the screen that owns the active Player window. + + :param reserve_main_window: optional flag that reserves space for the main Player window + :type reserve_main_window: bool + :return: QRect + """ + reference_point = self.UI.frameGeometry().center() + if self.lastActiveRealWindow is not None: + reference_point = self.lastActiveRealWindow.frameGeometry().center() + + screen = QApplication.screenAt(reference_point) + if screen is None: + screen = QApplication.primaryScreen() + if screen is not None: + available_rect = screen.availableGeometry() + else: + available_rect = QApplication.desktop().availableGeometry() + + if reserve_main_window: + return self.__arrangement_rect_excluding_main_window(available_rect) + + return available_rect + + def __arrangement_rect_excluding_main_window(self, available_rect): + """ + Moves the main Player window to the top-left and returns the remaining area for floating windows. + + :param available_rect: available screen geometry + :type available_rect: QRect + :return: QRect + """ + margin = 12 + spacing = 8 + main_window = self.UI + main_window.move(available_rect.topLeft() + QPoint(margin, margin)) + main_window.raise_() + + remaining_x = main_window.frameGeometry().right() + spacing + remaining_width = available_rect.right() - remaining_x - margin + 1 + if remaining_width >= 320: + return QRect( + remaining_x, + available_rect.y(), + remaining_width, + available_rect.height() + ) + + remaining_y = main_window.frameGeometry().bottom() + spacing + remaining_height = available_rect.bottom() - remaining_y - margin + 1 + if remaining_height >= 240: + return QRect( + available_rect.x(), + remaining_y, + available_rect.width(), + remaining_height + ) + + return available_rect def activeSubWindow(self): """ diff --git a/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py b/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py index 5a68425..4976988 100644 --- a/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py +++ b/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py @@ -350,9 +350,9 @@ def update_window_menu(self) -> None: window_menu.clear() window_menu.addAction(self.new_graphics_window_act) + window_menu.addAction(self.tile_act) + window_menu.addAction(self.cascade_act) if self.MDI_ON: - window_menu.addAction(self.tile_act) - window_menu.addAction(self.cascade_act) window_menu.addAction(self.minimize_all_graphics_windows_act) window_menu.addAction(self.restore_all_graphics_windows_act) window_menu.addSeparator() diff --git a/cc3d/player5/ViewManager/SimpleViewManager.py b/cc3d/player5/ViewManager/SimpleViewManager.py index 5a9afaa..91fac1a 100644 --- a/cc3d/player5/ViewManager/SimpleViewManager.py +++ b/cc3d/player5/ViewManager/SimpleViewManager.py @@ -215,6 +215,15 @@ def init_window_menu(self): :return: """ menu = QMenu(QApplication.translate('ViewManager', '&Window'), self.ui) + menu.menuAction().setMenuRole(QAction.NoRole) + menu.addAction(self.new_graphics_window_act) + menu.addAction(self.tile_act) + menu.addAction(self.cascade_act) + if getattr(self, 'MDI_ON', False): + menu.addAction(self.minimize_all_graphics_windows_act) + menu.addAction(self.restore_all_graphics_windows_act) + menu.addSeparator() + menu.addAction(self.close_active_window_act) # NOTE initialization of the menu is done in the updateWindowMenu function in SimpleTabView From 979df27fe7c8672688b5a9de89995b003cd0ec35 Mon Sep 17 00:00:00 2001 From: compucell3d Date: Sat, 27 Jun 2026 16:03:25 -0600 Subject: [PATCH 4/4] Added option to move all windows between different screens --- .../Plugins/ViewManagerPlugins/MainArea.py | 50 +++++++++++++++++++ .../ViewManagerPlugins/SimpleTabView.py | 26 ++++++++++ 2 files changed, 76 insertions(+) diff --git a/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py b/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py index a95edb2..10e3bf9 100644 --- a/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py +++ b/cc3d/player5/Plugins/ViewManagerPlugins/MainArea.py @@ -342,6 +342,56 @@ def cascadeSubWindows(self): self.setActiveSubWindow(windows[-1]) + def move_windows_to_screen(self, screen_index): + """ + Moves the main Player window and floating subwindows to another screen without resizing them. + + :param screen_index: target screen index in QApplication.screens() + :type screen_index: int + :return: None + """ + screens = QApplication.screens() + if screen_index < 0 or screen_index >= len(screens): + return + + windows = [self.UI] + self.__arrangeable_windows() + if not windows: + return + + target_rect = screens[screen_index].availableGeometry() + margin = 12 + source_rect = QRect(windows[0].frameGeometry()) + for win in windows[1:]: + source_rect = source_rect.united(win.frameGeometry()) + + offset = target_rect.topLeft() + QPoint(margin, margin) - source_rect.topLeft() + for win in windows: + target_pos = win.pos() + offset + target_pos = self.__constrained_window_position(win, target_pos, target_rect, margin) + win.move(target_pos) + win.raise_() + + def __constrained_window_position(self, win, position, available_rect, margin): + """ + Keeps a moved window's top-left position within the target screen. + + :param win: window being moved + :param position: requested top-left position + :param available_rect: target screen available geometry + :param margin: screen-edge margin + :return: QPoint + """ + width = win.frameGeometry().width() + height = win.frameGeometry().height() + min_x = available_rect.left() + margin + min_y = available_rect.top() + margin + max_x = max(min_x, available_rect.right() - width - margin + 1) + max_y = max(min_y, available_rect.bottom() - height - margin + 1) + x = max(min_x, min(position.x(), max_x)) + y = max(min_y, min(position.y(), max_y)) + + return QPoint(x, y) + def __arrangeable_windows(self): """ Returns visible floating subwindows that should participate in layout operations. diff --git a/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py b/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py index 4976988..9f3a363 100644 --- a/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py +++ b/cc3d/player5/Plugins/ViewManagerPlugins/SimpleTabView.py @@ -352,6 +352,8 @@ def update_window_menu(self) -> None: window_menu.addAction(self.tile_act) window_menu.addAction(self.cascade_act) + if not self.MDI_ON: + self.__add_move_windows_to_screen_menu(window_menu) if self.MDI_ON: window_menu.addAction(self.minimize_all_graphics_windows_act) window_menu.addAction(self.restore_all_graphics_windows_act) @@ -397,6 +399,30 @@ def update_window_menu(self) -> None: self.windowMapper.setMapping(action, win) counter += 1 + def __add_move_windows_to_screen_menu(self, window_menu): + """ + Adds monitor-placement actions to the Window menu for floating-window layout. + + :param window_menu: Window menu + :type window_menu: QMenu + :return: None + """ + screens = QApplication.screens() + if len(screens) < 2: + return + + move_menu = window_menu.addMenu("Move All Windows To Screen") + for idx, screen in enumerate(screens): + geometry = screen.availableGeometry() + action_text = "{0}. {1} ({2}x{3})".format( + idx + 1, + screen.name() or "Screen", + geometry.width(), + geometry.height() + ) + action = move_menu.addAction(action_text) + action.triggered.connect(lambda checked=False, screen_idx=idx: self.move_windows_to_screen(screen_idx)) + def handle_vis_field_created(self, field_name: str, field_type: int, precision_type: str) -> None: """ slot that handles new visualization field creation. This mechanism is necessary to handle fields