diff --git a/sras_viewer.py b/sras_viewer.py index 77a9a0c..c09d578 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -28,9 +28,9 @@ from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, - QDoubleSpinBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QLabel, - QMainWindow, QProgressDialog, QPushButton, QRadioButton, QSizePolicy, - QSpinBox, QSplitter, QVBoxLayout, QWidget, + QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, + QLabel, QMainWindow, QProgressDialog, QPushButton, QRadioButton, QScrollArea, + QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget, ) import sras_compute as compute @@ -78,6 +78,79 @@ _CSS_MUTED = "color: #888; font-size: 11px;" _CSS_WARN = "color: #e07000; font-size: 11px;" _CSS_BUSY = "color: #4a90d9; font-size: 11px;" +# Side-panel column widths (the scroll areas that hold the controls). +_LEFT_PANEL_W = 288 +_RIGHT_PANEL_W = 272 + +# Minimum width for a spin box so its value + suffix are never clipped. +_SPIN_MIN_W = 96 + + +# --------------------------------------------------------------------------- +# Small layout helpers +# --------------------------------------------------------------------------- + +def _wrap_label(text: str = "", css: str | None = None) -> QLabel: + """A word-wrapped QLabel that reports its *wrapped* height to the layout. + + A plain word-wrapped QLabel advertises a single-line minimum height, so in a + fixed-width column the layout happily shrinks it and the extra lines get + clipped. Enabling height-for-width makes the box layout ask for the real + height at the column's width instead. + """ + lbl = QLabel(text) + lbl.setWordWrap(True) + sp = lbl.sizePolicy() + sp.setVerticalPolicy(QSizePolicy.Policy.Minimum) + sp.setHeightForWidth(True) + lbl.setSizePolicy(sp) + if css: + lbl.setStyleSheet(css) + return lbl + + +def _group(title: str) -> tuple[QGroupBox, QVBoxLayout]: + """A group box with consistent, non-cramped internal margins.""" + grp = QGroupBox(title) + lay = QVBoxLayout(grp) + lay.setContentsMargins(10, 8, 10, 10) + lay.setSpacing(6) + return grp, lay + + +def _form() -> QFormLayout: + """A label/field form layout for a narrow side panel.""" + form = QFormLayout() + form.setContentsMargins(0, 0, 0, 0) + form.setHorizontalSpacing(8) + form.setVerticalSpacing(6) + form.setLabelAlignment(Qt.AlignmentFlag.AlignRight + | Qt.AlignmentFlag.AlignVCenter) + form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows) + return form + + +def _scroll_panel(inner: QWidget, width: int) -> QScrollArea: + """Put a side panel in a fixed-width scroll area. + + Without this the panels are sized by the window: a short window squeezes the + controls past their minimum heights, which is what makes text overlap the + widget below it. Scrolling keeps every control at its natural size. + """ + area = QScrollArea() + area.setWidget(inner) + area.setWidgetResizable(True) + area.setFrameShape(QFrame.Shape.NoFrame) + area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + area.setFixedWidth(width) + area.viewport().setAutoFillBackground(False) + inner.setAutoFillBackground(False) + return area + # --------------------------------------------------------------------------- # ROI (free quadrilateral in data coordinates) @@ -674,6 +747,7 @@ class SrasViewerWindow(QMainWindow): super().__init__() self.setWindowTitle("SRAS Scan Viewer") self.resize(1560, 840) + self.setMinimumSize(960, 560) self.setAcceptDrops(True) self._sras: SrasFile | None = None @@ -791,69 +865,69 @@ class SrasViewerWindow(QMainWindow): def _build_left_panel(self) -> QWidget: panel = QWidget() - panel.setFixedWidth(260) panel_layout = QVBoxLayout(panel) panel_layout.setContentsMargins(0, 0, 0, 0) - panel_layout.setSpacing(6) + panel_layout.setSpacing(8) # ---- File ------------------------------------------------------- - grp_file = QGroupBox("File") - fl = QVBoxLayout(grp_file) + grp_file, fl = _group("File") self.btn_open = QPushButton("Open .sras…") self.btn_open.clicked.connect(self._on_open) - self.lbl_filename = QLabel("No file loaded") - self.lbl_filename.setWordWrap(True) - self.lbl_filename.setStyleSheet(_CSS_MUTED) + self.lbl_filename = _wrap_label("No file loaded", _CSS_MUTED) fl.addWidget(self.btn_open) fl.addWidget(self.lbl_filename) panel_layout.addWidget(grp_file) # ---- Scan info -------------------------------------------------- - grp_info = QGroupBox("Scan Info") - il = QVBoxLayout(grp_info) + grp_info, il = _group("Scan Info") + il.setSpacing(3) self._info = {} for key in ("Angles", "Rows", "Frames / row", "Samples / frame", "Sample rate", "X start", "Pixel Δx", "Laser freq"): - lbl = QLabel(f"{key}: —") - lbl.setWordWrap(True) - lbl.setStyleSheet(_CSS_INFO) + lbl = _wrap_label(f"{key}: —", _CSS_INFO) il.addWidget(lbl) self._info[key] = lbl - self.lbl_frame_warn = QLabel("") # frame-count / format notes - self.lbl_frame_warn.setWordWrap(True) - self.lbl_frame_warn.setStyleSheet(_CSS_WARN) + # frame-count / format notes + self.lbl_frame_warn = _wrap_label("", _CSS_WARN) il.addWidget(self.lbl_frame_warn) - self.lbl_dc_precompute = QLabel("") # background DC-precompute progress - self.lbl_dc_precompute.setWordWrap(True) - self.lbl_dc_precompute.setStyleSheet(_CSS_BUSY) + # background DC-precompute progress + self.lbl_dc_precompute = _wrap_label("", _CSS_BUSY) il.addWidget(self.lbl_dc_precompute) panel_layout.addWidget(grp_info) # ---- View settings ---------------------------------------------- - grp_view = QGroupBox("View Settings") - vl = QVBoxLayout(grp_view) + grp_view, vl = _group("View Settings") + + view_form = _form() - ar = QHBoxLayout() - ar.addWidget(QLabel("Angle:")) self.spin_angle = QSpinBox() self.spin_angle.setRange(0, 0) self.spin_angle.setEnabled(False) + self.spin_angle.setMinimumWidth(64) self.spin_angle.editingFinished.connect(self._on_view_changed) self.lbl_angle_deg = QLabel("—") + angle_field = QWidget() + ar = QHBoxLayout(angle_field) + ar.setContentsMargins(0, 0, 0, 0) + ar.setSpacing(6) ar.addWidget(self.spin_angle) ar.addWidget(self.lbl_angle_deg) - vl.addLayout(ar) + ar.addStretch() + view_form.addRow("Angle:", angle_field) - cr = QHBoxLayout() - cr.addWidget(QLabel("Channel:")) self.combo_channel = QComboBox() self.combo_channel.addItems(CH_LABELS) self.combo_channel.setEnabled(False) + self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed) + self.combo_channel.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) + self.combo_channel.setMinimumContentsLength(12) self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) - cr.addWidget(self.combo_channel) - vl.addLayout(cr) + view_form.addRow("Channel:", self.combo_channel) + vl.addLayout(view_form) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) @@ -861,10 +935,8 @@ class SrasViewerWindow(QMainWindow): vl.addWidget(sep) # DC threshold (for RF / CH1 masking) - self.grp_threshold = QGroupBox("RF Mask Threshold (CH1 only)") - tl = QVBoxLayout(self.grp_threshold) - thr_row = QHBoxLayout() - thr_row.addWidget(QLabel("DC threshold:")) + self.grp_threshold, tl = _group("RF Mask Threshold (CH1 only)") + thr_form = _form() self.spin_threshold_mv = QDoubleSpinBox() self.spin_threshold_mv.setRange(-500.0, 500.0) self.spin_threshold_mv.setDecimals(3) @@ -872,11 +944,12 @@ class SrasViewerWindow(QMainWindow): self.spin_threshold_mv.setSuffix(" mV") self.spin_threshold_mv.setValue(50.0) self.spin_threshold_mv.setEnabled(False) + self.spin_threshold_mv.setMinimumWidth(_SPIN_MIN_W) self.spin_threshold_mv.editingFinished.connect(self._on_threshold_changed) - thr_row.addWidget(self.spin_threshold_mv) - tl.addLayout(thr_row) - self.lbl_threshold_adc = QLabel(f"≈ {mv_to_adc(50.0):.1f} ADC counts") - self.lbl_threshold_adc.setStyleSheet(_CSS_MUTED) + thr_form.addRow("DC threshold:", self.spin_threshold_mv) + tl.addLayout(thr_form) + self.lbl_threshold_adc = _wrap_label( + f"≈ {mv_to_adc(50.0):.1f} ADC counts", _CSS_MUTED) tl.addWidget(self.lbl_threshold_adc) vl.addWidget(self.grp_threshold) @@ -913,8 +986,7 @@ class SrasViewerWindow(QMainWindow): panel_layout.addWidget(grp_view) # ---- ROI --------------------------------------------------------- - grp_roi = QGroupBox("ROI (Region of Interest)") - rl = QVBoxLayout(grp_roi) + grp_roi, rl = _group("ROI (Region of Interest)") self.btn_draw_roi = QPushButton("Draw ROI") self.btn_draw_roi.setCheckable(True) @@ -943,24 +1015,26 @@ class SrasViewerWindow(QMainWindow): self.btn_export_roi.clicked.connect(self._on_export_roi_csv) rl.addWidget(self.btn_export_roi) - self.lbl_roi_center = QLabel("centroid: —") - self.lbl_roi_size = QLabel("bbox: —") - self.lbl_roi_npix = QLabel("pixels inside: —") + self.lbl_roi_center = _wrap_label("centroid: —", _CSS_HINT) + self.lbl_roi_size = _wrap_label("bbox: —", _CSS_HINT) + self.lbl_roi_npix = _wrap_label("pixels inside: —", _CSS_HINT) for lbl in (self.lbl_roi_center, self.lbl_roi_size, self.lbl_roi_npix): - lbl.setStyleSheet(_CSS_HINT) rl.addWidget(lbl) panel_layout.addWidget(grp_roi) panel_layout.addStretch() - return panel + return _scroll_panel(panel, _LEFT_PANEL_W) def _build_canvases(self) -> QWidget: splitter = QSplitter(Qt.Orientation.Vertical) + splitter.setChildrenCollapsible(False) img_widget = QWidget() img_vl = QVBoxLayout(img_widget) img_vl.setContentsMargins(0, 0, 0, 0) + img_vl.setSpacing(4) self.image_canvas = ImageCanvas() + self.image_canvas.setMinimumHeight(220) self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked) self.image_canvas.roi_changed.connect(self._update_roi_ui) self.image_canvas.draw_mode_changed.connect(self._on_draw_mode_changed) @@ -971,24 +1045,26 @@ class SrasViewerWindow(QMainWindow): wave_widget = QWidget() wave_vl = QVBoxLayout(wave_widget) wave_vl.setContentsMargins(0, 0, 0, 0) + wave_vl.setSpacing(4) self.lbl_wave_hint = QLabel( "Click a pixel in the image above to inspect its waveform.") self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lbl_wave_hint.setStyleSheet(_CSS_MUTED) self.wave_canvas = WaveformCanvas() + self.wave_canvas.setMinimumHeight(150) wave_vl.addWidget(self.lbl_wave_hint) wave_vl.addWidget(self.wave_canvas) splitter.addWidget(wave_widget) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) splitter.setSizes([580, 250]) return splitter def _build_right_panel(self) -> QWidget: # Velocity settings (visible only in velocity mode) - self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)") - vel_l = QVBoxLayout(self.grp_velocity) - grat_row = QHBoxLayout() - grat_row.addWidget(QLabel("Grating size:")) + self.grp_velocity, vel_l = _group("Velocity Settings (CH1 only)") + vel_form = _form() self.spin_grating_um = QDoubleSpinBox() self.spin_grating_um.setRange(0.1, 1000.0) self.spin_grating_um.setDecimals(2) @@ -996,53 +1072,52 @@ class SrasViewerWindow(QMainWindow): self.spin_grating_um.setSuffix(" µm") self.spin_grating_um.setValue(25) self.spin_grating_um.setEnabled(False) + self.spin_grating_um.setMinimumWidth(_SPIN_MIN_W) self.spin_grating_um.editingFinished.connect(self._on_grating_changed) - grat_row.addWidget(self.spin_grating_um) - vel_l.addLayout(grat_row) - lbl_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)") - lbl_formula.setStyleSheet("font-size: 10px; color: #888;") - vel_l.addWidget(lbl_formula) + vel_form.addRow("Grating size:", self.spin_grating_um) + vel_l.addLayout(vel_form) + vel_l.addWidget(_wrap_label("v (m/s) = freq (MHz) × grating (µm)", + "font-size: 10px; color: #888;")) self.grp_velocity.setVisible(False) - grp_display = QGroupBox("Display Options") - dl = QVBoxLayout(grp_display) + grp_display, dl = _group("Display Options") - cmr = QHBoxLayout() - cmr.addWidget(QLabel("Colormap:")) + cmap_form = _form() self.combo_cmap = QComboBox() self.combo_cmap.addItems(CMAPS) self.combo_cmap.setCurrentText("gray") self.combo_cmap.setEnabled(False) + self.combo_cmap.setSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed) self.combo_cmap.currentIndexChanged.connect(self._on_cmap_changed) - cmr.addWidget(self.combo_cmap) - dl.addLayout(cmr) + cmap_form.addRow("Colormap:", self.combo_cmap) + dl.addLayout(cmap_form) self.chk_auto = QCheckBox("Auto-scale colormap") self.chk_auto.setChecked(True) self.chk_auto.toggled.connect(self._on_autoscale_toggled) dl.addWidget(self.chk_auto) + range_form = _form() for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")): - row = QHBoxLayout() - row.addWidget(QLabel(label)) spin = QDoubleSpinBox() spin.setRange(-1e9, 1e9) spin.setDecimals(4) spin.setEnabled(False) + spin.setMinimumWidth(_SPIN_MIN_W) spin.editingFinished.connect(self._on_manual_range_changed) setattr(self, attr, spin) - row.addWidget(spin) - dl.addLayout(row) + range_form.addRow(label, spin) + dl.addLayout(range_form) right_panel = QWidget() - right_panel.setFixedWidth(270) layout = QVBoxLayout(right_panel) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) + layout.setSpacing(8) layout.addWidget(self.grp_velocity) layout.addWidget(grp_display) layout.addStretch() - return right_panel + return _scroll_panel(right_panel, _RIGHT_PANEL_W) def _build_menus(self): menubar = self.menuBar()