"""Capture native Qt client areas with isolated settings and sample data. Run with python -m tests.render_ui --output DIRECTORY. CI explicitly selects xcb, cocoa or windows before importing the test package; the default local test backend remains offscreen. No audio, API request or input is generated. Window decorations, native file dialogs and compositor effects are excluded. """ import argparse import html import json import platform import sys from pathlib import Path from types import SimpleNamespace from unittest import mock from PyQt6.QtCore import QCoreApplication, QEvent, QT_VERSION_STR, Qt from PyQt6.QtGui import QFontInfo from PyQt6.QtWidgets import QApplication, QLineEdit, QScrollArea from tests.test_ui import Settings from dikte import config as cfg, i18n, overlay, settings_ui, theme from dikte.home_ui import HomeWindow SAMPLE = "Bir sonraki sürüm için kayıt kontrollerini tamamlayalım. Ayarları gözden geçirip uygulamayı üç platformda da deneyelim." CASES = ( ("dictation", 620, 560), ("dictation-wide", 1900, 1000), ("file", 620, 640), ("meeting", 680, 760), ("ask", 680, 640), ("settings-general", 720, 760), ("settings-display", 720, 640), ("settings-api", 720, 760), ("settings-assistant", 720, 760), ("settings-shortcuts", 720, 640), ("overlay", 220, 48), ) SETTINGS_PAGES = {"general": 0, "display": 1, "api": 2, "assistant": 4, "shortcuts": 6} def settle(): for _ in range(5): QApplication.processEvents() def capture(widget, path, width, height): # Native styles and fonts still render; monitor size does not constrain # wide-window cases, and the runner's other windows cannot cover them. widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen) widget.resize(width, height) widget.show() settle() pixmap = widget.grab() if pixmap.isNull() or not pixmap.save(str(path), "PNG"): raise RuntimeError(f"Could not capture {path.name}") font = QFontInfo(widget.font()) result = { "file": path.name, "requested_size": [width, height], "logical_size": [widget.width(), widget.height()], "pixel_size": [pixmap.width(), pixmap.height()], "device_pixel_ratio": pixmap.devicePixelRatio(), "font": font.family(), "font_points": font.pointSizeF(), "horizontal_overflow": [], } for area in widget.findChildren(QScrollArea): if area.isVisible() and area.horizontalScrollBar().maximum() > 0: result["horizontal_overflow"].append(area.horizontalScrollBar().maximum()) widget.hide() print(f"Captured {path.name}: {result['logical_size']}, {font.family()}", flush=True) return result def capture_theme(name, output): harness = Settings("runTest") # Keep the actual platform's application branches as well as Qt's style. harness.platform = sys.platform harness.setUp() try: harness.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_sources_once", return_value=[])) conf = harness.config( theme=name, ui_language="tr", transcribe_provider="openrouter", openrouter_api_key="screenshot-only", openrouter_transcribe_model="openai/whisper-1", cleanup_provider="openrouter", assistant_provider="openrouter", ) i18n.set_language("tr") cfg.append_history({"ts": "2026-09-09 14:32:00", "duration": 18, "elapsed": 2, "text": SAMPLE, "raw": SAMPLE}) settings = harness.window(conf) settings.keep_audio.setText("Ses kayıtlarını sakla (örnek kayıt klasörü)") settings.assistant_dir.setPlaceholderText("Proje klasörü") for field in settings.findChildren(QLineEdit): if field.isReadOnly() and "__main__.py toggle" in field.text(): field.setText("dikte toggle") settings._saved_form = settings._form_values() settings._show_dirty() controller = SimpleNamespace( conf=conf, state="idle", ask_state="idle", meeting_state="idle", recording=False, paused=False, home_messages={}, meeting_message="", paste_override={}, meeting_elapsed=SimpleNamespace(elapsed=lambda: 12000), _recorded_seconds=lambda: 12, open_settings=settings.show, ) for method in ("reset_conversation", "_toggle_pause", "_cancel", "cancel_ask", "cancel_meeting", "_toggle_meeting", "start", "stop", "start_ask", "stop_ask"): setattr(controller, method, mock.Mock()) home = HomeWindow(controller, settings) harness.addCleanup(home.deleteLater) harness.addCleanup(home.close) home._timer.stop() settings.file_label.setText("örnek-kayıt.wav") settings.file_output.setPlainText(SAMPLE) home.ask_output.setPlainText("Örnek yanıt: Önce arayüzü doğrulayalım, ardından sürümü hazırlayalım.") indicator = overlay.Overlay(theme_name=name) harness.addCleanup(indicator.deleteLater) harness.addCleanup(indicator.close) images = [] for case, width, height in CASES: if case.startswith("settings-"): settings.tabs.setCurrentIndex(SETTINGS_PAGES[case.removeprefix("settings-")]) widget = settings elif case == "overlay": indicator.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen) indicator.show_recording() indicator._anim.stop() indicator.set_seconds(12) indicator.levels = [0.15, 0.3, 0.7, 0.4] * (overlay.BARS // 4) + [0.2] * (overlay.BARS % 4) widget = indicator width, height = indicator.width(), indicator.height() else: home.show_mode("dictation" if case == "dictation-wide" else case) widget = home images.append(capture(widget, output / f"{name}-{case}.png", width, height)) return images finally: harness.doCleanups() QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--expect-platform") args = parser.parse_args() app = QApplication.instance() backend = app.platformName() if args.expect_platform and backend != args.expect_platform: parser.error(f"Expected {args.expect_platform}, got {backend}") output = args.output.resolve() output.mkdir(parents=True, exist_ok=True) manifest = { "system": platform.system(), "python": platform.python_version(), "qt": QT_VERSION_STR, "qpa_backend": backend, "qt_style": app.style().objectName(), "language": "tr", "scope": "Native Qt client-area renders with sample data and isolated settings. No real recording, API call, window decorations, native file dialogs or compositor validation.", "images": [], } for name in theme.NAMES: manifest["images"].extend(capture_theme(name, output)) (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") title = f"Dikte: {manifest['system']} / {backend} / Qt {QT_VERSION_STR}" cards = "".join( f'
{html.escape(entry["file"])}
' for entry in manifest["images"] ) (output / "index.html").write_text( '' + html.escape(title) + '' '' '

' + html.escape(title) + '

' + html.escape(manifest["scope"]) + '

' + cards + '
', encoding="utf-8", ) if __name__ == "__main__": main()