From fd0edffdc98ff3c2bec7a53b76e7f2b7c29e53dc Mon Sep 17 00:00:00 2001 From: Dawn Date: Wed, 16 Sep 2026 10:30:21 +0200 Subject: [PATCH] WIP: resizable camera region, menu panel collapse, zoom ladder - center splitter: left controls | camera tabs | beamline column; drag resizes the camera width, View menu check actions collapse the fixed-width side panels (no on-screen buttons) - font zoom restricted to a 100/125/150 percent ladder; the fixed-width side boxes scale with it so zoomed text does not clip - camera tab widget min width clamped to 1/3 of its natural minimum - resize bars: short centered grip PNGs shared by QMainWindow separators and QSplitter handles, single style source in styles.py - window min width 1244 -> ~376: automation row and sample-list chip row stop propagating their natural minimums (Ignored-width hosts), portrait page fixed width removed (portrait mode pins the window width itself) Co-Authored-By: Claude Fable 5 --- .../gui/graphics/resize_grip_h_hint_dark.png | Bin 0 -> 139 bytes .../gui/graphics/resize_grip_h_hint_light.png | Bin 0 -> 141 bytes .../gui/graphics/resize_grip_h_idle_dark.png | Bin 0 -> 148 bytes .../gui/graphics/resize_grip_h_idle_light.png | Bin 0 -> 137 bytes .../gui/graphics/resize_grip_v_hint_dark.png | Bin 0 -> 145 bytes .../gui/graphics/resize_grip_v_hint_light.png | Bin 0 -> 155 bytes .../gui/graphics/resize_grip_v_idle_dark.png | Bin 0 -> 151 bytes .../gui/graphics/resize_grip_v_idle_light.png | Bin 0 -> 143 bytes src/aare/gui/main_window.py | 108 ++++++++++++++---- src/aare/gui/panels/tell_sample_panel.py | 11 +- src/aare/gui/styles.py | 107 ++++++++--------- tests/unit/gui/test_main_window.py | 35 +++++- 12 files changed, 173 insertions(+), 88 deletions(-) create mode 100644 src/aare/gui/graphics/resize_grip_h_hint_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_h_hint_light.png create mode 100644 src/aare/gui/graphics/resize_grip_h_idle_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_h_idle_light.png create mode 100644 src/aare/gui/graphics/resize_grip_v_hint_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_v_hint_light.png create mode 100644 src/aare/gui/graphics/resize_grip_v_idle_dark.png create mode 100644 src/aare/gui/graphics/resize_grip_v_idle_light.png diff --git a/src/aare/gui/graphics/resize_grip_h_hint_dark.png b/src/aare/gui/graphics/resize_grip_h_hint_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9bdd7921a5fde40f83a35b24155adf8f39a71c GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^PC(4e!3HGN18yn;Db50q$YKTtz9S&aI8~cZ8YpP% z>EaloG4bgIM_vX69+rc9_g!pRDRqE>Q%LaQf5pZ8ZU@s!&-*N4<@pzKLwmxcdUXb# jB|L9#j+S3j3^P6EaloG4bgILs13=o+B4x(oHrlW)d`QV7ycIY{J0<;SEaloG4bgIL(u~Y0xTCJ*Exo%w3uysq^LV7>G7=UZXT|EO_huP@6DSk-+itz<mdKI;Vst0Q8$I)&Kwi literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_h_idle_light.png b/src/aare/gui/graphics/resize_grip_h_idle_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ed9bffc9f4e53765105418b3774a10fcbbc4d4aa GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^PC(4e!3HGN18yn;Db50q$YKTtz9S&aI8~cZ8YpP( z>EaloG4bgIL(u~YJPrrBjrZ*ece^fefFW=5aw7=~*8hJi?&-I-zVutVR&hzS`T>!m h4C&Hc&lX(z#$TJoRnsWwz7A*%gQu&X%Q~loCIAMVDZ&5% literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_hint_dark.png b/src/aare/gui/graphics/resize_grip_v_hint_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7ca63bff592a0585086412e7e1e13e5a0b19accc GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-O z>EaloG4bv3jl2gGcvvrP&Q4Qu-pIi1!X|-2DINd- literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_hint_light.png b/src/aare/gui/graphics/resize_grip_v_hint_light.png new file mode 100644 index 0000000000000000000000000000000000000000..02c3badbc3f63e17d4827d840c2a200dc355e2c1 GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-N z>EaloG4btjMbQQWiPnqn|K70VdaxmbWu8b&M5AbgB3EES75`DomK&wv$177ibAp$r z-pxx0+rX~Im=N^A*vROUs|kY(cRAOG9l>#4uLK;9RJ?l&G?>BD)z4*}Q$iB}KVvXh literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_idle_dark.png b/src/aare/gui/graphics/resize_grip_v_idle_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3f125af8f6cfb2f6606a191ab233b4ea86378d54 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-L z>EaloG4bv3g`y1x0xk#lb3Qt?s3qaDnC+1uA=QNBce-qjf|BOD&hMRQw_=ys(%r5a wce`B~B3vG(r=~vbKEpC$=Fw2*&pWdABw35q&Gwtz1~inx)78&qol`;+0L3pbg8%>k literal 0 HcmV?d00001 diff --git a/src/aare/gui/graphics/resize_grip_v_idle_light.png b/src/aare/gui/graphics/resize_grip_v_idle_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ddef85aa1c9769af79d40052bf042f49f4d7ca65 GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^%s}kK!3HEJ>Ex6FDb50q$YKTtz9S&aI8~cZ8Yt-K z>EaloG4btjL%{|E0fvKrc~WaGDmR<4-D9vaoM0!C$ma57)$JSon>o)G1)t?YQ2rJP_juG>pO1)z4*}Q$iB}+W0GB literal 0 HcmV?d00001 diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 46423584..260e9fec 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -55,6 +55,7 @@ from PySide6.QtWidgets import ( QScrollArea, QSizePolicy, QSlider, + QSplitter, QStackedWidget, QTabWidget, QToolBar, @@ -101,7 +102,7 @@ from aare.gui.styles import ( APP_BACKGROUND, DARK_TEXT, DOCK_CONTENT_LEFT_PAD, - FONT_SCALE_STEP, + FONT_SCALE_LADDER, SEPARATOR_HINT_DELAY_MS, THEME_BLUEBIRD, THEME_FADE_MS, @@ -460,20 +461,32 @@ class MainWindow(QMainWindow): self.left_column_layout.addWidget(self.left_column_tabs) self.left_column_layout.addStretch() - top_widget_layout.addWidget(self.collection_controls_scroll) + # Splitter instead of a plain HBox: the side columns kept their fixed + # widths while the camera region could only grow vertically; dragging + # the handles now resizes the camera width too. Collapsing to zero is + # off — the View-menu toggles are the collapse feature. + # No local handle styling: the app stylesheet's QSplitter::handle rule + # already paints the same line as the QMainWindow::separator between + # docks, so both resize bars share the one definition in styles.py. + self.center_splitter = QSplitter(Qt.Orientation.Horizontal, top_widget) + self.center_splitter.setChildrenCollapsible(False) + top_widget_layout.addWidget(self.center_splitter) + + self.center_splitter.addWidget(self.collection_controls_scroll) self.collection_controls_scroll.setWidget(self.left_column) + # AsNeeded (was AlwaysOff): the splitter can now make the viewport + # narrower than the fixed-width column, and clipped controls must + # stay reachable. self.collection_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) self.collection_controls_scroll.setWidgetResizable(True) # No frame: its border drew a line above the tab bar (Dewar tabs have # none). Freeze the inner column width: widgetResizable makes it track # the viewport, so the scrollbar appearing used to re-flow every - # banner. Fixed width + a permanent 10px scrollbar gutter means the - # scrollbar pops into spare space and nothing moves. - self.collection_controls_scroll.setFrameShape(QFrame.Shape.NoFrame) - self.left_column.setFixedWidth(self.data_collection.set_width) - self.collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) + # banner. Fixed inner width + a 10px scrollbar gutter means the + # scrollbar pops into spare space and nothing moves; the widths are + # zoom-dependent and live in _apply_zoom_widths. self.video_tab = QTabWidget(parent=top_widget) @@ -514,6 +527,14 @@ class MainWindow(QMainWindow): self.video_tab.addTab(self.beamline_view_panel, "Beamline view") self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view") + # The camera region's natural minimum (~284px) came from the widest + # page's controls row (title + refresh button) and the tab labels — + # not from the video views, which rescale freely like they do in + # height. Clamp to 1/3 so the splitter can shrink all four camera + # views that far; past the natural width the tab bar scrolls and the + # controls rows clip. + self.video_tab.setMinimumWidth(self.video_tab.minimumSizeHint().width() // 3) + # if cfg_get("gui.cameras.secondary_beamline_camera_url", None): # self.secondary_beamline_view = VideoGraphicsView() # self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, @@ -548,28 +569,32 @@ class MainWindow(QMainWindow): portrait_page_layout = QHBoxLayout(self.portrait_mode_page) portrait_page_layout.setContentsMargins(0, 0, 0, 0) portrait_page_layout.setSpacing(0) - self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24) + # No fixed page width: an explicit minimum beats the content_stack's + # Ignored-when-hidden policy and pinned the whole window >=444px in + # NORMAL mode. enter_portrait_mode pins the window width itself. portrait_page_layout.addWidget( self.portrait_mode_panel, alignment=Qt.AlignmentFlag.AlignHCenter ) - top_widget_layout.addWidget(self.video_tab) + self.center_splitter.addWidget(self.video_tab) self._start_axis_camera_threads() self.beamline_controls_scroll = NoWheelScrollArea(top_widget) self.beamline = BeamlineControls(self.beamline_controls_scroll) - top_widget_layout.addWidget(self.beamline_controls_scroll) + self.center_splitter.addWidget(self.beamline_controls_scroll) self.beamline_controls_scroll.setWidget(self.beamline) # Resizable so the column shrinks when panels collapse; without it the # scrollbar keeps dead range below the collapsed panels. self.beamline_controls_scroll.setWidgetResizable(True) self.beamline_controls_scroll.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff + Qt.ScrollBarPolicy.ScrollBarAsNeeded ) - # Same gutter math as the left column: 10px scrollbar + 2px frame, so - # the fixed-width controls are never clipped when the scrollbar shows. - self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 12) + # Only the camera region absorbs a window resize; the side columns + # keep whatever width the user dragged. + self.center_splitter.setStretchFactor(1, 1) + # Gutter math per column (10px scrollbar + frame) lives here too. + self._apply_zoom_widths() self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[])) self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[])) @@ -624,7 +649,14 @@ class MainWindow(QMainWindow): # and the buttons balloon; five buttons plus two long checkboxes need # them at their natural size. automation_row.addStretch(1) - dewar_layout.addLayout(automation_row) + # Host widget with Ignored horizontal policy: the row's ~1000px + # natural minimum used to propagate dock -> window and pin the whole + # window wider than small screens (unshrinkable). Now the row clips + # from the right instead. + automation_row_host = QWidget(dewar_tab) + automation_row_host.setLayout(automation_row) + automation_row_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + dewar_layout.addWidget(automation_row_host) # "Remove selected" now unqueues the dewar-table selection — the # queue's own table is no longer displayed. @@ -2059,15 +2091,32 @@ class MainWindow(QMainWindow): settings.setValue("appearance/font_scale", font_scale()) def _change_font_zoom(self, direction: int) -> None: - """Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: step the FONT_* - ladder scale and rebuild the app stylesheet (same repolish path as a - theme switch, so the whole UI rescales in one pass).""" - # round: repeated 0.1 float steps otherwise drift (1.2000000000000002) - set_font_scale( - 1.0 if direction == 0 else round(font_scale() + direction * FONT_SCALE_STEP, 2) - ) + """Ctrl+plus / Ctrl+minus / Ctrl+0 accessibility zoom: walk the + 100/125/150% ladder and rebuild the app stylesheet (same repolish + path as a theme switch, so the whole UI rescales in one pass).""" + # index() is safe: set_font_scale snaps every value onto the ladder. + i = FONT_SCALE_LADDER.index(font_scale()) + i = 0 if direction == 0 else max(0, min(len(FONT_SCALE_LADDER) - 1, i + direction)) + set_font_scale(FONT_SCALE_LADDER[i]) self._save_theme_settings() self._apply_theme() + self._apply_zoom_widths() + + def _apply_zoom_widths(self) -> None: + # The side columns are fixed-width designs; when the ladder scales the + # text, the boxes must widen with it or the zoomed text clips. Runs + # once at construction and again on every zoom change. + s = font_scale() + left = round(self.data_collection.set_width * s) + self.data_collection.setFixedWidth(left) + self.left_column.setFixedWidth(left) + self.collection_controls_scroll.setMaximumWidth(left + 10) + right = round(self.beamline.set_width * s) + self.beamline.setFixedWidth(right) + self.beamline_controls_scroll.setMaximumWidth(right + 12) + # Re-seat the splitter: open both columns to their (new) full width, + # the camera region takes the rest. + self.center_splitter.setSizes([left + 10, 10_000, right + 12]) @Slot() def use_legacy_theme(self) -> None: @@ -2157,6 +2206,19 @@ class MainWindow(QMainWindow): reset_text_action.triggered.connect(lambda: self._change_font_zoom(0)) view_menu.addAction(reset_text_action) view_menu.addSeparator() + + # Side-panel collapse lives here, not as on-screen buttons. + self._show_left_panel_action = QAction("Show Left Panel", self) + self._show_left_panel_action.setCheckable(True) + self._show_left_panel_action.setChecked(True) + self._show_left_panel_action.toggled.connect(self.collection_controls_scroll.setVisible) + view_menu.addAction(self._show_left_panel_action) + self._show_right_panel_action = QAction("Show Right Panel", self) + self._show_right_panel_action.setCheckable(True) + self._show_right_panel_action.setChecked(True) + self._show_right_panel_action.toggled.connect(self.beamline_controls_scroll.setVisible) + view_menu.addAction(self._show_right_panel_action) + view_menu.addSeparator() view_menu.addAction(self._portrait_mode_action) view_menu.addAction(self._enter_automation_view_action) view_menu.addSeparator() diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index 1fcfcbba..cb20127d 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -15,7 +15,9 @@ from PySide6.QtWidgets import ( QHeaderView, QMenu, QPushButton, + QSizePolicy, QTableView, + QWidget, ) from aare.gui.constants import LOGGER_NAME @@ -196,7 +198,14 @@ class TellSamplePanel(QFrame): self.status_chips.addButton(chip) chip_row.addWidget(chip) chip_row.addStretch() - grid_layout.addLayout(chip_row, 1, 0, 1, 4) + # Ignored-width host (same trick as the automation row in + # main_window): the five chips' ~380px natural minimum otherwise + # propagates dock -> window and blocks shrinking the window on small + # screens. The row clips from the right instead. + chip_host = QWidget(self) + chip_host.setLayout(chip_row) + chip_host.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed) + grid_layout.addWidget(chip_host, 1, 0, 1, 4) self.status_chips.buttonClicked.connect( lambda chip: self.table_model.set_status_filter(chip.property("status_key")) ) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index 719a0d2e..3110354c 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -82,6 +82,20 @@ DARK_SLIDER_GRIP = (_GRAPHICS_DIR / "slider_grip_dark.png").as_posix() # Check marks (PRIMARY blue / dusk gold at generation time): CHECK_MARK = (_GRAPHICS_DIR / "check_mark_light.png").as_posix() DARK_CHECK_MARK = (_GRAPHICS_DIR / "check_mark_dark.png").as_posix() +# Resize-bar grips: a short (66px) centered bar instead of a full-length +# line — the full line read as a wall between panels. QSS can't paint a +# fixed-length centered segment, so PNGs like the arrows above; used by BOTH +# QMainWindow::separator and QSplitter::handle, so this is the one place. +# Colors baked in (SEPARATOR_IDLE / SEPARATOR_HINT / DARK_SEPARATOR_IDLE / +# DARK_ACCENT) — regenerate if those knobs change. +GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_light.png").as_posix() +GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_light.png").as_posix() +GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_light.png").as_posix() +GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_light.png").as_posix() +DARK_GRIP_IDLE_V = (_GRAPHICS_DIR / "resize_grip_v_idle_dark.png").as_posix() +DARK_GRIP_IDLE_H = (_GRAPHICS_DIR / "resize_grip_h_idle_dark.png").as_posix() +DARK_GRIP_HINT_V = (_GRAPHICS_DIR / "resize_grip_v_hint_dark.png").as_posix() +DARK_GRIP_HINT_H = (_GRAPHICS_DIR / "resize_grip_h_hint_dark.png").as_posix() # Borders (all can be "transparent" to hide the line): BORDER = "transparent" # main dividers, e.g. the beamline state bar top line @@ -124,32 +138,10 @@ SEPARATOR_HINT = "#3f4a5f" SEPARATOR_IDLE = "#47536a" SEPARATOR_HINT_DELAY_MS = 66 # int, used in code, not QSS -# The gutter keeps this full width for the mouse; only a 2px line + 1px -# shadow is painted inside it (a solid 5px bar read too heavy). 5px is a +# The gutter keeps this full width for the mouse; only the short GRIP_* +# bar is painted inside it (a full-length line read as a wall). 5px is a # first guess — adjust here if the grab target feels off. SEPARATOR_REGION = "5px" -SEPARATOR_SHADOW = "rgba(31, 41, 59, 25%)" - - -def _separator_gradient(line: str, shadow: str, axis: str) -> str: - """Paint of a resize gutter: transparent 1px, shadow 1px, line 2px, - shadow 1px — hard gradient stops at 1/5 steps of SEPARATOR_REGION. - axis "x" runs the gradient left->right (an upright line), "y" top->down - (a lying line).""" - x2, y2 = ("1", "0") if axis == "x" else ("0", "1") - return ( - f"qlineargradient(x1:0, y1:0, x2:{x2}, y2:{y2}," - f" stop:0 transparent, stop:0.19 transparent," - f" stop:0.2 {shadow}, stop:0.39 {shadow}," - f" stop:0.4 {line}, stop:0.79 {line}," - f" stop:0.8 {shadow}, stop:1 {shadow})" - ) - - -SEP_IDLE_X = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "x") -SEP_IDLE_Y = _separator_gradient(SEPARATOR_IDLE, SEPARATOR_SHADOW, "y") -SEP_HINT_X = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "x") -SEP_HINT_Y = _separator_gradient(SEPARATOR_HINT, SEPARATOR_SHADOW, "y") # Theme-switch screenshot cross-fade duration (int ms, used in code). THEME_FADE_MS = 250 @@ -257,11 +249,6 @@ DARK_ACCENT = "#e0913f" # gold # DARK_ACCENT @70%: 10% measured 1.16:1 on DARK_BG (invisible); 70% blends to # >=3.74:1 — same WCAG 1.4.11 floor as the light-theme separators. DARK_SEPARATOR_IDLE = "rgba(224, 145, 63, 70%)" -DARK_SEPARATOR_SHADOW = "rgba(0, 0, 0, 45%)" -DARK_SEP_IDLE_X = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "x") -DARK_SEP_IDLE_Y = _separator_gradient(DARK_SEPARATOR_IDLE, DARK_SEPARATOR_SHADOW, "y") -DARK_SEP_HINT_X = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "x") -DARK_SEP_HINT_Y = _separator_gradient(DARK_ACCENT, DARK_SEPARATOR_SHADOW, "y") DARK_ACCENT_HOVER = "#eaa253" # accent2 — brighter gold DARK_ACCENT_FILL = "#89b4fa" # action blue DARK_ACCENT_FILL_HOVER = "#9ec2fb" # +10% white, derived (site has no step) @@ -607,9 +594,10 @@ FONT_FINE = "11px" # fine print, queue titles # Whole-app text scale applied to the FONT_* ladder when the QSS is built, so # one re-apply of the stylesheet rescales every rule. Floats, so _palette()'s # str filter never picks them up as colors. -FONT_SCALE_MIN = 0.8 -FONT_SCALE_MAX = 1.6 -FONT_SCALE_STEP = 0.1 +# Discrete ladder: 100% / 125% / 150% only. Free ±10% steps produced +# in-between sizes where the fixed-width side boxes clipped their text; +# three vetted stops keep fonts and box widths in lockstep. +FONT_SCALE_LADDER = (1.0, 1.25, 1.5) _font_scale = 1.0 @@ -618,10 +606,10 @@ def font_scale() -> float: def set_font_scale(scale: float) -> None: - # Clamped: below 0.8 the fine-print sizes fall under 9px (unreadable), - # above 1.6 the fixed-height banner rows start clipping their text. + # Snap to the ladder so old saved settings (e.g. 1.2 from the former + # free-step zoom) land on the nearest vetted stop. global _font_scale - _font_scale = max(FONT_SCALE_MIN, min(FONT_SCALE_MAX, scale)) + _font_scale = min(FONT_SCALE_LADDER, key=lambda s: abs(s - scale)) # -- Hover tooltips (the QToolTip popup; styled borderless) ----------------- @@ -1169,20 +1157,21 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: } /* Resize gutters: the mouse keeps the full $separator_region, but only - a 2px line + 1px shadow paints (gradients in _separator_gradient). - An upright line needs the x-gradient: that is a :vertical main-window - separator but a :horizontal splitter handle (handle orientation - follows the splitter, not the bar). */ + the short centered GRIP_* bar paints (image: never stretches, it + centers). An upright grip is the _v file: that is a :vertical + main-window separator but a :horizontal splitter handle (handle + orientation follows the splitter, not the bar). */ QMainWindow::separator { width: $separator_region; height: $separator_region; background: transparent; } + QSplitter::handle { background: transparent; } QMainWindow::separator:vertical, QSplitter::handle:horizontal { - background: $sep_idle_x; + image: url($grip_idle_v); } QMainWindow::separator:horizontal, QSplitter::handle:vertical { - background: $sep_idle_y; + image: url($grip_idle_h); } QSplitter::handle:horizontal { width: $separator_region; } QSplitter::handle:vertical { height: $separator_region; } @@ -1191,19 +1180,20 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: MainWindow.event() after a 1s hover rest or on press; :hover limits the fill to the exact separator being dragged. */ QMainWindow[separatorHint="true"]::separator:vertical:hover { - background: $sep_hint_x; + image: url($grip_hint_v); } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - background: $sep_hint_y; + image: url($grip_hint_h); } - /* Splitter handles (prediction metrics) are plain child widgets the - property gate above doesn't reach — immediate hover/press hint. */ + /* Splitter handles (camera region, prediction metrics) are plain child + widgets the property gate above doesn't reach — immediate hover/press + hint. */ QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - background: $sep_hint_x; + image: url($grip_hint_v); } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - background: $sep_hint_y; + image: url($grip_hint_h); } QFrame#beamlineControls, @@ -1846,33 +1836,34 @@ def _sunset_stylesheet() -> str: background: transparent; } - /* Idle + hover resize lines, dark flavor — see the light-theme note. */ + /* Idle + hover resize grips, dark flavor — see the light-theme note. */ QMainWindow::separator { width: $separator_region; height: $separator_region; background: transparent; } + QSplitter::handle { background: transparent; } QMainWindow::separator:vertical, QSplitter::handle:horizontal { - background: $dark_sep_idle_x; + image: url($dark_grip_idle_v); } QMainWindow::separator:horizontal, QSplitter::handle:vertical { - background: $dark_sep_idle_y; + image: url($dark_grip_idle_h); } QSplitter::handle:horizontal { width: $separator_region; } QSplitter::handle:vertical { height: $separator_region; } QMainWindow[separatorHint="true"]::separator:vertical:hover { - background: $dark_sep_hint_x; + image: url($dark_grip_hint_v); } QMainWindow[separatorHint="true"]::separator:horizontal:hover { - background: $dark_sep_hint_y; + image: url($dark_grip_hint_h); } QSplitter::handle:horizontal:hover, QSplitter::handle:horizontal:pressed { - background: $dark_sep_hint_x; + image: url($dark_grip_hint_v); } QSplitter::handle:vertical:hover, QSplitter::handle:vertical:pressed { - background: $dark_sep_hint_y; + image: url($dark_grip_hint_h); } /* Plain scroll containers stay frameless. */ @@ -2047,12 +2038,12 @@ if __name__ == "__main__": assert APP_BACKGROUND not in build_app_stylesheet(THEME_BLUEBIRD) # Font zoom: the FONT_* ladder must follow the scale, and the scale must # clamp to its documented bounds. - set_font_scale(1.3) - assert "font-size: 21px" in build_app_stylesheet(THEME_SUNRISE) # title 16->21 + set_font_scale(1.3) # snaps to the 1.25 ladder stop + assert "font-size: 20px" in build_app_stylesheet(THEME_SUNRISE) # title 16->20 set_font_scale(99.0) - assert font_scale() == FONT_SCALE_MAX + assert font_scale() == FONT_SCALE_LADDER[-1] set_font_scale(0.0) - assert font_scale() == FONT_SCALE_MIN + assert font_scale() == FONT_SCALE_LADDER[0] set_font_scale(1.0) assert "font-size: 16px" in build_app_stylesheet(THEME_SUNRISE) # This line was added by Claude. But I would do the same. So all gude. diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 380424cc..da5e175e 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -72,6 +72,21 @@ def test_main_window_init(qtbot, mock_ui_state, daq_status_factory): win.data_collection._emit_change_energy() assert sent and abs(sent[0] - 12400.0) < 1e-6 + # Side panels collapse from the View menu (no on-screen buttons), and + # the camera region sits in a splitter so its width is drag-resizable. + assert win.center_splitter.widget(1) is win.video_tab + # The clamp lets the splitter shrink the camera region well below the + # natural minimum the tab labels + controls rows would demand. + assert 0 < win.video_tab.minimumWidth() < win.video_tab.minimumSizeHint().width() + win._show_left_panel_action.trigger() + assert win.collection_controls_scroll.isHidden() + win._show_left_panel_action.trigger() + assert not win.collection_controls_scroll.isHidden() + win._show_right_panel_action.trigger() + assert win.beamline_controls_scroll.isHidden() + win._show_right_panel_action.trigger() + assert not win.beamline_controls_scroll.isHidden() + # Motion watch: only the robot station switches to the combined # beamline view. Moving no longer does (users kept losing the sample # camera on short gonio moves), and busy alone never does — Sample @@ -525,20 +540,28 @@ def test_font_zoom_steps_clamps_and_resets(qtbot, mock_ui_state): try: base_pt = win._default_app_font.pointSizeF() win._change_font_zoom(1) - assert styles.font_scale() == pytest.approx(1.1) + assert styles.font_scale() == pytest.approx(1.25) # Part 2 of the zoom: the app default font scales with the ladder. app = QApplication.instance() assert isinstance(app, QApplication) # narrow from QCoreApplication|None - assert app.font().pointSizeF() == pytest.approx(base_pt * 1.1) - for _ in range(20): + assert app.font().pointSizeF() == pytest.approx(base_pt * 1.25) + # Part 3: the fixed-width side boxes follow the ladder, else the + # zoomed text clips inside them. + assert win.data_collection.minimumWidth() == round(win.data_collection.set_width * 1.25) + assert win.collection_controls_scroll.maximumWidth() == ( + round(win.data_collection.set_width * 1.25) + 10 + ) + assert win.beamline.minimumWidth() == round(win.beamline.set_width * 1.25) + for _ in range(5): win._change_font_zoom(1) - assert styles.font_scale() == styles.FONT_SCALE_MAX + assert styles.font_scale() == styles.FONT_SCALE_LADDER[-1] win._change_font_zoom(0) assert styles.font_scale() == 1.0 assert app.font().pointSizeF() == pytest.approx(base_pt) - for _ in range(20): + assert win.data_collection.minimumWidth() == win.data_collection.set_width + for _ in range(5): win._change_font_zoom(-1) - assert styles.font_scale() == styles.FONT_SCALE_MIN + assert styles.font_scale() == styles.FONT_SCALE_LADDER[0] # floor is 100% finally: styles.set_font_scale(1.0) if saved is None: