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(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()