From 2916b7e1b308501878cb90f30ebda13c054995d5 Mon Sep 17 00:00:00 2001 From: Lucia Poma Date: Fri, 29 May 2026 16:55:30 +0200 Subject: [PATCH 1/3] window risizing correction --- src/dbs_annotator/views/wizard_window.py | 81 +++++++++++++------ .../test_wizard_navigation_extra.py | 13 +++ 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/dbs_annotator/views/wizard_window.py b/src/dbs_annotator/views/wizard_window.py index 63e05de..e27c3ff 100644 --- a/src/dbs_annotator/views/wizard_window.py +++ b/src/dbs_annotator/views/wizard_window.py @@ -182,9 +182,6 @@ def _setup_window(self) -> None: self.setGeometry(x, y, width, height) self.setMinimumSize(WINDOW_MIN_SIZE["width"], WINDOW_MIN_SIZE["height"]) - # Make window resizable - self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True) - # Set smaller size for step 0 (mode selection) self._update_window_size_for_step0() @@ -866,49 +863,81 @@ def _update_window_size_for_step0(self) -> None: self.setGeometry(x, y, compact_width, compact_height) self.setMinimumSize(compact_width, compact_height) self.setMaximumSize(compact_width, compact_height) + self._refresh_title_bar_maximize(enabled=False) - def _update_window_size_for_main_workflow(self) -> None: - """Restore normal window size for main workflow (steps 1+).""" - # Restore normal size policies and remove fixed constraints from step0 - if hasattr(self, "stack"): - self.stack.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum - ) - self.stack.setMaximumWidth(16777215) # Remove fixed width - self.stack.setMaximumHeight(16777215) # Remove fixed height + def _refresh_title_bar_maximize(self, *, enabled: bool) -> None: + """ + Enable or disable the native title-bar maximize button. + + On Windows, fixed min/max sizing (step 0) removes WS_MAXIMIZEBOX from the + native frame; ``setWindowFlag`` alone does not restore it — the frame must + be recreated with ``setWindowFlags`` and ``show()``. + + All standard title-bar hints must be set explicitly; otherwise Windows may + drop the close or maximize button when the frame is recreated. + """ + flags = ( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.WindowMinimizeButtonHint + | Qt.WindowType.WindowCloseButtonHint + ) + if enabled: + flags |= Qt.WindowType.WindowMaximizeButtonHint + + geometry = self.geometry() + visible = self.isVisible() + self.setWindowFlags(flags) + if visible: + self.setGeometry(geometry) + self.show() + + def _release_stack_size_constraints(self) -> None: + """Remove fixed sizes left over from step 0 so content can grow with the window.""" + if not hasattr(self, "stack"): + return + self.stack.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum + ) + self.stack.setMinimumWidth(0) + self.stack.setMinimumHeight(0) + self.stack.setMaximumWidth(16777215) + self.stack.setMaximumHeight(16777215) if hasattr(self, "stack_scroll_area"): self.stack_scroll_area.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) - # Get original normal size + def _update_window_size_for_main_workflow(self) -> None: + """Restore normal window size for main workflow (steps 1+).""" + self._release_stack_size_constraints() + screen = self.app.primaryScreen() rect = screen.availableGeometry() screen_width = rect.width() screen_height = rect.height() - # Calculate desired window size with ratio + min_width = min(WINDOW_MIN_SIZE["width"], screen_width) + min_height = min(WINDOW_MIN_SIZE["height"], screen_height) + desired_width = int(screen_width * WINDOW_SIZE_RATIO["width"]) desired_height = int(screen_height * WINDOW_SIZE_RATIO["height"]) + width = max(desired_width, min_width) + height = max(desired_height, min_height) - # Apply minimum size constraints - width = max(desired_width, WINDOW_MIN_SIZE["width"]) - height = max(desired_height, WINDOW_MIN_SIZE["height"]) - - # Center the normal window x = int((screen_width - width) / 2) y = int((screen_height - height) / 2) - # Reset constraints first to avoid min/max conflicts + # Reset constraints first to avoid min/max conflicts from step 0 self.setMinimumSize(1, 1) - self.setMaximumSize(16777215, 16777215) # Qt max size + self.setMaximumSize(16777215, 16777215) - # Apply new geometry and constraints (allow full maximization) self.setGeometry(x, y, width, height) - self.setMinimumSize(WINDOW_MIN_SIZE["width"], WINDOW_MIN_SIZE["height"]) - self.setMaximumSize( - screen_width, screen_height - ) # Allow full screen maximization + self.setMinimumSize(min_width, min_height) + # No practical max cap — lets the title-bar maximize button work on Windows + self.setMaximumSize(16777215, 16777215) + self._refresh_title_bar_maximize(enabled=True) self._clamp_to_screen() diff --git a/tests/integration/test_wizard_navigation_extra.py b/tests/integration/test_wizard_navigation_extra.py index 7bc1012..d4c118e 100644 --- a/tests/integration/test_wizard_navigation_extra.py +++ b/tests/integration/test_wizard_navigation_extra.py @@ -53,6 +53,19 @@ def test_go_back_annotations_only_step1_to_step0(wizard, qtbot): assert wizard.stack.currentWidget() is wizard.step0_view +def test_main_workflow_window_allows_maximize(wizard, qtbot): + qtbot.mouseClick(wizard.step0_view.full_mode_button, Qt.MouseButton.LeftButton) + max_size = wizard.maximumSize() + min_size = wizard.minimumSize() + assert max_size.width() > min_size.width() + assert max_size.height() > min_size.height() + flags = wizard.windowFlags() + assert flags & Qt.WindowType.WindowMaximizeButtonHint + assert flags & Qt.WindowType.WindowSystemMenuHint + assert flags & Qt.WindowType.WindowCloseButtonHint + assert flags & Qt.WindowType.WindowMinimizeButtonHint + + def test_longitudinal_mode_sets_workflow_and_loads_view(wizard, qtbot): assert wizard.longitudinal_file_view is None wizard._select_longitudinal_report() From 42fa53410308d09edc6e0817ab4f680cef5d70be Mon Sep 17 00:00:00 2001 From: Lucia Poma Date: Fri, 29 May 2026 17:27:07 +0200 Subject: [PATCH 2/3] scrolling areas fixes --- src/dbs_annotator/views/step1_view.py | 216 ++++++++++++++++---------- 1 file changed, 132 insertions(+), 84 deletions(-) diff --git a/src/dbs_annotator/views/step1_view.py b/src/dbs_annotator/views/step1_view.py index 6fc8c08..ffb0590 100644 --- a/src/dbs_annotator/views/step1_view.py +++ b/src/dbs_annotator/views/step1_view.py @@ -11,7 +11,7 @@ from datetime import datetime from typing import cast -from PySide6.QtCore import QSize, Qt +from PySide6.QtCore import QSize, Qt, QTimer from PySide6.QtGui import QDoubleValidator, QIntValidator from PySide6.QtWidgets import ( QComboBox, @@ -29,6 +29,7 @@ QMessageBox, QPushButton, QScrollArea, + QScrollBar, QSizePolicy, QSplitter, QStyle, @@ -66,6 +67,52 @@ logger = logging.getLogger(__name__) +# Horizontal budget for the initial-settings strip (content + external scrollbar). +# Slightly narrower than before so the scrollbar fits without touching the canvases. +_SETTINGS_STRIP_CONTENT_WIDTH = 334 + + +def _panel_with_external_vertical_scroll(scroll: QScrollArea) -> QWidget: + """ + Settings strip: scroll content plus a dedicated vertical scrollbar column. + + The scrollbar sits inside this panel only; callers should leave a layout gap + before the electrode canvases so nothing overlaps. + """ + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + external_bar = QScrollBar(Qt.Orientation.Vertical) + external_bar.setFixedWidth(external_bar.sizeHint().width()) + internal_bar = scroll.verticalScrollBar() + + def sync_from_internal() -> None: + external_bar.blockSignals(True) + external_bar.setRange(internal_bar.minimum(), internal_bar.maximum()) + external_bar.setPageStep(internal_bar.pageStep()) + external_bar.setSingleStep(internal_bar.singleStep()) + external_bar.setValue(internal_bar.value()) + external_bar.blockSignals(False) + needs_scroll = internal_bar.maximum() > internal_bar.minimum() + external_bar.setVisible(needs_scroll) + external_bar.setEnabled(needs_scroll) + + internal_bar.rangeChanged.connect(lambda *_args: sync_from_internal()) + internal_bar.valueChanged.connect(external_bar.setValue) + external_bar.valueChanged.connect(internal_bar.setValue) + + panel = QWidget() + panel.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + row = QHBoxLayout(panel) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(4) + row.addWidget(scroll, 1) + row.addWidget(external_bar) + QTimer.singleShot(0, sync_from_internal) + + strip_width = _SETTINGS_STRIP_CONTENT_WIDTH + external_bar.width() + row.spacing() + panel.setFixedWidth(strip_width) + scroll.setMinimumWidth(_SETTINGS_STRIP_CONTENT_WIDTH) + return panel + class Step1View(BaseStepView): """ @@ -475,9 +522,10 @@ def _create_settings_group(self) -> QGroupBox: Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) sidebar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - sidebar_scroll.setMinimumWidth(380) sidebar_scroll.setWidget(sidebar_widget) + settings_panel = _panel_with_external_vertical_scroll(sidebar_scroll) + electrodes_layout = QVBoxLayout() electrodes_row = QHBoxLayout() @@ -507,8 +555,15 @@ def _create_settings_group(self) -> QGroupBox: electrodes_layout.addLayout(electrodes_row) electrodes_layout.addLayout(self._create_electrode_legend_layout()) - container_layout.addWidget(sidebar_scroll, 0) - container_layout.addLayout(electrodes_layout, 1) + electrodes_widget = QWidget() + electrodes_widget.setLayout(electrodes_layout) + electrodes_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + container_layout.setSpacing(12) + container_layout.addWidget(settings_panel, 0) + container_layout.addWidget(electrodes_widget, 1) layout = QVBoxLayout(gb_init) layout.addLayout(container_layout) @@ -892,25 +947,50 @@ def _create_clinical_scales_group(self) -> QGroupBox: layout = QVBoxLayout(gb_clinical) - # Preset buttons - preset_row = QHBoxLayout() + # Preset buttons — scroll horizontally when they exceed available width + self.preset_scroll_content = QWidget() + self.preset_scroll_content.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed + ) + self.preset_row_layout = QHBoxLayout(self.preset_scroll_content) + self.preset_row_layout.setContentsMargins(0, 0, 0, 0) self.preset_buttons = [] - # preset_row.addStretch(1) - # Settings button + self.preset_scroll_area = QScrollArea() + self.preset_scroll_area.setWidget(self.preset_scroll_content) + self.preset_scroll_area.setWidgetResizable(False) + self.preset_scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.preset_scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.preset_scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.preset_scroll_area.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.preset_scroll_area.setStyleSheet(""" + QScrollArea { + background: transparent; + border: none; + } + QScrollArea > QWidget > QWidget { + background: transparent; + } + """) + settings_btn = QPushButton() settings_btn.setIcon(self._create_settings_icon()) settings_btn.setObjectName("settings_clincal_scales") settings_btn.setToolTip("Settings clinical scales") settings_btn.clicked.connect(self._open_clinical_scales_settings) - preset_row.addWidget(settings_btn) - layout.addLayout(preset_row) + preset_bar = QHBoxLayout() + preset_bar.setContentsMargins(0, 0, 0, 0) + preset_bar.addWidget(self.preset_scroll_area, 1) + preset_bar.addWidget(settings_btn, 0, Qt.AlignmentFlag.AlignTop) + layout.addLayout(preset_bar) - # Store the layout for later updates - self.preset_row_layout = preset_row - - # Build buttons from current presets (JSON) once the row exists self._refresh_preset_buttons() # Container for dynamic scale rows - expands to show all rows @@ -1828,87 +1908,55 @@ def _on_presets_changed(self, new_presets: dict[str, list[str]]): # Preset was deleted - clear scales self._apply_preset_scales([]) + def _update_preset_buttons_geometry(self) -> None: + """Size the preset strip so horizontal scrolling appears when needed.""" + if not hasattr(self, "preset_scroll_content"): + return + self.preset_scroll_content.adjustSize() + hint = self.preset_scroll_content.sizeHint() + width = max(hint.width(), 1) + content_height = max(hint.height(), 1) + self.preset_scroll_content.setMinimumSize(width, content_height) + self.preset_scroll_content.resize(width, content_height) + + # Reserve space below the buttons for the horizontal scrollbar + scrollbar = self.preset_scroll_area.horizontalScrollBar() + sb_height = scrollbar.sizeHint().height() if scrollbar is not None else 0 + self.preset_scroll_area.setFixedHeight(content_height + sb_height) + def _refresh_preset_buttons(self): """Refresh preset buttons with new presets.""" - # Clear existing preset buttons for btn in self.preset_buttons: btn.setParent(None) btn.deleteLater() self.preset_buttons.clear() - # Use the stored preset row layout preset_row = self.preset_row_layout + if not preset_row: + return - if preset_row: - # Remove all existing widgets from the preset row (except the - # stretch and the settings button). - widgets_to_remove = [] - for i in range(preset_row.count()): - item = preset_row.itemAt(i) - if item and item.widget(): - widget = item.widget() - if widget and widget.objectName() != "settings_clincal_scales": - widgets_to_remove.append(widget) - - for widget in widgets_to_remove: - preset_row.removeWidget(widget) + while preset_row.count(): + item = preset_row.takeAt(0) + widget = item.widget() + if widget is not None: widget.setParent(None) widget.deleteLater() - # Find the settings button and its position - settings_btn = None - settings_index = -1 - for i in range(preset_row.count()): - item = preset_row.itemAt(i) - if item and item.widget(): - widget = item.widget() - if widget and widget.objectName() == "settings_clincal_scales": - settings_btn = widget - settings_index = i - break + ordered_names: list[str] = [] + for name in PRESET_BUTTONS: + if name in self.clinical_presets: + ordered_names.append(name) + for name in self.clinical_presets.keys(): + if name not in ordered_names: + ordered_names.append(name) - if settings_btn is None: - return + for preset_name in ordered_names: + btn = QPushButton(preset_name) + btn.setObjectName(f"preset_{preset_name}") + self.preset_buttons.append(btn) + preset_row.addWidget(btn) + + self._update_preset_buttons_geometry() - # Ensure exactly one stretch before the settings button - stretch_index = settings_index - 1 - if stretch_index < 0 or not ( - preset_row.itemAt(stretch_index) - and preset_row.itemAt(stretch_index).spacerItem() - ): - preset_row.insertStretch(settings_index, 1) - settings_index += 1 - stretch_index = settings_index - 1 - - # # Remove any other stretches before the settings button - # # (keep only the one right before it). - # for i in range(stretch_index): - # item = preset_row.itemAt(i) - # if item and item.spacerItem(): - # preset_row.takeAt(i) - # break - - # Insert new preset buttons before the stretch - insert_index = stretch_index - - # Prefer showing defaults first IF they exist in the current presets - ordered_names: list[str] = [] - for name in PRESET_BUTTONS: - if name in self.clinical_presets: - ordered_names.append(name) - for name in self.clinical_presets.keys(): - if name not in ordered_names: - ordered_names.append(name) - - for preset_name in ordered_names: - btn = QPushButton(preset_name) - btn.setObjectName(f"preset_{preset_name}") - self.preset_buttons.append(btn) - preset_row.insertWidget(insert_index, btn) - insert_index += 1 - settings_index += 1 - stretch_index += 1 - - # Reconnect all preset buttons after refresh - if hasattr(self, "on_add_callback") and hasattr(self, "on_remove_callback"): - self._connect_preset_buttons() + if hasattr(self, "on_add_callback") and hasattr(self, "on_remove_callback"): + self._connect_preset_buttons() From f856820ab5479b882402859e4324f79d53fa7218 Mon Sep 17 00:00:00 2001 From: Lucia Poma Date: Fri, 29 May 2026 17:29:07 +0200 Subject: [PATCH 3/3] changelog update --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6428d02..4cb0ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +### Changed + +- Step 1 **Clinical scales** preset buttons scroll horizontally when there are + more presets than fit on one row; the settings gear stays fixed on the right. + +### Fixed + +- Main wizard window: the title-bar **maximize** control works on workflow steps + 1+ (Step 0 stays compact); resizing and full-screen no longer blocked after + leaving the mode-selection screen. +- Step 1 **Initial settings**: the vertical scrollbar sits in its own column + beside the Electrode / Program / Left / Right controls (slightly narrower + strip), with clear spacing before the electrode canvases so the bar is not + covered by the diagram panels. + ## [0.4.0b2] - 2026-05-21 ### Added