from __future__ import annotations import argparse import json import os import re import sys import time from pathlib import Path from PySide6.QtCore import QPoint, Qt from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton, QWidget PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window from step_editor.widgets import NoWheelComboBox def _pump(app: QApplication, ms: int = 150) -> None: deadline = time.monotonic() + ms / 1000.0 while time.monotonic() < deadline: app.processEvents() time.sleep(0.005) def _native_grab(widget: QWidget, target: Path) -> None: app = QApplication.instance() if app is None: return screen = app.primaryScreen() if screen is None: return screen.grabWindow(int(widget.winId())).save(str(target)) def _compact_text(text: object, limit: int = 120) -> str: value = str(text or "").replace("\n", " ").strip() if len(value) <= limit: return value return f"{value[: limit - 1]}..." def _label_overflows(label: QLabel) -> bool: if label.width() <= 0: return False return bool(label.sizeHint().width() > label.width() + 3 and not label.wordWrap()) def _row_snapshot(window: StepEditorWindow) -> list[dict[str, object]]: rows = getattr(window, "property_card_rows", {}) result: list[dict[str, object]] = [] if not isinstance(rows, dict): return result for row, widgets in rows.items(): if not isinstance(widgets, dict): continue card = widgets.get("card") if not isinstance(card, QWidget): continue labels = card.findChildren(QLabel) result.append( { "row": int(row), "height": card.height(), "selected": bool(card.property("selected")), "visible": card.isVisible(), "title": _compact_text(widgets.get("status_label").text() if False else ""), "labels": [ { "object": label.objectName(), "text": _compact_text(label.text(), 90), "width": label.width(), "size_hint_width": label.sizeHint().width(), "overflow": _label_overflows(label), } for label in labels if label.isVisible() ], "has_editor": isinstance(widgets.get("target_editor"), QLineEdit), "has_scope": isinstance(widgets.get("scope_combo"), NoWheelComboBox), "has_button": isinstance(widgets.get("action_button"), QPushButton), } ) return result def _actionable_labels(window: StepEditorWindow) -> list[str]: return [str(spec.get("label", "") or "") for _row, spec in window._actionable_property_rows()] def _find_actionable_target(window: StepEditorWindow) -> dict[str, object] | None: if window.model is None: return None best: dict[str, object] | None = None best_score = -1 max_faces = len(window.model.faces) for mode, selector in (("Face", window.select_face), ("Feature", window.select_feature)): window._set_selection_mode(mode) for face_id in range(max_faces): selector(face_id) QApplication.processEvents() labels = _actionable_labels(window) score = len(labels) if score > best_score: best_score = score best = {"mode": mode, "face_id": face_id, "labels": labels} if score >= 3: return best return best def _numeric_target_text(text: str) -> str: match = re.search(r"[-+]?\d+(?:\.\d+)?", text) if not match: return "1" value = float(match.group(0)) if abs(value) < 1e-9: value = 1.0 else: value *= 1.05 return f"{value:.6g}" def _pick_editable_numeric_row(window: StepEditorWindow) -> int | None: for row, spec in window._actionable_property_rows(): effective = window._effective_property_spec(spec, row=row) if str(effective.get("value_type", "number")) == "number": return int(row) rows = window._actionable_property_rows() return int(rows[0][0]) if rows else None def _click_card(window: StepEditorWindow, row: int) -> bool: card = window._property_card_widgets(row).get("card") if not isinstance(card, QWidget): return False QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(18, max(3, card.height() // 2))) return True def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output-dir", default="assets/screenshots/property_panel_usability") parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH)) parser.add_argument("--face-id", type=int, default=None) parser.add_argument("--selection-mode", choices=("Face", "Feature"), default=None) parser.add_argument("--detection-level", choices=("current-only", "associated-only", "secondary"), default=None) parser.add_argument("--expand-diagnostics", action="store_true") args = parser.parse_args() output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) _suppress_vtk_output_window() app = QApplication(["property-panel-usability-probe"]) window = StepEditorWindow(Path(args.model), background_load=False) window.resize(1280, 820) window.show() _pump(app, 350) window.load_step(Path(args.model), background=False) _pump(app, 500) _native_grab(window, output_dir / "00_loaded.png") if args.detection_level and hasattr(window, "feature_detection_combo"): index = window.feature_detection_combo.findData(args.detection_level) if index >= 0: window.feature_detection_combo.setCurrentIndex(index) _pump(app, 250) target = None if args.face_id is not None: mode = args.selection_mode or "Feature" if mode == "Feature": window._set_selection_mode("Feature") window.select_feature(int(args.face_id)) else: window._set_selection_mode("Face") window.select_face(int(args.face_id)) _pump(app, 350) target = { "mode": mode, "face_id": int(args.face_id), "labels": _actionable_labels(window), } else: target = _find_actionable_target(window) if target is None or not target.get("labels"): summary = { "model": str(Path(args.model)), "error": "no actionable face or feature found", } (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") print(f"property panel usability probe wrote {output_dir}") return 2 mode = str(target["mode"]) face_id = int(target["face_id"]) window._set_selection_mode(mode) if mode == "Feature": window.select_feature(face_id) else: window.select_face(face_id) _pump(app, 350) _native_grab(window, output_dir / "01_compact_actionable.png") if args.expand_diagnostics and hasattr(window, "property_expand_button") and window.property_expand_button.isVisible(): QTest.mouseClick( window.property_expand_button, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)), ) _pump(app, 250) _native_grab(window, output_dir / "01b_expanded_diagnostics_before_edit.png") target_row = _pick_editable_numeric_row(window) if target_row is not None: _click_card(window, target_row) _pump(app, 250) _native_grab(window, output_dir / "02_expanded_first_action.png") expanded_rows = _row_snapshot(window) entered_target = "" button_text_after_input = "" selected_widgets = window._property_card_widgets(target_row) if target_row is not None else {} editor = selected_widgets.get("target_editor") if isinstance(editor, QLineEdit): entered_target = _numeric_target_text(editor.text()) editor.setFocus() editor.selectAll() QTest.keyClicks(editor, entered_target) _pump(app, 250) button = selected_widgets.get("action_button") if isinstance(button, QPushButton): button_text_after_input = button.text() _native_grab(window, output_dir / "03_after_target_input.png") collapsed_after_blank_click = False if target_row is not None: _click_card(window, target_row) _pump(app, 250) collapsed_after_blank_click = getattr(window, "property_editor_selected_row", None) is None _native_grab(window, output_dir / "04_collapsed_again.png") if hasattr(window, "property_expand_button") and window.property_expand_button.isVisible(): QTest.mouseClick( window.property_expand_button, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(max(3, window.property_expand_button.width() // 2), max(3, window.property_expand_button.height() // 2)), ) _pump(app, 250) _native_grab(window, output_dir / "05_more_diagnostics.png") compact_rows = _row_snapshot(window) overflow_labels = [ label for row in compact_rows + expanded_rows for label in row.get("labels", []) if bool(label.get("overflow")) ] summary = { "model": str(Path(args.model)), "target": { "mode": mode, "face_id": face_id, "actionable_labels": target.get("labels", []), }, "summary_label": getattr(window, "property_command_summary_label", None).text() if hasattr(window, "property_command_summary_label") else "", "target_row": target_row, "entered_target": entered_target, "button_text_after_input": button_text_after_input, "collapsed_after_blank_click": collapsed_after_blank_click, "compact_rows": compact_rows, "expanded_rows": expanded_rows, "overflow_labels": overflow_labels, "selected_row_after_diagnostics": getattr(window, "property_editor_selected_row", None), } (output_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") window.close() _pump(app, 100) app.quit() print(f"property panel usability probe wrote {output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())