diff --git a/.github/workflows/ui-screenshots.yml b/.github/workflows/ui-screenshots.yml
new file mode 100644
index 0000000..78a7db3
--- /dev/null
+++ b/.github/workflows/ui-screenshots.yml
@@ -0,0 +1,58 @@
+name: UI screenshots
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ capture:
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ platform: xcb
+ - os: macos-latest
+ platform: cocoa
+ - os: windows-latest
+ platform: windows
+ runs-on: ${{ matrix.os }}
+ env:
+ QT_QPA_PLATFORM: ${{ matrix.platform }}
+ QT_SCALE_FACTOR: "2"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - name: Install Linux display libraries
+ if: runner.os == 'Linux'
+ timeout-minutes: 5
+ run: |
+ apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
+ sudo apt-get $apt update
+ sudo apt-get $apt install --no-install-recommends -y \
+ xvfb xauth libegl1 libgl1 libxkbcommon0 libdbus-1-3 \
+ libglib2.0-0 libfontconfig1 libfreetype6 libgssapi-krb5-2 \
+ libxcb-cursor0 libxkbcommon-x11-0 libxcb-icccm4 \
+ libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-render-util0
+ - name: Install PyQt6
+ run: python -m pip install --quiet PyQt6
+ - name: Capture with X11
+ if: runner.os == 'Linux'
+ run: xvfb-run -a -s "-screen 0 2400x1600x24" python -m tests.render_ui --output ui-screenshots --expect-platform xcb
+ - name: Capture with the native platform
+ if: runner.os != 'Linux'
+ run: python -m tests.render_ui --output ui-screenshots --expect-platform ${{ matrix.platform }}
+ - name: Upload screenshots and rendering details
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-screenshots-${{ runner.os }}
+ path: ui-screenshots/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/tests/render_ui.py b/tests/render_ui.py
new file mode 100644
index 0000000..996f3f5
--- /dev/null
+++ b/tests/render_ui.py
@@ -0,0 +1,169 @@
+"""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(manifest["scope"]) + '