[office/crow-translate] /: LocalAI hotfixes + despaghettification of AI Vision OCR into an implementation of modular OCR
Maciej Bonin <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 5f16d059b4e983ac29be37965fa655459d59727a by Maciej Bonin.
Committed on 17/08/2026 at 21:10.
Pushed by pillowtrucker into branch 'master'.
LocalAI hotfixes + despaghettification of AI Vision OCR into an implementation of modular OCR
## What this MR does
Four milestones, in commit order:
### 1. URL normalization
Users paste either a base URL (SDK style) or the full endpoint from docs. `completionsUrl()`/`modelsUrl()` detect which shape was entered instead of blindly appending suffixes. A base that carries its own path (e.g. z.ai `https://api.z.ai/api/coding/paas/v4`) is treated as the complete SDK base: only the kind suffix is appended, never a second version segment.
### 2. Despaghettify AI Vision OCR into a modular OCR engine
Vision (translate an image) used to live INSIDE `LocalAiTranslationProvider`: `setSourceImage()` stashed image bytes on the translation provider, and `sendTranslation()` branched into a fused multimodal call that OCRed and translated in ONE request. Screen-capture OCR (Tesseract) was a completely separate pipeline sharing nothing with it.
OCR is now a provider concept:
- New `AOcrProvider` interface (`src/ocr/aocrprovider.h`): `recognize(image)`, `recognized`/`failed`/`canceled` signals. `Ocr` is renamed `TesseractOcr` and implements it; D-Bus object path unchanged.
- New `LlmOcr` engine sends the image to an OpenAI/Anthropic-compatible vision model with a **transcription-only** prompt and emits the recognized text - no translation happens inside it.
- MainWindow's image input (drag&drop/paste/file, preview overlay, Escape) routes through the configured OCR engine and recognizes a dropped image immediately; recognized text lands in the source edit and is translated by whatever translation backend is active - vision works with Google/Mozhi/LibreTranslate/LocalAI alike, and LLM OCR works for pure recognition too.
- Settings gain an engine picker on the OCR page (Tesseract / vision model). LLM OCR reuses the LocalAI provider's URL/key and gets a live vision-model probe (`VisionModelProbe`, native Ollama/LM Studio capability reporting where the server offers it) plus its own model/prompt/timeout.
- Shared OpenAI/Anthropic request-shape helpers (`llm/openaiendpoint.{h,cpp}`) extracted out of the translation provider.
- `LocalAiTranslationProvider` is back to pure text-in/text-out: all vision state, the multimodal branch and the old per-mode settings are deleted; `ATranslationProvider`'s image virtuals are removed.
- Dead settings dropped without migration (the old fused prompt is semantically wrong for OCR transcription). Language detection is honest about what it is, and the detection prompt is source-agnostic.
### 3. Module status strip
Nothing in the UI said what was actually happening once you pressed a button. `ModuleStatus` is a shared "what is running" model fed by both OCR engines, the screen grabber/snipping area, the active translator and the active TTS provider; `StatusStrip` renders it at the bottom of the main window and the popup, and removes itself cleanly from AT-SPI when hidden. LocalAI translation and Mozhi also report source-language detection through the same strip. A permanent `recognized -> replaceText` connection deliberately suppresses `textEdited` so armed OCR flows can chain their own translation - `handleRecognizedText` now runs the same follow-ups typed text gets (translate-button state, TTS button state, locale autodetection, auto-translation) whenever no armed connection owns the recognition, fixing an unarmed recognition (plain recognize-screen-area, or an image with auto-translate off) leaving the UI stuck until the next keystroke.
### 4. Fix duplicated OCR transcriptions from repeat-prone local models
Small local vision models (verified against a real Ollama instance running a glm-ocr model) reliably finish the correct transcription, then - lacking a clean stop token for the task - wrap it in a markdown fence and loop re-emitting the same text until they exhaust their token budget, so a single response lands doubled (or worse) in the source edit. A stop sequence on the fence marker cuts generation the instant that repeat would start: confirmed against the real model, completion tokens dropped from ~3900 to ~50 and the response is exactly the clean transcription. Also sends `reasoning_effort: none` when the provider's "Disable reasoning" setting is on, matching `LocalAiTranslationProvider`, for genuinely reasoning-capable vision models.
## Testing
- In-tree unit tests pin both URL helpers, the LlmOcr wire shape (multimodal request, transcription prompt, stop sequence, error/timeout paths), the ModuleStatus model, StatusStrip rendering, and everyday MainWindow features end-to-end against `MockHttpServer`.
- `tests/test_llmocr_live.cpp` is a live regression test that drives the real `LlmOcr` against a real local Ollama instance and vision-OCR model (`CROW_TEST_OLLAMA_MODEL`/`CROW_TEST_OLLAMA_URL` override which) - a mocked HTTP response can't reproduce a model's own decoding pathology, only a real one can prove the duplicated-transcription fix holds. It self-skips, never fails, when Ollama or the model isn't available, so any developer can run it without it ever blocking CI.
- Existing LocalAI provider tests unchanged and passing.
- Verified live against z.ai: `.../v4/v1/models` 404s, `.../v4/models` returns the model list.
M +16 -2 CMakeLists.txt
A +146 -0 src/llm/openaiendpoint.cpp [License: GPL(v3.0+)]
A +48 -0 src/llm/openaiendpoint.h [License: GPL(v3.0+)]
A +197 -0 src/llm/visionmodelprobe.cpp [License: GPL(v3.0+)]
A +66 -0 src/llm/visionmodelprobe.h [License: GPL(v3.0+)]
M +1 -1 src/main.cpp
M +310 -154 src/mainwindow.cpp
M +36 -3 src/mainwindow.h
M +16 -5 src/mainwindow.ui
A +294 -0 src/modulestatus.cpp [License: GPL(v3.0+)]
A +130 -0 src/modulestatus.h [License: GPL(v3.0+)]
A +39 -0 src/ocr/aocrprovider.h [License: GPL(v3.0+)]
A +281 -0 src/ocr/llmocr.cpp [License: GPL(v3.0+)]
A +67 -0 src/ocr/llmocr.h [License: GPL(v3.0+)]
M +6 -0 src/ocr/snippingarea.cpp
R +25 -15 src/ocr/tesseractocr.cpp [from: src/ocr/ocr.cpp - 081% similarity]
R +15 -14 src/ocr/tesseractocr.h [from: src/ocr/ocr.h - 063% similarity]
M +6 -0 src/popupwindow.cpp
M +37 -21 src/popupwindow.ui
M +0 -8 src/provideroptionsmanager.cpp
M +150 -78 src/settings/appsettings.cpp
M +39 -14 src/settings/appsettings.h
M +368 -272 src/settings/settingsdialog.cpp
M +34 -7 src/settings/settingsdialog.h
M +33 -17 src/settings/settingsdialog.ui
A +212 -0 src/statusstrip.cpp [License: GPL(v3.0+)]
A +66 -0 src/statusstrip.h [License: GPL(v3.0+)]
A +33 -0 src/statusstrip.ui
M +4 -10 src/translator/atranslationprovider.h
M +35 -143 src/translator/localaitranslationprovider.cpp
M +0 -19 src/translator/localaitranslationprovider.h
M +8 -0 src/translator/mozhitranslationprovider.cpp
A +38 -0 src/translator/translationlogic.cpp [License: GPL(v3.0+)]
A +38 -0 src/translator/translationlogic.h [License: GPL(v3.0+)]
M +109 -2 tests/CMakeLists.txt
A +117 -0 tests/test_appsettings_prompts.cpp [License: GPL(v3.0+)]
A +338 -0 tests/test_llmocr.cpp [License: GPL(v3.0+)]
A +132 -0 tests/test_llmocr_live.cpp [License: GPL(v3.0+)]
M +140 -3 tests/test_localai_provider.cpp
A +468 -0 tests/test_mainwindow_features.cpp [License: GPL(v3.0+)]
A +238 -0 tests/test_mainwindow_status.cpp [License: GPL(v3.0+)]
A +539 -0 tests/test_modulestatus.cpp [License: GPL(v3.0+)]
M +49 -3 tests/test_settingsdialog_localai.cpp
A +104 -0 tests/test_snippingarea_terminal.cpp [License: GPL(v3.0+)]
A +278 -0 tests/test_statusstrip.cpp [License: GPL(v3.0+)]
M +1 -1 tests/test_translation.cpp
A +74 -0 tests/test_translationlogic.cpp [License: GPL(v3.0+)]
https://invent.kde.org/office/crow-translate/-/commit/5f16d059b4e983ac29be37965fa655459d59727a
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7b11aa6f..2135a4a8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -170,8 +170,13 @@ add_library(${PROJECT_NAME}-lib STATIC
src/mainwindow.h
src/mainwindow.cpp
src/mainwindow.ui
- src/ocr/ocr.h
- src/ocr/ocr.cpp
+ src/modulestatus.h
+ src/modulestatus.cpp
+ src/ocr/aocrprovider.h
+ src/ocr/tesseractocr.h
+ src/ocr/tesseractocr.cpp
+ src/ocr/llmocr.h
+ src/ocr/llmocr.cpp
src/ocr/screengrabbers/abstractscreengrabber.h
src/ocr/screengrabbers/abstractscreengrabber.cpp
src/ocr/screengrabbers/genericscreengrabber.h
@@ -185,6 +190,9 @@ add_library(${PROJECT_NAME}-lib STATIC
src/popupwindow.ui
src/screenwatcher.h
src/screenwatcher.cpp
+ src/statusstrip.h
+ src/statusstrip.cpp
+ src/statusstrip.ui
src/selection.h
src/selection.cpp
src/settings/appsettings.h
@@ -226,6 +234,12 @@ add_library(${PROJECT_NAME}-lib STATIC
src/translator/mozhitranslationprovider.cpp
src/translator/localaitranslationprovider.h
src/translator/localaitranslationprovider.cpp
+ src/translator/translationlogic.h
+ src/translator/translationlogic.cpp
+ src/llm/openaiendpoint.h
+ src/llm/openaiendpoint.cpp
+ src/llm/visionmodelprobe.h
+ src/llm/visionmodelprobe.cpp
src/provideroptions.h
src/provideroptions.cpp
src/provideroptionsmanager.h
diff --git a/src/llm/openaiendpoint.cpp b/src/llm/openaiendpoint.cpp
new file mode 100644
index 00000000..ac4dfcc6
--- /dev/null
+++ b/src/llm/openaiendpoint.cpp
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Oleksandr Mikriukov <[email protected]>
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "openaiendpoint.h"
+
+#include <QByteArray>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkRequest>
+#include <QStringList>
+#include <QUrl>
+
+namespace OpenAiEndpoint
+{
+
+QString completionsUrl(const QString &baseUrl, bool isAnthropic)
+{
+ // Users paste either a base URL ("https://api.example.com/v1", as handed
+ // to official SDKs) or the full endpoint they got from docs
+ // (".../v1/chat/completions"). Always appending the suffix produced a
+ // doubled path for the latter. Detect which one was entered and normalize:
+ // strip trailing slashes, then append the kind-specific suffix only when
+ // the URL does not already end in it. Only suffixes this code actually
+ // builds a matching request body for are recognized here - notably NOT
+ // Ollama's native "/api/chat" (its request/response shape differs from
+ // the OpenAI-compatible one callers always send), so a URL pointed there
+ // falls through to getting a suffix appended instead of being silently
+ // treated as complete. Ollama also serves an OpenAI-compatible
+ // "/v1/chat/completions" endpoint, which is what this code targets by
+ // default.
+ QString base = baseUrl.trimmed();
+ while (base.endsWith(QLatin1Char('/'))) {
+ base.chop(1);
+ }
+
+ static const QStringList kCompletionsSuffixes = {
+ QStringLiteral("/v1/chat/completions"),
+ QStringLiteral("/v1/messages"), // Anthropic
+ };
+
+ for (const QString &suffix : kCompletionsSuffixes) {
+ if (base.endsWith(suffix, Qt::CaseInsensitive)) {
+ // Already a full completions endpoint: use as typed. An
+ // Anthropic-style "/v1/messages" URL in an OpenAI-kind provider
+ // (or vice versa) is still honored as-is - the user pointed at a
+ // concrete endpoint and knows better than the suffix defaults.
+ return base;
+ }
+ }
+ // A base that already carries a path (e.g. z.ai's
+ // ".../api/coding/paas/v4") is the complete SDK base URL: append only the
+ // kind suffix, as the official SDK would. Only a bare host (no path,
+ // crow's default "...:11434") needs the conventional "/v1" segment
+ // prepended.
+ const QUrl url(base);
+ const QString path = url.path();
+ const bool bareHost = path.isEmpty() || path == QLatin1String("/");
+ if (isAnthropic) {
+ return base + QStringLiteral("/v1/messages");
+ }
+ return bareHost ? base + QStringLiteral("/v1/chat/completions")
+ : base + QStringLiteral("/chat/completions");
+}
+
+QString serverRoot(const QString &baseUrl)
+{
+ // If the user pasted a full endpoint URL (any of the completions shapes),
+ // derive its base by stripping the endpoint path so a probe does not end
+ // up at ".../chat/completions/v1/models".
+ QString base = baseUrl.trimmed();
+ while (base.endsWith(QLatin1Char('/'))) {
+ base.chop(1);
+ }
+
+ static const QStringList kEndpointSuffixes = {
+ QStringLiteral("/v1/chat/completions"),
+ QStringLiteral("/v1/messages"),
+ QStringLiteral("/v1/models"),
+ };
+
+ for (const QString &suffix : kEndpointSuffixes) {
+ if (base.endsWith(suffix, Qt::CaseInsensitive)) {
+ base.chop(suffix.size());
+ break;
+ }
+ }
+ while (base.endsWith(QLatin1Char('/'))) {
+ base.chop(1);
+ }
+ return base;
+}
+
+QString modelsUrl(const QString &baseUrl)
+{
+ const QString base = serverRoot(baseUrl);
+ // A base that already carries a path (e.g. z.ai's
+ // ".../api/coding/paas/v4") is the complete SDK base: the probe lives at
+ // "<base>/models". A bare host (no path) needs the conventional "/v1"
+ // segment: "<host>/v1/models".
+ const QUrl url(base);
+ const QString path = url.path();
+ if (path.isEmpty() || path == QLatin1String("/")) {
+ return base + QStringLiteral("/v1/models");
+ }
+ return base + QStringLiteral("/models");
+}
+
+void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey)
+{
+ if (apiKey.isEmpty()) {
+ return;
+ }
+ if (isAnthropic) {
+ request.setRawHeader("x-api-key", apiKey.toUtf8());
+ request.setRawHeader("anthropic-version", "2023-06-01");
+ } else {
+ request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
+ }
+}
+
+QString extractContent(const QByteArray &data, bool isAnthropic)
+{
+ const QJsonObject obj = QJsonDocument::fromJson(data).object();
+ if (isAnthropic) {
+ const QJsonArray blocks = obj.value(QStringLiteral("content")).toArray();
+ for (const QJsonValue &block : blocks) {
+ const QJsonObject blockObj = block.toObject();
+ if (blockObj.value(QStringLiteral("type")).toString() == QLatin1String("text")) {
+ return blockObj.value(QStringLiteral("text")).toString();
+ }
+ }
+ return QString();
+ }
+ const QJsonArray choices = obj.value(QStringLiteral("choices")).toArray();
+ if (!choices.isEmpty()) {
+ return choices.first().toObject().value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
+ }
+ // Fallback for Ollama native shape, just in case.
+ return obj.value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
+}
+
+} // namespace OpenAiEndpoint
diff --git a/src/llm/openaiendpoint.h b/src/llm/openaiendpoint.h
new file mode 100644
index 00000000..57a90068
--- /dev/null
+++ b/src/llm/openaiendpoint.h
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Oleksandr Mikriukov <[email protected]>
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef OPENAIENDPOINT_H
+#define OPENAIENDPOINT_H
+
+#include <QString>
+
+class QByteArray;
+class QNetworkRequest;
+
+// Wire-level helpers for OpenAI-compatible and Anthropic-compatible HTTP
+// endpoints: turning whatever URL a user pasted into the concrete endpoint to
+// call, attaching the right auth headers, and pulling the assistant text back
+// out of a response.
+//
+// Deliberately provider-neutral and free of any translation or OCR concept:
+// LocalAiTranslationProvider and LlmOcr are independently configured features
+// that happen to speak the same two API shapes, so neither should have to
+// include the other to talk to its own endpoint.
+namespace OpenAiEndpoint
+{
+
+// Endpoint to POST a chat completion / message to.
+QString completionsUrl(const QString &baseUrl, bool isAnthropic);
+
+// Endpoint to GET the model list from.
+QString modelsUrl(const QString &baseUrl);
+
+// The server root a pasted URL refers to, with any trailing slashes and any
+// recognized endpoint path stripped. Callers that need a non-OpenAI path on
+// the same host - Ollama's "/api/tags", LM Studio's "/api/v0/models" - build
+// it from here so they honor the same paste-anything normalization.
+QString serverRoot(const QString &baseUrl);
+
+// Bearer for OpenAI-compatible, x-api-key + anthropic-version for Anthropic.
+// A blank key leaves the request untouched (local servers need no auth).
+void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey);
+
+// Assistant text out of a completions/messages response body.
+QString extractContent(const QByteArray &data, bool isAnthropic);
+
+} // namespace OpenAiEndpoint
+
+#endif // OPENAIENDPOINT_H
diff --git a/src/llm/visionmodelprobe.cpp b/src/llm/visionmodelprobe.cpp
new file mode 100644
index 00000000..733400ab
--- /dev/null
+++ b/src/llm/visionmodelprobe.cpp
@@ -0,0 +1,197 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "visionmodelprobe.h"
+
+#include "openaiendpoint.h"
+
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+
+namespace
+{
+
+constexpr int kProbeTimeoutMs = 5000;
+
+bool hasVisionCapability(const QJsonObject &model)
+{
+ // Ollama: "capabilities": ["completion", "vision", ...]
+ const QJsonArray capabilities = model.value(QStringLiteral("capabilities")).toArray();
+ for (const QJsonValue &capability : capabilities) {
+ if (capability.toString().compare(QLatin1String("vision"), Qt::CaseInsensitive) == 0) {
+ return true;
+ }
+ }
+ // LM Studio reports it as a flag, and types its vision-language models
+ // "vlm" rather than "llm". Accept both spellings - its native API is not
+ // versioned in a way worth betting a feature on.
+ if (model.value(QStringLiteral("vision")).toBool()) {
+ return true;
+ }
+ return model.value(QStringLiteral("type")).toString().compare(QLatin1String("vlm"), Qt::CaseInsensitive) == 0;
+}
+
+} // namespace
+
+VisionModelProbe::VisionModelProbe(QObject *parent)
+ : QObject(parent)
+ , m_network(new QNetworkAccessManager(this))
+{
+ m_network->setTransferTimeout(kProbeTimeoutMs);
+}
+
+VisionModelProbe::~VisionModelProbe()
+{
+ if (m_reply != nullptr) {
+ m_reply->disconnect(this);
+ m_reply->abort();
+ m_reply->deleteLater();
+ }
+}
+
+bool VisionModelProbe::reportsCapabilities(const QString &providerId)
+{
+ return providerId == QLatin1String("ollama") || providerId == QLatin1String("lmstudio");
+}
+
+void VisionModelProbe::cancel()
+{
+ if (m_reply != nullptr) {
+ m_reply->disconnect(this);
+ m_reply->abort();
+ m_reply->deleteLater();
+ m_reply = nullptr;
+ }
+}
+
+void VisionModelProbe::probe(const QString &providerId, const QString &baseUrl, const QString &apiKey)
+{
+ cancel();
+ m_providerId = providerId;
+ m_baseUrl = baseUrl;
+ m_apiKey = apiKey;
+
+ if (!reportsCapabilities(providerId)) {
+ requestOpenAiModels(baseUrl, apiKey);
+ return;
+ }
+
+ const QString root = OpenAiEndpoint::serverRoot(baseUrl);
+ const QString nativeUrl = providerId == QLatin1String("ollama")
+ ? root + QStringLiteral("/api/tags")
+ : root + QStringLiteral("/api/v0/models");
+
+ QNetworkRequest request{QUrl(nativeUrl)};
+ OpenAiEndpoint::setAuthHeaders(request, false, apiKey);
+ m_reply = m_network->get(request);
+ connect(m_reply, &QNetworkReply::finished, this, &VisionModelProbe::onNativeFinished);
+}
+
+void VisionModelProbe::requestOpenAiModels(const QString &baseUrl, const QString &apiKey)
+{
+ QNetworkRequest request{QUrl(OpenAiEndpoint::modelsUrl(baseUrl))};
+ OpenAiEndpoint::setAuthHeaders(request, m_providerId == QLatin1String("anthropic"), apiKey);
+ m_reply = m_network->get(request);
+ connect(m_reply, &QNetworkReply::finished, this, &VisionModelProbe::onOpenAiFinished);
+}
+
+void VisionModelProbe::onNativeFinished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+ if (reply == nullptr || reply != m_reply) {
+ if (reply != nullptr) {
+ reply->deleteLater();
+ }
+ return;
+ }
+ m_reply = nullptr;
+ reply->deleteLater();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ // The native endpoint is the only source of capability information,
+ // but it is not the only way to list models: an Ollama behind a proxy
+ // that only forwards /v1, or a newer LM Studio that moved its native
+ // API, still answers /v1/models. Fall back to the list without
+ // capabilities rather than failing the whole refresh.
+ requestOpenAiModels(m_baseUrl, m_apiKey);
+ return;
+ }
+
+ const QJsonObject root = QJsonDocument::fromJson(reply->readAll()).object();
+ // Ollama keys the array "models", LM Studio keys it "data".
+ QJsonArray models = root.value(QStringLiteral("models")).toArray();
+ if (models.isEmpty()) {
+ models = root.value(QStringLiteral("data")).toArray();
+ }
+
+ QStringList allModels;
+ QStringList visionModels;
+ for (const QJsonValue &value : std::as_const(models)) {
+ const QJsonObject model = value.toObject();
+ QString name = model.value(QStringLiteral("name")).toString();
+ if (name.isEmpty()) {
+ name = model.value(QStringLiteral("model")).toString();
+ }
+ if (name.isEmpty()) {
+ name = model.value(QStringLiteral("id")).toString();
+ }
+ if (name.isEmpty()) {
+ continue;
+ }
+ allModels.append(name);
+ if (hasVisionCapability(model)) {
+ visionModels.append(name);
+ }
+ }
+
+ if (allModels.isEmpty()) {
+ requestOpenAiModels(m_baseUrl, m_apiKey);
+ return;
+ }
+
+ emit finished(allModels, visionModels);
+}
+
+void VisionModelProbe::onOpenAiFinished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+ if (reply == nullptr || reply != m_reply) {
+ if (reply != nullptr) {
+ reply->deleteLater();
+ }
+ return;
+ }
+ m_reply = nullptr;
+ reply->deleteLater();
+
+ if (reply->error() != QNetworkReply::NoError) {
+ emit failed(reply->errorString());
+ return;
+ }
+
+ const QJsonArray data = QJsonDocument::fromJson(reply->readAll()).object().value(QStringLiteral("data")).toArray();
+
+ QStringList allModels;
+ QStringList visionModels;
+ for (const QJsonValue &value : std::as_const(data)) {
+ const QJsonObject model = value.toObject();
+ const QString name = model.value(QStringLiteral("id")).toString();
+ if (name.isEmpty()) {
+ continue;
+ }
+ allModels.append(name);
+ // Almost never present on a /v1/models response, but an OpenAI-
+ // compatible server that does report it should be believed.
+ if (hasVisionCapability(model)) {
+ visionModels.append(name);
+ }
+ }
+
+ emit finished(allModels, visionModels);
+}
diff --git a/src/llm/visionmodelprobe.h b/src/llm/visionmodelprobe.h
new file mode 100644
index 00000000..03fcaf36
--- /dev/null
+++ b/src/llm/visionmodelprobe.h
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef VISIONMODELPROBE_H
+#define VISIONMODELPROBE_H
+
+#include <QObject>
+#include <QString>
+#include <QStringList>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// Asks a server what models it has and, where the server is willing to say,
+// which of them accept images.
+//
+// Only two of the supported server kinds report capabilities at all: Ollama
+// inlines a "capabilities" array in /api/tags, and LM Studio reports a model
+// type/vision flag in its native /api/v0/models. The OpenAI-compatible
+// /v1/models response - which is all Anthropic, FastFlowLM and arbitrary
+// cloud endpoints offer - carries no capability information whatsoever.
+//
+// So "not in visionModels" means "this server did not tell us", never "this
+// model cannot do vision". Callers must present the two lists as
+// proven/unproven and must not filter the unproven ones away: a correct
+// vision model behind a plain /v1/models endpoint would vanish from the UI.
+class VisionModelProbe : public QObject
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(VisionModelProbe)
+
+public:
+ explicit VisionModelProbe(QObject *parent = nullptr);
+ ~VisionModelProbe() override;
+
+ // providerId selects the probing strategy; baseUrl is whatever the user
+ // typed and is normalized through OpenAiEndpoint.
+ void probe(const QString &providerId, const QString &baseUrl, const QString &apiKey);
+ void cancel();
+
+ // True when the provider kind can report vision capability at all, i.e.
+ // when an empty visionModels list is meaningful rather than merely
+ // uninformative.
+ static bool reportsCapabilities(const QString &providerId);
+
+signals:
+ // allModels is everything the server listed, in server order.
+ // visionModels is the subset it explicitly marked as image-capable.
+ void finished(const QStringList &allModels, const QStringList &visionModels);
+ void failed(const QString &error);
+
+private:
+ void requestOpenAiModels(const QString &baseUrl, const QString &apiKey);
+ void onNativeFinished();
+ void onOpenAiFinished();
+
+ QNetworkAccessManager *m_network;
+ QNetworkReply *m_reply = nullptr;
+ QString m_providerId;
+ QString m_baseUrl;
+ QString m_apiKey;
+};
+
+#endif // VISIONMODELPROBE_H
diff --git a/src/main.cpp b/src/main.cpp
index f892e1d8..d2c31323 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -25,7 +25,7 @@
#endif
#ifdef Q_OS_UNIX
-#include "ocr/ocr.h"
+#include "ocr/tesseractocr.h"
#include <QDBusConnection>
#include <QDBusError>
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index a6486ad9..2f87b005 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -9,28 +9,34 @@
#include "mainwindow.h"
#include "ui_mainwindow.h"
+#include "modulestatus.h"
#include "popupwindow.h"
#include "provideroptions.h"
#include "provideroptionsmanager.h"
#include "screenwatcher.h"
#include "selection.h"
#include "singleapplication.h"
+#include "statusstrip.h"
+#include "ocr/aocrprovider.h"
+#include "ocr/llmocr.h"
#include "ocr/screengrabbers/abstractscreengrabber.h"
+#include "ocr/tesseractocr.h"
#include "settings/appsettings.h"
#include "settings/settingsdialog.h"
#include "translator/atranslationprovider.h"
+#include "translator/translationlogic.h"
#include "tts/attsprovider.h"
#include "tts/voice.h"
#include <QAbstractItemView>
#include <QApplication>
-#include <QBuffer>
#include <QButtonGroup>
#include <QClipboard>
#include <QCloseEvent>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFile>
+#include <QFileDialog>
#include <QFontMetrics>
#include <QImage>
#include <QKeyEvent>
@@ -38,6 +44,7 @@
#include <QMessageBox>
#include <QMimeData>
#include <QSet>
+#include <QStandardPaths>
#include <QTimer>
#include <cassert>
@@ -59,14 +66,24 @@ MainWindow::MainWindow(QWidget *parent)
, m_closeWindowsShortcut(new QShortcut(this))
, ui(new Ui::MainWindow)
, m_optionsManager(new ProviderOptionsManager(this))
- , m_ocr(new Ocr())
+ , m_tesseractOcr(new TesseractOcr(this))
+ , m_llmOcr(new LlmOcr(this))
, m_screenCaptureTimer(new QTimer(this))
, m_snippingArea(new SnippingArea(this))
, m_trayIcon(new TrayIcon(this))
, m_orientationWatcher(new ScreenWatcher(this))
, m_screenGrabber(AbstractScreenGrabber::createScreenGrabber(this))
+ , m_moduleStatus(new ModuleStatus(this))
{
ui->setupUi(this);
+
+ // The status strip lives in the status bar rather than centralLayout:
+ // setOrientation() reassigns that layout's direction per screen
+ // orientation, which would place the strip beside the columns in
+ // landscape and invert its position in InvertedPortraitOrientation.
+ m_statusStrip = new StatusStrip;
+ m_statusStrip->setModel(m_moduleStatus);
+ ui->statusbar->addWidget(m_statusStrip, 1);
// Save original Mozhi engine combo items so they can be restored after
// switching to LocalAI (which clears the combo).
for (int i = 0; i < ui->engineComboBox->count(); ++i) {
@@ -81,8 +98,10 @@ MainWindow::MainWindow(QWidget *parent)
ui->sourceEdit->setAccessibleName(QStringLiteral("sourceEdit"));
ui->translationEdit->setAccessibleName(QStringLiteral("translationEdit"));
ui->translateButton->setAccessibleName(QStringLiteral("translateButton"));
+ ui->abortButton->setAccessibleName(QStringLiteral("abortButton"));
ui->sourcePlayPauseButton->setAccessibleName(QStringLiteral("sourcePlayPauseButton"));
ui->translationPlayPauseButton->setAccessibleName(QStringLiteral("translationPlayPauseButton"));
+ ui->openImageButton->setAccessibleName(QStringLiteral("openImageButton"));
// Screen orientation
connect(m_orientationWatcher, &ScreenWatcher::screenOrientationChanged, this, &MainWindow::setOrientation);
@@ -110,15 +129,51 @@ MainWindow::MainWindow(QWidget *parent)
// OCR logic
connect(m_screenGrabber, &AbstractScreenGrabber::grabbed, m_snippingArea, &SnippingArea::snip);
- connect(m_snippingArea, &SnippingArea::snipped, m_ocr, &Ocr::recognize);
- connect(m_ocr, &Ocr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+ connect(m_snippingArea, &SnippingArea::snipped, this, [this](const QPixmap &pixmap, int dpi) {
+ activeOcr()->recognize(pixmap.toImage(), dpi);
+ });
+ // An abandoned snip must not leave a translation armed for whatever gets
+ // recognized next.
+ connect(m_snippingArea, &SnippingArea::cancelled, this, &MainWindow::disarmOcrTranslation);
+ // A failed grab never opens the snipping area, so it emits no SnippingArea
+ // signal either - the same armed-connection leak as an abandoned snip.
+ connect(m_screenGrabber, &AbstractScreenGrabber::grabbingFailed, this, &MainWindow::disarmOcrTranslation);
+ connect(m_tesseractOcr, &TesseractOcr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+ connect(m_llmOcr, &LlmOcr::recognized, ui->sourceEdit, &SourceTextEdit::replaceText);
+ // Text inserted through replaceText() deliberately suppresses
+ // SourceTextEdit::textEdited (the armed flows chain their own follow-up),
+ // so an UNarmed recognition - a plain recognize-screen-area, or an image
+ // with auto-translate off - used to leave the translate button disabled
+ // and language detection not running until the user typed something:
+ // any real edit fires textEdited, which drives all of these. Run the
+ // same follow-ups typed text gets. These permanent connections are
+ // created before any armed/temporary one, so the gate below sees the
+ // current armed state correctly.
+ const auto handleRecognizedText = [this]() {
+ if (m_ocrTranslateConnection)
+ return; // the armed path chains its own translation
+ updateTranslateButtonState();
+ updateTTSButtonStates();
+ updateAutoLocales();
+ handleAutoTranslation();
+ };
+ connect(m_tesseractOcr, &TesseractOcr::recognized, this, handleRecognizedText);
+ connect(m_llmOcr, &LlmOcr::recognized, this, handleRecognizedText);
m_screenCaptureTimer->setSingleShot(true);
+ // Both engines and the grabber/snipping area are long-lived; bind them
+ // once. Both engines, not activeOcr(): the active one switches per
+ // settings read.
+ m_moduleStatus->bindOcr(m_tesseractOcr, m_llmOcr);
+ m_moduleStatus->bindCapture(m_screenGrabber, m_snippingArea);
+
loadAppSettings();
m_tts = ATTSProvider::createTTSProvider(this, m_chosenTTSBackend);
connect(m_tts, &ATTSProvider::errorOccurred, this, &MainWindow::onTTSError);
+ m_moduleStatus->bindTtsProvider(m_tts);
applyTTSProviderSettings();
m_translator = ATranslationProvider::createTranslationProvider(this, m_chosenTranslationBackend);
+ m_moduleStatus->bindTranslator(m_translator);
updateProviderUI();
@@ -287,9 +342,33 @@ MainWindow::~MainWindow()
delete ui;
}
-Ocr *MainWindow::ocr() const
+AOcrProvider *MainWindow::ocr() const
{
- return m_ocr;
+ return activeOcr();
+}
+
+TesseractOcr *MainWindow::tesseractOcr() const
+{
+ return m_tesseractOcr;
+}
+
+AOcrProvider *MainWindow::activeOcr() const
+{
+ if (AppSettings().ocrEngine() == AppSettings::OcrEngine::Llm) {
+ return m_llmOcr;
+ }
+ return m_tesseractOcr;
+}
+
+void MainWindow::configureLlmOcr()
+{
+ const AppSettings settings;
+ const QString providerId = settings.ocrLlmProvider();
+ m_llmOcr->setEndpoint(settings.localProviderUrl(providerId), AppSettings::localProviderIsAnthropic(providerId), settings.localProviderApiKey(providerId));
+ m_llmOcr->setModel(settings.ocrLlmModel(providerId));
+ m_llmOcr->setTimeout(settings.ocrLlmTimeout(providerId));
+ m_llmOcr->setPrompt(settings.ocrLlmPrompt(settings.ocrLlmModel(providerId)));
+ m_llmOcr->setDisableThinking(settings.localAiDisableThinking(providerId));
}
QComboBox *MainWindow::getEngineComboBox() const
@@ -362,6 +441,11 @@ SourceTextEdit *MainWindow::sourceEdit() const
return ui->sourceEdit;
}
+ModuleStatus *MainWindow::moduleStatus() const
+{
+ return m_moduleStatus;
+}
+
QToolButton *MainWindow::sourcePlayPauseButton() const
{
return ui->sourcePlayPauseButton;
@@ -540,89 +624,102 @@ Q_SCRIPTABLE void MainWindow::copyTranslatedSelection()
}
}
-Q_SCRIPTABLE void MainWindow::recognizeScreenArea()
+// Common preamble for every OCR entry point. Configuring the LLM engine
+// before asking whether it is configured is not optional: LlmOcr::isConfigured()
+// reports the state of the last configureLlmOcr() call, not the state of the
+// settings, so checking first rejects an engine the user has just set up.
+bool MainWindow::prepareOcr()
{
- if (m_ocr->languagesString().isEmpty()) {
- QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
- return;
+ configureLlmOcr();
+ if (!activeOcr()->isConfigured()) {
+ QMessageBox::critical(this, TesseractOcr::tr("OCR is not configured"), TesseractOcr::tr("Set up the OCR engine in the application settings"));
+ return false;
}
- AppSettings settings;
+ const AppSettings settings;
if (settings.isForceSourceAutodetect()) {
ui->sourceLanguagesWidget->checkAutoButton();
}
if (settings.isForceTranslationAutodetect()) {
ui->translationLanguagesWidget->checkAutoButton();
}
+ return true;
+}
- if (m_screenGrabber != nullptr) {
- m_screenGrabber->grab();
- }
+// Chains one translation onto the next successful recognition. The recognized
+// text reaches the source edit through the permanent
+// AOcrProvider::recognized -> SourceTextEdit::replaceText connections; this
+// only adds the translation, so the text is never written twice.
+//
+// The connection is a member rather than a local shared_ptr because it has to
+// survive until it fires *or* until something cancels the capture - a snip
+// abandoned with Escape used to leave it armed, so the next recognition of any
+// kind (including a plain "recognize screen area") translated unexpectedly.
+void MainWindow::armOcrTranslation()
+{
+ disarmOcrTranslation();
+ m_ocrTranslateConnection = connect(activeOcr(), &AOcrProvider::recognized, this, [this](const QString &text) {
+ disarmOcrTranslation();
+ ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
+
+ // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
+ const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
+ const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
+ const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
+ const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
+
+ if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
+ emit translationRequested(text, destinationLanguage, sourceLanguage);
+ return;
+ }
+ // Wait for translator to be ready
+ disconnect(m_ocrTranslatorReadyConnection);
+ m_ocrTranslatorReadyConnection =
+ connect(m_translator, &ATranslationProvider::stateChanged, this, [this, text, sourceLanguage, destinationLanguage](ATranslationProvider::State state) {
+ if (state == ATranslationProvider::State::Ready) {
+ disconnect(m_ocrTranslatorReadyConnection);
+ emit translationRequested(text, destinationLanguage, sourceLanguage);
+ }
+ });
+ });
}
-Q_SCRIPTABLE void MainWindow::translateScreenArea()
+void MainWindow::disarmOcrTranslation()
+{
+ disconnect(m_ocrTranslateConnection);
+ disconnect(m_ocrTranslatorReadyConnection);
+}
+
+Q_SCRIPTABLE void MainWindow::recognizeScreenArea()
{
- if (m_ocr->languagesString().isEmpty()) {
- QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+ if (!prepareOcr()) {
return;
}
- AppSettings settings;
- if (settings.isForceSourceAutodetect()) {
- ui->sourceLanguagesWidget->checkAutoButton();
+ if (m_screenGrabber != nullptr) {
+ disarmOcrTranslation();
+ startScreenCapture();
}
- if (settings.isForceTranslationAutodetect()) {
- ui->translationLanguagesWidget->checkAutoButton();
+}
+
+Q_SCRIPTABLE void MainWindow::translateScreenArea()
+{
+ if (!prepareOcr()) {
+ return;
}
if (m_screenGrabber != nullptr) {
- auto ocrConnection = std::make_shared<QMetaObject::Connection>();
- *ocrConnection = connect(m_ocr, &Ocr::recognized, this, [this, ocrConnection](const QString &text) {
- ui->sourceEdit->setPlainText(text);
- ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
-
- // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
- const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
- const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
- const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
- const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
- qDebug() << "OCR: sourceAutoChecked:" << isSourceAutoChecked << "translationAutoChecked:" << isTranslationAutoChecked
- << "sourceLanguage:" << sourceLanguage.name() << "destinationLanguage:" << destinationLanguage.name();
-
- if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
- emit translationRequested(text, destinationLanguage, sourceLanguage);
- } else {
- // Wait for translator to be ready
- auto connection = std::make_shared<QMetaObject::Connection>();
- *connection =
- connect(m_translator, &ATranslationProvider::stateChanged, this, [this, text, sourceLanguage, destinationLanguage, connection](ATranslationProvider::State state) {
- if (state == ATranslationProvider::State::Ready) {
- emit translationRequested(text, destinationLanguage, sourceLanguage);
- disconnect(*connection);
- }
- });
- }
- disconnect(*ocrConnection);
- });
- m_screenGrabber->grab();
+ armOcrTranslation();
+ startScreenCapture();
}
}
Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
{
- if (m_ocr->languagesString().isEmpty()) {
- QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+ if (!prepareOcr()) {
return;
}
- AppSettings settings;
- if (settings.isForceSourceAutodetect()) {
- ui->sourceLanguagesWidget->checkAutoButton();
- }
- if (settings.isForceTranslationAutodetect()) {
- ui->translationLanguagesWidget->checkAutoButton();
- }
-
if ((m_screenCaptureTimer != nullptr) && (m_screenGrabber != nullptr)) {
const AppSettings settings;
m_screenCaptureTimer->start(settings.captureDelay());
@@ -631,7 +728,8 @@ Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
&QTimer::timeout,
this,
[this]() {
- m_screenGrabber->grab();
+ disarmOcrTranslation();
+ startScreenCapture();
},
Qt::SingleShotConnection);
}
@@ -639,19 +737,10 @@ Q_SCRIPTABLE void MainWindow::delayedRecognizeScreenArea()
Q_SCRIPTABLE void MainWindow::delayedTranslateScreenArea()
{
- if (m_ocr->languagesString().isEmpty()) {
- QMessageBox::critical(this, Ocr::tr("OCR languages are not loaded"), Ocr::tr("You should set at least one OCR language in the application settings"));
+ if (!prepareOcr()) {
return;
}
- AppSettings settings;
- if (settings.isForceSourceAutodetect()) {
- ui->sourceLanguagesWidget->checkAutoButton();
- }
- if (settings.isForceTranslationAutodetect()) {
- ui->translationLanguagesWidget->checkAutoButton();
- }
-
if ((m_screenCaptureTimer != nullptr) && (m_screenGrabber != nullptr)) {
const AppSettings settings;
m_screenCaptureTimer->start(settings.captureDelay());
@@ -660,37 +749,8 @@ Q_SCRIPTABLE void MainWindow::delayedTranslateScreenArea()
&QTimer::timeout,
this,
[this]() {
- auto ocrConnection = std::make_shared<QMetaObject::Connection>();
- *ocrConnection = connect(m_ocr, &Ocr::recognized, this, [this, ocrConnection](const QString &text) {
- ui->sourceEdit->setPlainText(text);
- ui->sourceEdit->stopEditTimer(); // Prevent delayed textEdited signal
-
- // Use auto-detect for OCR text if auto button is checked, otherwise use selected language
- const bool isSourceAutoChecked = ui->sourceLanguagesWidget->isAutoButtonChecked();
- const bool isTranslationAutoChecked = ui->translationLanguagesWidget->isAutoButtonChecked();
- const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
- const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
- qDebug() << "Delayed OCR: sourceAutoChecked:" << isSourceAutoChecked << "translationAutoChecked:" << isTranslationAutoChecked
- << "sourceLanguage:" << sourceLanguage.name() << "destinationLanguage:" << destinationLanguage.name();
-
- if (m_translator && m_translator->getState() == ATranslationProvider::State::Ready) {
- emit translationRequested(text, destinationLanguage, sourceLanguage);
- } else {
- // Wait for translator to be ready
- auto connection = std::make_shared<QMetaObject::Connection>();
- *connection = connect(m_translator,
- &ATranslationProvider::stateChanged,
- this,
- [this, text, sourceLanguage, destinationLanguage, connection](ATranslationProvider::State state) {
- if (state == ATranslationProvider::State::Ready) {
- emit translationRequested(text, destinationLanguage, sourceLanguage);
- disconnect(*connection);
- }
- });
- }
- disconnect(*ocrConnection);
- });
- m_screenGrabber->grab();
+ armOcrTranslation();
+ startScreenCapture();
},
Qt::SingleShotConnection);
}
@@ -702,6 +762,15 @@ Q_SCRIPTABLE void MainWindow::clearText()
ui->translationEdit->clear();
}
+void MainWindow::startScreenCapture()
+{
+ if (m_screenGrabber == nullptr)
+ return;
+
+ m_moduleStatus->beginScreenCapture();
+ m_screenGrabber->grab();
+}
+
Q_SCRIPTABLE void MainWindow::openSettings()
{
SettingsDialog config(this);
@@ -861,6 +930,11 @@ void MainWindow::loadAppSettings()
// Interface
ui->translationEdit->setFont(settings.font());
ui->sourceEdit->setFont(settings.font());
+ // De-identify BEFORE hiding the status bar: the AT-SPI bridge ignores
+ // property updates for widgets in an already-hidden hierarchy, so the
+ // strip's labels would keep announcing their last name indefinitely.
+ m_statusStrip->setShown(settings.isShowStatusBar());
+ ui->statusbar->setVisible(settings.isShowStatusBar());
ui->sourceLanguagesWidget->setLanguageFormat(settings.mainWindowLanguageFormat());
ui->translationLanguagesWidget->setLanguageFormat(settings.mainWindowLanguageFormat());
@@ -888,16 +962,17 @@ void MainWindow::loadAppSettings()
ui->sourceEdit->setSimplifySource(settings.isSimplifySource());
if (const QByteArray languages = settings.ocrLanguagesString(), path = settings.ocrLanguagesPath();
- !m_ocr->init(languages, path, settings.tesseractParameters())) {
+ !m_tesseractOcr->init(languages, path, settings.tesseractParameters())) {
if (languages != AppSettings::defaultOcrLanguagesString() || path != AppSettings::defaultOcrLanguagesPath())
- m_trayIcon->showMessage(Ocr::tr("Unable to set OCR languages"), Ocr::tr("Unable to initialize Tesseract with %1").arg(QString(languages)));
+ m_trayIcon->showMessage(TesseractOcr::tr("Unable to set OCR languages"), TesseractOcr::tr("Unable to initialize Tesseract with %1").arg(QString(languages)));
}
if (const AppSettings::RegionRememberType type = settings.regionRememberType(); m_snippingArea->regionRememberType() != type) {
m_snippingArea->setRegionRememberType(type);
if (type == AppSettings::RememberAlways)
m_snippingArea->setCropRegion(settings.cropRegion());
}
- m_ocr->setConvertLineBreaks(settings.isConvertLineBreaks());
+ m_tesseractOcr->setConvertLineBreaks(settings.isConvertLineBreaks());
+ configureLlmOcr();
m_screenCaptureTimer->setInterval(settings.captureDelay());
m_snippingArea->setCaptureOnRelese(settings.isConfirmOnRelease());
m_snippingArea->setShowMagnifier(settings.isShowMagnifier());
@@ -1167,6 +1242,7 @@ void MainWindow::swapTranslator(ATranslationProvider::ProviderBackend newBackend
m_chosenTranslationBackend = newBackend;
m_translator = ATranslationProvider::createTranslationProvider(this, m_chosenTranslationBackend);
+ m_moduleStatus->bindTranslator(m_translator);
updateProviderUI();
@@ -1235,6 +1311,7 @@ void MainWindow::swapTTSProvider(ATTSProvider::ProviderBackend newBackend)
m_chosenTTSBackend = newBackend;
m_tts = ATTSProvider::createTTSProvider(this, m_chosenTTSBackend);
+ m_moduleStatus->bindTtsProvider(m_tts);
connect(m_tts, &ATTSProvider::stateChanged, this, &MainWindow::ttsStateChanged);
connect(m_tts, &ATTSProvider::errorOccurred, this, &MainWindow::onTTSError);
@@ -1708,6 +1785,13 @@ void MainWindow::on_translateButton_clicked()
const Language sourceLanguage = isSourceAutoChecked ? Language::autoLanguage() : m_sourceLang;
const Language destinationLanguage = isTranslationAutoChecked ? Language::autoLanguage() : m_destLang;
+ if (m_hasSourceImage) {
+ // Recognition is still running on the image; translate whatever it
+ // produces rather than translating an empty source edit.
+ armOcrTranslation();
+ return;
+ }
+
emit translationRequested(ui->sourceEdit->toSourceText(), destinationLanguage, sourceLanguage);
}
@@ -1802,24 +1886,10 @@ void MainWindow::on_delayedTranslateScreenAreaButton_clicked()
Language MainWindow::preferredTranslationLanguage(const Language &sourceLang) const
{
const AppSettings settings;
- Language primaryLang = settings.primaryLanguage();
- if (primaryLang == Language::autoLanguage())
- primaryLang = Language(QLocale::system());
-
- // First choice: use primary if different from source
- if (primaryLang != sourceLang)
- return primaryLang;
-
- // Primary same as source, try secondary
- Language secondaryLang = settings.secondaryLanguage();
- if (secondaryLang == Language::autoLanguage())
- secondaryLang = Language(QLocale::system());
-
- if (secondaryLang != sourceLang)
- return secondaryLang;
-
- // Both primary and secondary same as source, fall back to system language
- return Language(QLocale::system());
+ return TranslationLogic::preferredDestination(sourceLang,
+ settings.primaryLanguage(),
+ settings.secondaryLanguage(),
+ Language(QLocale::system()));
}
void MainWindow::handleTranslationRequest(const QString &text, const Language &destLang, const Language &srcLang)
@@ -1859,19 +1929,7 @@ void MainWindow::setSourceImageInternal(const QImage &src)
img = img.scaled(AppSettings::maxSourceImageDimension, AppSettings::maxSourceImageDimension, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
- QByteArray data;
- QBuffer buf(&data);
- buf.open(QIODevice::WriteOnly);
- img.save(&buf, "JPEG", AppSettings::sourceImageJpegQuality);
-
- if (data.size() > AppSettings::maxSourceImageBytes) {
- ui->sourceEdit->setPlainText(tr("Image too large (%1 MB). Max %2 MB.")
- .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1)
- .arg(AppSettings::maxSourceImageBytes / (1024.0 * 1024.0), 0, 'f', 1));
- return;
- }
-
- m_translator->setSourceImage(data);
+ m_pendingOcrImage = img;
// Show preview over sourceEdit using geometry
m_originalPixmap = QPixmap::fromImage(img);
@@ -1884,9 +1942,7 @@ void MainWindow::setSourceImageInternal(const QImage &src)
updateTranslateButtonState();
- if (ui->autoTranslateCheckBox->isChecked()) {
- on_translateButton_clicked();
- }
+ recognizeSourceImage();
}
void MainWindow::clearSourceImage()
@@ -1894,13 +1950,102 @@ void MainWindow::clearSourceImage()
if (!m_hasSourceImage) {
return;
}
+ // Recognition now starts the moment an image arrives, so dropping the
+ // image while it is still running has to stop it too - otherwise Clear or
+ // Swap leaves a request in flight that lands its text (and possibly a
+ // translation) in a source edit the user has already emptied. The
+ // recognized/failed/canceled handlers disconnect themselves before calling
+ // this, so a still-connected handler is exactly the "aborted early" case.
+ if (m_imageOcrRecognizedConnection) {
+ disconnect(m_imageOcrRecognizedConnection);
+ disconnect(m_imageOcrCanceledConnection);
+ disconnect(m_imageOcrFailedConnection);
+ disarmOcrTranslation();
+ activeOcr()->cancel();
+ }
+
m_hasSourceImage = false;
- m_translator->clearSourceImage();
+ m_pendingOcrImage = QImage();
m_imagePreview->hide();
m_originalPixmap = QPixmap();
ui->sourceLanguagesWidget->setEnabled(true);
}
+static QByteArray stripNonStandardPngChunks(const QByteArray &data);
+
+// An image that arrives by drop, paste or the open button is transcribed
+// straight away, the same way a screen-area capture is: the point of dropping
+// an image is to see its text. Whether that text then gets translated is the
+// auto-translate setting's business, exactly as it is for typed text.
+void MainWindow::recognizeSourceImage()
+{
+ if (!prepareOcr()) {
+ clearSourceImage();
+ return;
+ }
+
+ AOcrProvider *engine = activeOcr();
+
+ disconnect(m_imageOcrRecognizedConnection);
+ disconnect(m_imageOcrCanceledConnection);
+ disconnect(m_imageOcrFailedConnection);
+
+ if (ui->autoTranslateCheckBox->isChecked()) {
+ armOcrTranslation();
+ } else {
+ disarmOcrTranslation();
+ }
+
+ // The recognized text itself lands in the source edit through the
+ // permanent recognized -> replaceText connection; these only manage the
+ // preview overlay that the text replaces.
+ m_imageOcrRecognizedConnection = connect(engine, &AOcrProvider::recognized, this, [this]() {
+ disconnect(m_imageOcrRecognizedConnection);
+ disconnect(m_imageOcrCanceledConnection);
+ disconnect(m_imageOcrFailedConnection);
+ clearSourceImage();
+ });
+ m_imageOcrCanceledConnection = connect(engine, &AOcrProvider::canceled, this, [this]() {
+ disconnect(m_imageOcrRecognizedConnection);
+ disconnect(m_imageOcrCanceledConnection);
+ disconnect(m_imageOcrFailedConnection);
+ disarmOcrTranslation();
+ clearSourceImage();
+ updateTranslateButtonState();
+ });
+ m_imageOcrFailedConnection = connect(engine, &AOcrProvider::failed, this, [this](const QString &error) {
+ disconnect(m_imageOcrRecognizedConnection);
+ disconnect(m_imageOcrCanceledConnection);
+ disconnect(m_imageOcrFailedConnection);
+ disarmOcrTranslation();
+ clearSourceImage();
+ ui->sourceEdit->replaceText(tr("OCR failed: %1").arg(error));
+ updateTranslateButtonState();
+ });
+
+ engine->recognize(m_pendingOcrImage, 96);
+}
+
+// Loads an image file, retrying through a PNG repair pass for the files Qt
+// refuses because of non-standard ancillary chunks (screenshots from some
+// tools carry them). Shared by drag-and-drop and the open-image button.
+bool MainWindow::loadImageFromFile(const QString &path, QImage &out)
+{
+ if (out.load(path)) {
+ return true;
+ }
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly)) {
+ return false;
+ }
+ const QByteArray raw = file.readAll();
+ const QByteArray clean = stripNonStandardPngChunks(raw);
+ if (clean != raw) {
+ out.loadFromData(clean);
+ }
+ return !out.isNull();
+}
+
static QByteArray stripNonStandardPngChunks(const QByteArray &data)
{
constexpr int SIG_LEN = 8;
@@ -1967,19 +2112,9 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
QImage img;
if (drop->mimeData()->hasUrls()) {
const QString path = drop->mimeData()->urls().constFirst().toLocalFile();
- if (!img.load(path)) {
- QFile f(path);
- if (f.open(QIODevice::ReadOnly)) {
- const QByteArray raw = f.readAll();
- const QByteArray clean = stripNonStandardPngChunks(raw);
- if (clean != raw) {
- img.loadFromData(clean);
- }
- }
- if (img.isNull()) {
- ui->sourceEdit->setPlainText(tr("Cannot open image:\n%1").arg(path));
- return true;
- }
+ if (!loadImageFromFile(path, img)) {
+ ui->sourceEdit->replaceText(tr("Cannot open image:\n%1").arg(path));
+ return true;
}
}
if (img.isNull() && drop->mimeData()->hasImage()) {
@@ -2014,8 +2149,29 @@ bool MainWindow::eventFilter(QObject *obj, QEvent *event)
void MainWindow::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Escape && m_hasSourceImage) {
+ // clearSourceImage() cancels the engine that is actually running.
+ // Cancelling both unconditionally used to make LlmOcr::cancel() emit a
+ // spurious canceled() for a Tesseract run (and vice versa).
clearSourceImage();
return;
}
QMainWindow::keyPressEvent(event);
}
+
+void MainWindow::on_openImageButton_clicked()
+{
+ const QString path = QFileDialog::getOpenFileName(this,
+ tr("Open image"),
+ QStandardPaths::writableLocation(QStandardPaths::PicturesLocation),
+ tr("Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp *.tif *.tiff);;All files (*)"));
+ if (path.isEmpty()) {
+ return;
+ }
+
+ QImage img;
+ if (!loadImageFromFile(path, img)) {
+ QMessageBox::warning(this, tr("Open image"), tr("Cannot open image:\n%1").arg(path));
+ return;
+ }
+ setSourceImageInternal(img);
+}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 7c4f13d3..f93356e1 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -12,9 +12,10 @@
#include "qhotkey.h"
#include "screenwatcher.h"
#include "trayicon.h"
-#include "ocr/ocr.h"
+#include "ocr/aocrprovider.h"
#include "ocr/screengrabbers/abstractscreengrabber.h"
#include "ocr/snippingarea.h"
+#include "ocr/tesseractocr.h"
#include "settings/appsettings.h"
#include "translator/atranslationprovider.h"
#include "tts/attsprovider.h"
@@ -33,7 +34,12 @@ class LanguageButtonsWidget;
class PopupWindow;
class SourceTextEdit;
class ProviderOptionsManager;
+class ModuleStatus;
+class StatusStrip;
class QLabel;
+class AOcrProvider;
+class LlmOcr;
+class TesseractOcr;
namespace Ui
{
@@ -51,7 +57,8 @@ public:
~MainWindow() override;
bool eventFilter(QObject *obj, QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
- Ocr *ocr() const;
+ AOcrProvider *ocr() const;
+ TesseractOcr *tesseractOcr() const;
QComboBox *getEngineComboBox() const;
QComboBox *sourceVoiceComboBox() const;
QComboBox *translationVoiceComboBox() const;
@@ -66,6 +73,7 @@ public:
QToolButton *copyAllTranslationButton() const;
QTextEdit *translationEdit() const;
SourceTextEdit *sourceEdit() const;
+ ModuleStatus *moduleStatus() const;
QToolButton *sourcePlayPauseButton() const;
QToolButton *sourceStopButton() const;
QToolButton *translationPlayPauseButton() const;
@@ -108,9 +116,15 @@ private:
ATranslationProvider *m_translator;
ATranslationProvider::ProviderBackend m_chosenTranslationBackend;
ProviderOptionsManager *m_optionsManager;
- Ocr *m_ocr;
+ TesseractOcr *m_tesseractOcr;
+ LlmOcr *m_llmOcr;
QTimer *m_screenCaptureTimer;
SnippingArea *m_snippingArea;
+ ModuleStatus *m_moduleStatus;
+ StatusStrip *m_statusStrip = nullptr;
+ // Reports the capture start to the status model; grab() itself has no
+ // "started" signal and adding one would touch every grabber subclass.
+ void startScreenCapture();
void loadAppSettings();
void swapTranslator(ATranslationProvider::ProviderBackend newBackend);
void setupEngineComboBoxConnection();
@@ -140,9 +154,27 @@ private:
QList<QIcon> m_engineItemsIcons;
void clearSourceImage();
void setSourceImageInternal(const QImage &img);
+ bool loadImageFromFile(const QString &path, QImage &out);
bool m_hasSourceImage = false;
QPixmap m_originalPixmap;
QLabel *m_imagePreview = nullptr;
+ QImage m_pendingOcrImage;
+ AOcrProvider *activeOcr() const;
+ void configureLlmOcr();
+ // Configures the active engine from settings and reports whether it is
+ // usable; every OCR entry point goes through it.
+ bool prepareOcr();
+ void recognizeSourceImage();
+ // Chains a translation onto the next recognition. Armed by the
+ // translate-screen-area paths and by an image drop while auto-translate is
+ // on; disarmed on cancel so it never fires for an unrelated later OCR.
+ void armOcrTranslation();
+ void disarmOcrTranslation();
+ QMetaObject::Connection m_ocrTranslateConnection;
+ QMetaObject::Connection m_ocrTranslatorReadyConnection;
+ QMetaObject::Connection m_imageOcrRecognizedConnection;
+ QMetaObject::Connection m_imageOcrCanceledConnection;
+ QMetaObject::Connection m_imageOcrFailedConnection;
// Disconnected explicitly in ~MainWindow() before `delete ui` - QWidget's
// own destructor destroys child widgets (deleteChildren(), which runs
// the popup's destroyed() lambda below) *before* QObject's destructor
@@ -173,6 +205,7 @@ private slots:
void on_swapButton_clicked();
void on_abortButton_clicked();
void on_copySourceButton_clicked();
+ void on_openImageButton_clicked();
void on_delayedRecognizeScreenAreaButton_clicked();
void on_clearButton_clicked();
void on_copyTranslationButton_clicked();
diff --git a/src/mainwindow.ui b/src/mainwindow.ui
index a1a76932..22443f37 100644
--- a/src/mainwindow.ui
+++ b/src/mainwindow.ui
@@ -146,6 +146,16 @@
</property>
</widget>
</item>
+ <item>
+ <widget class="QToolButton" name="openImageButton">
+ <property name="toolTip">
+ <string>Recognize text in an image file</string>
+ </property>
+ <property name="icon">
+ <iconset theme="insert-image"/>
+ </property>
+ </widget>
+ </item>
<item>
<widget class="QToolButton" name="delayedRecognizeScreenAreaButton">
<property name="toolTip">
@@ -387,12 +397,13 @@
</item>
</layout>
</item>
- </layout>
- </item>
- </layout>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QStatusBar" name="statusbar"/>
</widget>
- </widget>
- <customwidgets>
+ <customwidgets>
<customwidget>
<class>LanguageButtonsWidget</class>
<extends>QWidget</extends>
diff --git a/src/modulestatus.cpp b/src/modulestatus.cpp
new file mode 100644
index 00000000..e41ba970
--- /dev/null
+++ b/src/modulestatus.cpp
@@ -0,0 +1,294 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "modulestatus.h"
+
+#include "ocr/aocrprovider.h"
+#include "ocr/screengrabbers/abstractscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "tts/attsprovider.h"
+#include "tts/noopttsprovider.h"
+
+ModuleStatus::ModuleStatus(QObject *parent)
+ : QObject(parent)
+{
+}
+
+ModuleStatus::Activity ModuleStatus::activity(Module module) const
+{
+ return m_entries[static_cast<int>(module)].activity;
+}
+
+QString ModuleStatus::message(Module module) const
+{
+ return messageText(m_entries[static_cast<int>(module)].message);
+}
+
+QString ModuleStatus::detail(Module module) const
+{
+ return m_entries[static_cast<int>(module)].detail;
+}
+
+bool ModuleStatus::isBusy() const
+{
+ for (const ModuleEntry &entry : m_entries) {
+ if (entry.activity == Activity::Busy)
+ return true;
+ }
+ return false;
+}
+
+bool ModuleStatus::isAvailable(Module module) const
+{
+ if (module != Module::Tts)
+ return true;
+
+ // NoopTTSProvider is what backend "None" creates, and it never emits
+ // anything at all - the segment would sit permanently idle.
+ return m_tts != nullptr && qobject_cast<NoopTTSProvider *>(m_tts) == nullptr;
+}
+
+void ModuleStatus::bindTranslator(ATranslationProvider *translator)
+{
+ if (m_translator != nullptr)
+ disconnect(m_translator, nullptr, this, nullptr);
+ m_translator = translator;
+ if (translator == nullptr)
+ return;
+
+ connect(translator, &ATranslationProvider::stateChanged, this, [this](ATranslationProvider::State newState) {
+ handleTranslationState(newState);
+ });
+ connect(translator, &ATranslationProvider::detectionStarted, this, [this]() {
+ // A detection chained inside a translation must not clobber
+ // "Translating" - it is part of that work, not new work.
+ if (m_entries[static_cast<int>(Module::Translation)].activity != Activity::Busy) {
+ m_detectionInFlight = true;
+ setEntry(Module::Translation, Activity::Busy, Message::DetectingLanguage);
+ }
+ });
+ connect(translator, &ATranslationProvider::languageDetected, this, [this]() {
+ if (!m_detectionInFlight)
+ return; // detection chained into a translation: "Translating" stays
+ m_detectionInFlight = false;
+ markIdle(Module::Translation);
+ });
+
+ // Seed by pulling: a provider swapped in mid-flight would otherwise stay
+ // unreported until its next state change.
+ m_detectionInFlight = false;
+ setEntry(Module::Translation, Activity::Idle, Message::None);
+ if (translator->getState() == ATranslationProvider::State::Processing)
+ setEntry(Module::Translation, Activity::Busy, Message::Translating);
+}
+
+void ModuleStatus::bindTtsProvider(ATTSProvider *tts)
+{
+ if (m_tts != nullptr)
+ disconnect(m_tts, nullptr, this, nullptr);
+ m_tts = tts;
+ if (tts == nullptr)
+ return;
+
+ connect(tts, &ATTSProvider::stateChanged, this, [this](QTextToSpeech::State newState) {
+ handleTtsState(newState);
+ });
+ connect(tts, &ATTSProvider::errorOccurred, this, [this](QTextToSpeech::ErrorReason reason, const QString &errorString) {
+ Q_UNUSED(reason)
+ setEntry(Module::Tts, Activity::Error, Message::SpeechError, errorString);
+ });
+
+ seedTtsState();
+}
+
+void ModuleStatus::bindOcr(AOcrProvider *tesseract, AOcrProvider *llm)
+{
+ if (m_tesseractOcr != nullptr)
+ disconnect(m_tesseractOcr, nullptr, this, nullptr);
+ if (m_llmOcr != nullptr)
+ disconnect(m_llmOcr, nullptr, this, nullptr);
+ m_tesseractOcr = tesseract;
+ m_llmOcr = llm;
+
+ bindOneOcr(tesseract);
+ bindOneOcr(llm);
+}
+
+void ModuleStatus::bindCapture(AbstractScreenGrabber *grabber, SnippingArea *snippingArea)
+{
+ if (m_grabber != nullptr)
+ disconnect(m_grabber, nullptr, this, nullptr);
+ if (m_snippingArea != nullptr)
+ disconnect(m_snippingArea, nullptr, this, nullptr);
+ m_grabber = grabber;
+ m_snippingArea = snippingArea;
+
+ if (grabber != nullptr) {
+ connect(grabber, &AbstractScreenGrabber::grabbed, this, [this]() {
+ setEntry(Module::Snipping, Activity::Busy, Message::SelectRegion);
+ });
+ connect(grabber, &AbstractScreenGrabber::grabbingFailed, this, [this]() {
+ setEntry(Module::Snipping, Activity::Error, Message::CaptureFailed);
+ });
+ }
+
+ if (snippingArea != nullptr) {
+ connect(snippingArea, &SnippingArea::snipped, this, [this]() {
+ markIdle(Module::Snipping);
+ });
+ connect(snippingArea, &SnippingArea::cancelled, this, [this]() {
+ markIdle(Module::Snipping);
+ });
+ }
+}
+
+void ModuleStatus::beginScreenCapture()
+{
+ setEntry(Module::Snipping, Activity::Busy, Message::WaitingForCapture);
+}
+
+void ModuleStatus::setEntry(Module module, Activity activity, Message message, const QString &detail)
+{
+ ModuleEntry &entry = m_entries[static_cast<int>(module)];
+ if (entry.activity == activity && entry.message == message && entry.detail == detail)
+ return;
+
+ entry.activity = activity;
+ entry.message = message;
+ entry.detail = detail;
+ emit changed();
+}
+
+void ModuleStatus::markIdle(Module module)
+{
+ if (m_entries[static_cast<int>(module)].activity == Activity::Busy)
+ setEntry(module, Activity::Idle, Message::None);
+}
+
+void ModuleStatus::bindOneOcr(AOcrProvider *engine)
+{
+ if (engine == nullptr)
+ return;
+
+ connect(engine, &AOcrProvider::started, this, [this]() {
+ setEntry(Module::Ocr, Activity::Busy, Message::RecognizingText);
+ });
+ connect(engine, &AOcrProvider::recognized, this, [this]() {
+ markIdle(Module::Ocr);
+ });
+ connect(engine, &AOcrProvider::failed, this, [this](const QString &error) {
+ setEntry(Module::Ocr, Activity::Error, Message::OcrFailed, error);
+ });
+ connect(engine, &AOcrProvider::canceled, this, [this]() {
+ markIdle(Module::Ocr);
+ });
+}
+
+void ModuleStatus::handleTranslationState(ATranslationProvider::State newState)
+{
+ // Any state transition ends the standalone-detection story: the abort
+ // and cancel paths never emit languageDetected, and this is the safety
+ // net that keeps a "Detecting language" status from stranding.
+ m_detectionInFlight = false;
+
+ switch (newState) {
+ case ATranslationProvider::State::Processing:
+ // Busy clears a sticky error from a previous run.
+ setEntry(Module::Translation, Activity::Busy, Message::Translating);
+ break;
+ case ATranslationProvider::State::Ready:
+ markIdle(Module::Translation);
+ break;
+ case ATranslationProvider::State::Processed:
+ case ATranslationProvider::State::Finished:
+ if (m_translator == nullptr) {
+ markIdle(Module::Translation);
+ } else if (m_translator->error == ATranslationProvider::TranslationError::Aborted) {
+ // A deliberate abort is the user's own action, not a failure.
+ markIdle(Module::Translation);
+ } else if (m_translator->error != ATranslationProvider::TranslationError::NoError) {
+ // Sticky: Processed -> Finished -> Ready runs to completion in
+ // one call stack, so a non-sticky error would be overwritten by
+ // the trailing Ready before anything could repaint.
+ setEntry(Module::Translation, Activity::Error, Message::TranslationFailed, m_translator->getErrorString());
+ } else {
+ markIdle(Module::Translation);
+ }
+ break;
+ }
+}
+
+void ModuleStatus::handleTtsState(QTextToSpeech::State newState)
+{
+ switch (newState) {
+ case QTextToSpeech::Speaking:
+ setEntry(Module::Tts, Activity::Busy, Message::Speaking);
+ break;
+ case QTextToSpeech::Synthesizing:
+ // MainWindow::ttsStateChanged() throws this away; for a slow network
+ // synthesizer it is the only feedback there is.
+ setEntry(Module::Tts, Activity::Busy, Message::PreparingSpeech);
+ break;
+ case QTextToSpeech::Paused:
+ case QTextToSpeech::Ready:
+ markIdle(Module::Tts);
+ break;
+ case QTextToSpeech::Error:
+ setEntry(Module::Tts, Activity::Error, Message::SpeechError, m_tts != nullptr ? m_tts->errorString() : QString());
+ break;
+ }
+}
+
+void ModuleStatus::seedTtsState()
+{
+ // A freshly bound provider owes nothing to the previous one's sticky error.
+ setEntry(Module::Tts, Activity::Idle, Message::None);
+ if (m_tts == nullptr || !isAvailable(Module::Tts))
+ return;
+
+ // Seed by pulling ATTSProvider::state(): NoopTTSProvider never emits
+ // anything, ever, so waiting for stateChanged would never converge.
+ switch (m_tts->state()) {
+ case QTextToSpeech::Speaking:
+ case QTextToSpeech::Synthesizing:
+ case QTextToSpeech::Error:
+ handleTtsState(m_tts->state());
+ break;
+ case QTextToSpeech::Ready:
+ case QTextToSpeech::Paused:
+ break;
+ }
+}
+
+QString ModuleStatus::messageText(Message message) const
+{
+ switch (message) {
+ case Message::None:
+ return {};
+ case Message::WaitingForCapture:
+ return tr("Waiting for capture");
+ case Message::SelectRegion:
+ return tr("Select a region");
+ case Message::RecognizingText:
+ return tr("Recognizing text");
+ case Message::Translating:
+ return tr("Translating");
+ case Message::DetectingLanguage:
+ return tr("Detecting language");
+ case Message::PreparingSpeech:
+ return tr("Preparing speech");
+ case Message::Speaking:
+ return tr("Speaking");
+ case Message::CaptureFailed:
+ return tr("Capture failed");
+ case Message::OcrFailed:
+ return tr("OCR failed");
+ case Message::TranslationFailed:
+ return tr("Translation failed");
+ case Message::SpeechError:
+ return tr("Speech error");
+ }
+ return {};
+}
diff --git a/src/modulestatus.h b/src/modulestatus.h
new file mode 100644
index 00000000..965ef718
--- /dev/null
+++ b/src/modulestatus.h
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef MODULESTATUS_H
+#define MODULESTATUS_H
+
+#include "translator/atranslationprovider.h"
+
+#include <QObject>
+#include <QString>
+#include <QTextToSpeech>
+
+#include <array>
+
+class ATTSProvider;
+class AOcrProvider;
+class AbstractScreenGrabber;
+class SnippingArea;
+
+// Aggregator for "what is currently running" across the four async
+// subsystems (screen capture, OCR, translation, TTS). Both status strips -
+// the main window's and the pop-up's - read this one model instead of each
+// re-deriving state from the providers, since the pop-up has no providers
+// of its own.
+class ModuleStatus : public QObject
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(ModuleStatus)
+
+public:
+ enum class Module : uint8_t { Snipping,
+ Ocr,
+ Translation,
+ Tts }; // display order
+ enum class Activity : uint8_t { Idle,
+ Busy,
+ Error };
+
+ explicit ModuleStatus(QObject *parent = nullptr);
+
+ Activity activity(Module module) const;
+ // tr()'d on demand so a QEvent::LanguageChange re-render picks up the new
+ // locale; never carries trailing dots - the view animates those.
+ QString message(Module module) const;
+ // Tooltip text: the full error message of the provider, not translated.
+ QString detail(Module module) const;
+ // Any module Busy; drives the view's ellipsis timer.
+ bool isBusy() const;
+ // False only for TTS when the backend is None - the view omits the
+ // segment entirely rather than show a permanently idle module.
+ bool isAvailable(Module module) const;
+
+ // The translator and TTS objects are replaced on backend change
+ // (MainWindow::swapTranslator()/swapTTSProvider()); both call the
+ // matching bind again. The OCR engines and the grabber/snipping area are
+ // long-lived and are bound once.
+ void bindTranslator(ATranslationProvider *translator);
+ void bindTtsProvider(ATTSProvider *tts);
+ // Both engines, not activeOcr(): the active one switches per settings read.
+ void bindOcr(AOcrProvider *tesseract, AOcrProvider *llm);
+ void bindCapture(AbstractScreenGrabber *grabber, SnippingArea *snippingArea);
+
+ static constexpr int moduleCount()
+ {
+ return s_moduleCount;
+ }
+
+public slots:
+ // The one moment with no signal to hang off: AbstractScreenGrabber::grab()
+ // has no "started" signal (adding one would mean touching every grabber
+ // subclass), so MainWindow reports it here right before calling grab().
+ void beginScreenCapture();
+
+signals:
+ void changed();
+
+private:
+ enum class Message : uint8_t {
+ None,
+ WaitingForCapture,
+ SelectRegion,
+ RecognizingText,
+ Translating,
+ DetectingLanguage,
+ PreparingSpeech,
+ Speaking,
+ CaptureFailed,
+ OcrFailed,
+ TranslationFailed,
+ SpeechError,
+ };
+
+ struct ModuleEntry {
+ Activity activity = Activity::Idle;
+ Message message = Message::None;
+ QString detail;
+ };
+
+ void setEntry(Module module, Activity activity, Message message, const QString &detail = QString());
+ // Demotes Busy to Idle but never clears a sticky Error: a translation
+ // error would otherwise be overwritten by the Ready that follows in the
+ // same call stack (Processed -> Finished -> reset() -> Ready runs to
+ // completion before anything can repaint), and never render at all.
+ void markIdle(Module module);
+ void bindOneOcr(AOcrProvider *engine);
+ void handleTranslationState(ATranslationProvider::State newState);
+ void handleTtsState(QTextToSpeech::State newState);
+ void seedTtsState();
+
+ QString messageText(Message message) const;
+
+ static constexpr int s_moduleCount = 4;
+ std::array<ModuleEntry, s_moduleCount> m_entries;
+
+ // Set when a standalone language detection is in flight (a detection
+ // chained inside a translation is NOT tracked - "Translating" already
+ // covers it). Cleared by languageDetected() or any stateChanged().
+ bool m_detectionInFlight = false;
+
+ ATranslationProvider *m_translator = nullptr;
+ ATTSProvider *m_tts = nullptr;
+ AOcrProvider *m_tesseractOcr = nullptr;
+ AOcrProvider *m_llmOcr = nullptr;
+ AbstractScreenGrabber *m_grabber = nullptr;
+ SnippingArea *m_snippingArea = nullptr;
+};
+
+#endif // MODULESTATUS_H
diff --git a/src/ocr/aocrprovider.h b/src/ocr/aocrprovider.h
new file mode 100644
index 00000000..7e887a55
--- /dev/null
+++ b/src/ocr/aocrprovider.h
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef AOCRPROVIDER_H
+#define AOCRPROVIDER_H
+
+#include <QImage>
+#include <QObject>
+#include <QString>
+
+class AOcrProvider : public QObject
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(AOcrProvider)
+
+public:
+ explicit AOcrProvider(QObject *parent = nullptr)
+ : QObject(parent)
+ {
+ }
+ ~AOcrProvider() override = default;
+
+ virtual QString engineName() const = 0;
+ virtual bool isConfigured() const = 0;
+ virtual void recognize(const QImage &image, int dpi) = 0;
+ virtual void cancel() = 0;
+
+signals:
+ // Recognition has actually begun (both engines are genuinely async), so
+ // "recognizing" is an observable interval for the status strip.
+ void started();
+ void recognized(const QString &text);
+ void failed(const QString &error);
+ void canceled();
+};
+
+#endif // AOCRPROVIDER_H
diff --git a/src/ocr/llmocr.cpp b/src/ocr/llmocr.cpp
new file mode 100644
index 00000000..099e63ae
--- /dev/null
+++ b/src/ocr/llmocr.cpp
@@ -0,0 +1,281 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "llmocr.h"
+
+#include "llm/openaiendpoint.h"
+
+#include <QBuffer>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QNetworkAccessManager>
+#include <QNetworkReply>
+#include <QNetworkRequest>
+
+LlmOcr::LlmOcr(QObject *parent)
+ : AOcrProvider(parent)
+ , m_network(new QNetworkAccessManager(this))
+{
+}
+
+LlmOcr::~LlmOcr()
+{
+ if (m_reply != nullptr) {
+ m_reply->disconnect(this);
+ m_reply->abort();
+ m_reply->deleteLater();
+ }
+}
+
+QString LlmOcr::engineName() const
+{
+ return QStringLiteral("llm");
+}
+
+bool LlmOcr::isConfigured() const
+{
+ return !m_url.isEmpty() && !m_model.isEmpty();
+}
+
+void LlmOcr::setEndpoint(const QString &url, bool isAnthropic, const QString &apiKey)
+{
+ m_url = url;
+ m_isAnthropic = isAnthropic;
+ m_apiKey = apiKey;
+}
+
+void LlmOcr::setModel(const QString &model)
+{
+ m_model = model;
+}
+
+void LlmOcr::setTimeout(int seconds)
+{
+ m_timeout = seconds;
+}
+
+void LlmOcr::setPrompt(const QString &prompt)
+{
+ m_prompt = prompt;
+}
+
+void LlmOcr::setDisableThinking(bool disable)
+{
+ m_disableThinking = disable;
+}
+
+QString LlmOcr::defaultPrompt()
+{
+ return QStringLiteral(
+ "Transcribe ALL text visible in this image, exactly as written, in reading order. "
+ "Preserve the original language, line breaks and paragraphs. "
+ "Output only the transcribed text, nothing else. If there is no text, output nothing.");
+}
+
+QString LlmOcr::collapseRepeatedTranscription(const QString &text)
+{
+ QString normalized = text;
+ normalized.replace(QLatin1String("\r\n"), QLatin1String("\n"));
+ normalized.replace(QLatin1Char('\r'), QLatin1Char('\n'));
+
+ // Collapse runs of blank lines to a single newline so verbatim re-emissions
+ // of the same block become a detectable repeating sequence of lines.
+ QString folded;
+ folded.reserve(normalized.size());
+ const QChar newline = QLatin1Char('\n');
+ int newlineRun = 0;
+ for (const QChar c : normalized) {
+ if (c == newline) {
+ if (newlineRun == 0) {
+ folded.append(newline);
+ }
+ ++newlineRun;
+ } else {
+ folded.append(c);
+ newlineRun = 0;
+ }
+ }
+
+ QStringList lines;
+ const QStringList parts = folded.split(newline, Qt::SkipEmptyParts);
+ lines.reserve(parts.size());
+ for (const QString &part : parts) {
+ const QString line = part.trimmed();
+ if (!line.isEmpty()) {
+ lines.append(line);
+ }
+ }
+
+ const int n = lines.size();
+ if (n < 2) {
+ return text.trimmed();
+ }
+
+ // A whole-response repetition is the pathological signature: the completed
+ // transcription, then the same block re-emitted verbatim until the token
+ // budget runs out - which typically cuts the FINAL copy mid-block, leaving
+ // a partial tail whose last line is a prefix of the block's next line.
+ // Poetry can legitimately repeat lines, so single-line loops stay intact
+ // unless they run very long (>= 4 copies); a multi-line block repeated
+ // twice or more is collapsed even at 2 copies (user decision 2026-08-17),
+ // accepting that a 2-line refrain doubled verbatim would also collapse.
+ for (int period = 1; period <= n / 2; ++period) {
+ const int copies = n / period;
+ if (copies < 2) {
+ continue;
+ }
+ bool tiled = true;
+ for (int i = period; i < n; ++i) {
+ const QString &expected = lines[i % period];
+ const bool matches = (i == n - 1) ? expected.startsWith(lines[i]) : (lines[i] == expected);
+ if (!matches) {
+ tiled = false;
+ break;
+ }
+ }
+ if (!tiled) {
+ continue;
+ }
+ const bool runAwayLoop = (period == 1 && copies >= 4) || (period >= 2 && copies >= 2);
+ if (runAwayLoop) {
+ QStringList unit = lines.mid(0, period);
+ return unit.join(newline);
+ }
+ }
+ return text.trimmed();
+}
+
+void LlmOcr::recognize(const QImage &image, int dpi)
+{
+ Q_UNUSED(dpi)
+
+ emit started();
+
+ if (m_reply != nullptr) {
+ m_reply->disconnect(this);
+ m_reply->abort();
+ m_reply->deleteLater();
+ m_reply = nullptr;
+ }
+ m_userCanceled = false;
+
+ QByteArray imageData;
+ QBuffer buffer(&imageData);
+ buffer.open(QIODevice::WriteOnly);
+ image.save(&buffer, "JPEG");
+ const QString base64Data = QString::fromLatin1(imageData.toBase64());
+
+ const QString prompt = m_prompt.isEmpty() ? defaultPrompt() : m_prompt;
+
+ QJsonObject textPart;
+ textPart.insert(QStringLiteral("type"), QStringLiteral("text"));
+ textPart.insert(QStringLiteral("text"), prompt);
+
+ QJsonObject imagePart;
+ if (m_isAnthropic) {
+ imagePart.insert(QStringLiteral("type"), QStringLiteral("image"));
+ QJsonObject source;
+ source.insert(QStringLiteral("type"), QStringLiteral("base64"));
+ source.insert(QStringLiteral("media_type"), QStringLiteral("image/jpeg"));
+ source.insert(QStringLiteral("data"), base64Data);
+ imagePart.insert(QStringLiteral("source"), source);
+ } else {
+ imagePart.insert(QStringLiteral("type"), QStringLiteral("image_url"));
+ QJsonObject imageUrl;
+ imageUrl.insert(QStringLiteral("url"), QStringLiteral("data:image/jpeg;base64,") + base64Data);
+ imagePart.insert(QStringLiteral("image_url"), imageUrl);
+ }
+
+ QJsonArray content;
+ content.append(textPart);
+ content.append(imagePart);
+
+ QJsonObject message;
+ message.insert(QStringLiteral("role"), QStringLiteral("user"));
+ message.insert(QStringLiteral("content"), content);
+
+ QJsonArray messages;
+ messages.append(message);
+
+ // Small local vision models (an Ollama-hosted OCR model is the case that
+ // surfaced this) reliably finish the real transcription, then - lacking a
+ // clean stop token for the task - wrap it in a markdown fence and loop
+ // re-emitting it verbatim until they hit the token budget. A stop
+ // sequence on the fence marker cuts generation the instant that starts,
+ // instead of shipping the runaway repeat to the caller and trying to
+ // clean it up after the fact. Plain transcriptions never emit "```"
+ // themselves (the prompt asks for the transcribed text and nothing else).
+ static const QJsonArray kFenceStopSequence = {QStringLiteral("```")};
+
+ QJsonObject body;
+ body.insert(QStringLiteral("model"), m_model);
+ body.insert(QStringLiteral("messages"), messages);
+ body.insert(QStringLiteral("temperature"), 0.0);
+ if (m_isAnthropic) {
+ body.insert(QStringLiteral("max_tokens"), 4096);
+ body.insert(QStringLiteral("stop_sequences"), kFenceStopSequence);
+ } else {
+ body.insert(QStringLiteral("stream"), false);
+ body.insert(QStringLiteral("stop"), kFenceStopSequence);
+ if (m_disableThinking) {
+ body.insert(QStringLiteral("reasoning_effort"), QStringLiteral("none"));
+ }
+ }
+
+ QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_url, m_isAnthropic)));
+ request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
+ OpenAiEndpoint::setAuthHeaders(request, m_isAnthropic, m_apiKey);
+ m_network->setTransferTimeout(m_timeout * 1000);
+
+ m_reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
+ connect(m_reply, &QNetworkReply::finished, this, &LlmOcr::onFinished);
+}
+
+void LlmOcr::cancel()
+{
+ if (m_reply != nullptr) {
+ m_userCanceled = true;
+ m_reply->abort();
+ } else {
+ emit canceled();
+ }
+}
+
+void LlmOcr::onFinished()
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
+ if (reply == nullptr) {
+ return;
+ }
+ if (reply != m_reply) {
+ reply->deleteLater();
+ return;
+ }
+ m_reply = nullptr;
+ reply->deleteLater();
+
+ if (reply->error() == QNetworkReply::OperationCanceledError) {
+ if (m_userCanceled) {
+ emit canceled();
+ } else {
+ emit failed(QStringLiteral("LLM OCR request timed out"));
+ }
+ return;
+ }
+
+ if (reply->error() != QNetworkReply::NoError) {
+ emit failed(QStringLiteral("LLM OCR error: ") + reply->errorString());
+ return;
+ }
+
+ const QString text = OpenAiEndpoint::extractContent(reply->readAll(), m_isAnthropic);
+ if (text.isEmpty()) {
+ emit failed(QStringLiteral("LLM OCR returned an empty response"));
+ return;
+ }
+
+ emit recognized(collapseRepeatedTranscription(text));
+}
diff --git a/src/ocr/llmocr.h b/src/ocr/llmocr.h
new file mode 100644
index 00000000..a2130089
--- /dev/null
+++ b/src/ocr/llmocr.h
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef LLMOCR_H
+#define LLMOCR_H
+
+#include "aocrprovider.h"
+
+#include <QImage>
+#include <QString>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+// OCR engine backed by an OpenAI-compatible or Anthropic-compatible vision
+// model. Unlike the Tesseract engine it knows nothing about translation: it
+// sends the image to a multimodal chat completions endpoint with a
+// transcription prompt and emits the recognized text, which then flows
+// through the same source-edit -> translation path as any other OCR result.
+class LlmOcr : public AOcrProvider
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(LlmOcr)
+
+public:
+ explicit LlmOcr(QObject *parent = nullptr);
+ ~LlmOcr() override;
+
+ QString engineName() const override;
+ bool isConfigured() const override;
+ void recognize(const QImage &image, int dpi) override;
+ void cancel() override;
+
+ void setEndpoint(const QString &url, bool isAnthropic, const QString &apiKey);
+ void setModel(const QString &model);
+ void setTimeout(int seconds);
+ void setPrompt(const QString &prompt);
+ void setDisableThinking(bool disable);
+
+ static QString defaultPrompt();
+
+ // Collapses a transcription that a repeat-prone local vision model
+ // re-emits verbatim (blank-line separated) until it exhausts its token
+ // budget, so a runaway repeat can never land doubled in the source edit
+ // even when generation isn't stopped upstream. Static so the unit test
+ // can pin it directly.
+ static QString collapseRepeatedTranscription(const QString &text);
+
+private:
+ void onFinished();
+
+ QNetworkAccessManager *m_network;
+ QNetworkReply *m_reply = nullptr;
+
+ QString m_url;
+ QString m_model;
+ QString m_prompt;
+ QString m_apiKey;
+ bool m_isAnthropic = false;
+ int m_timeout = 300;
+ bool m_userCanceled = false;
+ bool m_disableThinking = false;
+};
+
+#endif // LLMOCR_H
diff --git a/src/ocr/snippingarea.cpp b/src/ocr/snippingarea.cpp
index a2dd324c..178f8e4e 100644
--- a/src/ocr/snippingarea.cpp
+++ b/src/ocr/snippingarea.cpp
@@ -952,6 +952,12 @@ void SnippingArea::acceptSelection()
} else {
emit snipped(selectedPixmap(), static_cast<int>(dpi));
}
+ } else {
+ // An empty selection (click without a drag) still has to emit a
+ // terminal signal: without one, a pending translate-screen-area
+ // stays armed for the *next* recognition and the status strip
+ // sticks on "Select a region".
+ emit cancelled();
}
hide();
releaseKeyboard();
diff --git a/src/ocr/ocr.cpp b/src/ocr/tesseractocr.cpp
similarity index 81%
rename from src/ocr/ocr.cpp
rename to src/ocr/tesseractocr.cpp
index f531ee4c..5120361b 100644
--- a/src/ocr/ocr.cpp
+++ b/src/ocr/tesseractocr.cpp
@@ -5,19 +5,18 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
-#include "ocr.h"
+#include "tesseractocr.h"
#include "settings/appsettings.h"
-#include <QPixmap>
#include <QtConcurrent>
#if TESSERACT_MAJOR_VERSION < 5
#include <tesseract/genericvector.h>
#endif
-Ocr::Ocr(QObject *parent)
- : QObject(parent)
+TesseractOcr::TesseractOcr(QObject *parent)
+ : AOcrProvider(parent)
{
// For the ability to cancel task
m_monitor.cancel_this = &m_future;
@@ -26,12 +25,22 @@ Ocr::Ocr(QObject *parent)
};
}
-void Ocr::setConvertLineBreaks(bool convert)
+void TesseractOcr::setConvertLineBreaks(bool convert)
{
m_convertLineBreaks = convert;
}
-QStringList Ocr::availableLanguages() const
+QString TesseractOcr::engineName() const
+{
+ return QStringLiteral("tesseract");
+}
+
+bool TesseractOcr::isConfigured() const
+{
+ return !languagesString().isEmpty();
+}
+
+QStringList TesseractOcr::availableLanguages() const
{
QStringList availableLanguages;
#if TESSERACT_MAJOR_VERSION < 5
@@ -53,12 +62,12 @@ QStringList Ocr::availableLanguages() const
return availableLanguages;
}
-QByteArray Ocr::languagesString() const
+QByteArray TesseractOcr::languagesString() const
{
return QByteArray::fromRawData(m_tesseract.GetInitLanguagesAsString(), static_cast<int>(qstrlen(m_tesseract.GetInitLanguagesAsString())));
}
-bool Ocr::init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> ¶meters)
+bool TesseractOcr::init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> ¶meters)
{
// Call even if the specified language is empty to initialize (Tesseract will try to load eng by default)
if (languagesString() != languages || languages.isEmpty() || m_parameters != parameters) {
@@ -73,12 +82,13 @@ bool Ocr::init(const QByteArray &languages, const QByteArray &languagesPath, con
return true;
}
-void Ocr::recognize(const QPixmap &pixmap, int dpi)
+void TesseractOcr::recognize(const QImage &image, int dpi)
{
Q_ASSERT_X(qstrlen(m_tesseract.GetInitLanguagesAsString()) != 0, "recognize", "You should call init first");
+ emit started();
m_future.waitForFinished();
- m_future = QtConcurrent::run([this, dpi, image = pixmap.toImage()] {
+ m_future = QtConcurrent::run([this, dpi, image] {
m_tesseract.SetImage(image.constBits(), image.width(), image.height(), image.depth() / 8, image.bytesPerLine());
m_tesseract.SetSourceResolution(dpi);
m_tesseract.Recognize(&m_monitor);
@@ -95,12 +105,12 @@ void Ocr::recognize(const QPixmap &pixmap, int dpi)
});
}
-void Ocr::cancel()
+void TesseractOcr::cancel()
{
m_future.cancel();
}
-QStringList Ocr::availableLanguages(const QString &languagesPath)
+QStringList TesseractOcr::availableLanguages(const QString &languagesPath)
{
// From the specified directory
if (!languagesPath.isEmpty())
@@ -121,7 +131,7 @@ QStringList Ocr::availableLanguages(const QString &languagesPath)
return {};
}
-void Ocr::applyParameters(const QMap<QString, QVariant> ¶meters, bool saveSettings)
+void TesseractOcr::applyParameters(const QMap<QString, QVariant> ¶meters, bool saveSettings)
{
// Apply new parameters
for (auto it = parameters.cbegin(); it != parameters.cend(); ++it) {
@@ -137,7 +147,7 @@ void Ocr::applyParameters(const QMap<QString, QVariant> ¶meters, bool saveSe
AppSettings().setTesseractParameters(m_parameters);
}
-QStringList Ocr::parseLanguageFiles(const QDir &directory)
+QStringList TesseractOcr::parseLanguageFiles(const QDir &directory)
{
const QFileInfoList files = directory.entryInfoList({QStringLiteral("*.traineddata")}, QDir::Files);
QStringList languages;
@@ -146,4 +156,4 @@ QStringList Ocr::parseLanguageFiles(const QDir &directory)
languages.append(file.baseName());
return languages;
-}
+}
\ No newline at end of file
diff --git a/src/ocr/ocr.h b/src/ocr/tesseractocr.h
similarity index 63%
rename from src/ocr/ocr.h
rename to src/ocr/tesseractocr.h
index d793b3c1..f81363a8 100644
--- a/src/ocr/ocr.h
+++ b/src/ocr/tesseractocr.h
@@ -5,27 +5,30 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
-#ifndef OCR_H
-#define OCR_H
+#ifndef TESSERACTOCR_H
+#define TESSERACTOCR_H
+#include "aocrprovider.h"
#include "cmake.h"
#include <QFuture>
-#include <QObject>
#include <tesseract/baseapi.h>
#include <tesseract/ocrclass.h>
class QDir;
-class Ocr : public QObject
+// Tesseract-backed OCR engine. Tesseract-specific configuration (language
+// packs, parameters) lives here and on the OCR settings page; the active
+// engine is selected by AppSettings::ocrEngine().
+class TesseractOcr : public AOcrProvider
{
Q_OBJECT
- Q_CLASSINFO("D-Bus Interface", APPLICATION_ID ".Ocr")
- Q_DISABLE_COPY(Ocr)
+ Q_CLASSINFO("D-Bus Interface", APPLICATION_ID ".TesseractOcr")
+ Q_DISABLE_COPY(TesseractOcr)
public:
- explicit Ocr(QObject *parent = nullptr);
+ explicit TesseractOcr(QObject *parent = nullptr);
void setConvertLineBreaks(bool convert);
@@ -33,18 +36,16 @@ public:
QByteArray languagesString() const;
bool init(const QByteArray &languages, const QByteArray &languagesPath, const QMap<QString, QVariant> ¶meters);
- void recognize(const QPixmap &pixmap, int dpi);
- void cancel();
+ QString engineName() const override;
+ bool isConfigured() const override;
+ void recognize(const QImage &image, int dpi) override;
+ void cancel() override;
static QStringList availableLanguages(const QString &languagesPath);
public slots:
Q_SCRIPTABLE void applyParameters(const QMap<QString, QVariant> ¶meters, bool saveSettings = false);
-signals:
- void recognized(const QString &text);
- void canceled();
-
private:
static QStringList parseLanguageFiles(const QDir &directory);
@@ -61,4 +62,4 @@ private:
bool m_convertLineBreaks = false;
};
-#endif // OCR_H
+#endif // TESSERACTOCR_H
\ No newline at end of file
diff --git a/src/popupwindow.cpp b/src/popupwindow.cpp
index 201db679..a213cf9d 100644
--- a/src/popupwindow.cpp
+++ b/src/popupwindow.cpp
@@ -131,6 +131,11 @@ PopupWindow::PopupWindow(MainWindow *parent)
m_closeWindowsShortcut->setKey(parent->closeWindowShortcut());
connect(m_closeWindowsShortcut, &QShortcut::activated, this, &PopupWindow::close);
+ // Status strip: mirrors MainWindow's model, and only appears while
+ // something is running so the resting pop-up is unchanged.
+ ui->statusStrip->setModel(parent->moduleStatus());
+ ui->statusStrip->setHideWhenIdle(true);
+
loadSettings();
}
@@ -147,6 +152,7 @@ void PopupWindow::loadSettings()
const AppSettings settings;
setWindowOpacity(settings.popupOpacity());
resize(settings.popupWidth(), settings.popupHeight());
+ ui->statusStrip->setShown(settings.isShowStatusBar());
ui->sourceLanguagesWidget->setLanguageFormat(settings.popupLanguageFormat());
ui->translationLanguagesWidget->setLanguageFormat(settings.popupLanguageFormat());
diff --git a/src/popupwindow.ui b/src/popupwindow.ui
index b30ab735..5229981c 100644
--- a/src/popupwindow.ui
+++ b/src/popupwindow.ui
@@ -278,27 +278,43 @@
</property>
</widget>
</item>
- <item>
- <widget class="QToolButton" name="copyTranslationButton">
- <property name="toolTip">
- <string>Copy translation to the clipboard</string>
- </property>
- <property name="icon">
- <iconset theme="edit-copy"/>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- </layout>
- </widget>
- <customwidgets>
- <customwidget>
- <class>LanguageButtonsWidget</class>
- <extends>QWidget</extends>
- <header>languagebuttonswidget.h</header>
- <container>1</container>
- </customwidget>
+ <item>
+ <widget class="QToolButton" name="copyTranslationButton">
+ <property name="toolTip">
+ <string>Copy translation to the clipboard</string>
+ </property>
+ <property name="icon">
+ <iconset theme="edit-copy"/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="StatusStrip" name="statusStrip">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>LanguageButtonsWidget</class>
+ <extends>QWidget</extends>
+ <header>languagebuttonswidget.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>StatusStrip</class>
+ <extends>QWidget</extends>
+ <header>statusstrip.h</header>
+ <container>1</container>
+ </customwidget>
<customwidget>
<class>TranslationEdit</class>
<extends>QTextEdit</extends>
diff --git a/src/provideroptionsmanager.cpp b/src/provideroptionsmanager.cpp
index 20168c60..058b471b 100644
--- a/src/provideroptionsmanager.cpp
+++ b/src/provideroptionsmanager.cpp
@@ -117,15 +117,7 @@ std::unique_ptr<ProviderOptions> ProviderOptionsManager::createLocalAiTranslatio
options->setOption("api_key", settings.localProviderApiKey(active));
options->setOption("is_anthropic", AppSettings::localProviderIsAnthropic(active));
- options->setOption("vision_enabled", settings.isVisionEnabled(active));
- const QString visionModel = settings.localVisionModel(active);
- options->setOption("vision_model", visionModel);
- options->setOption("vision_prompt", settings.localVisionPrompt(visionModel));
- options->setOption("vision_disable_thinking", settings.localAiDisableVisionThinking(active));
- options->setOption("vision_timeout", settings.localAiVisionTimeout(active));
-
const QString detectProvider = settings.detectProvider();
- options->setOption("detect_via_llm", settings.detectViaLlm());
options->setOption("detect_url", settings.localProviderUrl(detectProvider));
options->setOption("detect_model", settings.detectModel());
options->setOption("detect_api_key", settings.localProviderApiKey(detectProvider));
diff --git a/src/settings/appsettings.cpp b/src/settings/appsettings.cpp
index 915ff182..f5738e8e 100644
--- a/src/settings/appsettings.cpp
+++ b/src/settings/appsettings.cpp
@@ -358,6 +358,21 @@ QString AppSettings::defaultCustomIconPath()
return TrayIcon::trayIconName(AppSettings::DefaultIcon);
}
+bool AppSettings::isShowStatusBar() const
+{
+ return m_settings->value(QStringLiteral("Interface/ShowStatusBar"), defaultShowStatusBar()).toBool();
+}
+
+void AppSettings::setShowStatusBar(bool visible)
+{
+ m_settings->setValue(QStringLiteral("Interface/ShowStatusBar"), visible);
+}
+
+bool AppSettings::defaultShowStatusBar()
+{
+ return true;
+}
+
bool AppSettings::isSourceTranslitEnabled() const
{
return m_settings->value(QStringLiteral("Translation/SourceTranslitEnabled"), defaultSourceTranslitEnabled()).toBool();
@@ -655,6 +670,15 @@ void AppSettings::setLibreTranslateDirect(bool direct)
// āā LocalAI backend āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+// Model names double as QSettings keys, and a '/' would silently create a
+// nested group ("openai/gpt-oss" -> group "openai", key "gpt-oss").
+static QString promptKey(const QString &model)
+{
+ QString key = model;
+ key.replace(QLatin1Char('/'), QLatin1Char('_'));
+ return key;
+}
+
QStringList AppSettings::localProviderIds()
{
return {QStringLiteral("ollama"), QStringLiteral("fastflowlm"), QStringLiteral("lmstudio"),
@@ -761,16 +785,6 @@ void AppSettings::setLocalProviderApiKey(const QString &id, const QString &apiKe
m_settings->setValue(QStringLiteral("LocalAI/") + id + QStringLiteral("/ApiKey"), apiKey);
}
-bool AppSettings::detectViaLlm() const
-{
- return m_settings->value(QStringLiteral("Translation/DetectViaLLM"), false).toBool();
-}
-
-void AppSettings::setDetectViaLlm(bool enabled)
-{
- m_settings->setValue(QStringLiteral("Translation/DetectViaLLM"), enabled);
-}
-
QString AppSettings::detectProvider() const
{
return m_settings->value(QStringLiteral("LocalAI/DetectProvider"), QStringLiteral("ollama")).toString();
@@ -816,56 +830,33 @@ void AppSettings::setLocalAiDisableThinking(const QString &providerId, bool disa
m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableThinking"), disable);
}
-int AppSettings::localAiVisionTimeout(const QString &providerId) const
-{
- return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionTimeout"), defaultLocalAiTimeout()).toInt();
-}
-
-void AppSettings::setLocalAiVisionTimeout(const QString &providerId, int seconds)
-{
- m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionTimeout"), seconds);
-}
-
-bool AppSettings::localAiDisableVisionThinking(const QString &providerId) const
-{
- return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableVisionThinking"), false).toBool();
-}
-
-void AppSettings::setLocalAiDisableVisionThinking(const QString &providerId, bool disable)
-{
- m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/DisableVisionThinking"), disable);
-}
-
-bool AppSettings::isVisionEnabled(const QString &providerId) const
-{
- return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionEnabled"), false).toBool();
-}
-
-void AppSettings::setVisionEnabled(const QString &providerId, bool enabled)
-{
- m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionEnabled"), enabled);
-}
-
-QString AppSettings::localVisionModel(const QString &providerId) const
-{
- return m_settings->value(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionModel")).toString();
-}
-
-void AppSettings::setLocalVisionModel(const QString &providerId, const QString &model)
-{
- m_settings->setValue(QStringLiteral("LocalAI/") + providerId + QStringLiteral("/VisionModel"), model);
-}
-
-QString AppSettings::defaultVisionPrompt()
+QString AppSettings::defaultLocalAiPrompt()
{
return QStringLiteral(
- "Replace EVERY piece of text visible in the image with its {target_lang} ({target_code}) translation. "
- "Do NOT show original text ā no arrows, no \"->\", no showing both languages. "
- "Preserve the original formatting (line breaks, paragraphs). "
- "Output only the translated text, nothing else.");
-}
-
-QString AppSettings::defaultLocalAiPrompt()
+ "You are a professional translator. Determine the source language of "
+ "the text yourself, then translate it into {target_lang} ({target_code}), "
+ "accurately conveying its meaning and nuances while adhering to "
+ "{target_lang} grammar, vocabulary, and cultural sensitivities.\n"
+ "Produce only the {target_lang} translation, without any additional "
+ "explanations or commentary. Translate the following text into "
+ "{target_lang}:\n\n\n{text}");
+}
+
+// The default translation prompt as it stood before the current one replaced
+// it. It asserted a specific source language into every request ("You are a
+// professional {source_lang} ... translate the following {source_lang} text"),
+// which the model does not need and can get wrong.
+//
+// It has to stay named here because the settings dialog used to persist the
+// prompt on every keystroke, so anyone who has merely *opened* the LocalAI page
+// on a master build has this string stored under their model - and a stored
+// prompt wins over the default. Left alone, upgrading would go on sending the
+// old prompt while the settings page showed it as the current default.
+//
+// Byte-identical to this means nobody typed it: it is that auto-save, not a
+// customization, so it reads back as "nothing stored". Anything else, however
+// similar, is the user's own text and is returned untouched.
+static QString supersededSourceLangPrompt()
{
return QStringLiteral(
"You are a professional {source_lang} ({source_code}) to {target_lang} ({target_code}) translator. "
@@ -877,30 +868,16 @@ QString AppSettings::defaultLocalAiPrompt()
QString AppSettings::localAiPrompt(const QString &model) const
{
- QString key = model;
- key.replace(QLatin1Char('/'), QLatin1Char('_'));
- return m_settings->value(QStringLiteral("LocalAI/Prompts/") + key, defaultLocalAiPrompt()).toString();
+ const QString stored = m_settings->value(QStringLiteral("LocalAI/Prompts/") + promptKey(model)).toString();
+ if (stored.isEmpty() || stored == supersededSourceLangPrompt()) {
+ return defaultLocalAiPrompt();
+ }
+ return stored;
}
void AppSettings::setLocalAiPrompt(const QString &model, const QString &prompt)
{
- QString key = model;
- key.replace(QLatin1Char('/'), QLatin1Char('_'));
- m_settings->setValue(QStringLiteral("LocalAI/Prompts/") + key, prompt);
-}
-
-QString AppSettings::localVisionPrompt(const QString &model) const
-{
- QString key = model;
- key.replace(QLatin1Char('/'), QLatin1Char('_'));
- return m_settings->value(QStringLiteral("LocalAI/VisionPrompts/") + key, defaultVisionPrompt()).toString();
-}
-
-void AppSettings::setLocalVisionPrompt(const QString &model, const QString &prompt)
-{
- QString key = model;
- key.replace(QLatin1Char('/'), QLatin1Char('_'));
- m_settings->setValue(QStringLiteral("LocalAI/VisionPrompts/") + key, prompt);
+ m_settings->setValue(QStringLiteral("LocalAI/Prompts/") + promptKey(model), prompt);
}
QNetworkProxy::ProxyType AppSettings::proxyType() const
@@ -1288,6 +1265,101 @@ bool AppSettings::defaultConvertLineBreaks()
return true;
}
+AppSettings::OcrEngine AppSettings::ocrEngine() const
+{
+ return static_cast<OcrEngine>(m_settings->value(QStringLiteral("OCR/Engine"), static_cast<int>(defaultOcrEngine())).toInt());
+}
+
+void AppSettings::setOcrEngine(OcrEngine engine)
+{
+ m_settings->setValue(QStringLiteral("OCR/Engine"), static_cast<int>(engine));
+}
+
+AppSettings::OcrEngine AppSettings::defaultOcrEngine()
+{
+ return OcrEngine::Tesseract;
+}
+
+QString AppSettings::ocrLlmProvider() const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/Provider"), QStringLiteral("ollama")).toString();
+}
+
+void AppSettings::setOcrLlmProvider(const QString &providerId)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/Provider"), providerId);
+}
+
+QString AppSettings::ocrLlmUrl(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Url"), defaultLocalProviderUrl(providerId)).toString();
+}
+
+void AppSettings::setOcrLlmUrl(const QString &providerId, const QString &url)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Url"), url);
+}
+
+QString AppSettings::ocrLlmApiKey(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/ApiKey")).toString();
+}
+
+void AppSettings::setOcrLlmApiKey(const QString &providerId, const QString &apiKey)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/ApiKey"), apiKey);
+}
+
+QString AppSettings::ocrLlmModel(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Model")).toString();
+}
+
+void AppSettings::setOcrLlmModel(const QString &providerId, const QString &model)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Model"), model);
+}
+
+QStringList AppSettings::ocrLlmModels(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Models")).toStringList();
+}
+
+void AppSettings::setOcrLlmModels(const QString &providerId, const QStringList &models)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Models"), models);
+}
+
+QStringList AppSettings::ocrLlmVisionModels(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/VisionModels")).toStringList();
+}
+
+void AppSettings::setOcrLlmVisionModels(const QString &providerId, const QStringList &models)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/VisionModels"), models);
+}
+
+QString AppSettings::ocrLlmPrompt(const QString &model) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/Prompts/") + promptKey(model)).toString();
+}
+
+void AppSettings::setOcrLlmPrompt(const QString &model, const QString &prompt)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/Prompts/") + promptKey(model), prompt);
+}
+
+int AppSettings::ocrLlmTimeout(const QString &providerId) const
+{
+ return m_settings->value(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Timeout"), defaultLocalAiTimeout()).toInt();
+}
+
+void AppSettings::setOcrLlmTimeout(const QString &providerId, int seconds)
+{
+ m_settings->setValue(QStringLiteral("OcrLlm/") + providerId + QStringLiteral("/Timeout"), seconds);
+}
+
QByteArray AppSettings::ocrLanguagesPath() const
{
return m_settings->value(QStringLiteral("OCR/LanguagesPath"), defaultOcrLanguagesPath()).toByteArray();
diff --git a/src/settings/appsettings.h b/src/settings/appsettings.h
index f0a0cdde..0730d1f8 100644
--- a/src/settings/appsettings.h
+++ b/src/settings/appsettings.h
@@ -138,6 +138,10 @@ public:
void setCustomIconPath(const QString &path);
static QString defaultCustomIconPath();
+ bool isShowStatusBar() const;
+ void setShowStatusBar(bool visible);
+ static bool defaultShowStatusBar();
+
// Translation settings
bool isSourceTranslitEnabled() const;
void setSourceTranslitEnabled(bool enable);
@@ -219,8 +223,6 @@ public:
QString localProviderApiKey(const QString &id) const;
void setLocalProviderApiKey(const QString &id, const QString &apiKey);
- bool detectViaLlm() const;
- void setDetectViaLlm(bool enabled);
QString detectProvider() const;
void setDetectProvider(const QString &id);
QString detectModel() const;
@@ -232,21 +234,9 @@ public:
bool localAiDisableThinking(const QString &providerId) const;
void setLocalAiDisableThinking(const QString &providerId, bool disable);
- int localAiVisionTimeout(const QString &providerId) const;
- void setLocalAiVisionTimeout(const QString &providerId, int seconds);
- bool localAiDisableVisionThinking(const QString &providerId) const;
- void setLocalAiDisableVisionThinking(const QString &providerId, bool disable);
-
QString localAiPrompt(const QString &model) const;
void setLocalAiPrompt(const QString &model, const QString &prompt);
static QString defaultLocalAiPrompt();
- QString localVisionPrompt(const QString &model) const;
- void setLocalVisionPrompt(const QString &model, const QString &prompt);
- static QString defaultVisionPrompt();
- QString localVisionModel(const QString &providerId) const;
- void setLocalVisionModel(const QString &providerId, const QString &model);
- bool isVisionEnabled(const QString &providerId) const;
- void setVisionEnabled(const QString &providerId, bool enabled);
static constexpr qint64 maxSourceImagePixels = 100LL * 1000 * 1000;
static constexpr int maxSourceImageDimension = 2048;
@@ -368,6 +358,41 @@ public:
static QKeySequence defaultCopyTranslationShortcut();
// OCR settings
+ enum class OcrEngine {
+ Tesseract,
+ Llm
+ };
+ OcrEngine ocrEngine() const;
+ void setOcrEngine(OcrEngine engine);
+ static OcrEngine defaultOcrEngine();
+
+ // Vision-model OCR engine. Its provider ids are the same kinds as the
+ // LocalAI translation backend's (localProviderIds()), but every value
+ // lives under its own "OcrLlm/" group: the endpoint that transcribes your
+ // screenshots is not the endpoint that translates your text, and changing
+ // one must never move the other. Url falls back to the same per-kind
+ // default the translation backend uses, so a fresh install still points
+ // at localhost rather than nowhere.
+ QString ocrLlmProvider() const;
+ void setOcrLlmProvider(const QString &providerId);
+ QString ocrLlmUrl(const QString &providerId) const;
+ void setOcrLlmUrl(const QString &providerId, const QString &url);
+ QString ocrLlmApiKey(const QString &providerId) const;
+ void setOcrLlmApiKey(const QString &providerId, const QString &apiKey);
+ QString ocrLlmModel(const QString &providerId) const;
+ void setOcrLlmModel(const QString &providerId, const QString &model);
+ // Full model list from the last probe, and the subset the server proved
+ // vision-capable. A model absent from the vision list is "unproven", not
+ // "unsupported" - most OpenAI-compatible /v1/models responses carry no
+ // capability information at all, so nothing is ever hidden on that basis.
+ QStringList ocrLlmModels(const QString &providerId) const;
+ void setOcrLlmModels(const QString &providerId, const QStringList &models);
+ QStringList ocrLlmVisionModels(const QString &providerId) const;
+ void setOcrLlmVisionModels(const QString &providerId, const QStringList &models);
+ QString ocrLlmPrompt(const QString &model) const;
+ void setOcrLlmPrompt(const QString &model, const QString &prompt);
+ int ocrLlmTimeout(const QString &providerId) const;
+ void setOcrLlmTimeout(const QString &providerId, int seconds);
bool isConvertLineBreaks() const;
void setConvertLineBreaks(bool convert);
static bool defaultConvertLineBreaks();
diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp
index 7f4fbd64..8ff2562e 100644
--- a/src/settings/settingsdialog.cpp
+++ b/src/settings/settingsdialog.cpp
@@ -16,11 +16,13 @@
#include "screenwatcher.h"
#include "trayicon.h"
#include "autostartmanager/abstractautostartmanager.h"
-#include "ocr/ocr.h"
+#include "llm/openaiendpoint.h"
+#include "llm/visionmodelprobe.h"
+#include "ocr/llmocr.h"
+#include "ocr/tesseractocr.h"
#include "shortcutsmodel/shortcutitem.h"
#include "shortcutsmodel/shortcutsmodel.h"
#include "translator/atranslationprovider.h"
-#include "translator/localaitranslationprovider.h"
#include "tts/attsprovider.h"
#include <QButtonGroup>
@@ -109,13 +111,14 @@ SettingsDialog::SettingsDialog(MainWindow *parent)
}
}
- ui->ocrLanguagesListWidget->addLanguages(parent->ocr()->availableLanguages());
+ ui->ocrLanguagesListWidget->addLanguages(parent->tesseractOcr()->availableLanguages());
// Set all available instances
ui->mozhiUrlComboBox->addItems(InstancePinger::instances());
connect(ui->mozhiUrlComboBox, &QComboBox::currentTextChanged, this, &SettingsDialog::mozhiInstanceChanged);
buildLocalAiTabs();
+ buildOcrEngineUi();
// Adjust tab widgets to fit the scroll area viewport (sidebar occupies ~200 px).
connect(ui->pagesStackedWidget, &QStackedWidget::currentChanged, this, [this](int /*idx*/) {
@@ -252,6 +255,7 @@ void SettingsDialog::accept()
settings.setTrayIconType(static_cast<AppSettings::IconType>(ui->trayIconComboBox->currentIndex()));
settings.setCustomIconPath(ui->customTrayIconEdit->text());
+ settings.setShowStatusBar(ui->showStatusBarCheckBox->isChecked());
// Translation settings
const ATranslationProvider::ProviderBackend currentBackend = settings.translationProviderBackend();
const ATranslationProvider::ProviderBackend newBackend = ui->translationProviderComboBox->currentData().value<ATranslationProvider::ProviderBackend>();
@@ -289,7 +293,7 @@ void SettingsDialog::accept()
// LocalAI settings
saveLocalAiSettings();
- settings.setDetectViaLlm(ui->detectViaLlmCheckBox->isChecked());
+ saveOcrEngineSettings();
settings.setDetectProvider(ui->detectProviderComboBox->currentData().toString());
settings.setDetectModel(ui->detectModelComboBox->currentText());
@@ -363,42 +367,6 @@ void SettingsDialog::buildLocalAiTabs()
grid->addLayout(urlLayout, 0, 0, Qt::AlignVCenter);
grid->addWidget(refresh, 0, 1, Qt::AlignRight | Qt::AlignVCenter);
- auto *toggleWidget = new QWidget(outer);
- auto *toggleHL = new QHBoxLayout(toggleWidget);
- toggleHL->setContentsMargins(0, 0, 0, 0);
- toggleHL->setSpacing(0);
-
- auto *visionToggle = new QPushButton(tr("Vision"), outer);
- visionToggle->setCheckable(true);
- visionToggle->setObjectName(QStringLiteral("visionToggle"));
- auto *textToggle = new QPushButton(tr("Text"), outer);
- textToggle->setCheckable(true);
- textToggle->setObjectName(QStringLiteral("textToggle"));
- textToggle->setChecked(true);
- toggleWidget->setStyleSheet(QStringLiteral(
- "QPushButton#visionToggle:checked, QPushButton#textToggle:checked{font-weight:bold;font-style:normal}"
- "QPushButton#visionToggle:not(:checked), QPushButton#textToggle:not(:checked){font-weight:normal;font-style:italic}"));
-
- auto *modeGroup = new QButtonGroup(outer);
- modeGroup->setExclusive(true);
- modeGroup->addButton(visionToggle, 0);
- modeGroup->addButton(textToggle, 1);
-
- auto *helpBtn = new QPushButton(tr("?"), outer);
- helpBtn->setFixedWidth(helpBtn->fontMetrics().horizontalAdvance(QStringLiteral(" ?? ")));
- helpBtn->setToolTip(tr("How to use this tab"));
-
- toggleHL->addWidget(visionToggle);
- toggleHL->addWidget(textToggle);
- toggleHL->addWidget(helpBtn);
- grid->addWidget(toggleWidget, 1, 1, Qt::AlignRight | Qt::AlignVCenter);
-
- auto *debugCheck = new QCheckBox(tr("Show both"), outer);
- debugCheck->setToolTip(tr("Debug: show Text and Vision blocks simultaneously"));
- debugCheck->setVisible(false);
- grid->addWidget(debugCheck, 1, 0, Qt::AlignLeft | Qt::AlignVCenter);
-
- auto *stack = new QStackedWidget(outer);
LocalProviderTab t;
{
@@ -442,62 +410,10 @@ void SettingsDialog::buildLocalAiTabs()
connect(resetBtn, &QPushButton::clicked, this, [this, id]() {
m_localTabs[id].text.prompt->setPlainText(AppSettings::defaultLocalAiPrompt());
});
+ t.text.promptModel.clear();
}
- {
- t.vision.page = new QWidget();
- auto *vl = new QVBoxLayout(t.vision.page);
- vl->setContentsMargins(0, 0, 0, 0);
- vl->setSpacing(8);
- auto *modelRow = new QHBoxLayout();
- t.vision.modelLabel = new QLabel(tr("Vision model:"), t.vision.page);
- t.vision.model = new QComboBox(t.vision.page);
- t.vision.model->setObjectName(QStringLiteral("localAiVisionModelCombo"));
- t.vision.model->setEditable(true);
- t.vision.model->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
- modelRow->addWidget(t.vision.modelLabel);
- modelRow->addWidget(t.vision.model);
- modelRow->addSpacing(12);
- auto *visionTimeoutLabel = new QLabel(tr("Timeout:"), t.vision.page);
- t.vision.timeout = new QSpinBox(t.vision.page);
- t.vision.timeout->setRange(60, 3600);
- t.vision.timeout->setSingleStep(30);
- t.vision.timeout->setSuffix(QStringLiteral(" s"));
- t.vision.timeout->setToolTip(tr("Maximum time to wait for a translation or detection response."));
- modelRow->addWidget(visionTimeoutLabel);
- modelRow->addWidget(t.vision.timeout);
- vl->addLayout(modelRow);
- if (id == QLatin1String("ollama")) {
- t.vision.disableThinking = new QCheckBox(tr("Disable reasoning"), t.vision.page);
- vl->addWidget(t.vision.disableThinking);
- }
- auto *sep = new QFrame(t.vision.page);
- sep->setFrameShape(QFrame::HLine);
- sep->setFrameShadow(QFrame::Sunken);
- vl->addWidget(sep);
- auto *hint = new QLabel(tr("Placeholders: %1").arg(QStringLiteral("{source_lang} {source_code} {target_lang} {target_code}")), t.vision.page);
- hint->setWordWrap(true);
- vl->addWidget(hint);
- auto *resetBtn = new QPushButton(tr("Reset Vision prompt"), t.vision.page);
- vl->addWidget(resetBtn);
- t.vision.prompt = new QPlainTextEdit(t.vision.page);
- vl->addWidget(t.vision.prompt, 1);
- connect(resetBtn, &QPushButton::clicked, this, [this, id]() {
- m_localTabs[id].vision.prompt->setPlainText(AppSettings::defaultVisionPrompt());
- });
- }
-
- stack->addWidget(t.vision.page);
- stack->addWidget(t.text.page);
- stack->setCurrentIndex(1);
- grid->addWidget(stack, 2, 0, 1, 2);
-
- auto *debugContainer = new QWidget(outer);
- auto *debugLayout = new QVBoxLayout(debugContainer);
- debugLayout->setContentsMargins(0, 0, 0, 0);
- debugLayout->setSpacing(12);
- debugContainer->hide();
- grid->addWidget(debugContainer, 2, 0, 1, 2);
+ grid->addWidget(t.text.page, 2, 0, 1, 2);
grid->setColumnStretch(0, 1);
grid->setColumnStretch(1, 0);
@@ -508,144 +424,344 @@ void SettingsDialog::buildLocalAiTabs()
t.url = url;
t.apiKey = apiKey;
t.refresh = refresh;
- t.visionToggle = visionToggle;
- t.textToggle = textToggle;
- t.modeGroup = modeGroup;
- t.stack = stack;
- t.debugContainer = debugContainer;
- t.debugCheckBox = debugCheck;
m_localTabs.insert(id, t);
- connect(helpBtn, &QPushButton::clicked, this, [this]() {
- const QString helpText = tr(
- "<h3>How the LocalAI tab works</h3>"
- "<p><b>Text / Vision modes:</b> Each provider has two independent "
- "blocks ā <i>Text</i> for translating text and <i>Vision</i> for "
- "translating text from images (drag & drop or paste an image "
- "into the source field). Switch with the [Vision] [Text] buttons.</p>"
- "<p><b>Prompts are per-model:</b> <u>Each model has its own prompt.</u> "
- "When you change the model, its saved prompt is loaded. "
- "Your edits are saved automatically as you type ā no need to click Save.</p>"
- "<p><b>Default text prompt:</b><br>"
- "<code>%1</code></p>"
- "<p><b>Default vision prompt:</b><br>"
- "<code>%2</code></p>"
- "<p><b>Example ā describe a photo (vision mode):</b><br>"
- "<code>You are a visual analyst. Examine the image and provide "
- "a detailed description in {target_lang} ({target_code}). "
- "Include: scene type, subjects, colors, lighting, notable details. "
- "If there is text, translate it. Output only the description, "
- "3ā6 sentences.</code></p>"
- "<p><b>Placeholders:</b><br>"
- "<code>{source_lang}</code> ā source language name<br>"
- "<code>{source_code}</code> ā source language ISO code<br>"
- "<code>{target_lang}</code> ā target language name<br>"
- "<code>{target_code}</code> ā target language ISO code<br>"
- "<code>{text}</code> ā the input text (text mode only)</p>"
- "<p><b>Disable reasoning:</b> Skips thinking tokens on supported "
- "models. Only shown for Ollama providers.</p>"
- "<p><b>Timeout:</b> Maximum time to wait for a response.</p>")
- .arg(AppSettings::defaultLocalAiPrompt().toHtmlEscaped(),
- AppSettings::defaultVisionPrompt().toHtmlEscaped());
- auto *msg = new QMessageBox(QMessageBox::Information, tr("LocalAI Tab Help"),
- helpText, QMessageBox::Ok, this);
- msg->setMinimumWidth(680);
- msg->exec();
- });
-
- connect(modeGroup, &QButtonGroup::idClicked, this, [this, id](int mode) {
- LocalProviderTab &tab = m_localTabs[id];
- if (tab.debugCheckBox && tab.debugCheckBox->isChecked()) {
- return;
- }
- tab.stack->setCurrentIndex(mode == 0 ? 0 : 1);
- });
-
- connect(debugCheck, &QCheckBox::toggled, this, [this, id](bool checked) {
- LocalProviderTab &tab = m_localTabs[id];
- if (checked) {
- const int idx = tab.stack->currentIndex();
- tab.stack->removeWidget(tab.text.page);
- tab.stack->removeWidget(tab.vision.page);
- auto *debugLayout = tab.debugContainer->layout();
- debugLayout->addWidget(tab.text.page);
- debugLayout->addWidget(tab.vision.page);
- tab.text.page->show();
- tab.vision.page->show();
- tab.stack->hide();
- tab.debugContainer->show();
- if (idx == 0) {
- tab.visionToggle->setChecked(true);
- }
- } else {
- auto *debugLayout = tab.debugContainer->layout();
- debugLayout->removeWidget(tab.text.page);
- debugLayout->removeWidget(tab.vision.page);
- tab.stack->addWidget(tab.vision.page);
- tab.stack->addWidget(tab.text.page);
- tab.stack->setCurrentIndex(tab.modeGroup->checkedId() == 0 ? 0 : 1);
- tab.debugContainer->hide();
- tab.stack->show();
- }
- });
-
connect(refresh, &QPushButton::clicked, this, [this, id]() {
refreshLocalModels(id);
});
- connect(url, &QLineEdit::textChanged, this, [this, id](const QString &text) {
- AppSettings().setLocalProviderUrl(id, text);
- });
-
- connect(apiKey, &QLineEdit::textChanged, this, [this, id](const QString &text) {
- AppSettings().setLocalProviderApiKey(id, text);
- });
-
- // Text page signal handlers
- connect(t.text.model, &QComboBox::currentTextChanged, this, [this, id](const QString &m) {
- LocalProviderTab &tab = m_localTabs[id];
- AppSettings().setLocalProviderModel(id, m);
- const QString p = AppSettings().localAiPrompt(m);
- tab.text.prompt->setPlainText(p);
+ // URL and key are written by saveLocalAiSettings() on accept, so
+ // Cancel actually cancels them. refreshLocalModels() reads the widget
+ // directly, so probing an unsaved URL still works.
+
+ // Text page signal handlers. The model combo is editable, so its
+ // currentTextChanged fires per keystroke - binding a settings write to
+ // it saved a model (and a prompt) under every prefix of what was being
+ // typed. textActivated only fires when a model is actually picked from
+ // the list; everything else is written by saveLocalAiSettings() when
+ // the dialog is accepted.
+ //
+ // Deliberately not QLineEdit::editingFinished: it also fires on
+ // focus-out, including the focus-out that ~QDialog() triggers while
+ // hiding itself - by then SettingsDialog's own members (m_localTabs
+ // among them) have already been destroyed, and the handler crashes.
+ connect(t.text.model, &QComboBox::textActivated, this, [this, id](const QString &m) {
+ showLocalAiPromptFor(id, m);
});
connect(t.text.timeout, qOverload<int>(&QSpinBox::valueChanged), this, [this, id](int val) {
AppSettings().setLocalAiTimeout(id, val);
});
- connect(t.text.prompt, &QPlainTextEdit::textChanged, this, [this, id]() {
- LocalProviderTab &tab = m_localTabs[id];
- const QString curModel = tab.text.model->currentText();
- if (!curModel.isEmpty()) {
- AppSettings().setLocalAiPrompt(curModel, tab.text.prompt->toPlainText());
- }
- });
if (t.text.disableThinking) {
connect(t.text.disableThinking, &QCheckBox::toggled, this, [this, id](bool checked) {
AppSettings().setLocalAiDisableThinking(id, checked);
});
}
+ }
+}
- // Vision page signal handlers
- connect(t.vision.model, &QComboBox::currentTextChanged, this, [this, id](const QString &m) {
- LocalProviderTab &tab = m_localTabs[id];
- AppSettings().setLocalVisionModel(id, m);
- const QString p = AppSettings().localVisionPrompt(m);
- tab.vision.prompt->setPlainText(p);
- });
- connect(t.vision.timeout, qOverload<int>(&QSpinBox::valueChanged), this, [this, id](int val) {
- AppSettings().setLocalAiVisionTimeout(id, val);
- });
- connect(t.vision.prompt, &QPlainTextEdit::textChanged, this, [this, id]() {
- LocalProviderTab &tab = m_localTabs[id];
- const QString curModel = tab.vision.model->currentText();
- if (!curModel.isEmpty()) {
- AppSettings().setLocalVisionPrompt(curModel, tab.vision.prompt->toPlainText());
+void SettingsDialog::buildOcrEngineUi()
+{
+ auto *engineRow = new QWidget(ui->ocrPage);
+ auto *engineLayout = new QHBoxLayout(engineRow);
+ engineLayout->setContentsMargins(0, 0, 0, 0);
+
+ auto *engineLabel = new QLabel(tr("OCR engine:"), engineRow);
+ m_ocrEngineCombo = new QComboBox(engineRow);
+ m_ocrEngineCombo->setObjectName(QStringLiteral("ocrEngineCombo"));
+ m_ocrEngineCombo->addItem(tr("Tesseract"), static_cast<int>(AppSettings::OcrEngine::Tesseract));
+ m_ocrEngineCombo->addItem(tr("Vision model (LLM)"), static_cast<int>(AppSettings::OcrEngine::Llm));
+ engineLayout->addWidget(engineLabel);
+ engineLayout->addWidget(m_ocrEngineCombo);
+ engineLayout->addStretch(1);
+
+ m_ocrEngineStack = new QStackedWidget(ui->ocrPage);
+
+ auto *tesseractPage = new QWidget(m_ocrEngineStack);
+ auto *tesseractLayout = new QVBoxLayout(tesseractPage);
+ tesseractLayout->setContentsMargins(0, 0, 0, 0);
+ auto *tesseractNote = new QLabel(tr("Tesseract runs locally. Configure languages and parameters below."), tesseractPage);
+ tesseractNote->setWordWrap(true);
+ tesseractLayout->addWidget(tesseractNote);
+ m_ocrEngineStack->addWidget(tesseractPage);
+
+ auto *llmPage = new QWidget(m_ocrEngineStack);
+ auto *llmLayout = new QVBoxLayout(llmPage);
+ llmLayout->setContentsMargins(0, 0, 0, 0);
+ llmLayout->setSpacing(8);
+
+ auto *providerRow = new QHBoxLayout();
+ auto *providerLabel = new QLabel(tr("Provider:"), llmPage);
+ m_ocrLlmProviderCombo = new QComboBox(llmPage);
+ m_ocrLlmProviderCombo->setObjectName(QStringLiteral("ocrLlmProviderCombo"));
+ for (const QString &id : AppSettings::localProviderIds()) {
+ m_ocrLlmProviderCombo->addItem(AppSettings::localProviderDisplayName(id), id);
+ }
+ providerRow->addWidget(providerLabel);
+ providerRow->addWidget(m_ocrLlmProviderCombo);
+ providerRow->addSpacing(12);
+ auto *timeoutLabel = new QLabel(tr("Timeout:"), llmPage);
+ m_ocrLlmTimeoutSpin = new QSpinBox(llmPage);
+ m_ocrLlmTimeoutSpin->setRange(60, 3600);
+ m_ocrLlmTimeoutSpin->setSingleStep(30);
+ m_ocrLlmTimeoutSpin->setSuffix(QStringLiteral(" s"));
+ m_ocrLlmTimeoutSpin->setToolTip(tr("Maximum time to wait for a transcription response."));
+ providerRow->addWidget(timeoutLabel);
+ providerRow->addWidget(m_ocrLlmTimeoutSpin);
+ providerRow->addStretch(1);
+ llmLayout->addLayout(providerRow);
+
+ // The OCR endpoint is configured here in full. It is deliberately not the
+ // translation backend's endpoint: transcribing a screenshot and
+ // translating a sentence are different jobs, often on different hosts and
+ // certainly on different models.
+ auto *urlRow = new QHBoxLayout();
+ auto *urlLabel = new QLabel(tr("URL:"), llmPage);
+ m_ocrLlmUrlEdit = new QLineEdit(llmPage);
+ m_ocrLlmUrlEdit->setObjectName(QStringLiteral("ocrLlmUrlEdit"));
+ auto *apiKeyLabel = new QLabel(tr("Key:"), llmPage);
+ m_ocrLlmApiKeyEdit = new QLineEdit(llmPage);
+ m_ocrLlmApiKeyEdit->setObjectName(QStringLiteral("ocrLlmApiKeyEdit"));
+ m_ocrLlmApiKeyEdit->setEchoMode(QLineEdit::Password);
+ m_ocrLlmApiKeyEdit->setPlaceholderText(tr("optional, e.g. for a cloud endpoint"));
+ m_ocrLlmRefreshButton = new QPushButton(tr("Refresh models"), llmPage);
+ m_ocrLlmRefreshButton->setObjectName(QStringLiteral("ocrLlmRefreshButton"));
+ urlRow->addWidget(urlLabel);
+ urlRow->addWidget(m_ocrLlmUrlEdit);
+ urlRow->addWidget(apiKeyLabel);
+ urlRow->addWidget(m_ocrLlmApiKeyEdit);
+ urlRow->addWidget(m_ocrLlmRefreshButton);
+ llmLayout->addLayout(urlRow);
+
+ auto *modelRow = new QHBoxLayout();
+ auto *modelLabel = new QLabel(tr("Model:"), llmPage);
+ m_ocrLlmModelCombo = new QComboBox(llmPage);
+ m_ocrLlmModelCombo->setObjectName(QStringLiteral("ocrLlmModelCombo"));
+ m_ocrLlmModelCombo->setEditable(true);
+ m_ocrLlmModelCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+ modelRow->addWidget(modelLabel);
+ modelRow->addWidget(m_ocrLlmModelCombo);
+ llmLayout->addLayout(modelRow);
+
+ m_ocrLlmCapabilityHint = new QLabel(llmPage);
+ m_ocrLlmCapabilityHint->setWordWrap(true);
+ llmLayout->addWidget(m_ocrLlmCapabilityHint);
+
+ m_ocrLlmResetPromptButton = new QPushButton(tr("Reset OCR prompt"), llmPage);
+ llmLayout->addWidget(m_ocrLlmResetPromptButton);
+ m_ocrLlmPromptEdit = new QPlainTextEdit(llmPage);
+ m_ocrLlmPromptEdit->setPlaceholderText(LlmOcr::defaultPrompt());
+ llmLayout->addWidget(m_ocrLlmPromptEdit, 1);
+ m_ocrEngineStack->addWidget(llmPage);
+
+ if (auto *ocrLayout = ui->ocrPage->findChild<QVBoxLayout *>(QStringLiteral("ocrLayout"))) {
+ ocrLayout->insertWidget(0, engineRow);
+ ocrLayout->insertWidget(1, m_ocrEngineStack);
+ }
+
+ connect(m_ocrEngineCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
+ m_ocrEngineStack->setCurrentIndex(index);
+ updateOcrEngineVisibility(m_ocrEngineCombo->itemData(index).toInt());
+ });
+ connect(m_ocrLlmProviderCombo, &QComboBox::currentIndexChanged, this, [this]() {
+ loadOcrLlmProvider(m_ocrLlmProviderCombo->currentData().toString());
+ });
+ // Persist on commit, never per keystroke: an editable combo emits
+ // currentTextChanged for every character typed, which used to write a
+ // settings key (and a prompt) for each prefix of a model name. See
+ // buildLocalAiTabs() for why editingFinished is not used either.
+ connect(m_ocrLlmModelCombo, &QComboBox::textActivated, this, [this](const QString &model) {
+ showOcrPromptFor(model);
+ });
+ connect(m_ocrLlmRefreshButton, &QPushButton::clicked, this, &SettingsDialog::refreshOcrModels);
+ connect(m_ocrLlmResetPromptButton, &QPushButton::clicked, this, [this]() {
+ m_ocrLlmPromptEdit->setPlainText(QString());
+ });
+}
+
+// Lists every model the last probe returned, grouped by whether the server
+// vouched for image support. Nothing is filtered out: most OpenAI-compatible
+// endpoints report no capabilities at all, so hiding the unvouched ones would
+// hide every working model behind such an endpoint.
+void SettingsDialog::populateOcrModelCombo(const QString &id)
+{
+ const AppSettings settings;
+ const QStringList all = settings.ocrLlmModels(id);
+ const QStringList vision = settings.ocrLlmVisionModels(id);
+ const QString current = m_ocrLlmModelCombo->currentText().isEmpty() ? settings.ocrLlmModel(id)
+ : m_ocrLlmModelCombo->currentText();
+
+ QStringList proven;
+ QStringList unproven;
+ for (const QString &model : all) {
+ (vision.contains(model) ? proven : unproven).append(model);
+ }
+
+ QSignalBlocker blocker(m_ocrLlmModelCombo);
+ m_ocrLlmModelCombo->clear();
+
+ const auto addHeader = [this](const QString &text) {
+ m_ocrLlmModelCombo->addItem(text);
+ const int row = m_ocrLlmModelCombo->count() - 1;
+ m_ocrLlmModelCombo->setItemData(row, QVariant(), Qt::UserRole - 1); // non-selectable
+ };
+
+ const bool grouped = !proven.isEmpty() && !unproven.isEmpty();
+ if (grouped) {
+ addHeader(tr("ā Server reports image support ā"));
+ }
+ m_ocrLlmModelCombo->addItems(proven);
+ if (grouped) {
+ addHeader(tr("ā Image support not reported ā"));
+ }
+ m_ocrLlmModelCombo->addItems(unproven);
+
+ if (!current.isEmpty() && m_ocrLlmModelCombo->findText(current) < 0) {
+ m_ocrLlmModelCombo->insertItem(0, current);
+ }
+ m_ocrLlmModelCombo->setCurrentText(current);
+ blocker.unblock();
+
+ widenComboPopup(m_ocrLlmModelCombo);
+
+ if (all.isEmpty()) {
+ m_ocrLlmCapabilityHint->setText(tr("No models loaded yet ā press \"Refresh models\", or type a model name."));
+ } else if (!VisionModelProbe::reportsCapabilities(id)) {
+ m_ocrLlmCapabilityHint->setText(tr("This endpoint does not report model capabilities, so image support cannot be verified here. "
+ "Pick a model you know accepts images."));
+ } else if (proven.isEmpty()) {
+ m_ocrLlmCapabilityHint->setText(tr("The server reported no image-capable models. A model listed here may still work, "
+ "but recognition will fail if it cannot accept images."));
+ } else {
+ m_ocrLlmCapabilityHint->setText(tr("%n model(s) reported as image-capable.", nullptr, static_cast<int>(proven.size())));
+ }
+}
+
+void SettingsDialog::loadOcrLlmProvider(const QString &id)
+{
+ storeOcrPromptEdits();
+
+ const AppSettings settings;
+ {
+ QSignalBlocker urlBlocker(m_ocrLlmUrlEdit);
+ m_ocrLlmUrlEdit->setText(settings.ocrLlmUrl(id));
+ m_ocrLlmUrlEdit->setPlaceholderText(AppSettings::defaultLocalProviderUrl(id));
+ QSignalBlocker keyBlocker(m_ocrLlmApiKeyEdit);
+ m_ocrLlmApiKeyEdit->setText(settings.ocrLlmApiKey(id));
+ QSignalBlocker timeoutBlocker(m_ocrLlmTimeoutSpin);
+ m_ocrLlmTimeoutSpin->setValue(settings.ocrLlmTimeout(id));
+ QSignalBlocker modelBlocker(m_ocrLlmModelCombo);
+ m_ocrLlmModelCombo->setCurrentText(settings.ocrLlmModel(id));
+ }
+ populateOcrModelCombo(id);
+
+ m_ocrLlmPromptModel.clear();
+ showOcrPromptFor(m_ocrLlmModelCombo->currentText());
+}
+
+// Files whatever is in the prompt editor under the model it was written for,
+// before the selection moves on to a different model.
+void SettingsDialog::storeOcrPromptEdits()
+{
+ if (m_ocrLlmPromptModel.isEmpty()) {
+ return;
+ }
+ AppSettings settings;
+ const QString edited = m_ocrLlmPromptEdit->toPlainText();
+ if (edited != settings.ocrLlmPrompt(m_ocrLlmPromptModel)) {
+ settings.setOcrLlmPrompt(m_ocrLlmPromptModel, edited);
+ }
+}
+
+void SettingsDialog::showOcrPromptFor(const QString &model)
+{
+ if (model == m_ocrLlmPromptModel) {
+ return;
+ }
+ storeOcrPromptEdits();
+ m_ocrLlmPromptModel = model;
+ m_ocrLlmPromptEdit->setPlainText(AppSettings().ocrLlmPrompt(model));
+}
+
+void SettingsDialog::refreshOcrModels()
+{
+ const QString id = m_ocrLlmProviderCombo->currentData().toString();
+ QString base = m_ocrLlmUrlEdit->text().trimmed();
+ if (base.isEmpty()) {
+ base = AppSettings::defaultLocalProviderUrl(id);
+ }
+ if (base.isEmpty()) {
+ QMessageBox::warning(this, tr("Vision model"), tr("Enter the endpoint URL first."));
+ return;
+ }
+
+ if (m_ocrLlmProbe == nullptr) {
+ m_ocrLlmProbe = new VisionModelProbe(this);
+ connect(m_ocrLlmProbe, &VisionModelProbe::finished, this, [this](const QStringList &all, const QStringList &vision) {
+ m_ocrLlmRefreshButton->setEnabled(true);
+ const QString probedId = m_ocrLlmProviderCombo->currentData().toString();
+ if (all.isEmpty()) {
+ QMessageBox::information(this, AppSettings::localProviderDisplayName(probedId), tr("No models found."));
+ return;
}
+ AppSettings settings;
+ settings.setOcrLlmModels(probedId, all);
+ settings.setOcrLlmVisionModels(probedId, vision);
+ populateOcrModelCombo(probedId);
});
- if (t.vision.disableThinking) {
- connect(t.vision.disableThinking, &QCheckBox::toggled, this, [this, id](bool checked) {
- AppSettings().setLocalAiDisableVisionThinking(id, checked);
- });
- }
+ connect(m_ocrLlmProbe, &VisionModelProbe::failed, this, [this](const QString &error) {
+ m_ocrLlmRefreshButton->setEnabled(true);
+ const QString probedId = m_ocrLlmProviderCombo->currentData().toString();
+ QMessageBox::warning(this,
+ AppSettings::localProviderDisplayName(probedId),
+ tr("Could not reach %1 at %2:\n%3")
+ .arg(AppSettings::localProviderDisplayName(probedId), m_ocrLlmUrlEdit->text(), error));
+ });
+ }
+
+ m_ocrLlmRefreshButton->setEnabled(false);
+ m_ocrLlmProbe->probe(id, base, m_ocrLlmApiKeyEdit->text());
+}
+
+void SettingsDialog::updateOcrEngineVisibility(int engineValue)
+{
+ const bool isTesseract = (engineValue == static_cast<int>(AppSettings::OcrEngine::Tesseract));
+ ui->languagesGroupBox->setVisible(isTesseract);
+ ui->ocrParametersGroupBox->setVisible(isTesseract);
+ // screenCaptureGroupBox is engine-agnostic and stays visible always.
+}
+
+void SettingsDialog::loadOcrEngineSettings()
+{
+ const AppSettings settings;
+ const int index = m_ocrEngineCombo->findData(static_cast<int>(settings.ocrEngine()));
+ if (index >= 0) {
+ m_ocrEngineCombo->setCurrentIndex(index);
+ }
+ const QString providerId = settings.ocrLlmProvider();
+ const int providerIndex = m_ocrLlmProviderCombo->findData(providerId);
+ if (providerIndex >= 0) {
+ QSignalBlocker blocker(m_ocrLlmProviderCombo);
+ m_ocrLlmProviderCombo->setCurrentIndex(providerIndex);
+ }
+ loadOcrLlmProvider(m_ocrLlmProviderCombo->currentData().toString());
+ updateOcrEngineVisibility(static_cast<int>(settings.ocrEngine()));
+}
+
+void SettingsDialog::saveOcrEngineSettings()
+{
+ AppSettings settings;
+ const QString id = m_ocrLlmProviderCombo->currentData().toString();
+ const QString model = m_ocrLlmModelCombo->currentText();
+ settings.setOcrEngine(static_cast<AppSettings::OcrEngine>(m_ocrEngineCombo->currentData().toInt()));
+ settings.setOcrLlmProvider(id);
+ settings.setOcrLlmUrl(id, m_ocrLlmUrlEdit->text().trimmed());
+ settings.setOcrLlmApiKey(id, m_ocrLlmApiKeyEdit->text());
+ settings.setOcrLlmModel(id, model);
+ settings.setOcrLlmTimeout(id, m_ocrLlmTimeoutSpin->value());
+ // The editor's contents belong to the model that is about to be used - a
+ // model typed by hand and never picked from the list included.
+ if (!model.isEmpty()) {
+ settings.setOcrLlmPrompt(model, m_ocrLlmPromptEdit->toPlainText());
}
}
@@ -660,18 +776,6 @@ void SettingsDialog::loadLocalAiSettings()
QSignalBlocker apiKeyBlocker(tab.apiKey);
tab.apiKey->setText(settings.localProviderApiKey(id));
- const bool isVision = settings.isVisionEnabled(id);
- {
- QSignalBlocker blocker(tab.modeGroup);
- if (isVision) {
- tab.visionToggle->setChecked(true);
- tab.stack->setCurrentIndex(0);
- } else {
- tab.textToggle->setChecked(true);
- tab.stack->setCurrentIndex(1);
- }
- }
-
QStringList models = settings.localProviderModels(id);
const QString textModel = settings.localProviderModel(id);
if (!textModel.isEmpty() && !models.contains(textModel)) {
@@ -685,28 +789,12 @@ void SettingsDialog::loadLocalAiSettings()
}
widenComboPopup(tab.text.model);
- const QString visionModel = settings.localVisionModel(id);
- if (!visionModel.isEmpty() && !models.contains(visionModel)) {
- models.prepend(visionModel);
- }
- {
- QSignalBlocker b(tab.vision.model);
- tab.vision.model->clear();
- tab.vision.model->addItems(models);
- tab.vision.model->setCurrentText(visionModel);
- }
- widenComboPopup(tab.vision.model);
-
- tab.text.prompt->setPlainText(settings.localAiPrompt(textModel));
- tab.vision.prompt->setPlainText(settings.localVisionPrompt(visionModel));
+ tab.text.promptModel.clear();
+ showLocalAiPromptFor(id, textModel);
tab.text.timeout->setValue(settings.localAiTimeout(id));
- tab.vision.timeout->setValue(settings.localAiVisionTimeout(id));
if (tab.text.disableThinking) {
tab.text.disableThinking->setChecked(settings.localAiDisableThinking(id));
}
- if (tab.vision.disableThinking) {
- tab.vision.disableThinking->setChecked(settings.localAiDisableVisionThinking(id));
- }
}
const int di = ui->detectProviderComboBox->findData(settings.detectProvider());
@@ -716,7 +804,6 @@ void SettingsDialog::loadLocalAiSettings()
ui->detectProviderComboBox->setCurrentIndex(di);
}
}
- ui->detectViaLlmCheckBox->setChecked(settings.detectViaLlm());
populateDetectModels();
{
QSignalBlocker blocker(ui->detectModelComboBox);
@@ -732,28 +819,45 @@ void SettingsDialog::saveLocalAiSettings()
const LocalProviderTab &tab = m_localTabs[id];
settings.setLocalProviderUrl(id, tab.url->text());
settings.setLocalProviderApiKey(id, tab.apiKey->text());
- settings.setVisionEnabled(id, tab.modeGroup->checkedId() == 0);
settings.setLocalProviderModel(id, tab.text.model->currentText());
- settings.setLocalVisionModel(id, tab.vision.model->currentText());
settings.setLocalAiTimeout(id, tab.text.timeout->value());
- settings.setLocalAiVisionTimeout(id, tab.vision.timeout->value());
if (tab.text.disableThinking) {
settings.setLocalAiDisableThinking(id, tab.text.disableThinking->isChecked());
}
- if (tab.vision.disableThinking) {
- settings.setLocalAiDisableVisionThinking(id, tab.vision.disableThinking->isChecked());
- }
- const QString tModel = tab.text.model->currentText();
- if (!tModel.isEmpty()) {
- settings.setLocalAiPrompt(tModel, tab.text.prompt->toPlainText());
- }
- const QString vModel = tab.vision.model->currentText();
- if (!vModel.isEmpty()) {
- settings.setLocalVisionPrompt(vModel, tab.vision.prompt->toPlainText());
+ const QString model = tab.text.model->currentText();
+ if (!model.isEmpty()) {
+ settings.setLocalAiPrompt(model, tab.text.prompt->toPlainText());
}
}
}
+// Same commit-on-switch discipline as the OCR prompt: whatever is in the
+// editor belongs to the model it was typed for, not to whichever model gets
+// selected next.
+void SettingsDialog::storeLocalAiPromptEdits(const QString &id)
+{
+ LocalProviderTab &tab = m_localTabs[id];
+ if (tab.text.promptModel.isEmpty()) {
+ return;
+ }
+ AppSettings settings;
+ const QString edited = tab.text.prompt->toPlainText();
+ if (edited != settings.localAiPrompt(tab.text.promptModel)) {
+ settings.setLocalAiPrompt(tab.text.promptModel, edited);
+ }
+}
+
+void SettingsDialog::showLocalAiPromptFor(const QString &id, const QString &model)
+{
+ LocalProviderTab &tab = m_localTabs[id];
+ if (model == tab.text.promptModel) {
+ return;
+ }
+ storeLocalAiPromptEdits(id);
+ tab.text.promptModel = model;
+ tab.text.prompt->setPlainText(AppSettings().localAiPrompt(model));
+}
+
void SettingsDialog::populateDetectModels()
{
const QString id = ui->detectProviderComboBox->currentData().toString();
@@ -785,8 +889,8 @@ void SettingsDialog::refreshLocalModels(const QString &id)
refreshButton->setEnabled(false);
}
- QNetworkRequest request(QUrl(base + QStringLiteral("/v1/models")));
- LocalAiTranslationProvider::setAuthHeaders(request, AppSettings::localProviderIsAnthropic(id), m_localTabs[id].apiKey->text());
+ QNetworkRequest request(QUrl(OpenAiEndpoint::modelsUrl(base)));
+ OpenAiEndpoint::setAuthHeaders(request, AppSettings::localProviderIsAnthropic(id), m_localTabs[id].apiKey->text());
auto *manager = new QNetworkAccessManager(this);
manager->setTransferTimeout(5000);
@@ -834,17 +938,6 @@ void SettingsDialog::refreshLocalModels(const QString &id)
}
widenComboPopup(tab.text.model);
- const QString curVision = tab.vision.model->currentText();
- {
- QSignalBlocker blocker(tab.vision.model);
- tab.vision.model->clear();
- tab.vision.model->addItems(names);
- if (!curVision.isEmpty()) {
- tab.vision.model->setCurrentText(curVision);
- }
- }
- widenComboPopup(tab.vision.model);
-
if (ui->detectProviderComboBox->currentData().toString() == id) {
populateDetectModels();
}
@@ -936,7 +1029,7 @@ void SettingsDialog::selectOcrLanguagesPath()
void SettingsDialog::onOcrLanguagesPathChanged(const QString &path)
{
ui->ocrLanguagesListWidget->clear();
- ui->ocrLanguagesListWidget->addLanguages(Ocr::availableLanguages(path));
+ ui->ocrLanguagesListWidget->addLanguages(TesseractOcr::availableLanguages(path));
}
#ifdef WITH_PIPER_TTS
@@ -1064,6 +1157,7 @@ void SettingsDialog::restoreDefaults()
ui->trayIconComboBox->setCurrentIndex(AppSettings::defaultTrayIconType());
ui->customTrayIconEdit->setText(AppSettings::defaultCustomIconPath());
+ ui->showStatusBarCheckBox->setChecked(AppSettings::defaultShowStatusBar());
// Translation settings
const int defaultTranslationBackendIndex = ui->translationProviderComboBox->findData(QVariant::fromValue(AppSettings().defaultTranslationProviderBackend()));
@@ -1180,6 +1274,7 @@ void SettingsDialog::loadSettings()
ui->trayIconComboBox->setCurrentIndex(settings.trayIconType());
ui->customTrayIconEdit->setText(settings.customIconPath());
+ ui->showStatusBarCheckBox->setChecked(settings.isShowStatusBar());
// Translation settings
// Translation provider backend
@@ -1216,6 +1311,7 @@ void SettingsDialog::loadSettings()
// LocalAI
loadLocalAiSettings();
+ loadOcrEngineSettings();
// OCR
ui->convertLineBreaksCheckBox->setChecked(settings.isConvertLineBreaks());
diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h
index 47fc9bb9..a27fd385 100644
--- a/src/settings/settingsdialog.h
+++ b/src/settings/settingsdialog.h
@@ -32,6 +32,7 @@ class QMediaPlaylist;
class QLabel;
class QToolButton;
class ShortcutItem;
+class VisionModelProbe;
#ifdef WITH_PORTABLE_MODE
class QCheckBox;
#endif
@@ -100,6 +101,27 @@ private:
Ui::SettingsDialog *ui;
+ // OCR engine selection (Tesseract / LLM) ā dynamic widgets on the OCR page.
+ // The LLM engine carries a complete endpoint of its own (URL, key, model
+ // list); it shares nothing but the provider *kinds* with the LocalAI
+ // translation backend.
+ QComboBox *m_ocrEngineCombo = nullptr;
+ QStackedWidget *m_ocrEngineStack = nullptr;
+ QComboBox *m_ocrLlmProviderCombo = nullptr;
+ QLineEdit *m_ocrLlmUrlEdit = nullptr;
+ QLineEdit *m_ocrLlmApiKeyEdit = nullptr;
+ QPushButton *m_ocrLlmRefreshButton = nullptr;
+ QComboBox *m_ocrLlmModelCombo = nullptr;
+ QSpinBox *m_ocrLlmTimeoutSpin = nullptr;
+ QLabel *m_ocrLlmCapabilityHint = nullptr;
+ QPlainTextEdit *m_ocrLlmPromptEdit = nullptr;
+ QPushButton *m_ocrLlmResetPromptButton = nullptr;
+ VisionModelProbe *m_ocrLlmProbe = nullptr;
+ // Which model the prompt editor is currently showing text for, so a
+ // model switch can file the edits under the model they were written for
+ // instead of under whatever is selected afterwards.
+ QString m_ocrLlmPromptModel;
+
// LocalAI settings (Ollama / FastFlowLM / LM Studio) ā dynamic sub-tabs.
struct LocalProviderMode {
QWidget *page = nullptr;
@@ -108,23 +130,28 @@ private:
QSpinBox *timeout = nullptr;
QCheckBox *disableThinking = nullptr;
QPlainTextEdit *prompt = nullptr;
+ QString promptModel;
};
struct LocalProviderTab {
QLineEdit *url = nullptr;
QLineEdit *apiKey = nullptr;
QPushButton *refresh = nullptr;
- QPushButton *visionToggle = nullptr;
- QPushButton *textToggle = nullptr;
- QButtonGroup *modeGroup = nullptr;
- QStackedWidget *stack = nullptr;
- QWidget *debugContainer = nullptr;
- QCheckBox *debugCheckBox = nullptr;
LocalProviderMode text;
- LocalProviderMode vision;
};
void buildLocalAiTabs();
+ void buildOcrEngineUi();
+ void loadOcrEngineSettings();
+ void saveOcrEngineSettings();
+ void updateOcrEngineVisibility(int engineValue);
+ void loadOcrLlmProvider(const QString &id);
+ void populateOcrModelCombo(const QString &id);
+ void refreshOcrModels();
+ void showOcrPromptFor(const QString &model);
+ void storeOcrPromptEdits();
void loadLocalAiSettings();
void saveLocalAiSettings();
+ void showLocalAiPromptFor(const QString &id, const QString &model);
+ void storeLocalAiPromptEdits(const QString &id);
void refreshLocalModels(const QString &id);
void populateDetectModels();
diff --git a/src/settings/settingsdialog.ui b/src/settings/settingsdialog.ui
index 47e376ed..562147b7 100644
--- a/src/settings/settingsdialog.ui
+++ b/src/settings/settingsdialog.ui
@@ -626,8 +626,27 @@
</layout>
</widget>
</item>
- <item>
- <spacer name="interfacePageSpacer">
+ <item>
+ <widget class="QGroupBox" name="statusBarGroupBox">
+ <property name="title">
+ <string>Status bar</string>
+ </property>
+ <layout class="QVBoxLayout" name="statusBarLayout">
+ <item>
+ <widget class="QCheckBox" name="showStatusBarCheckBox">
+ <property name="toolTip">
+ <string><html><head/<body><p>Show what the application is currently doing at the bottom of the main and pop-up windows</p></body></html></string>
+ </property>
+ <property name="text">
+ <string>Show status bar</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="interfacePageSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
@@ -729,38 +748,35 @@
</property>
</widget>
</item>
- <item row="4" column="0" colspan="2">
- <widget class="QCheckBox" name="detectViaLlmCheckBox">
- <property name="toolTip">
- <string>Detect the source language using a LocalAI model instead of the built-in heuristic. Choose the provider and model below.</string>
- </property>
- <property name="text">
- <string>Detect language via LLM (LocalAI)</string>
- </property>
- </widget>
- </item>
- <item row="5" column="0">
+ <item row="4" column="0">
<widget class="QLabel" name="detectProviderLabel">
<property name="text">
<string>Detection provider:</string>
</property>
</widget>
</item>
- <item row="5" column="1">
- <widget class="QComboBox" name="detectProviderComboBox"/>
+ <item row="4" column="1">
+ <widget class="QComboBox" name="detectProviderComboBox">
+ <property name="toolTip">
+ <string>Provider and model used to detect the source language when it is set to Auto. If no detection model is selected, translation falls back to the configured primary language above.</string>
+ </property>
+ </widget>
</item>
- <item row="6" column="0">
+ <item row="5" column="0">
<widget class="QLabel" name="detectModelLabel">
<property name="text">
<string>Detection model:</string>
</property>
</widget>
</item>
- <item row="6" column="1">
+ <item row="5" column="1">
<widget class="QComboBox" name="detectModelComboBox">
<property name="editable">
<bool>true</bool>
</property>
+ <property name="toolTip">
+ <string>Provider and model used to detect the source language when it is set to Auto. If no detection model is selected, translation falls back to the configured primary language above.</string>
+ </property>
</widget>
</item>
</layout>
diff --git a/src/statusstrip.cpp b/src/statusstrip.cpp
new file mode 100644
index 00000000..8271d0ff
--- /dev/null
+++ b/src/statusstrip.cpp
@@ -0,0 +1,212 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "statusstrip.h"
+#include "ui_statusstrip.h"
+
+#include <QEvent>
+#include <QLabel>
+#include <QPalette>
+#include <QTimer>
+
+using namespace std::chrono_literals;
+
+StatusStrip::StatusStrip(QWidget *parent)
+ : QWidget(parent)
+ , ui(new Ui::StatusStrip)
+ , m_ellipsisTimer(new QTimer(this))
+{
+ ui->setupUi(this);
+
+ m_readyLabel = new QLabel(tr("Ready"), this);
+ m_readyLabel->setObjectName(QStringLiteral("readyLabel"));
+ ui->stripLayout->addWidget(m_readyLabel);
+ static const char *const segmentNames[] = {"snippingSegmentLabel", "ocrSegmentLabel", "translationSegmentLabel", "ttsSegmentLabel"};
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+ auto *label = new QLabel(this);
+ // Named for automated UI testing (findChild) - QLabel reports nothing
+ // to AT-SPI without an accessible name either.
+ label->setObjectName(QString::fromLatin1(segmentNames[i]));
+ // The layout has zero spacing; give every segment after the first a
+ // little air so simultaneous segments don't mash together.
+ if (i > 0)
+ label->setContentsMargins(9, 0, 0, 0);
+ m_moduleLabels[static_cast<size_t>(i)] = label;
+ ui->stripLayout->addWidget(label);
+ }
+
+ // Busy messages are stored dot-free in the model; the view owns the
+ // animation, and the timer runs only while something is actually Busy.
+ m_ellipsisTimer->setInterval(400ms);
+ connect(m_ellipsisTimer, &QTimer::timeout, this, &StatusStrip::renderDots);
+
+ renderModules();
+}
+
+StatusStrip::~StatusStrip()
+{
+ delete ui;
+}
+
+void StatusStrip::setModel(ModuleStatus *model)
+{
+ if (m_model != nullptr)
+ disconnect(m_model, &ModuleStatus::changed, this, &StatusStrip::renderModules);
+ m_model = model;
+ if (model != nullptr)
+ connect(model, &ModuleStatus::changed, this, &StatusStrip::renderModules);
+ renderModules();
+}
+
+void StatusStrip::setHideWhenIdle(bool hide)
+{
+ m_hideWhenIdle = hide;
+ updateVisibility();
+}
+
+void StatusStrip::setShown(bool shown)
+{
+ m_shown = shown;
+ renderModules();
+}
+
+void StatusStrip::changeEvent(QEvent *event)
+{
+ switch (event->type()) {
+ case QEvent::LanguageChange:
+ m_readyLabel->setText(tr("Ready"));
+ renderModules(); // messages are tr()'d on demand by the model
+ break;
+ case QEvent::PaletteChange:
+ case QEvent::ApplicationPaletteChange:
+ renderModules(); // the error colour is derived from the palette
+ break;
+ default:
+ QWidget::changeEvent(event);
+ }
+}
+
+void StatusStrip::renderModules()
+{
+ if (m_model != nullptr) {
+ bool anyActive = false;
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+ const auto module = static_cast<ModuleStatus::Module>(i);
+ QLabel *label = m_moduleLabels[static_cast<size_t>(i)];
+ const auto activity = m_model->activity(module);
+
+ if (!m_model->isAvailable(module) || activity == ModuleStatus::Activity::Idle) {
+ label->hide();
+ label->clear();
+ label->setAccessibleName({});
+ label->setToolTip({});
+ continue;
+ }
+
+ anyActive = true;
+ const bool busy = activity == ModuleStatus::Activity::Busy;
+ label->setText(m_model->message(module) + (busy ? dots(m_dotCount) : QString()));
+ // The accessible name carries the dot-free message: the animated
+ // ellipsis must not rename the widget for AT-SPI/UIA exact-match
+ // lookups (and screen readers) four times a second.
+ label->setAccessibleName(m_model->message(module));
+ label->setToolTip(m_model->detail(module));
+
+ QPalette labelPalette = palette();
+ labelPalette.setColor(label->foregroundRole(), busy ? labelPalette.color(QPalette::WindowText) : errorTextColor(this));
+ label->setPalette(labelPalette);
+ label->show();
+ }
+ m_readyLabel->setVisible(!anyActive);
+ if (!anyActive) {
+ // Same identity discipline as the segments below: a label that
+ // isn't shown must not keep announcing (or keep a name-matchable
+ // identity) through the AT-SPI tree.
+ m_readyLabel->setText(tr("Ready"));
+ m_readyLabel->setAccessibleName(tr("Ready"));
+ } else {
+ m_readyLabel->clear();
+ m_readyLabel->setAccessibleName({});
+ }
+
+ if (m_model->isBusy())
+ m_ellipsisTimer->start();
+ else {
+ m_ellipsisTimer->stop();
+ m_dotCount = 0;
+ }
+ } else {
+ for (QLabel *label : m_moduleLabels)
+ label->hide();
+ m_readyLabel->setText(tr("Ready"));
+ m_readyLabel->setAccessibleName(tr("Ready"));
+ m_readyLabel->show();
+ }
+
+ updateVisibility();
+}
+
+void StatusStrip::renderDots()
+{
+ if (m_model == nullptr)
+ return;
+
+ m_dotCount = (m_dotCount + 1) % 4;
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+ const auto module = static_cast<ModuleStatus::Module>(i);
+ if (m_model->isAvailable(module) && m_model->activity(module) == ModuleStatus::Activity::Busy)
+ m_moduleLabels[static_cast<size_t>(i)]->setText(m_model->message(module) + dots(m_dotCount));
+ }
+}
+
+bool StatusStrip::hasActiveSegments() const
+{
+ if (m_model == nullptr)
+ return false;
+
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i) {
+ const auto module = static_cast<ModuleStatus::Module>(i);
+ if (m_model->isAvailable(module) && m_model->activity(module) != ModuleStatus::Activity::Idle)
+ return true;
+ }
+ return false;
+}
+
+void StatusStrip::updateVisibility()
+{
+ const bool visible = m_shown && (!m_hideWhenIdle || hasActiveSegments());
+ if (!visible) {
+ // Hide and de-identify the labels themselves, not just this widget:
+ // the AT-SPI bridge never prunes interfaces, so a label keeps its
+ // name-matchable identity (and keeps announcing to screen readers)
+ // unless it is cleared and hidden itself - hiding an ancestor alone
+ // does nothing. This covers a strip hidden via its parent
+ // (QStatusBar::setVisible(false) from the ShowStatusBar setting)
+ // and the hide-while-idle pop-up at rest.
+ m_readyLabel->clear();
+ m_readyLabel->setAccessibleName({});
+ m_readyLabel->hide();
+ for (QLabel *label : m_moduleLabels)
+ label->hide();
+ }
+ setVisible(visible);
+}
+
+QColor StatusStrip::errorTextColor(const QWidget *widget)
+{
+ // QPalette has no error role; blend the window text colour toward red so
+ // the result stays legible on both light and dark themes.
+ const QColor base = widget->palette().color(QPalette::WindowText);
+ QColor result;
+ result.setRed(static_cast<int>(base.red() * 0.35 + 255 * 0.65));
+ result.setGreen(static_cast<int>(base.green() * 0.35));
+ result.setBlue(static_cast<int>(base.blue() * 0.35));
+ return result;
+}
+
+QString StatusStrip::dots(int count)
+{
+ return QString(count, QLatin1Char('.'));
+}
diff --git a/src/statusstrip.h b/src/statusstrip.h
new file mode 100644
index 00000000..82f4b3a9
--- /dev/null
+++ b/src/statusstrip.h
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef STATUSSTRIP_H
+#define STATUSSTRIP_H
+
+#include "modulestatus.h"
+
+#include <QWidget>
+
+#include <array>
+
+class QLabel;
+class QTimer;
+
+namespace Ui
+{
+class StatusStrip;
+} // namespace Ui
+
+// The strip at the bottom of the windows naming what is currently running.
+// One label per module plus a "Ready" label, all created up front and
+// shown/hidden per ModuleStatus::activity() - no add/remove churn on state
+// changes. Only active modules are shown; "Ready" appears when nothing runs.
+class StatusStrip : public QWidget
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(StatusStrip)
+
+public:
+ explicit StatusStrip(QWidget *parent = nullptr);
+ ~StatusStrip() override;
+
+ void setModel(ModuleStatus *model);
+ // The pop-up window hides the whole strip at rest so the resting pop-up
+ // is unchanged; the main window keeps showing "Ready".
+ void setHideWhenIdle(bool hide);
+ // Interface/ShowStatusBar pick-up: a strip hidden by the setting stays
+ // hidden whatever the model does.
+ void setShown(bool shown);
+
+protected:
+ void changeEvent(QEvent *event) override;
+
+private:
+ void renderModules();
+ void renderDots();
+ bool hasActiveSegments() const;
+ void updateVisibility();
+
+ static QColor errorTextColor(const QWidget *widget);
+ static QString dots(int count);
+
+ Ui::StatusStrip *ui;
+ ModuleStatus *m_model = nullptr;
+ QTimer *m_ellipsisTimer;
+ QLabel *m_readyLabel = nullptr;
+ std::array<QLabel *, ModuleStatus::moduleCount()> m_moduleLabels{};
+ int m_dotCount = 0;
+ bool m_hideWhenIdle = false;
+ bool m_shown = true;
+};
+
+#endif // STATUSSTRIP_H
diff --git a/src/statusstrip.ui b/src/statusstrip.ui
new file mode 100644
index 00000000..be815871
--- /dev/null
+++ b/src/statusstrip.ui
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>StatusStrip</class>
+ <widget class="QWidget" name="StatusStrip">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>120</width>
+ <height>22</height>
+ </rect>
+ </property>
+ <layout class="QHBoxLayout" name="stripLayout">
+ <property name="spacing">
+ <number>0</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/translator/atranslationprovider.h b/src/translator/atranslationprovider.h
index 72d8ddcd..196c605e 100644
--- a/src/translator/atranslationprovider.h
+++ b/src/translator/atranslationprovider.h
@@ -50,16 +50,6 @@ public:
virtual void abort();
virtual void reset();
virtual void finish();
- virtual void setSourceImage(const QByteArray & /*imageData*/)
- {
- }
- virtual void clearSourceImage()
- {
- }
- virtual bool hasSourceImage() const
- {
- return false;
- }
virtual QVector<Language> supportedSourceLanguages() = 0;
virtual QVector<Language> supportedDestinationLanguages() = 0;
virtual bool supportsAutodetection() const = 0;
@@ -93,6 +83,10 @@ protected:
signals:
void stateChanged(State newState);
+ // A real (asynchronous) language detection has begun. Copy's
+ // detectLanguage() is synchronous and never emits this. Terminates via
+ // languageDetected() or any stateChanged() (abort/cancel safety net).
+ void detectionStarted();
void languageDetected(const Language &detectedLanguage, bool isTranslationContext = true);
void engineChanged(int engineIndex);
};
diff --git a/src/translator/localaitranslationprovider.cpp b/src/translator/localaitranslationprovider.cpp
index 1f927df9..71b2d357 100644
--- a/src/translator/localaitranslationprovider.cpp
+++ b/src/translator/localaitranslationprovider.cpp
@@ -6,12 +6,14 @@
#include "localaitranslationprovider.h"
#include "provideroptions.h"
+#include "llm/openaiendpoint.h"
#include "settings/appsettings.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
+#include <QLocale>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
@@ -30,7 +32,6 @@ LocalAiTranslationProvider::LocalAiTranslationProvider(QObject *parent)
, m_url(AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")))
, m_model(AppSettings::defaultLocalProviderModel(QStringLiteral("ollama")))
, m_prompt(AppSettings::defaultLocalAiPrompt())
- , m_detectViaLlm(false)
, m_detectUrl(AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")))
, m_detectModel()
, m_sourceWasAuto(false)
@@ -47,28 +48,6 @@ QString LocalAiTranslationProvider::getProviderType() const
return QStringLiteral("LocalAiTranslationProvider");
}
-QString LocalAiTranslationProvider::completionsUrl(const QString &baseUrl, bool isAnthropic)
-{
- QString base = baseUrl;
- while (base.endsWith(QLatin1Char('/'))) {
- base.chop(1);
- }
- return base + (isAnthropic ? QStringLiteral("/v1/messages") : QStringLiteral("/v1/chat/completions"));
-}
-
-void LocalAiTranslationProvider::setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey)
-{
- if (apiKey.isEmpty()) {
- return;
- }
- if (isAnthropic) {
- request.setRawHeader("x-api-key", apiKey.toUtf8());
- request.setRawHeader("anthropic-version", "2023-06-01");
- } else {
- request.setRawHeader("Authorization", "Bearer " + apiKey.toUtf8());
- }
-}
-
// āā Supported languages āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
QVector<Language> LocalAiTranslationProvider::supportedSourceLanguages()
@@ -91,28 +70,25 @@ bool LocalAiTranslationProvider::supportsAutodetection() const
Language LocalAiTranslationProvider::detectLanguage(const QString &text)
{
- Q_UNUSED(text);
+ if (!m_detectModel.isEmpty()) {
+ // Real detection is possible: fire it async through the same
+ // machinery translate() uses for auto-source detection. This is a
+ // detect-only call (no translate follow-up), so make sure that flag
+ // is false regardless of what any in-flight translate()-triggered
+ // detection left it as - onDetectFinished() branches on it.
+ m_detectThenTranslate = false;
+ sendDetection(text);
+ return Language(QLocale::system()); // placeholder; real result via languageDetected
+ }
+
+ // No detection model configured: no real detection is possible right
+ // now, fall back to the configured primary language.
const Language lang = AppSettings().primaryLanguage();
sourceLanguage = lang;
emit languageDetected(lang, false);
return lang;
}
-void LocalAiTranslationProvider::setSourceImage(const QByteArray &imageData)
-{
- m_imageData = imageData;
-}
-
-void LocalAiTranslationProvider::clearSourceImage()
-{
- m_imageData.clear();
-}
-
-bool LocalAiTranslationProvider::hasSourceImage() const
-{
- return !m_imageData.isEmpty();
-}
-
// āā Prompt & formatting āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
QString LocalAiTranslationProvider::languageDisplayName(const QString &code)
@@ -172,8 +148,10 @@ void LocalAiTranslationProvider::translate(const QString &inputText, const Langu
m_sourceWasAuto = (sourceLang == Language::autoLanguage());
- // Model-based detection (opt-in): detect first, then translate.
- if (m_sourceWasAuto && m_detectViaLlm && !m_detectModel.isEmpty()) {
+ // Detect the source language whenever a detect provider/model is
+ // actually configured (the capability genuinely exists) - the same
+ // machinery detectLanguage() uses.
+ if (m_sourceWasAuto && !m_detectModel.isEmpty()) {
m_detectThenTranslate = true;
m_pendingDstCode = dstCode;
sendDetection(inputText);
@@ -187,7 +165,7 @@ void LocalAiTranslationProvider::translate(const QString &inputText, const Langu
}
const QString srcCode = srcLang.toCode();
- if (srcCode == dstCode && !hasSourceImage()) {
+ if (srcCode == dstCode) {
result = formatResult(inputText);
error = TranslationError::NoError;
state = State::Processed;
@@ -210,56 +188,15 @@ void LocalAiTranslationProvider::sendTranslation(const QString &srcCode, const Q
m_reply = nullptr;
}
- const bool isVision = !m_imageData.isEmpty();
-
QJsonObject message;
message.insert(QStringLiteral("role"), QStringLiteral("user"));
-
- if (isVision) {
- const QString srcName = languageDisplayName(srcCode);
- const QString dstName = languageDisplayName(dstCode);
- QString vp = m_visionPrompt.isEmpty() ? AppSettings::defaultVisionPrompt() : m_visionPrompt;
- vp.replace(QStringLiteral("{source_lang}"), srcName);
- vp.replace(QStringLiteral("{source_code}"), srcCode);
- vp.replace(QStringLiteral("{target_lang}"), dstName);
- vp.replace(QStringLiteral("{target_code}"), dstCode);
-
- QJsonArray content;
- QJsonObject textPart;
- textPart.insert(QStringLiteral("type"), QStringLiteral("text"));
- textPart.insert(QStringLiteral("text"), vp);
- content.append(textPart);
-
- QJsonObject imagePart;
- const QString base64Data = QString::fromLatin1(m_imageData.toBase64());
- if (m_isAnthropic) {
- imagePart.insert(QStringLiteral("type"), QStringLiteral("image"));
- QJsonObject source;
- source.insert(QStringLiteral("type"), QStringLiteral("base64"));
- source.insert(QStringLiteral("media_type"), QStringLiteral("image/jpeg"));
- source.insert(QStringLiteral("data"), base64Data);
- imagePart.insert(QStringLiteral("source"), source);
- } else {
- imagePart.insert(QStringLiteral("type"), QStringLiteral("image_url"));
- QJsonObject imageUrl;
- imageUrl.insert(QStringLiteral("url"), QStringLiteral("data:image/jpeg;base64,") + base64Data);
- imagePart.insert(QStringLiteral("image_url"), imageUrl);
- }
- content.append(imagePart);
-
- message.insert(QStringLiteral("content"), content);
- } else {
- const QString prompt = buildPrompt(srcCode, dstCode, text);
- message.insert(QStringLiteral("content"), prompt);
- }
+ message.insert(QStringLiteral("content"), buildPrompt(srcCode, dstCode, text));
QJsonArray messages;
messages.append(message);
- const QString model = isVision ? m_visionModel : m_model;
-
QJsonObject body;
- body.insert(QStringLiteral("model"), model);
+ body.insert(QStringLiteral("model"), m_model);
body.insert(QStringLiteral("messages"), messages);
body.insert(QStringLiteral("temperature"), 0.0);
if (m_isAnthropic) {
@@ -268,15 +205,15 @@ void LocalAiTranslationProvider::sendTranslation(const QString &srcCode, const Q
body.insert(QStringLiteral("max_tokens"), 4096);
} else {
body.insert(QStringLiteral("stream"), false);
- if ((isVision ? m_visionDisableThinking : m_disableThinking)) {
+ if (m_disableThinking) {
body.insert(QStringLiteral("reasoning_effort"), QStringLiteral("none"));
}
}
- QNetworkRequest request(QUrl(completionsUrl(m_url, m_isAnthropic)));
+ QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_url, m_isAnthropic)));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
- setAuthHeaders(request, m_isAnthropic, m_apiKey);
- m_network->setTransferTimeout((isVision ? m_visionTimeout : m_timeout) * 1000);
+ OpenAiEndpoint::setAuthHeaders(request, m_isAnthropic, m_apiKey);
+ m_network->setTransferTimeout(m_timeout * 1000);
m_reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(m_reply, &QNetworkReply::finished, this, &LocalAiTranslationProvider::onReplyFinished);
@@ -303,6 +240,8 @@ static QString detectPrompt()
void LocalAiTranslationProvider::sendDetection(const QString &text)
{
+ emit detectionStarted();
+
if (m_detectReply != nullptr) {
m_detectReply->disconnect(this);
m_detectReply->abort();
@@ -333,36 +272,15 @@ void LocalAiTranslationProvider::sendDetection(const QString &text)
}
}
- QNetworkRequest request(QUrl(completionsUrl(m_detectUrl, m_detectIsAnthropic)));
+ QNetworkRequest request(QUrl(OpenAiEndpoint::completionsUrl(m_detectUrl, m_detectIsAnthropic)));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
- setAuthHeaders(request, m_detectIsAnthropic, m_detectApiKey);
+ OpenAiEndpoint::setAuthHeaders(request, m_detectIsAnthropic, m_detectApiKey);
m_network->setTransferTimeout(m_timeout * 1000);
m_detectReply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
connect(m_detectReply, &QNetworkReply::finished, this, &LocalAiTranslationProvider::onDetectFinished);
}
-static QString extractContent(const QByteArray &data, bool isAnthropic)
-{
- const QJsonObject obj = QJsonDocument::fromJson(data).object();
- if (isAnthropic) {
- const QJsonArray blocks = obj.value(QStringLiteral("content")).toArray();
- for (const QJsonValue &block : blocks) {
- const QJsonObject blockObj = block.toObject();
- if (blockObj.value(QStringLiteral("type")).toString() == QLatin1String("text")) {
- return blockObj.value(QStringLiteral("text")).toString();
- }
- }
- return QString();
- }
- const QJsonArray choices = obj.value(QStringLiteral("choices")).toArray();
- if (!choices.isEmpty()) {
- return choices.first().toObject().value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
- }
- // Fallback for Ollama native shape, just in case.
- return obj.value(QStringLiteral("message")).toObject().value(QStringLiteral("content")).toString();
-}
-
void LocalAiTranslationProvider::onReplyFinished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
@@ -395,7 +313,7 @@ void LocalAiTranslationProvider::onReplyFinished()
return;
}
- const QString translated = extractContent(reply->readAll(), m_isAnthropic);
+ const QString translated = OpenAiEndpoint::extractContent(reply->readAll(), m_isAnthropic);
if (translated.isEmpty()) {
error = TranslationError::Custom;
errorString = QStringLiteral("LocalAI returned an empty response");
@@ -445,7 +363,7 @@ void LocalAiTranslationProvider::onDetectFinished()
QString code;
if (reply->error() == QNetworkReply::NoError) {
- const QString resp = extractContent(reply->readAll(), m_detectIsAnthropic).toLower();
+ const QString resp = OpenAiEndpoint::extractContent(reply->readAll(), m_detectIsAnthropic).toLower();
static const QRegularExpression re(QStringLiteral("\\b[a-z]{2,3}([-][A-Za-z0-9]+)*\\b"));
QRegularExpressionMatchIterator it = re.globalMatch(resp);
if (it.hasNext()) {
@@ -464,7 +382,7 @@ void LocalAiTranslationProvider::onDetectFinished()
const QString srcCode = detected.toCode();
emit languageDetected(detected, true);
- if (srcCode == m_pendingDstCode && !hasSourceImage()) {
+ if (srcCode == m_pendingDstCode) {
result = formatResult(m_pendingText);
error = TranslationError::NoError;
state = State::Processed;
@@ -524,9 +442,6 @@ void LocalAiTranslationProvider::applyOptions(const ProviderOptions &options)
if (options.hasOption("is_anthropic")) {
m_isAnthropic = options.getOption("is_anthropic").toBool();
}
- if (options.hasOption("detect_via_llm")) {
- m_detectViaLlm = options.getOption("detect_via_llm").toBool();
- }
if (options.hasOption("detect_url")) {
m_detectUrl = options.getOption("detect_url").toString();
}
@@ -539,21 +454,6 @@ void LocalAiTranslationProvider::applyOptions(const ProviderOptions &options)
if (options.hasOption("detect_is_anthropic")) {
m_detectIsAnthropic = options.getOption("detect_is_anthropic").toBool();
}
- if (options.hasOption("vision_enabled")) {
- m_visionEnabled = options.getOption("vision_enabled").toBool();
- }
- if (options.hasOption("vision_model")) {
- m_visionModel = options.getOption("vision_model").toString();
- }
- if (options.hasOption("vision_prompt")) {
- m_visionPrompt = options.getOption("vision_prompt").toString();
- }
- if (options.hasOption("vision_disable_thinking")) {
- m_visionDisableThinking = options.getOption("vision_disable_thinking").toBool();
- }
- if (options.hasOption("vision_timeout")) {
- m_visionTimeout = options.getOption("vision_timeout").toInt();
- }
}
std::unique_ptr<ProviderOptions> LocalAiTranslationProvider::getDefaultOptions() const
@@ -566,16 +466,10 @@ std::unique_ptr<ProviderOptions> LocalAiTranslationProvider::getDefaultOptions()
options->setOption("timeout", AppSettings::defaultLocalAiTimeout());
options->setOption("api_key", QString());
options->setOption("is_anthropic", false);
- options->setOption("detect_via_llm", false);
options->setOption("detect_url", AppSettings::defaultLocalProviderUrl(QStringLiteral("ollama")));
options->setOption("detect_model", QString());
options->setOption("detect_api_key", QString());
options->setOption("detect_is_anthropic", false);
- options->setOption("vision_enabled", false);
- options->setOption("vision_model", QString());
- options->setOption("vision_prompt", AppSettings::defaultVisionPrompt());
- options->setOption("vision_disable_thinking", false);
- options->setOption("vision_timeout", AppSettings::defaultLocalAiTimeout());
return options;
}
@@ -584,10 +478,8 @@ QStringList LocalAiTranslationProvider::getAvailableOptions() const
return {QStringLiteral("url"), QStringLiteral("model"), QStringLiteral("prompt"),
QStringLiteral("disable_thinking"), QStringLiteral("timeout"),
QStringLiteral("api_key"), QStringLiteral("is_anthropic"),
- QStringLiteral("detect_via_llm"), QStringLiteral("detect_url"), QStringLiteral("detect_model"),
- QStringLiteral("detect_api_key"), QStringLiteral("detect_is_anthropic"),
- QStringLiteral("vision_enabled"), QStringLiteral("vision_model"), QStringLiteral("vision_prompt"),
- QStringLiteral("vision_disable_thinking"), QStringLiteral("vision_timeout")};
+ QStringLiteral("detect_url"), QStringLiteral("detect_model"),
+ QStringLiteral("detect_api_key"), QStringLiteral("detect_is_anthropic")};
}
ProviderUIRequirements LocalAiTranslationProvider::getUIRequirements() const
diff --git a/src/translator/localaitranslationprovider.h b/src/translator/localaitranslationprovider.h
index b08616e4..c841305c 100644
--- a/src/translator/localaitranslationprovider.h
+++ b/src/translator/localaitranslationprovider.h
@@ -15,7 +15,6 @@
class QNetworkAccessManager;
class QNetworkReply;
-class QNetworkRequest;
// Translation backend for local, OpenAI-compatible servers (Ollama,
// FastFlowLM, LM Studio) as well as arbitrary remote endpoints: a custom
@@ -42,10 +41,6 @@ public:
Language detectLanguage(const QString &text) override;
void abort() override;
- void setSourceImage(const QByteArray &imageData) override;
- void clearSourceImage() override;
- bool hasSourceImage() const override;
-
void applyOptions(const ProviderOptions &options) override;
std::unique_ptr<ProviderOptions> getDefaultOptions() const override;
QStringList getAvailableOptions() const override;
@@ -54,10 +49,6 @@ public:
void saveOptionToSettings(const QString &optionKey, const QVariant &value) override;
- // Shared with SettingsDialog's "Refresh models" probe, which hits the
- // same /v1/models endpoint and needs the same auth shape.
- static void setAuthHeaders(QNetworkRequest &request, bool isAnthropic, const QString &apiKey);
-
public slots:
void translate(const QString &inputText, const Language &translationLanguage, const Language &sourceLanguage) override;
@@ -69,7 +60,6 @@ private:
QString buildPrompt(const QString &srcCode, const QString &dstCode, const QString &text) const;
static QString languageDisplayName(const QString &code);
static QString formatResult(const QString &text);
- static QString completionsUrl(const QString &baseUrl, bool isAnthropic);
void sendDetection(const QString &text);
void sendTranslation(const QString &srcCode, const QString &dstCode, const QString &text);
@@ -86,7 +76,6 @@ private:
QString m_apiKey;
// Detection (independent provider/model)
- bool m_detectViaLlm;
QString m_detectUrl;
QString m_detectModel;
bool m_detectIsAnthropic = false;
@@ -100,14 +89,6 @@ private:
QString m_pendingDstCode;
int m_timeout = 300;
-
- // Vision
- bool m_visionEnabled = false;
- QString m_visionModel;
- QString m_visionPrompt;
- bool m_visionDisableThinking = false;
- int m_visionTimeout = 300;
- QByteArray m_imageData;
};
#endif // LOCALAITRANSLATIONPROVIDER_H
diff --git a/src/translator/mozhitranslationprovider.cpp b/src/translator/mozhitranslationprovider.cpp
index ca8c3b58..89552fda 100644
--- a/src/translator/mozhitranslationprovider.cpp
+++ b/src/translator/mozhitranslationprovider.cpp
@@ -58,6 +58,7 @@ Language MozhiTranslationProvider::detectLanguage(const QString &text)
{
qDebug() << "MozhiTranslationProvider::detectLanguage - text:" << text.left(50) << "current state:" << static_cast<int>(state);
m_isDetecting = true;
+ emit detectionStarted();
m_translator->detectLanguage(text, m_engine);
return QLocale::system();
@@ -169,6 +170,13 @@ void MozhiTranslationProvider::onTranslationFinished()
<< "new state:" << static_cast<int>(state);
emit languageDetected(detectedLanguage, false); // Detection context only
emit stateChanged(state);
+ } else {
+ // A failed detection must still emit a terminal languageDetected
+ // (with the same placeholder detectLanguage() returns), or a
+ // "Detecting language" status would strand forever. The state
+ // machine is untouched: state stays Ready either way.
+ qDebug() << "MozhiTranslationProvider::onTranslationFinished - detection failed";
+ emit languageDetected(Language(QLocale::system()), false);
}
return;
}
diff --git a/src/translator/translationlogic.cpp b/src/translator/translationlogic.cpp
new file mode 100644
index 00000000..ba8c5ba8
--- /dev/null
+++ b/src/translator/translationlogic.cpp
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "translationlogic.h"
+
+namespace TranslationLogic
+{
+
+bool sameLanguage(const Language &a, const Language &b)
+{
+ if (a == b) {
+ return true;
+ }
+ if (a.hasQLocaleEquivalent() && b.hasQLocaleEquivalent()) {
+ return a.toQLocale().language() == b.toQLocale().language();
+ }
+ return false;
+}
+
+Language preferredDestination(const Language &source,
+ const Language &primary,
+ const Language &secondary,
+ const Language &fallback)
+{
+ const Language primaryLang = (primary == Language::autoLanguage()) ? fallback : primary;
+ if (!sameLanguage(primaryLang, source)) {
+ return primaryLang;
+ }
+ const Language secondaryLang = (secondary == Language::autoLanguage()) ? fallback : secondary;
+ if (!sameLanguage(secondaryLang, source)) {
+ return secondaryLang;
+ }
+ return fallback;
+}
+
+} // namespace TranslationLogic
\ No newline at end of file
diff --git a/src/translator/translationlogic.h b/src/translator/translationlogic.h
new file mode 100644
index 00000000..66bc93c7
--- /dev/null
+++ b/src/translator/translationlogic.h
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef TRANSLATIONLOGIC_H
+#define TRANSLATIONLOGIC_H
+
+#include "language.h"
+
+#include <QLocale>
+
+// The auto (target / source) language rule, shared by every translation
+// backend so none of them can drift. This has regressed repeatedly; the
+// canonical behaviour lives here and is pinned by tests/test_translationlogic.cpp.
+namespace TranslationLogic
+{
+
+// True when the two languages are the same for translation purposes: identical
+// locale, or the same base language ignoring script/territory (en == en_US).
+// A non-locale language only matches itself exactly.
+bool sameLanguage(const Language &a, const Language &b);
+
+// The destination a backend should pick when the user asked for "auto".
+// source is the (already known) source language; primary/secondary come from
+// the settings; fallback (normally the system locale) is used when a primary
+// or secondary isn't configured.
+// - source != primary -> primary
+// - source == primary -> secondary
+// - source equals both -> fallback
+Language preferredDestination(const Language &source,
+ const Language &primary,
+ const Language &secondary,
+ const Language &fallback);
+
+} // namespace TranslationLogic
+
+#endif // TRANSLATIONLOGIC_H
\ No newline at end of file
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c2d6b2a4..0f973761 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -101,6 +101,16 @@ target_link_libraries(test_localai_provider PRIVATE
)
add_test(NAME LocalAiProviderTest COMMAND test_localai_provider)
+# Canonical auto-destination language rule - pure, headless (see gotcha list).
+add_executable(test_translationlogic
+ test_translationlogic.cpp
+)
+target_link_libraries(test_translationlogic PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME TranslationLogicTest COMMAND test_translationlogic)
+
# LocalAI settings dialog tests (gotchas #6/#7) - needs a display (real
# MainWindow + SettingsDialog).
add_executable(test_settingsdialog_localai
@@ -113,6 +123,103 @@ target_link_libraries(test_settingsdialog_localai PRIVATE
)
add_test(NAME SettingsDialogLocalAiTest COMMAND test_settingsdialog_localai)
+# LlmOcr (vision-model OCR engine) contract tests - headless OK.
+add_executable(test_llmocr
+ test_llmocr.cpp
+)
+target_link_libraries(test_llmocr PRIVATE
+ ${PROJECT_NAME}-lib
+ test-support
+ Qt6::Test
+)
+add_test(NAME LlmOcrTest COMMAND test_llmocr)
+
+# Live LlmOcr regression against a real, locally-running Ollama + vision-OCR
+# model - see test_llmocr_live.cpp for why MockHttpServer can't stand in for
+# this one. Headless OK; self-skips (not fails) when Ollama/the model isn't
+# available, so it is safe in the default test run on machines without it.
+add_executable(test_llmocr_live
+ test_llmocr_live.cpp
+)
+target_link_libraries(test_llmocr_live PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME LlmOcrLiveTest COMMAND test_llmocr_live)
+
+# ModuleStatus aggregator contract - headless OK (nothing is ever shown).
+add_executable(test_modulestatus
+ test_modulestatus.cpp
+)
+target_link_libraries(test_modulestatus PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME ModuleStatusTest COMMAND test_modulestatus)
+
+# StatusStrip view contract - headless OK: visibility is asserted through
+# isHidden() on never-shown widgets, so no window exposure is needed.
+add_executable(test_statusstrip
+ test_statusstrip.cpp
+)
+target_link_libraries(test_statusstrip PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME StatusStripTest COMMAND test_statusstrip)
+
+# SnippingArea terminal-signal regression guard (empty selection used to emit
+# nothing at all) - key/mouse events on a never-shown widget, headless OK.
+add_executable(test_snippingarea_terminal
+ test_snippingarea_terminal.cpp
+)
+target_link_libraries(test_snippingarea_terminal PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME SnippingAreaTerminalTest COMMAND test_snippingarea_terminal)
+
+# Module status through a real MainWindow (Copy backend, no network): busy
+# observation during the synchronous cascade, sticky errors through
+# MainWindow's own reset wiring, the pop-up strip, and the ShowStatusBar
+# pick-up - needs a display.
+add_executable(test_mainwindow_status
+ test_mainwindow_status.cpp
+)
+target_compile_definitions(test_mainwindow_status PRIVATE BUILD_TESTING)
+target_link_libraries(test_mainwindow_status PRIVATE
+ ${PROJECT_NAME}-lib
+ Qt6::Test
+)
+add_test(NAME MainWindowStatusTest COMMAND test_mainwindow_status)
+
+# Everyday MainWindow features end-to-end, offline: swap, clear, copy
+# buttons, abort, paste-image OCR chain, Escape-cancel, ShowStatusBar
+# settings round-trip - LocalAI translation and LLM OCR both pointed at
+# MockHttpServer - needs a display.
+add_executable(test_mainwindow_features
+ test_mainwindow_features.cpp
+)
+target_compile_definitions(test_mainwindow_features PRIVATE BUILD_TESTING)
+target_link_libraries(test_mainwindow_features PRIVATE
+ ${PROJECT_NAME}-lib
+ test-support
+ Qt6::Test
+)
+add_test(NAME MainWindowFeaturesTest COMMAND test_mainwindow_features)
+
+# LocalAI prompt resolution: stored-vs-default precedence, including the
+# superseded default that master still persists - headless OK.
+add_executable(test_appsettings_prompts
+ test_appsettings_prompts.cpp
+)
+target_link_libraries(test_appsettings_prompts PRIVATE
+ ${PROJECT_NAME}-lib
+ test-support
+ Qt6::Test
+)
+add_test(NAME AppSettingsPromptsTest COMMAND test_appsettings_prompts)
+
# Generic ATranslationProvider contract - runs the same invariants (state
# machine, MainWindow's finish()/reset() cascades, the Q_UNREACHABLE()-
# guarded Processed/NoError pairing) against every backend automatically.
@@ -132,7 +239,7 @@ add_test(NAME TranslationProviderContractTest COMMAND test_translation_provider_
# When using static ONNX Runtime, also configure test executables that link
# the app library (they pull in Piper TTS transitively through it).
if(WITH_PIPER_TTS AND ONNXRuntime_USE_STATIC)
- foreach(_test_target IN ITEMS test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_translation_provider_contract)
+ foreach(_test_target IN ITEMS test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_llmocr test_llmocr_live test_translationlogic test_modulestatus test_statusstrip test_snippingarea_terminal test_mainwindow_status test_mainwindow_features test_appsettings_prompts test_translation_provider_contract)
configure_onnxruntime_static(${_test_target})
add_dependencies(${_test_target} onnxruntime_ready)
endforeach()
@@ -149,7 +256,7 @@ endif()
# configuration (see build.zsh's build-test/build-test-nopiper) still builds
# and runs them.
if(NOT BUILD_TESTING)
- foreach(_test_target IN ITEMS test_helper_window test_dbus_static_init test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_translation_provider_contract)
+ foreach(_test_target IN ITEMS test_helper_window test_dbus_static_init test_popupwindow_lifetime test_snippingarea_flags test_ttsprovider_isolation test_translation test_localai_provider test_settingsdialog_localai test_llmocr test_llmocr_live test_translationlogic test_modulestatus test_statusstrip test_snippingarea_terminal test_mainwindow_status test_mainwindow_features test_appsettings_prompts test_translation_provider_contract)
set_target_properties(${_test_target} PROPERTIES EXCLUDE_FROM_ALL TRUE)
endforeach()
if(TARGET test_singleapplication_recovery)
diff --git a/tests/test_appsettings_prompts.cpp b/tests/test_appsettings_prompts.cpp
new file mode 100644
index 00000000..2c9f02be
--- /dev/null
+++ b/tests/test_appsettings_prompts.cpp
@@ -0,0 +1,117 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// localAiPrompt() has to distinguish "the user wrote this prompt" from "an
+// older build's settings dialog auto-saved its default here", because only the
+// former may override the current default. Getting that wrong is what made
+// ollama keep showing the superseded source-language prompt while a model added
+// later showed the new one.
+
+#include "testisolation.h"
+#include "settings/appsettings.h"
+
+#include <QCoreApplication>
+#include <QSettings>
+#include <QTest>
+
+namespace
+{
+
+// Byte-for-byte the default that master still ships, and that the old dialog
+// persisted under every model whose page was opened.
+QString supersededPrompt()
+{
+ return QStringLiteral(
+ "You are a professional {source_lang} ({source_code}) to {target_lang} ({target_code}) translator. "
+ "Your goal is to accurately convey the meaning and nuances of the original {source_lang} text "
+ "while adhering to {target_lang} grammar, vocabulary, and cultural sensitivities.\n"
+ "Produce only the {target_lang} translation, without any additional explanations or commentary. "
+ "Please translate the following {source_lang} text into {target_lang}:\n\n\n{text}");
+}
+
+void storePrompt(const QString &model, const QString &prompt)
+{
+ QSettings settings;
+ settings.setValue(QStringLiteral("LocalAI/Prompts/") + model, prompt);
+ settings.sync();
+}
+
+} // namespace
+
+class AppSettingsPromptsTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void init()
+ {
+ QSettings settings;
+ settings.remove(QStringLiteral("LocalAI"));
+ settings.sync();
+ }
+
+ void testUnsetPromptIsTheCurrentDefault()
+ {
+ const QString prompt = AppSettings().localAiPrompt(QStringLiteral("never-configured"));
+ QCOMPARE(prompt, AppSettings::defaultLocalAiPrompt());
+ }
+
+ // The reported bug, at the level it actually lives: a stored copy of the
+ // superseded default must not win over the current default.
+ void testSupersededDefaultDoesNotOverrideCurrentDefault()
+ {
+ storePrompt(QStringLiteral("translategemma_12b_128k:latest"), supersededPrompt());
+
+ const QString prompt = AppSettings().localAiPrompt(QStringLiteral("translategemma_12b_128k:latest"));
+ QCOMPARE(prompt, AppSettings::defaultLocalAiPrompt());
+ QVERIFY(!prompt.contains(QStringLiteral("{source_lang}")));
+ }
+
+ void testEmptyStoredPromptFallsBackToDefault()
+ {
+ storePrompt(QStringLiteral("half-typed"), QString());
+
+ QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("half-typed")), AppSettings::defaultLocalAiPrompt());
+ }
+
+ void testCustomPromptIsReturnedUntouched()
+ {
+ const QString mine = QStringLiteral("Translate into {target_lang}. Be terse. {text}");
+ storePrompt(QStringLiteral("glm-4.5"), mine);
+
+ QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("glm-4.5")), mine);
+ }
+
+ // A prompt that merely resembles the superseded default - the user's own
+ // edit of it - is still the user's, and must survive.
+ void testEditedSupersededPromptIsRespected()
+ {
+ const QString edited = supersededPrompt() + QStringLiteral("\nKeep proper nouns untranslated.");
+ storePrompt(QStringLiteral("my-model"), edited);
+
+ QCOMPARE(AppSettings().localAiPrompt(QStringLiteral("my-model")), edited);
+ }
+
+ // A '/' in a model name would otherwise open a nested QSettings group,
+ // filing the prompt under a key the reader never looks at.
+ void testSlashInModelNameRoundTrips()
+ {
+ AppSettings settings;
+ const QString mine = QStringLiteral("Transcribe, do not translate. {text}");
+ settings.setLocalAiPrompt(QStringLiteral("openai/gpt-oss"), mine);
+
+ QCOMPARE(settings.localAiPrompt(QStringLiteral("openai/gpt-oss")), mine);
+ }
+};
+
+int main(int argc, char *argv[])
+{
+ isolateTestSettings();
+ QCoreApplication app(argc, argv);
+ AppSettingsPromptsTest test;
+ return QTest::qExec(&test, argc, argv);
+}
+
+#include "test_appsettings_prompts.moc"
diff --git a/tests/test_llmocr.cpp b/tests/test_llmocr.cpp
new file mode 100644
index 00000000..893e2897
--- /dev/null
+++ b/tests/test_llmocr.cpp
@@ -0,0 +1,338 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// LlmOcr (vision-model OCR engine) contract: image in -> transcription out,
+// never a translation. Pins the wire shape (multimodal chat-completions with
+// an image part, transcription prompt) and that the OCR path is fully
+// decoupled from LocalAiTranslationProvider.
+
+#include "mockhttpserver.h"
+#include "provideroptions.h"
+#include "ocr/llmocr.h"
+#include "translator/localaitranslationprovider.h"
+
+#include <QCoreApplication>
+#include <QImage>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QSignalSpy>
+#include <QTest>
+
+using Response = MockHttpServer::Response;
+
+namespace
+{
+
+QByteArray chatCompletionJson(const QString &content)
+{
+ QJsonObject message;
+ message.insert(QStringLiteral("role"), QStringLiteral("assistant"));
+ message.insert(QStringLiteral("content"), content);
+ QJsonObject choice;
+ choice.insert(QStringLiteral("message"), message);
+ QJsonArray choices;
+ choices.append(choice);
+ QJsonObject body;
+ body.insert(QStringLiteral("choices"), choices);
+ return QJsonDocument(body).toJson(QJsonDocument::Compact);
+}
+
+} // namespace
+
+class LlmOcrTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testRecognizeSendsMultimodalTranscriptionRequest()
+ {
+ MockHttpServer server;
+ Response response;
+ response.status = 200;
+ response.body = chatCompletionJson(QStringLiteral("Hello world"));
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("vision-model"));
+
+ QImage image(8, 8, QImage::Format_RGB32);
+ image.fill(Qt::white);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+
+ QVERIFY(spy.wait(5000));
+ QCOMPARE(spy.constFirst().at(0).toString(), QStringLiteral("Hello world"));
+
+ QCOMPARE(server.requestCount(), 1);
+ QVERIFY(server.requestPath(0).endsWith(QStringLiteral("/v1/chat/completions")));
+
+ const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+ QCOMPARE(body.value(QStringLiteral("model")).toString(), QStringLiteral("vision-model"));
+
+ const QJsonArray messages = body.value(QStringLiteral("messages")).toArray();
+ QCOMPARE(messages.size(), 1);
+ const QJsonArray content = messages.at(0).toObject().value(QStringLiteral("content")).toArray();
+ QCOMPARE(content.size(), 2);
+ QCOMPARE(content.at(0).toObject().value(QStringLiteral("type")).toString(), QStringLiteral("text"));
+ QCOMPARE(content.at(1).toObject().value(QStringLiteral("type")).toString(), QStringLiteral("image_url"));
+
+ const QString prompt = content.at(0).toObject().value(QStringLiteral("text")).toString();
+ QVERIFY(prompt.contains(QStringLiteral("Transcribe"), Qt::CaseInsensitive));
+ }
+
+ void testCollapseRepeatedTranscription()
+ {
+ // The repeat-prone-local-model signature: the completed transcription
+ // re-emitted verbatim, blank-line separated. Collapses to one copy.
+ const QString block = QStringLiteral("System Monitor\nCPU 12% Memory 1.2GiB\nNotifications");
+ const QString repeated = block + QStringLiteral("\n\n") + block + QStringLiteral("\n\n") + block;
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(repeated), block);
+
+ // A single clean transcription is left untouched.
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(block), block);
+
+ // Empty / whitespace-only trims to empty.
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(QString()), QString());
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(QStringLiteral(" ")), QString());
+
+ // A single line looped to runaway length collapses.
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(QStringLiteral("no\nno\nno\nno")), QStringLiteral("no"));
+
+ // A multi-line block repeated twice now collapses too (2-copy bar,
+ // user decision): a 2-line refrain poem doubled verbatim is the known,
+ // accepted edge.
+ const QString twoCopies = block + QStringLiteral("\n\n") + block;
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(twoCopies), block);
+
+ // A short poem repeating one line three times stays intact (single
+ // lines only read as runaway at four or more copies).
+ const QString triple = QStringLiteral("Water\nWater\nWater");
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(triple), triple);
+
+ // A poem that repeats its first stanza verbatim at the end (not an
+ // exact tiling of the whole response) keeps every line.
+ const QString enveloped = QStringLiteral("Once upon a time\nIn a land far away\nOnce upon a time");
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(enveloped), enveloped);
+
+ // The real runaway signature ends mid-line when the token budget cuts
+ // generation: full verbatim copies plus a partial block whose last
+ // line is a prefix of the block's next line.
+ const QString truncated = block + QStringLiteral("\n\n") + block + QStringLiteral("\n\n") + block
+ + QStringLiteral("\n\nSystem Monitor\nCPU 12%");
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(truncated), block);
+
+ // A single line repeated, ending in a line the token budget cut
+ // mid-word (period=1 case) collapses to one line too.
+ const QString singleLineLoop = QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+ + QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+ + QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant.\n")
+ + QStringLiteral("Try integrating using the Ollama web service to create a locally-run");
+ QCOMPARE(LlmOcr::collapseRepeatedTranscription(singleLineLoop),
+ QStringLiteral("Try integrating using the Ollama web service to create a locally-run personal code assistant."));
+ }
+
+ // A reasoning-capable vision model left with thinking on can spell out
+ // its chain of thought inline before the final answer.
+ // LocalAiTranslationProvider already asks such models to stop reasoning
+ // via reasoning_effort; LlmOcr should offer the same knob for parity,
+ // even though the concrete "text doubled in the source edit" bug this
+ // engine shipped with (see testStopSequenceIsSentToPreventRunawayRepeat
+ // below) turned out to be a different failure mode entirely - a small
+ // local model looping on its own output with no clean stop token.
+ void testDisableThinkingSendsReasoningEffortNone()
+ {
+ MockHttpServer server;
+ Response response;
+ response.body = chatCompletionJson(QStringLiteral("Hello world"));
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("glm-ocr"));
+ ocr.setDisableThinking(true);
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+
+ const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+ QCOMPARE(body.value(QStringLiteral("reasoning_effort")).toString(), QStringLiteral("none"));
+ }
+
+ void testThinkingLeftAloneByDefault()
+ {
+ MockHttpServer server;
+ Response response;
+ response.body = chatCompletionJson(QStringLiteral("Hello world"));
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("glm-ocr"));
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+
+ const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+ QVERIFY(!body.contains(QStringLiteral("reasoning_effort")));
+ }
+
+ // The actual reported bug, reproduced live against a real Ollama +
+ // glm-ocr in testLiveOllamaGlmOcrDoesNotRepeatTranscription (see
+ // test_llmocr_live.cpp): a small local vision model finishes the correct
+ // transcription, then - having no clean stop token for "done" - wraps it
+ // in a markdown fence and loops re-emitting it until it runs out of
+ // token budget. A stop sequence on the fence marker cuts generation the
+ // instant that starts. This test only pins that the wire shape carries
+ // the stop sequence; the live test proves it actually works.
+ void testStopSequenceIsSentToPreventRunawayRepeat()
+ {
+ MockHttpServer server;
+ Response response;
+ response.body = chatCompletionJson(QStringLiteral("Hello world"));
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("glm-ocr"));
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+
+ const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+ const QJsonArray stop = body.value(QStringLiteral("stop")).toArray();
+ QCOMPARE(stop.size(), 1);
+ QCOMPARE(stop.first().toString(), QStringLiteral("```"));
+ }
+
+ void testCustomPromptIsSent()
+ {
+ MockHttpServer server;
+ Response response;
+ response.body = chatCompletionJson(QStringLiteral("text"));
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("m"));
+ ocr.setPrompt(QStringLiteral("CUSTOM OCR PROMPT"));
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+
+ const QJsonObject body = QJsonDocument::fromJson(server.requestBody(0)).object();
+ const QJsonArray content = body.value(QStringLiteral("messages")).toArray().at(0).toObject().value(QStringLiteral("content")).toArray();
+ QCOMPARE(content.at(0).toObject().value(QStringLiteral("text")).toString(), QStringLiteral("CUSTOM OCR PROMPT"));
+ }
+
+ void testHttpErrorEmitsFailed()
+ {
+ MockHttpServer server;
+ Response response;
+ response.status = 500;
+ response.body = QByteArrayLiteral("{}");
+ server.queueResponse(response);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("m"));
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::failed);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+ QVERIFY(!spy.constFirst().at(0).toString().isEmpty());
+ }
+
+ void testTimeoutEmitsFailed()
+ {
+ MockHttpServer server;
+ Response hangResponse;
+ hangResponse.hang = true;
+ server.queueResponse(hangResponse);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(server.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("m"));
+ ocr.setTimeout(1);
+
+ QImage image(4, 4, QImage::Format_RGB32);
+ image.fill(Qt::black);
+
+ QSignalSpy spy(&ocr, &LlmOcr::failed);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+ QVERIFY(spy.constFirst().at(0).toString().contains(QStringLiteral("timed out"), Qt::CaseInsensitive));
+ }
+
+ void testIsConfiguredRequiresUrlAndModel()
+ {
+ LlmOcr ocr;
+ QVERIFY(!ocr.isConfigured());
+ ocr.setEndpoint(QStringLiteral("http://localhost:11434"), false, QString());
+ QVERIFY(!ocr.isConfigured());
+ ocr.setModel(QStringLiteral("m"));
+ QVERIFY(ocr.isConfigured());
+ }
+
+ // The OCR engine talks to the endpoint it was given, not to whatever the
+ // translation provider happens to be configured with. Both are pointed at
+ // mock servers here; only the OCR one may receive the request.
+ void testUsesItsOwnEndpointNotTheTranslationProviders()
+ {
+ MockHttpServer ocrServer;
+ MockHttpServer translationServer;
+ Response response;
+ response.status = 200;
+ response.body = chatCompletionJson(QStringLiteral("transcribed"));
+ ocrServer.queueResponse(response);
+
+ LocalAiTranslationProvider translationProvider;
+ auto options = translationProvider.getDefaultOptions();
+ options->setOption("url", translationServer.baseUrl());
+ options->setOption("model", QStringLiteral("translation-model"));
+ translationProvider.applyOptions(*options);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(ocrServer.baseUrl(), false, QString());
+ ocr.setModel(QStringLiteral("vision-model"));
+
+ QImage image(8, 8, QImage::Format_RGB32);
+ image.fill(Qt::white);
+
+ QSignalSpy spy(&ocr, &LlmOcr::recognized);
+ ocr.recognize(image, 96);
+ QVERIFY(spy.wait(5000));
+
+ QCOMPARE(ocrServer.requestCount(), 1);
+ QCOMPARE(translationServer.requestCount(), 0);
+ // And it asked the vision model, not the translation model.
+ QVERIFY(ocrServer.requestBody(0).contains("vision-model"));
+ QVERIFY(!ocrServer.requestBody(0).contains("translation-model"));
+ }
+};
+
+QTEST_MAIN(LlmOcrTest)
+#include "test_llmocr.moc"
\ No newline at end of file
diff --git a/tests/test_llmocr_live.cpp b/tests/test_llmocr_live.cpp
new file mode 100644
index 00000000..b8159323
--- /dev/null
+++ b/tests/test_llmocr_live.cpp
@@ -0,0 +1,132 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Live regression test for the "OCR text repeats until it runs out of
+// tokens" bug (see llmocr.cpp's stop-sequence comment): drives the real
+// LlmOcr class against a real, locally-running Ollama instance and a real
+// vision-OCR model, instead of MockHttpServer's canned responses. The bug
+// this guards against is a small model's own decoding pathology (looping
+// on its output with no clean stop token) - a mocked response can echo
+// whatever string a test hands it, but it cannot reproduce that failure
+// mode, so only a real model can prove the fix actually holds.
+//
+// Any developer can run this: `ollama pull glm-ocr` (or point
+// CROW_TEST_OLLAMA_MODEL at whatever vision-capable model is already
+// installed) and `ollama serve`, then run the LlmOcrLiveTest ctest target
+// directly. It SKIPS - never fails - when Ollama or the model isn't
+// reachable, so it never blocks CI or a machine without Ollama.
+
+#include "llm/visionmodelprobe.h"
+#include "ocr/llmocr.h"
+
+#include <QFont>
+#include <QImage>
+#include <QPainter>
+#include <QSignalSpy>
+#include <QTest>
+
+namespace
+{
+
+QString ollamaUrl()
+{
+ const QByteArray fromEnv = qgetenv("CROW_TEST_OLLAMA_URL");
+ return fromEnv.isEmpty() ? QStringLiteral("http://localhost:11434") : QString::fromLocal8Bit(fromEnv);
+}
+
+QString ollamaModel()
+{
+ const QByteArray fromEnv = qgetenv("CROW_TEST_OLLAMA_MODEL");
+ return fromEnv.isEmpty() ? QStringLiteral("glm-ocr:latest") : QString::fromLocal8Bit(fromEnv);
+}
+
+// A document-like image with real, unambiguous text. The near-blank
+// single-word case is exactly the pathological input that first pushed this
+// model into its repeat loop during investigation of the reported bug.
+QImage renderTextImage(const QString &text)
+{
+ QImage image(560, 200, QImage::Format_RGB32);
+ image.fill(Qt::white);
+ QPainter painter(&image);
+ painter.setPen(Qt::black);
+ QFont font = painter.font();
+ font.setPointSize(16);
+ painter.setFont(font);
+ painter.drawText(image.rect().adjusted(16, 16, -16, -16), Qt::TextWordWrap, text);
+ painter.end();
+ return image;
+}
+
+} // namespace
+
+class LlmOcrLiveTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase()
+ {
+ VisionModelProbe probe;
+ QSignalSpy finishedSpy(&probe, &VisionModelProbe::finished);
+ QSignalSpy failedSpy(&probe, &VisionModelProbe::failed);
+ probe.probe(QStringLiteral("ollama"), ollamaUrl(), QString());
+
+ if (!finishedSpy.wait(6000) || !failedSpy.isEmpty()) {
+ QSKIP(qPrintable(QStringLiteral("Ollama not reachable at %1 - run `ollama serve` to enable this test").arg(ollamaUrl())));
+ }
+
+ const QStringList allModels = finishedSpy.constFirst().at(0).toStringList();
+ const QString model = ollamaModel();
+ if (!allModels.contains(model)) {
+ QSKIP(qPrintable(QStringLiteral("Model \"%1\" not installed in Ollama - run `ollama pull %1` to enable this test").arg(model)));
+ }
+ }
+
+ // The actual reported bug: a small local vision model (this one via
+ // Ollama) finishes the real transcription, then - lacking a clean stop
+ // token for the task - wraps it in a markdown fence and loops
+ // re-emitting it until it exhausts its token budget, so the same text
+ // lands in the source edit more than once. Reproduces it end to end
+ // against the real model and asserts the fix (a stop sequence on the
+ // fence marker, see llmocr.cpp) holds: one clean transcription, no
+ // repeated block, no fence noise.
+ void testLiveOllamaDoesNotRepeatTranscription()
+ {
+ const QString phrase = QStringLiteral("The quick brown fox jumps over the lazy dog. Invoice #4471 Total 128.50 EUR.");
+ const QImage image = renderTextImage(phrase);
+
+ LlmOcr ocr;
+ ocr.setEndpoint(ollamaUrl(), false, QString());
+ ocr.setModel(ollamaModel());
+ ocr.setTimeout(120);
+
+ QSignalSpy recognizedSpy(&ocr, &LlmOcr::recognized);
+ QSignalSpy failedSpy(&ocr, &LlmOcr::failed);
+ ocr.recognize(image, 96);
+
+ QVERIFY2(recognizedSpy.wait(120000) || !failedSpy.isEmpty(), "LlmOcr neither recognized nor failed within the timeout");
+ if (!failedSpy.isEmpty()) {
+ QFAIL(qPrintable(QStringLiteral("LlmOcr reported failure: %1").arg(failedSpy.constFirst().at(0).toString())));
+ }
+
+ const QString recognized = recognizedSpy.constFirst().at(0).toString();
+ QVERIFY2(!recognized.isEmpty(), "recognized text was empty");
+
+ // The regression this test exists for: the model repeating its own
+ // output. In practice that repeat is introduced by exactly this
+ // markdown fence marker, so its presence alone is a solid signal.
+ QVERIFY2(!recognized.contains(QStringLiteral("```")),
+ qPrintable(QStringLiteral("recognized text still contains fenced repeat noise: %1").arg(recognized)));
+
+ // A distinctive substring from the source text must appear exactly
+ // once - more than once is the duplicated-text bug this test guards
+ // against.
+ const QString needle = QStringLiteral("Invoice #4471");
+ QCOMPARE(recognized.count(needle), 1);
+ }
+};
+
+QTEST_MAIN(LlmOcrLiveTest)
+#include "test_llmocr_live.moc"
diff --git a/tests/test_localai_provider.cpp b/tests/test_localai_provider.cpp
index 8844123d..d4527bb1 100644
--- a/tests/test_localai_provider.cpp
+++ b/tests/test_localai_provider.cpp
@@ -17,6 +17,8 @@
#include "mockhttpserver.h"
#include "provideroptions.h"
#include "testisolation.h"
+#include "llm/openaiendpoint.h"
+#include "settings/appsettings.h"
#include "translator/atranslationprovider.h"
#include "translator/localaitranslationprovider.h"
@@ -163,7 +165,7 @@ private slots:
// Gotcha #4: buildPrompt() round-trips the language code through
// QLocale, which silently collapses an unrecognized/custom code to the
- // C locale, corrupting the prompt with "You are a professional C ...".
+ // C locale, corrupting the prompt with "... professional C ...".
// Fixed by 797fedec on localai-backend-salvage.
void testCustomLanguageCodeDoesNotCorruptPrompt()
{
@@ -173,6 +175,15 @@ private slots:
server.queueJson(200, chatCompletionJson(QStringLiteral("translated")));
auto provider = makeProvider(server.baseUrl());
+ ProviderOptions opts = *provider->getDefaultOptions();
+ opts.setOption(QStringLiteral("url"), server.baseUrl());
+ // The default prompt no longer renders {source_lang} (it lets the
+ // model infer the source language itself) - use a custom prompt
+ // that does, to keep exercising buildPrompt()'s
+ // languageDisplayName()/QLocale-collapse regression coverage.
+ opts.setOption(QStringLiteral("prompt"), QStringLiteral("Translate this {source_lang} text into {target_lang}: {text}"));
+ provider->applyOptions(opts);
+
provider->translate(QStringLiteral("Hello"), Language(QStringLiteral("es")), Language(QStringLiteral("zzq")));
QVERIFY(QTest::qWaitFor([&server]() {
@@ -181,7 +192,8 @@ private slots:
5000));
const QByteArray body = server.requestBody(0);
- QVERIFY2(!body.contains("professional C "), qUtf8Printable(QStringLiteral("Prompt corrupted to C-locale for a custom language code:\n") + QString::fromUtf8(body)));
+ QVERIFY2(body.contains("Zzqian"), qUtf8Printable(QStringLiteral("Custom language name not substituted:\n") + QString::fromUtf8(body)));
+ QVERIFY2(!body.contains("this C text"), qUtf8Printable(QStringLiteral("Prompt corrupted to C-locale for a custom language code:\n") + QString::fromUtf8(body)));
}
// Gotcha #5: the detection regex must take the FIRST match in the
@@ -195,7 +207,6 @@ private slots:
auto provider = makeProvider(server.baseUrl());
ProviderOptions opts = *provider->getDefaultOptions();
opts.setOption(QStringLiteral("url"), server.baseUrl());
- opts.setOption(QStringLiteral("detect_via_llm"), true);
opts.setOption(QStringLiteral("detect_url"), server.baseUrl());
opts.setOption(QStringLiteral("detect_model"), QStringLiteral("some-model"));
provider->applyOptions(opts);
@@ -207,6 +218,132 @@ private slots:
const Language detected = qvariant_cast<Language>(detectSpy.constFirst().at(0));
QCOMPARE(detected.toCode(), QStringLiteral("en"));
}
+
+ // detectLanguage() must be honest about supportsAutodetection() == true:
+ // when a detect provider/model is actually configured, it should fire a
+ // real async detection call (mirroring MozhiTranslationProvider), not
+ // silently substitute the primary language.
+ void testDetectLanguageWithDetectModelConfiguredHitsNetworkAndEmitsRealResult()
+ {
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("de - German text")));
+
+ auto provider = makeProvider(server.baseUrl());
+ ProviderOptions opts = *provider->getDefaultOptions();
+ opts.setOption(QStringLiteral("url"), server.baseUrl());
+ opts.setOption(QStringLiteral("detect_url"), server.baseUrl());
+ opts.setOption(QStringLiteral("detect_model"), QStringLiteral("some-model"));
+ provider->applyOptions(opts);
+
+ QSignalSpy detectSpy(provider.get(), &ATranslationProvider::languageDetected);
+ provider->detectLanguage(QStringLiteral("Guten Tag"));
+
+ QVERIFY(detectSpy.wait(5000));
+ const Language detected = qvariant_cast<Language>(detectSpy.constFirst().at(0));
+ QCOMPARE(detected.toCode(), QStringLiteral("de"));
+ QCOMPARE(server.requestCount(), 1);
+ QVERIFY(server.requestPath(0).contains(QStringLiteral("/v1/chat/completions")));
+ }
+
+ // Without a configured detect model, no real detection is possible:
+ // detectLanguage() must fall back to the primary language synchronously
+ // and must not make a network call.
+ void testDetectLanguageWithoutDetectModelFallsBackToPrimaryLanguageSynchronously()
+ {
+ AppSettings().setPrimaryLanguage(Language(QStringLiteral("fr")));
+
+ MockHttpServer server;
+ auto provider = makeProvider(server.baseUrl());
+
+ QSignalSpy detectSpy(provider.get(), &ATranslationProvider::languageDetected);
+ const Language detected = provider->detectLanguage(QStringLiteral("Bonjour"));
+
+ QCOMPARE(detected.toCode(), QStringLiteral("fr"));
+ QCOMPARE(detectSpy.count(), 1);
+ const Language emitted = qvariant_cast<Language>(detectSpy.constFirst().at(0));
+ QCOMPARE(emitted.toCode(), QStringLiteral("fr"));
+ QCOMPARE(server.requestCount(), 0);
+ }
+
+ // URL normalization: users paste either a base URL (SDK style) or a full
+ // endpoint URL (docs style). completionsUrl()/modelsUrl() must accept
+ // both without doubling or mangling the path.
+ void testCompletionsUrlNormalizesBaseUrlVsEndpoint()
+ {
+ // Base URLs get the kind-specific suffix appended.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://localhost:11434"), false),
+ QStringLiteral("http://localhost:11434/v1/chat/completions"));
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://localhost:11434/v1"), false),
+ QStringLiteral("http://localhost:11434/v1/chat/completions"));
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/"), true),
+ QStringLiteral("https://api.example.com/v1/messages"));
+
+ // Full endpoint URLs are used as typed - no doubling.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/chat/completions"), false),
+ QStringLiteral("https://api.example.com/v1/chat/completions"));
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/messages"), true),
+ QStringLiteral("https://api.example.com/v1/messages"));
+
+ // Cross-kind endpoint URLs that ARE recognized suffixes are still
+ // honored as typed (user knows best) - only the kind of the suffix
+ // matters for recognition, not the isAnthropic flag passed in.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.example.com/v1/messages"), false),
+ QStringLiteral("https://api.example.com/v1/messages"));
+
+ // A base that already carries its own path is the complete SDK base
+ // (z.ai's base is ".../paas/v4", not "/v1"): only the kind suffix is
+ // appended - never a second version segment.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("https://api.z.ai/api/coding/paas/v4"), false),
+ QStringLiteral("https://api.z.ai/api/coding/paas/v4/chat/completions"));
+
+ // "/api/chat" (Ollama's native, non-OpenAI-compatible endpoint) is
+ // intentionally NOT recognized as a complete endpoint here - this
+ // codebase only ever builds OpenAI-compatible request bodies, so
+ // treating it as "already complete" would silently point at an
+ // endpoint expecting a different wire shape. It's now treated as an
+ // ordinary path segment and gets the OpenAI suffix appended,
+ // producing an obviously-broken URL rather than a silently-wrong
+ // one - a saved "/api/chat" URL should be changed to
+ // "/v1/chat/completions" instead.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080/api/chat"), false),
+ QStringLiteral("http://host:8080/api/chat/chat/completions"));
+
+ // Trailing slashes are still stripped first, same doubled-path result.
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080/api/chat/"), false),
+ QStringLiteral("http://host:8080/api/chat/chat/completions"));
+ QCOMPARE(OpenAiEndpoint::completionsUrl(QStringLiteral("http://host:8080///"), false),
+ QStringLiteral("http://host:8080/v1/chat/completions"));
+ }
+
+ void testModelsUrlDerivesBaseFromEndpointUrl()
+ {
+ // Plain base URLs go straight to /v1/models.
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://localhost:11434")),
+ QStringLiteral("http://localhost:11434/v1/models"));
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1")),
+ QStringLiteral("https://api.example.com/v1/models"));
+
+ // Full endpoint URLs have their path stripped back to the base first.
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1/chat/completions")),
+ QStringLiteral("https://api.example.com/v1/models"));
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.example.com/v1/messages")),
+ QStringLiteral("https://api.example.com/v1/models"));
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/v1/models")),
+ QStringLiteral("http://host:8080/v1/models"));
+
+ // "/api/chat" is intentionally not a recognized suffix (see
+ // completionsUrl's test above for why) - it's treated as an
+ // ordinary path segment the probe lives under.
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/api/chat")),
+ QStringLiteral("http://host:8080/api/chat/models"));
+
+ // A path-bearing SDK base keeps its own version segment; the probe is
+ // "<base>/models", never ".../v4/v1/models" (z.ai's base is "/v4").
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("https://api.z.ai/api/coding/paas/v4")),
+ QStringLiteral("https://api.z.ai/api/coding/paas/v4/models"));
+ QCOMPARE(OpenAiEndpoint::modelsUrl(QStringLiteral("http://host:8080/some/custom/path")),
+ QStringLiteral("http://host:8080/some/custom/path/models"));
+ }
};
int main(int argc, char *argv[])
diff --git a/tests/test_mainwindow_features.cpp b/tests/test_mainwindow_features.cpp
new file mode 100644
index 00000000..e013b33f
--- /dev/null
+++ b/tests/test_mainwindow_features.cpp
@@ -0,0 +1,468 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// GUI-level feature tests through a real MainWindow: swap, clear, the copy
+// buttons, abort, the paste-image OCR chain, Escape-cancels-image, and the
+// Interface/ShowStatusBar settings round-trip. Everything runs offline - the
+// LocalAI translation backend and the LLM OCR engine are pointed at
+// MockHttpServer on 127.0.0.1 via the same settings keys the settings dialog
+// writes, so these exercise the full MainWindow -> provider -> UI path with
+// deterministic canned responses (and hang=true for the abort cases, the
+// only way to hold Processing/recognizing open long enough to interact).
+
+#include "language.h"
+#include "languagebuttonswidget.h"
+#include "mainwindow.h"
+#include "mockhttpserver.h"
+#include "modulestatus.h"
+#include "singleapplication.h"
+#include "sourcetextedit.h"
+#include "testisolation.h"
+#include "ocr/llmocr.h"
+#include "settings/appsettings.h"
+#include "settings/settingsdialog.h"
+#include "translator/atranslationprovider.h"
+#include "translator/localaitranslationprovider.h"
+#include "tts/attsprovider.h"
+
+#include <QApplication>
+#include <QCheckBox>
+#include <QClipboard>
+#include <QKeyEvent>
+#include <QLocale>
+#include <QTest>
+#include <QTextEdit>
+#include <QToolButton>
+
+#include <algorithm>
+
+namespace
+{
+QByteArray chatCompletionJson(const QString &content)
+{
+ return QStringLiteral(R"({"choices":[{"message":{"role":"assistant","content":"%1"}}]})").arg(content).toUtf8();
+}
+
+MockHttpServer::Response hangResponse()
+{
+ MockHttpServer::Response response;
+ response.hang = true;
+ return response;
+}
+
+// Drives the LocalAI backend (Translation/Backend=2) at the mock server. The
+// provider id must be one of AppSettings::localProviderIds() so the engine
+// combo population in updateProviderUI() stays consistent.
+void pinLocalAiSettings(const MockHttpServer &server)
+{
+ AppSettings settings;
+ settings.setShowPrivacyPopup(false);
+ settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::LocalAI);
+ settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+ settings.setActiveLocalProvider(QStringLiteral("openai_custom"));
+ settings.setLocalProviderUrl(QStringLiteral("openai_custom"), server.baseUrl());
+ settings.setLocalProviderModel(QStringLiteral("openai_custom"), QStringLiteral("mock-model"));
+ settings.setLocalAiTimeout(QStringLiteral("openai_custom"), 300);
+ settings.setForceSourceAutodetect(false);
+ settings.setForceTranslationAutodetect(false);
+
+ const Language english(QLocale::English);
+ const Language spanish(QLocale::Spanish);
+ settings.setLanguages(AppSettings::Source, {english});
+ settings.setLanguages(AppSettings::Translation, {spanish});
+ settings.setCheckedButton(AppSettings::Source, 0);
+ settings.setCheckedButton(AppSettings::Translation, 0);
+}
+
+void pinLlmOcrSettings(const MockHttpServer &server)
+{
+ AppSettings settings;
+ settings.setOcrEngine(AppSettings::OcrEngine::Llm);
+ settings.setOcrLlmProvider(QStringLiteral("openai_custom"));
+ settings.setOcrLlmUrl(QStringLiteral("openai_custom"), server.baseUrl());
+ settings.setOcrLlmModel(QStringLiteral("openai_custom"), QStringLiteral("mock-model"));
+}
+
+// Same delivery rationale as test_translation.cpp's typeText(): synthesized
+// platform key events can't inject into this window on Wayland, manually
+// constructed QKeyEvents through sendEvent() exercise the real handlers.
+void sendKey(QWidget *widget, int key, const QString &text = {})
+{
+ QKeyEvent press(QEvent::KeyPress, key, Qt::NoModifier, text);
+ QKeyEvent release(QEvent::KeyRelease, key, Qt::NoModifier, text);
+ QApplication::sendEvent(widget, &press);
+ QApplication::sendEvent(widget, &release);
+}
+
+void typeText(QWidget *widget, const QString &text)
+{
+ for (const QChar &ch : text)
+ sendKey(widget, Qt::Key_unknown, QString(ch));
+}
+
+QImage solidImage()
+{
+ QImage image(64, 32, QImage::Format_ARGB32);
+ image.fill(Qt::white);
+ return image;
+}
+} // namespace
+
+class MainWindowFeaturesTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+ void cleanupTestCase();
+ void cleanup();
+
+ void testSwapSwapsTextsAndLanguages();
+ void testClearButtonClearsBothEdits();
+ void testCopyButtons();
+ void testAbortReturnsToReadyWithoutError();
+ void testPasteImageRecognizeAndAutoTranslate();
+ void testEscapeCancelsSourceImage();
+ void testShowStatusBarSettingsRoundTrip();
+ void testRecognizedTextActivatesUiWithoutKeystroke();
+ void testRecognizedTextAutoTranslatesWhenEnabled();
+
+private:
+ static void waitForTranslateButton(MainWindow &window);
+};
+
+void MainWindowFeaturesTest::initTestCase()
+{
+ if (qgetenv("DISPLAY").isEmpty() && qgetenv("WAYLAND_DISPLAY").isEmpty()) {
+ QSKIP("No display server available - skipping GUI tests");
+ }
+
+ Q_INIT_RESOURCE(engines);
+ Q_INIT_RESOURCE(icon_theme);
+}
+
+void MainWindowFeaturesTest::cleanupTestCase()
+{
+ // Leave the shared isolated settings in the Copy/None state the rest of
+ // the suite expects, whatever the last test happened to pin.
+ AppSettings settings;
+ settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+ settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+ settings.setOcrEngine(AppSettings::OcrEngine::Tesseract);
+ QApplication::clipboard()->clear();
+}
+
+void MainWindowFeaturesTest::cleanup()
+{
+ QApplication::clipboard()->clear();
+}
+
+void MainWindowFeaturesTest::waitForTranslateButton(MainWindow &window)
+{
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.translateButton()->isEnabled();
+ },
+ 3000));
+}
+
+// Swap must exchange the edits' contents AND the language buttons' checked
+// languages - the translation direction has to flip with the text.
+void MainWindowFeaturesTest::testSwapSwapsTextsAndLanguages()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+ pinLocalAiSettings(server);
+ pinLlmOcrSettings(server); // keep prepareOcr() usable regardless of engine
+
+ MainWindow window;
+ typeText(window.sourceEdit(), QStringLiteral("Hello"));
+ waitForTranslateButton(window);
+ QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.translationEdit()->toPlainText() == QStringLiteral("mocked output");
+ },
+ 5000));
+
+ const Language sourceBefore = window.sourceLanguageButtons()->checkedLanguage();
+ const Language destBefore = window.translationLanguageButtons()->checkedLanguage();
+
+ QTest::mouseClick(window.swapButton(), Qt::LeftButton);
+
+ // The texts exchange places (the old source text follows to the
+ // translation side), and the checked languages flip with them.
+ QCOMPARE(window.sourceEdit()->toPlainText(), QStringLiteral("mocked output"));
+ QCOMPARE(window.translationEdit()->toPlainText(), QStringLiteral("Hello"));
+ QCOMPARE(window.sourceLanguageButtons()->checkedLanguage(), destBefore);
+ QCOMPARE(window.translationLanguageButtons()->checkedLanguage(), sourceBefore);
+}
+
+void MainWindowFeaturesTest::testClearButtonClearsBothEdits()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+ pinLocalAiSettings(server);
+
+ MainWindow window;
+ typeText(window.sourceEdit(), QStringLiteral("Hello"));
+ waitForTranslateButton(window);
+ QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return !window.translationEdit()->toPlainText().isEmpty();
+ },
+ 5000));
+
+ QToolButton *clearButton = window.findChild<QToolButton *>(QStringLiteral("clearButton"));
+ QVERIFY(clearButton != nullptr);
+ QTest::mouseClick(clearButton, Qt::LeftButton);
+
+ QVERIFY(window.sourceEdit()->toPlainText().isEmpty());
+ QVERIFY(window.translationEdit()->toPlainText().isEmpty());
+}
+
+void MainWindowFeaturesTest::testCopyButtons()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("mocked output")));
+ pinLocalAiSettings(server);
+
+ MainWindow window;
+ typeText(window.sourceEdit(), QStringLiteral("Hello"));
+ waitForTranslateButton(window);
+ QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return !window.translationEdit()->toPlainText().isEmpty();
+ },
+ 5000));
+
+ QTest::mouseClick(window.copySourceButton(), Qt::LeftButton);
+ QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("Hello"));
+
+ QTest::mouseClick(window.copyTranslationButton(), Qt::LeftButton);
+ QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("mocked output"));
+
+ // "Copy all" joins source and translation with a newline.
+ QTest::mouseClick(window.copyAllTranslationButton(), Qt::LeftButton);
+ QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("Hello\nmocked output"));
+}
+
+// Aborting a hung translation must land back at Ready with the strip Idle -
+// not Error: a deliberate abort is the user's own action, not a failure.
+void MainWindowFeaturesTest::testAbortReturnsToReadyWithoutError()
+{
+ MockHttpServer server;
+ server.queueResponse(hangResponse());
+ pinLocalAiSettings(server);
+
+ MainWindow window;
+ typeText(window.sourceEdit(), QStringLiteral("Hello"));
+ waitForTranslateButton(window);
+
+ bool sawBusy = false;
+ connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+ sawBusy = sawBusy || window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Busy;
+ });
+
+ QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.findChild<QToolButton *>(QStringLiteral("abortButton"))->isEnabled();
+ },
+ 5000));
+ QVERIFY(sawBusy);
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Busy);
+
+ QTest::mouseClick(window.findChild<QToolButton *>(QStringLiteral("abortButton")), Qt::LeftButton);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Idle;
+ },
+ 5000));
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+
+ // Existing behavior, deliberately pinned: MainWindow writes the abort
+ // error text into translationEdit (translatorStateChanged's Finished
+ // branch) even though the strip correctly reports Idle, not Error.
+ QVERIFY(window.translationEdit()->toPlainText().contains(QStringLiteral("Error")));
+}
+
+// Paste an image into sourceEdit: recognition starts immediately (dropped
+// image = transcribe it), the recognized text lands in sourceEdit, and with
+// auto-translate on the translation follows - the full offline chain.
+void MainWindowFeaturesTest::testPasteImageRecognizeAndAutoTranslate()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("HELLO FROM IMAGE")));
+ server.queueJson(200, chatCompletionJson(QStringLiteral("mocked translation")));
+ pinLocalAiSettings(server);
+ pinLlmOcrSettings(server);
+
+ AppSettings settings;
+ settings.setAutoTranslateEnabled(true);
+
+ MainWindow window;
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ bool sawOcrBusy = false;
+ connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+ sawOcrBusy = sawOcrBusy || window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Busy;
+ });
+
+ QApplication::clipboard()->setImage(solidImage());
+ // The eventFilter handles Ctrl+V for images: matches(QKeySequence::Paste)
+ // needs the Control modifier on the synthesized event.
+ QKeyEvent paste(QEvent::KeyPress, Qt::Key_V, Qt::ControlModifier);
+ QApplication::sendEvent(window.sourceEdit(), &paste);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.sourceEdit()->toPlainText() == QStringLiteral("HELLO FROM IMAGE");
+ },
+ 8000));
+ QVERIFY(sawOcrBusy);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.translationEdit()->toPlainText() == QStringLiteral("mocked translation");
+ },
+ 8000));
+}
+
+// Escape while recognition is still running cancels it: preview cleared,
+// language buttons re-enabled, strip back to Idle.
+void MainWindowFeaturesTest::testEscapeCancelsSourceImage()
+{
+ MockHttpServer server;
+ server.queueResponse(hangResponse()); // recognition hangs until cancelled
+ pinLocalAiSettings(server);
+ pinLlmOcrSettings(server);
+
+ MainWindow window;
+
+ QApplication::clipboard()->setImage(solidImage());
+ QKeyEvent paste(QEvent::KeyPress, Qt::Key_V, Qt::ControlModifier);
+ QApplication::sendEvent(window.sourceEdit(), &paste);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Busy;
+ },
+ 5000));
+ QVERIFY(!window.sourceLanguageButtons()->isEnabled());
+
+ sendKey(&window, Qt::Key_Escape);
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.moduleStatus()->activity(ModuleStatus::Module::Ocr) == ModuleStatus::Activity::Idle;
+ },
+ 5000));
+ QVERIFY(window.sourceEdit()->toPlainText().isEmpty());
+ QVERIFY(window.sourceLanguageButtons()->isEnabled());
+}
+
+// SettingsDialog accept() must persist the checkbox, restoreDefaults() must
+// reset it - the four-point pattern minus the two UI-only touch points.
+void MainWindowFeaturesTest::testShowStatusBarSettingsRoundTrip()
+{
+ MainWindow window;
+
+ {
+ SettingsDialog dialog(&window);
+ QCheckBox *box = dialog.findChild<QCheckBox *>(QStringLiteral("showStatusBarCheckBox"));
+ QVERIFY(box != nullptr);
+ QVERIFY(box->isChecked()); // default true
+
+ box->setChecked(false);
+ dialog.accept();
+ }
+ QCOMPARE(AppSettings().isShowStatusBar(), false);
+
+ {
+ SettingsDialog dialog(&window);
+ QCheckBox *box = dialog.findChild<QCheckBox *>(QStringLiteral("showStatusBarCheckBox"));
+ QVERIFY(box != nullptr);
+ QVERIFY(!box->isChecked());
+
+ // Private slot - reachable through the meta-object by name.
+ QVERIFY(QMetaObject::invokeMethod(&dialog, "restoreDefaults", Qt::DirectConnection));
+ QVERIFY(box->isChecked());
+ dialog.accept();
+ }
+ QCOMPARE(AppSettings().isShowStatusBar(), true);
+}
+
+// The user-visible regression this file guards: text inserted by OCR used to
+// leave the translate button disabled until the user typed something (a
+// space) - SourceTextEdit::replaceText() deliberately suppresses textEdited,
+// and textEdited is what drives every follow-up typed text gets. The
+// recognized handler must run them itself, with no keystroke involved.
+void MainWindowFeaturesTest::testRecognizedTextActivatesUiWithoutKeystroke()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("en"))); // detection response
+ pinLocalAiSettings(server);
+ pinLlmOcrSettings(server);
+ AppSettings settings;
+ settings.setDetectProvider(QStringLiteral("openai_custom"));
+ settings.setDetectModel(QStringLiteral("mock-model"));
+
+ MainWindow window;
+ QCheckBox *autoTranslate = window.findChild<QCheckBox *>(QStringLiteral("autoTranslateCheckBox"));
+ QVERIFY(autoTranslate != nullptr);
+ autoTranslate->setChecked(false); // pin: a prior test may have left it on
+
+ QVERIFY(!window.translateButton()->isEnabled()); // empty source: disabled
+
+ bool sawDetecting = false;
+ connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+ sawDetecting = sawDetecting || window.moduleStatus()->message(ModuleStatus::Module::Translation) == QStringLiteral("Detecting language");
+ });
+
+ // Not a keystroke - the real engine signal, exactly as recognize()
+ // delivers it. With auto-translate off, the follow-up is standalone
+ // language detection (updateAutoLocales), which is async against the
+ // mock and must show in the strip while in flight.
+ emit window.ocr()->recognized(QStringLiteral("Hello from OCR"));
+
+ QCOMPARE(window.sourceEdit()->toPlainText(), QStringLiteral("Hello from OCR"));
+ QVERIFY(window.translateButton()->isEnabled());
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.moduleStatus()->activity(ModuleStatus::Module::Translation) == ModuleStatus::Activity::Idle;
+ },
+ 5000));
+ QVERIFY(sawDetecting);
+}
+
+// Same path with auto-translate on: the recognized text must chain straight
+// into a translation, like typed text does - no keystroke, no button click.
+void MainWindowFeaturesTest::testRecognizedTextAutoTranslatesWhenEnabled()
+{
+ MockHttpServer server;
+ server.queueJson(200, chatCompletionJson(QStringLiteral("mocked translation")));
+ pinLocalAiSettings(server);
+ pinLlmOcrSettings(server);
+
+ MainWindow window;
+ QCheckBox *autoTranslate = window.findChild<QCheckBox *>(QStringLiteral("autoTranslateCheckBox"));
+ QVERIFY(autoTranslate != nullptr);
+ autoTranslate->setChecked(true);
+
+ emit window.ocr()->recognized(QStringLiteral("Hello from OCR"));
+
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.translationEdit()->toPlainText() == QStringLiteral("mocked translation");
+ },
+ 5000));
+ QVERIFY(window.translateButton()->isEnabled());
+}
+
+int main(int argc, char *argv[])
+{
+ isolateTestSettings();
+
+ SingleApplication app(argc, argv, true);
+ MainWindowFeaturesTest tc;
+ QTEST_SET_MAIN_SOURCE_PATH
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_mainwindow_features.moc"
diff --git a/tests/test_mainwindow_status.cpp b/tests/test_mainwindow_status.cpp
new file mode 100644
index 00000000..48c3458c
--- /dev/null
+++ b/tests/test_mainwindow_status.cpp
@@ -0,0 +1,238 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// GUI-level integration tests for the module status system, driven through a
+// real MainWindow on the Copy backend (fully synchronous, no network):
+// what moduleStatus() reports while a translation cascades through
+// MainWindow's own stateChanged/translationAccepted/resetTranslator wiring,
+// what the pop-up's strip does with it, and the Interface/ShowStatusBar
+// pick-up in loadAppSettings().
+//
+// Copy's translate() is synchronous, so its Busy interval only exists during
+// the emission cascade - observing it requires recording snapshots from a
+// changed() handler connected to the model (a direct connection fires inside
+// the cascade), not sampling after the click returns.
+
+#include "language.h"
+#include "mainwindow.h"
+#include "modulestatus.h"
+#include "popupwindow.h"
+#include "singleapplication.h"
+#include "sourcetextedit.h"
+#include "statusstrip.h"
+#include "testisolation.h"
+#include "settings/appsettings.h"
+#include "translator/atranslationprovider.h"
+#include "tts/attsprovider.h"
+
+#include <QApplication>
+#include <QKeyEvent>
+#include <QLocale>
+#include <QStatusBar>
+#include <QTest>
+
+#include <algorithm>
+#include <vector>
+
+class MainWindowStatusTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void initTestCase();
+ void cleanup();
+
+ void testModelExposedAndTtsUnavailableWithNone();
+ void testBusyObservedDuringTranslateClick();
+ void testStickyErrorThroughRealCascade();
+ void testStickyErrorClearedByNextTranslation();
+ void testPopupStripOnlyVisibleWhileBusy();
+ void testShowStatusBarSettingPickUp();
+
+private:
+ static void pinCopySettings();
+};
+
+void MainWindowStatusTest::initTestCase()
+{
+ if (qgetenv("DISPLAY").isEmpty() && qgetenv("WAYLAND_DISPLAY").isEmpty()) {
+ QSKIP("No display server available - skipping GUI tests");
+ }
+
+ Q_INIT_RESOURCE(engines);
+ Q_INIT_RESOURCE(icon_theme);
+
+ pinCopySettings();
+}
+
+void MainWindowStatusTest::cleanup()
+{
+ // Backends and languages drift per test; re-pin for the next one.
+ pinCopySettings();
+}
+
+void MainWindowStatusTest::pinCopySettings()
+{
+ AppSettings settings;
+ settings.setShowPrivacyPopup(false);
+ settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+ settings.setTTSProviderBackend(ATTSProvider::ProviderBackend::None);
+ settings.setWindowMode(AppSettings::MainWindow);
+
+ const Language systemLanguage(QLocale::system());
+ settings.setLanguages(AppSettings::Source, {systemLanguage});
+ settings.setLanguages(AppSettings::Translation, {systemLanguage});
+ settings.setCheckedButton(AppSettings::Source, 0);
+ settings.setCheckedButton(AppSettings::Translation, 0);
+}
+
+void MainWindowStatusTest::testModelExposedAndTtsUnavailableWithNone()
+{
+ MainWindow window;
+ QVERIFY(window.moduleStatus() != nullptr);
+ QVERIFY(!window.moduleStatus()->isBusy());
+
+ // TTS backend None: NoopTTSProvider never emits and the strip omits the
+ // segment entirely.
+ QVERIFY(!window.moduleStatus()->isAvailable(ModuleStatus::Module::Tts));
+
+ // The strip itself lives in the main window's status bar.
+ QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+ QVERIFY(bar != nullptr);
+ QVERIFY(bar->findChild<StatusStrip *>() != nullptr);
+}
+
+void MainWindowStatusTest::testBusyObservedDuringTranslateClick()
+{
+ MainWindow window;
+ window.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&window));
+
+ std::vector<ModuleStatus::Activity> seen;
+ std::vector<QString> messages;
+ connect(window.moduleStatus(), &ModuleStatus::changed, [&]() {
+ seen.push_back(window.moduleStatus()->activity(ModuleStatus::Module::Translation));
+ messages.push_back(window.moduleStatus()->message(ModuleStatus::Module::Translation));
+ });
+
+ // Same typeText-then-click flow as test_translation.cpp's
+ // testGuiTranslation; sourceEdit's textEdited is debounced, so wait for
+ // the translate button to arm first.
+ for (const QChar &ch : QStringLiteral("Hello World")) {
+ QKeyEvent press(QEvent::KeyPress, Qt::Key_unknown, Qt::NoModifier, QString(ch));
+ QKeyEvent release(QEvent::KeyRelease, Qt::Key_unknown, Qt::NoModifier, QString(ch));
+ QApplication::sendEvent(window.sourceEdit(), &press);
+ QApplication::sendEvent(window.sourceEdit(), &release);
+ }
+ QVERIFY(QTest::qWaitFor([&window]() {
+ return window.translateButton()->isEnabled();
+ },
+ 3000));
+ QTest::mouseClick(window.translateButton(), Qt::LeftButton);
+
+ QVERIFY(window.translationEdit()->toPlainText() == QStringLiteral("Hello World"));
+ QVERIFY(std::find(seen.begin(), seen.end(), ModuleStatus::Activity::Busy) != seen.end());
+ QVERIFY(std::find(messages.begin(), messages.end(), QStringLiteral("Translating")) != messages.end());
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+ QVERIFY(!window.moduleStatus()->isBusy());
+}
+
+// A Finished-with-error runs the full synchronous cascade -
+// translatorStateChanged() writes the error into translationEdit and emits
+// resetTranslator(), whose reset() lands on Ready in the same call stack -
+// and the model must still report the error afterwards. This is the
+// integration twin of test_modulestatus's stickiness test, through
+// MainWindow's real wiring instead of a bare provider.
+void MainWindowStatusTest::testStickyErrorThroughRealCascade()
+{
+ MainWindow window;
+
+ // translationRequested is a public signal in Qt 6; emitting it drives
+ // MainWindow::handleTranslationRequest exactly like the translate button
+ // does, but with a language pair the Copy backend rejects.
+ emit window.translationRequested(QStringLiteral("hello"),
+ Language(QLocale::French),
+ Language(QLocale::English));
+
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Error);
+ QVERIFY(!window.moduleStatus()->detail(ModuleStatus::Module::Translation).isEmpty());
+ QVERIFY(window.translationEdit()->toPlainText().contains(QStringLiteral("Error")));
+}
+
+void MainWindowStatusTest::testStickyErrorClearedByNextTranslation()
+{
+ MainWindow window;
+
+ emit window.translationRequested(QStringLiteral("hello"),
+ Language(QLocale::French),
+ Language(QLocale::English));
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Error);
+
+ // A matching pair reaches Processed/NoError; the cascade ends Idle and
+ // the sticky error is gone.
+ emit window.translationRequested(QStringLiteral("hello"),
+ Language(QLocale::English),
+ Language(QLocale::English));
+ QCOMPARE(window.moduleStatus()->activity(ModuleStatus::Module::Translation), ModuleStatus::Activity::Idle);
+ QVERIFY(window.translationEdit()->toPlainText() == QStringLiteral("hello"));
+}
+
+// The pop-up window's strip mirrors the main window's model and only appears
+// while something is running, so the resting pop-up is unchanged.
+void MainWindowStatusTest::testPopupStripOnlyVisibleWhileBusy()
+{
+ MainWindow window;
+ window.hide();
+
+ PopupWindow popup(&window);
+ StatusStrip *strip = popup.findChild<StatusStrip *>(QStringLiteral("statusStrip"));
+ QVERIFY(strip != nullptr);
+ QVERIFY(strip->isHidden()); // at rest
+
+ // beginScreenCapture() is a public slot on the model; it is the one
+ // moment with no provider signal to hang off.
+ window.moduleStatus()->beginScreenCapture();
+ QVERIFY(!strip->isHidden());
+}
+
+// Interface/ShowStatusBar pick-up happens in loadAppSettings(), which re-runs
+// after the settings dialog is accepted - each new window reads it fresh.
+void MainWindowStatusTest::testShowStatusBarSettingPickUp()
+{
+ AppSettings settings;
+
+ settings.setShowStatusBar(false);
+ {
+ MainWindow window;
+ QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+ QVERIFY(bar != nullptr);
+ QVERIFY(bar->isHidden());
+
+ // The setting hides the whole bar; the strip inside goes with it and
+ // nothing the model does can bring it back.
+ window.moduleStatus()->beginScreenCapture();
+ QVERIFY(bar->isHidden());
+ }
+
+ settings.setShowStatusBar(true);
+ {
+ MainWindow window;
+ QStatusBar *bar = window.findChild<QStatusBar *>(QStringLiteral("statusbar"));
+ QVERIFY(bar != nullptr);
+ QVERIFY(!bar->isHidden());
+ }
+}
+
+int main(int argc, char *argv[])
+{
+ isolateTestSettings();
+
+ SingleApplication app(argc, argv, true);
+ MainWindowStatusTest tc;
+ QTEST_SET_MAIN_SOURCE_PATH
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_mainwindow_status.moc"
diff --git a/tests/test_modulestatus.cpp b/tests/test_modulestatus.cpp
new file mode 100644
index 00000000..6da4254f
--- /dev/null
+++ b/tests/test_modulestatus.cpp
@@ -0,0 +1,539 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Headless contract tests for the ModuleStatus aggregator: every provider
+// signal is driven directly (they are public), TTS is exercised through the
+// pull-seeded bind path because ATTSProvider::stateChanged is a private
+// signal. Nothing is ever shown, so no display server is needed beyond what
+// QApplication itself requires.
+
+#include "language.h"
+#include "modulestatus.h"
+#include "provideroptions.h"
+#include "ocr/aocrprovider.h"
+#include "ocr/screengrabbers/genericscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "translator/atranslationprovider.h"
+#include "translator/copytranslationprovider.h"
+#include "tts/attsprovider.h"
+#include "tts/noopttsprovider.h"
+#include "tts/voice.h"
+
+#include <QApplication>
+#include <QImage>
+#include <QPixmap>
+#include <QScreen>
+#include <QSignalSpy>
+#include <QTest>
+#include <QTextToSpeech>
+
+// The smallest AOcrProvider implementation; the model only ever talks to the
+// base-class signals, and driving those directly keeps this test independent
+// of Tesseract/LLM setup.
+class StubOcr : public AOcrProvider
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(StubOcr)
+
+public:
+ using AOcrProvider::AOcrProvider;
+
+ QString engineName() const override
+ {
+ return QStringLiteral("stub");
+ }
+ bool isConfigured() const override
+ {
+ return true;
+ }
+ void recognize(const QImage &, int) override
+ {
+ emit started();
+ }
+ void cancel() override
+ {
+ }
+};
+
+// The smallest ATTSProvider implementation that can report a chosen state;
+// ATTSProvider::stateChanged is private, so pull-seeding is the only
+// observable path for bindTtsProvider() anyway.
+class StubTtsProvider : public ATTSProvider
+{
+ Q_OBJECT
+ Q_DISABLE_COPY(StubTtsProvider)
+
+public:
+ explicit StubTtsProvider(QTextToSpeech::State state, QObject *parent = nullptr)
+ : ATTSProvider(parent)
+ , m_state(state)
+ {
+ }
+
+ QString getProviderType() const override
+ {
+ return QStringLiteral("StubTtsProvider");
+ }
+ void say(const QString &) override
+ {
+ }
+ void stop() override
+ {
+ }
+ void pause() override
+ {
+ }
+ void resume() override
+ {
+ }
+ QTextToSpeech::State state() const override
+ {
+ return m_state;
+ }
+ QTextToSpeech::ErrorReason errorReason() const override
+ {
+ return QTextToSpeech::ErrorReason::NoError;
+ }
+ QString errorString() const override
+ {
+ return QStringLiteral("stub tts failure");
+ }
+ Language language() const override
+ {
+ return Language::autoLanguage();
+ }
+ void setLanguage(const Language &) override
+ {
+ }
+ Voice voice() const override
+ {
+ return {};
+ }
+ void setVoice(const Voice &) override
+ {
+ }
+ QList<Voice> availableVoices() const override
+ {
+ return {};
+ }
+ QList<Voice> findVoices(const Language &) const override
+ {
+ return {};
+ }
+ double rate() const override
+ {
+ return 0.0;
+ }
+ void setRate(double) override
+ {
+ }
+ double pitch() const override
+ {
+ return 0.0;
+ }
+ void setPitch(double) override
+ {
+ }
+ double volume() const override
+ {
+ return 1.0;
+ }
+ void setVolume(double) override
+ {
+ }
+ QList<Language> availableLanguages() const override
+ {
+ return {};
+ }
+ void applyOptions(const ProviderOptions &) override
+ {
+ }
+ std::unique_ptr<ProviderOptions> getDefaultOptions() const override
+ {
+ return std::make_unique<ProviderOptions>();
+ }
+ QStringList getAvailableOptions() const override
+ {
+ return {};
+ }
+ ProviderUIRequirements getUIRequirements() const override
+ {
+ return {};
+ }
+ QStringList availableSpeakers() const override
+ {
+ return {};
+ }
+ QStringList availableSpeakersForVoice(const Voice &) const override
+ {
+ return {};
+ }
+ QString currentSpeaker() const override
+ {
+ return {};
+ }
+ void setSpeaker(const QString &) override
+ {
+ }
+
+private:
+ QTextToSpeech::State m_state;
+};
+
+class ModuleStatusTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testTranslationStates();
+ void testTranslationErrorStickiness();
+ void testTranslationAbortIsNotAnError();
+ void testDetectionStates();
+ void testOcrStates();
+ void testBothOcrEnginesShareOneSegment();
+ void testCaptureStates();
+ void testTtsPullSeeding();
+ void testTtsNoopUnavailable();
+ void testIsBusy();
+ void testChangedSignal();
+ void testRebindingClearsStickyState();
+
+private:
+ static constexpr auto s_translating = ModuleStatus::Module::Translation;
+ static constexpr auto s_ocr = ModuleStatus::Module::Ocr;
+ static constexpr auto s_snipping = ModuleStatus::Module::Snipping;
+ static constexpr auto s_tts = ModuleStatus::Module::Tts;
+};
+
+// Copy's translate() drives the whole state machine synchronously: success is
+// Processing -> Processed/NoError, a language mismatch is Processing ->
+// Finished/UnsupportedDstLanguage. "Busy" is only observable during the
+// emission itself, so record it from a stateChanged handler connected after
+// the model's own (same-emitter direct connections run in creation order).
+void ModuleStatusTest::testTranslationStates()
+{
+ ModuleStatus status;
+ CopyTranslationProvider translator;
+
+ QSignalSpy changedSpy(&status, &ModuleStatus::changed);
+ status.bindTranslator(&translator);
+ QVERIFY(changedSpy.isEmpty()); // Ready seeds Idle, not Busy
+
+ ModuleStatus::Activity seenWhileProcessing = ModuleStatus::Activity::Idle;
+ QString messageWhileProcessing;
+ connect(&translator, &ATranslationProvider::stateChanged, &translator, [&](ATranslationProvider::State state) {
+ if (state == ATranslationProvider::State::Processing) {
+ seenWhileProcessing = status.activity(s_translating);
+ messageWhileProcessing = status.message(s_translating);
+ }
+ });
+
+ const Language lang(QLocale::system());
+ translator.translate(QStringLiteral("hello"), lang, lang);
+ QCOMPARE(seenWhileProcessing, ModuleStatus::Activity::Busy);
+ QCOMPARE(messageWhileProcessing, QStringLiteral("Translating"));
+
+ // The synchronous cascade to Processed/Finished/Ready leaves it Idle.
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+ QVERIFY(status.message(s_translating).isEmpty());
+
+ translator.reset();
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testTranslationErrorStickiness()
+{
+ ModuleStatus status;
+ CopyTranslationProvider translator;
+ status.bindTranslator(&translator);
+
+ // Finished-with-error: the source/destination mismatch path.
+ translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+ QCOMPARE(status.message(s_translating), QStringLiteral("Translation failed"));
+ QVERIFY(!status.detail(s_translating).isEmpty());
+
+ // The synchronous trailing Ready (reset()) must not overwrite the error.
+ translator.reset();
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+
+ // The next Busy clears it.
+ const Language lang(QLocale::system());
+ translator.translate(QStringLiteral("hello"), lang, lang);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+ translator.reset();
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+// reset() on a Processed provider goes through abort(): Finished/Aborted ->
+// Ready. A deliberate abort is not a failure and must never read as Error -
+// not even transiently, during the Finished emission itself.
+void ModuleStatusTest::testTranslationAbortIsNotAnError()
+{
+ ModuleStatus status;
+ CopyTranslationProvider translator;
+ status.bindTranslator(&translator);
+
+ ModuleStatus::Activity seenAtFinished = ModuleStatus::Activity::Idle;
+ connect(&translator, &ATranslationProvider::stateChanged, &translator, [&](ATranslationProvider::State state) {
+ if (state == ATranslationProvider::State::Finished)
+ seenAtFinished = status.activity(s_translating);
+ });
+
+ const Language lang(QLocale::system());
+ translator.translate(QStringLiteral("hello"), lang, lang);
+ QCOMPARE(translator.getState(), ATranslationProvider::State::Processed);
+
+ translator.reset();
+ QCOMPARE(translator.error, ATranslationProvider::TranslationError::NoError); // reset() clears it
+ QCOMPARE(seenAtFinished, ModuleStatus::Activity::Idle);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+// Language detection in the strip: a standalone detection is the only
+// translation-module work in flight and shows as Busy "Detecting language";
+// a detection chained inside a translation must not clobber "Translating";
+// any stateChanged is the abort/cancel safety net; and like any Busy it
+// clears a sticky error.
+void ModuleStatusTest::testDetectionStates()
+{
+ ModuleStatus status;
+ CopyTranslationProvider translator;
+ status.bindTranslator(&translator);
+
+ emit translator.detectionStarted();
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_translating), QStringLiteral("Detecting language"));
+
+ emit translator.languageDetected(Language(QLocale::English), false);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+ // Chained detection (detect-then-translate): "Translating" is already
+ // up, so detectionStarted is not new work, and its languageDetected
+ // must not end the translation.
+ emit translator.stateChanged(ATranslationProvider::State::Processing);
+ QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+ emit translator.detectionStarted();
+ QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+ emit translator.languageDetected(Language(QLocale::English), true);
+ QCOMPARE(status.message(s_translating), QStringLiteral("Translating"));
+
+ // Safety net: a state transition with no languageDetected at all (the
+ // abort path) still demotes the detection Busy.
+ emit translator.stateChanged(ATranslationProvider::State::Ready);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+
+ // A detection Busy clears a sticky error, like any other Busy.
+ translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+ emit translator.detectionStarted();
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_translating), QStringLiteral("Detecting language"));
+ emit translator.languageDetected(Language(QLocale::English), false);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testOcrStates()
+{
+ ModuleStatus status;
+ StubOcr engine;
+ status.bindOcr(&engine, nullptr);
+
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+ engine.recognize(QImage(), 96);
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_ocr), QStringLiteral("Recognizing text"));
+
+ emit engine.canceled();
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+ emit engine.recognized(QStringLiteral("text"));
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+ engine.recognize(QImage(), 96);
+ emit engine.failed(QStringLiteral("boom"));
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Error);
+ QCOMPARE(status.message(s_ocr), QStringLiteral("OCR failed"));
+ QCOMPARE(status.detail(s_ocr), QStringLiteral("boom"));
+
+ // Sticky until the next Busy.
+ emit engine.recognized(QStringLiteral("text"));
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Error);
+
+ engine.recognize(QImage(), 96);
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+ emit engine.recognized(QStringLiteral("text"));
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testCaptureStates()
+{
+ ModuleStatus status;
+ GenericScreenGrabber grabber;
+ SnippingArea snippingArea;
+ status.bindCapture(&grabber, &snippingArea);
+
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+ status.beginScreenCapture();
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_snipping), QStringLiteral("Waiting for capture"));
+
+ emit grabber.grabbed({});
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_snipping), QStringLiteral("Select a region"));
+
+ emit snippingArea.snipped(QPixmap(), 96);
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+ status.beginScreenCapture();
+ emit snippingArea.cancelled();
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Idle);
+
+ status.beginScreenCapture();
+ emit grabber.grabbingFailed();
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Error);
+ QCOMPARE(status.message(s_snipping), QStringLiteral("Capture failed"));
+
+ // The error survives the next idle transition and clears on the next Busy.
+ emit snippingArea.cancelled();
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Error);
+ status.beginScreenCapture();
+ QCOMPARE(status.activity(s_snipping), ModuleStatus::Activity::Busy);
+}
+
+// ATTSProvider::stateChanged is private, so bindTtsProvider()'s pull-seeding
+// is the only path a test can observe.
+void ModuleStatusTest::testTtsPullSeeding()
+{
+ ModuleStatus status;
+
+ StubTtsProvider speaking(QTextToSpeech::Speaking);
+ status.bindTtsProvider(&speaking);
+ QVERIFY(status.isAvailable(s_tts));
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_tts), QStringLiteral("Speaking"));
+
+ StubTtsProvider synthesizing(QTextToSpeech::Synthesizing);
+ status.bindTtsProvider(&synthesizing);
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+ QCOMPARE(status.message(s_tts), QStringLiteral("Preparing speech"));
+
+ StubTtsProvider ready(QTextToSpeech::Ready);
+ status.bindTtsProvider(&ready);
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+
+ StubTtsProvider failed(QTextToSpeech::Error);
+ status.bindTtsProvider(&failed);
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Error);
+ QCOMPARE(status.detail(s_tts), QStringLiteral("stub tts failure"));
+}
+
+void ModuleStatusTest::testTtsNoopUnavailable()
+{
+ ModuleStatus status;
+ NoopTTSProvider noop;
+
+ QVERIFY(!status.isAvailable(s_tts)); // not bound at all
+ status.bindTtsProvider(&noop);
+ QVERIFY(!status.isAvailable(s_tts));
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+}
+
+void ModuleStatusTest::testIsBusy()
+{
+ ModuleStatus status;
+ QVERIFY(!status.isBusy());
+
+ StubTtsProvider speaking(QTextToSpeech::Speaking);
+ status.bindTtsProvider(&speaking);
+ QVERIFY(status.isBusy());
+
+ StubTtsProvider ready(QTextToSpeech::Ready);
+ status.bindTtsProvider(&ready);
+ QVERIFY(!status.isBusy());
+
+ // An Error is not Busy - the ellipsis timer must not run for it.
+ CopyTranslationProvider translator;
+ status.bindTranslator(&translator);
+ translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+ QVERIFY(!status.isBusy());
+}
+
+void ModuleStatusTest::testChangedSignal()
+{
+ ModuleStatus status;
+ QSignalSpy changedSpy(&status, &ModuleStatus::changed);
+
+ status.beginScreenCapture();
+ QCOMPARE(changedSpy.count(), 1);
+
+ // Redundant transitions must not spam the view into repainting.
+ status.beginScreenCapture();
+ QCOMPARE(changedSpy.count(), 1);
+}
+
+// The strip has a single OCR segment, but both engines are bound (the active
+// one switches per settings read). A terminal from the engine that didn't
+// start the run still clears the segment - the engines never run
+// concurrently, so any terminal ends "recognizing".
+void ModuleStatusTest::testBothOcrEnginesShareOneSegment()
+{
+ ModuleStatus status;
+ StubOcr tesseract;
+ StubOcr llm;
+ status.bindOcr(&tesseract, &llm);
+
+ tesseract.recognize(QImage(), 96);
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+ emit llm.recognized(QStringLiteral("text"));
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+
+ llm.recognize(QImage(), 96);
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Busy);
+
+ emit tesseract.canceled();
+ QCOMPARE(status.activity(s_ocr), ModuleStatus::Activity::Idle);
+}
+
+// swapTranslator()/swapTTSProvider() rebind mid-flight: a freshly bound
+// provider owes nothing to its predecessor's sticky error or busy state.
+void ModuleStatusTest::testRebindingClearsStickyState()
+{
+ ModuleStatus status;
+
+ CopyTranslationProvider failing;
+ status.bindTranslator(&failing);
+ failing.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Error);
+
+ CopyTranslationProvider fresh;
+ status.bindTranslator(&fresh);
+ QCOMPARE(status.activity(s_translating), ModuleStatus::Activity::Idle);
+ QVERIFY(status.message(s_translating).isEmpty());
+
+ StubTtsProvider speaking(QTextToSpeech::Speaking);
+ status.bindTtsProvider(&speaking);
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Busy);
+
+ StubTtsProvider ready(QTextToSpeech::Ready);
+ status.bindTtsProvider(&ready);
+ QCOMPARE(status.activity(s_tts), ModuleStatus::Activity::Idle);
+}
+
+int main(int argc, char *argv[])
+{
+ QApplication app(argc, argv);
+ ModuleStatusTest tc;
+ QTEST_SET_MAIN_SOURCE_PATH
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_modulestatus.moc"
diff --git a/tests/test_settingsdialog_localai.cpp b/tests/test_settingsdialog_localai.cpp
index 13f3b691..78efb9cd 100644
--- a/tests/test_settingsdialog_localai.cpp
+++ b/tests/test_settingsdialog_localai.cpp
@@ -17,6 +17,7 @@
#include "translator/atranslationprovider.h"
#include <QComboBox>
+#include <QGroupBox>
#include <QLineEdit>
#include <QListWidget>
#include <QPushButton>
@@ -90,9 +91,8 @@ private slots:
QVERIFY(urlEdit != nullptr);
urlEdit->setText(server.baseUrl());
- // The tab now has separate Text/Vision model combos (vision-mode UI);
- // the dialog defaults to the Text sub-page, which is what "Refresh
- // models" against a plain chat-completions mock exercises.
+ // The tab has a single text-model combo; "Refresh models" against a
+ // plain chat-completions mock exercises the /v1/models probe.
auto *modelCombo = ollamaPage->findChild<QComboBox *>(QStringLiteral("localAiTextModelCombo"));
QVERIFY(modelCombo != nullptr);
@@ -161,6 +161,52 @@ private slots:
QCOMPARE(restoredItems, originalItems);
}
+
+ // The OCR settings page's Tesseract-only widgets (languages,
+ // parameters) must only show up when Tesseract is the selected engine;
+ // the engine-agnostic screen-capture options stay visible either way.
+ void testOcrEngineVisibilitySwitchesTesseractSpecificGroups()
+ {
+ AppSettings settings;
+ settings.setTranslationProviderBackend(ATranslationProvider::ProviderBackend::Copy);
+
+ MainWindow window;
+ SettingsDialog dialog(&window);
+ dialog.show();
+ QVERIFY(QTest::qWaitForWindowExposed(&dialog));
+
+ auto *ocrPage = dialog.findChild<QWidget *>(QStringLiteral("ocrPage"));
+ QVERIFY(ocrPage != nullptr);
+ auto *pagesStack = dialog.findChild<QStackedWidget *>(QStringLiteral("pagesStackedWidget"));
+ QVERIFY(pagesStack != nullptr);
+ auto *pagesList = dialog.findChild<QListWidget *>(QStringLiteral("pagesListWidget"));
+ QVERIFY(pagesList != nullptr);
+ pagesList->setCurrentRow(pagesStack->indexOf(ocrPage));
+
+ auto *engineCombo = dialog.findChild<QComboBox *>(QStringLiteral("ocrEngineCombo"));
+ QVERIFY(engineCombo != nullptr);
+ auto *languagesGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("languagesGroupBox"));
+ QVERIFY(languagesGroupBox != nullptr);
+ auto *ocrParametersGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("ocrParametersGroupBox"));
+ QVERIFY(ocrParametersGroupBox != nullptr);
+ auto *screenCaptureGroupBox = dialog.findChild<QGroupBox *>(QStringLiteral("screenCaptureGroupBox"));
+ QVERIFY(screenCaptureGroupBox != nullptr);
+
+ const int tesseractIndex = engineCombo->findData(static_cast<int>(AppSettings::OcrEngine::Tesseract));
+ const int llmIndex = engineCombo->findData(static_cast<int>(AppSettings::OcrEngine::Llm));
+ QVERIFY(tesseractIndex >= 0);
+ QVERIFY(llmIndex >= 0);
+
+ engineCombo->setCurrentIndex(tesseractIndex);
+ QVERIFY(languagesGroupBox->isVisible());
+ QVERIFY(ocrParametersGroupBox->isVisible());
+ QVERIFY(screenCaptureGroupBox->isVisible());
+
+ engineCombo->setCurrentIndex(llmIndex);
+ QVERIFY(!languagesGroupBox->isVisible());
+ QVERIFY(!ocrParametersGroupBox->isVisible());
+ QVERIFY(screenCaptureGroupBox->isVisible());
+ }
};
int main(int argc, char *argv[])
diff --git a/tests/test_snippingarea_terminal.cpp b/tests/test_snippingarea_terminal.cpp
new file mode 100644
index 00000000..b9dd7a4b
--- /dev/null
+++ b/tests/test_snippingarea_terminal.cpp
@@ -0,0 +1,104 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Regression tests for SnippingArea's terminal-signal contract: every exit
+// from a snip must emit either snipped or cancelled. Before the fix,
+// acceptSelection() with an empty selection (Enter without selecting, or a
+// drag released without moving) hid the widget while emitting *nothing*,
+// leaving a pending translate-screen-area armed for the next recognition and
+// the status strip stuck on "Select a region".
+//
+// Driven entirely through QApplication::sendEvent() on a never-shown widget,
+// so no display interaction is needed beyond QApplication itself.
+
+#include "ocr/snippingarea.h"
+
+#include <QApplication>
+#include <QKeyEvent>
+#include <QMouseEvent>
+#include <QSignalSpy>
+#include <QTest>
+
+class SnippingAreaTerminalTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testEnterWithEmptySelectionEmitsCancelled();
+ void testEscapeEmitsCancelled();
+ void testDragReleasedWithoutMoveEmitsCancelled();
+
+private:
+ static void sendKey(QWidget *receiver, int key);
+ static void sendMouse(QWidget *receiver, QEvent::Type type, const QPoint &pos);
+};
+
+// Enter with no selection made: acceptSelection() takes the empty branch.
+void SnippingAreaTerminalTest::testEnterWithEmptySelectionEmitsCancelled()
+{
+ SnippingArea area;
+ area.setRegionRememberType(AppSettings::NeverRemember);
+
+ QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+ sendKey(&area, Qt::Key_Return);
+
+ QCOMPARE(cancelledSpy.count(), 1);
+}
+
+// The long-standing Escape path, pinned so the "always a terminal" contract
+// has both halves locked down.
+void SnippingAreaTerminalTest::testEscapeEmitsCancelled()
+{
+ SnippingArea area;
+
+ QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+ sendKey(&area, Qt::Key_Escape);
+
+ QCOMPARE(cancelledSpy.count(), 1);
+}
+
+// Start a region drag and release without moving: mouseReleaseEvent takes the
+// Outside + confirmOnRelease path into acceptSelection() with an empty
+// selection - the manual "click without drag" scenario, driven headlessly.
+void SnippingAreaTerminalTest::testDragReleasedWithoutMoveEmitsCancelled()
+{
+ SnippingArea area;
+ area.setRegionRememberType(AppSettings::NeverRemember);
+ area.setCaptureOnRelese(true);
+
+ QSignalSpy cancelledSpy(&area, &SnippingArea::cancelled);
+ sendMouse(&area, QEvent::MouseButtonPress, QPoint(100, 100));
+ sendMouse(&area, QEvent::MouseButtonRelease, QPoint(100, 100));
+
+ QCOMPARE(cancelledSpy.count(), 1);
+}
+
+void SnippingAreaTerminalTest::sendKey(QWidget *receiver, int key)
+{
+ QKeyEvent press(QEvent::KeyPress, key, Qt::NoModifier);
+ QKeyEvent release(QEvent::KeyRelease, key, Qt::NoModifier);
+ QApplication::sendEvent(receiver, &press);
+ QApplication::sendEvent(receiver, &release);
+}
+
+void SnippingAreaTerminalTest::sendMouse(QWidget *receiver, QEvent::Type type, const QPoint &pos)
+{
+ const QPointF local(pos);
+ const QPointF global(receiver->mapToGlobal(pos));
+ QMouseEvent event(type, local, global, Qt::LeftButton,
+ type == QEvent::MouseButtonPress ? Qt::LeftButton : Qt::NoButton,
+ Qt::NoModifier);
+ QApplication::sendEvent(receiver, &event);
+}
+
+int main(int argc, char *argv[])
+{
+ QApplication app(argc, argv);
+ SnippingAreaTerminalTest tc;
+ QTEST_SET_MAIN_SOURCE_PATH
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_snippingarea_terminal.moc"
diff --git a/tests/test_statusstrip.cpp b/tests/test_statusstrip.cpp
new file mode 100644
index 00000000..6f393030
--- /dev/null
+++ b/tests/test_statusstrip.cpp
@@ -0,0 +1,278 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// View-contract tests for StatusStrip. The strip is never shown; all
+// visibility assertions use isHidden()/!isHidden(), which reflect the
+// explicit show/hide calls synchronously without depending on window
+// exposure. Busy/error states are driven through ModuleStatus with providers
+// that hold those states persistently (screen capture via beginScreenCapture,
+// a synchronous Copy translation error), so no timing races are involved -
+// except the ellipsis animation, which is the one thing that genuinely needs
+// the timer and gets a qWaitFor().
+
+#include "language.h"
+#include "modulestatus.h"
+#include "statusstrip.h"
+#include "ocr/screengrabbers/genericscreengrabber.h"
+#include "ocr/snippingarea.h"
+#include "translator/copytranslationprovider.h"
+#include "tts/noopttsprovider.h"
+
+#include <QApplication>
+#include <QLabel>
+#include <QLocale>
+#include <QTest>
+
+class StatusStripTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testReadyAtRest();
+ void testBusySegment();
+ void testErrorSegmentShowsDetailInTooltip();
+ void testSimultaneousBusyAndErrorSegments();
+ void testHideWhenIdle();
+ void testSetShownOverridesModel();
+ void testTtsSegmentOmittedForNoopBackend();
+ void testAnimatedEllipsisWhileBusy();
+ void testDotsStopWhenIdle();
+ void testHiddenStripHidesItsLabels();
+
+private:
+ static constexpr auto s_snipping = ModuleStatus::Module::Snipping;
+ static constexpr auto s_translating = ModuleStatus::Module::Translation;
+
+ static QLabel *segmentLabel(const StatusStrip *strip, ModuleStatus::Module module);
+ static void driveTranslationError(ModuleStatus &model);
+};
+
+void StatusStripTest::testReadyAtRest()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ // Not hide-when-idle (the main window configuration): the strip is up
+ // with its "Ready" label and every module segment hidden.
+ QVERIFY(!strip.isHidden());
+ QVERIFY(!strip.findChild<QLabel *>(QStringLiteral("readyLabel"))->isHidden());
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i)
+ QVERIFY(segmentLabel(&strip, static_cast<ModuleStatus::Module>(i))->isHidden());
+}
+
+void StatusStripTest::testBusySegment()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ model.beginScreenCapture();
+
+ QLabel *ready = strip.findChild<QLabel *>(QStringLiteral("readyLabel"));
+ QLabel *segment = segmentLabel(&strip, s_snipping);
+ QVERIFY(ready->isHidden());
+ QVERIFY(!segment->isHidden());
+ QCOMPARE(segment->text(), QStringLiteral("Waiting for capture"));
+}
+
+void StatusStripTest::testErrorSegmentShowsDetailInTooltip()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ driveTranslationError(model);
+
+ QLabel *segment = segmentLabel(&strip, s_translating);
+ QVERIFY(!segment->isHidden());
+ QCOMPARE(segment->text(), QStringLiteral("Translation failed"));
+ QVERIFY(!segment->toolTip().isEmpty());
+}
+
+void StatusStripTest::testSimultaneousBusyAndErrorSegments()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ model.beginScreenCapture();
+ driveTranslationError(model);
+
+ QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+ QVERIFY(!segmentLabel(&strip, s_translating)->isHidden());
+ QCOMPARE(segmentLabel(&strip, s_snipping)->text(), QStringLiteral("Waiting for capture"));
+ QCOMPARE(segmentLabel(&strip, s_translating)->text(), QStringLiteral("Translation failed"));
+}
+
+// The pop-up window configuration: strip hidden at rest, up while anything is
+// active (busy or error - an error must be visible too, not just work).
+void StatusStripTest::testHideWhenIdle()
+{
+ ModuleStatus model;
+ GenericScreenGrabber grabber;
+ model.bindCapture(&grabber, nullptr);
+
+ StatusStrip strip;
+ strip.setModel(&model);
+ strip.setHideWhenIdle(true);
+
+ QVERIFY(strip.isHidden());
+
+ model.beginScreenCapture();
+ QVERIFY(!strip.isHidden());
+
+ emit grabber.grabbingFailed();
+ QVERIFY(!strip.isHidden());
+}
+
+// Interface/ShowStatusBar pick-up: a strip hidden by the setting stays hidden
+// whatever the model does, and comes back when re-enabled.
+void StatusStripTest::testSetShownOverridesModel()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ strip.setShown(false);
+ QVERIFY(strip.isHidden());
+
+ model.beginScreenCapture();
+ QVERIFY(strip.isHidden());
+
+ strip.setShown(true);
+ QVERIFY(!strip.isHidden());
+ QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+}
+
+void StatusStripTest::testTtsSegmentOmittedForNoopBackend()
+{
+ ModuleStatus model;
+ NoopTTSProvider noop;
+ model.bindTtsProvider(&noop);
+
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ // Something else busy, TTS idle: only that segment shows. The TTS label
+ // must never appear for the no-op backend (isAvailable == false).
+ model.beginScreenCapture();
+
+ QVERIFY(!segmentLabel(&strip, s_snipping)->isHidden());
+ QVERIFY(segmentLabel(&strip, ModuleStatus::Module::Tts)->isHidden());
+}
+
+void StatusStripTest::testAnimatedEllipsisWhileBusy()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ model.beginScreenCapture();
+ const QLabel *segment = segmentLabel(&strip, s_snipping);
+ QCOMPARE(segment->text(), QStringLiteral("Waiting for capture"));
+
+ // ~400 ms per dot; give it ample room rather than an exact count, since
+ // the assertion is "dots appear and grow", not "exactly N fired".
+ QVERIFY(QTest::qWaitFor([&segment]() {
+ const QString text = segment->text();
+ return text.startsWith(QStringLiteral("Waiting for capture")) && text.endsWith(QLatin1Char('.'));
+ },
+ 3000));
+ QVERIFY(QTest::qWaitFor([&segment]() {
+ return segment->text().endsWith(QStringLiteral(".."));
+ },
+ 3000));
+
+ // The animated ellipsis must not rename the widget for exact-match
+ // lookups (AT-SPI Name is what read_widget_text matches on): the
+ // accessible name stays the dot-free message while the dots animate.
+ QCOMPARE(segment->accessibleName(), QStringLiteral("Waiting for capture"));
+}
+
+void StatusStripTest::testDotsStopWhenIdle()
+{
+ ModuleStatus model;
+ GenericScreenGrabber grabber;
+ SnippingArea snippingArea;
+ model.bindCapture(&grabber, &snippingArea);
+
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ model.beginScreenCapture();
+ QTest::qWait(900); // let some dots animate
+
+ emit snippingArea.cancelled();
+ const QLabel *segment = segmentLabel(&strip, s_snipping);
+ QVERIFY(segment->isHidden());
+ QVERIFY(segment->text().isEmpty());
+ QVERIFY(segment->accessibleName().isEmpty());
+
+ // No further ticks: the state stays clean after the timer's last slot.
+ QTest::qWait(900);
+ QVERIFY(segment->text().isEmpty());
+}
+
+QLabel *StatusStripTest::segmentLabel(const StatusStrip *strip, ModuleStatus::Module module)
+{
+ static const char *const segmentNames[] = {"snippingSegmentLabel", "ocrSegmentLabel", "translationSegmentLabel", "ttsSegmentLabel"};
+ return strip->findChild<QLabel *>(QString::fromLatin1(segmentNames[static_cast<int>(module)]));
+}
+
+void StatusStripTest::driveTranslationError(ModuleStatus &model)
+{
+ CopyTranslationProvider translator;
+ model.bindTranslator(&translator);
+ translator.translate(QStringLiteral("hello"), Language(QLocale::English), Language(QLocale::French));
+}
+
+// A strip that goes away (hide-when-idle at rest, or the ShowStatusBar
+// setting) must hide and DE-IDENTIFY its labels, not just hide the
+// container: the AT-SPI bridge never prunes interfaces, so a label keeps
+// its name-matchable identity (and keeps announcing to screen readers)
+// unless it is cleared and hidden itself.
+void StatusStripTest::testHiddenStripHidesItsLabels()
+{
+ ModuleStatus model;
+ StatusStrip strip;
+ strip.setModel(&model);
+
+ QLabel *ready = strip.findChild<QLabel *>(QStringLiteral("readyLabel"));
+
+ // Hide-when-idle at rest: strip hidden, "Ready" label hidden and
+ // de-identified.
+ strip.setHideWhenIdle(true);
+ QVERIFY(strip.isHidden());
+ QVERIFY(ready->isHidden());
+ QVERIFY(ready->text().isEmpty());
+ QVERIFY(ready->accessibleName().isEmpty());
+
+ // The setting pick-up path: strip suppressed by setShown(false) even
+ // while the model is busy.
+ strip.setHideWhenIdle(false);
+ strip.setShown(false);
+ model.beginScreenCapture();
+ QVERIFY(strip.isHidden());
+ for (int i = 0; i < ModuleStatus::moduleCount(); ++i)
+ QVERIFY(segmentLabel(&strip, static_cast<ModuleStatus::Module>(i))->isHidden());
+
+ // Coming back re-shows what the state warrants.
+ strip.setShown(true);
+ QVERIFY(!strip.isHidden());
+ QVERIFY(!segmentLabel(&strip, ModuleStatus::Module::Snipping)->isHidden());
+ QVERIFY(ready->isHidden());
+ QVERIFY(ready->text().isEmpty());
+}
+
+int main(int argc, char *argv[])
+{
+ QApplication app(argc, argv);
+ StatusStripTest tc;
+ QTEST_SET_MAIN_SOURCE_PATH
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include "test_statusstrip.moc"
diff --git a/tests/test_translation.cpp b/tests/test_translation.cpp
index a495b10f..5a4f0e43 100644
--- a/tests/test_translation.cpp
+++ b/tests/test_translation.cpp
@@ -12,7 +12,7 @@
#include "singleapplication.h"
#include "sourcetextedit.h"
#include "testisolation.h"
-#include "ocr/ocr.h"
+#include "ocr/tesseractocr.h"
#include "settings/appsettings.h"
#include "translator/atranslationprovider.h"
#include "tts/attsprovider.h"
diff --git a/tests/test_translationlogic.cpp b/tests/test_translationlogic.cpp
new file mode 100644
index 00000000..2330f471
--- /dev/null
+++ b/tests/test_translationlogic.cpp
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Mauritius Clemens <[email protected]>
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+// Pins the canonical auto-destination rule (TranslationLogic) so it can never
+// regress in one backend while another is correct. This behaviour has broken
+// repeatedly; keep it in one place and covered here.
+
+#include "translator/translationlogic.h"
+
+#include <QtTest>
+
+class TranslationLogicTest : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void sameLanguageTreatsBaseLanguageAsEqual();
+ void preferredDestinationPicksPrimaryWhenSourceDiffers();
+ void preferredDestinationPicksSecondaryWhenSourceIsPrimary();
+ void preferredDestinationFallsBackToSystem();
+};
+
+void TranslationLogicTest::sameLanguageTreatsBaseLanguageAsEqual()
+{
+ using TranslationLogic::sameLanguage;
+ QVERIFY(sameLanguage(Language(QStringLiteral("en")), Language(QLocale(QStringLiteral("en_US")))));
+ QVERIFY(sameLanguage(Language(QStringLiteral("en")), Language(QLocale(QStringLiteral("en_GB")))));
+ QVERIFY(sameLanguage(Language(QStringLiteral("pl")), Language(QStringLiteral("pl"))));
+ QVERIFY(!sameLanguage(Language(QStringLiteral("en")), Language(QStringLiteral("pl"))));
+}
+
+void TranslationLogicTest::preferredDestinationPicksPrimaryWhenSourceDiffers()
+{
+ using TranslationLogic::preferredDestination;
+ const Language primary(QStringLiteral("en"));
+ const Language secondary(QStringLiteral("ru"));
+ const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+ QCOMPARE(preferredDestination(Language(QStringLiteral("pl")), primary, secondary, fallback).toCode(), primary.toCode());
+ QCOMPARE(preferredDestination(Language(QStringLiteral("de")), primary, secondary, fallback).toCode(), primary.toCode());
+}
+
+void TranslationLogicTest::preferredDestinationPicksSecondaryWhenSourceIsPrimary()
+{
+ using TranslationLogic::preferredDestination;
+ const Language primary(QStringLiteral("en"));
+ const Language secondary(QStringLiteral("ru"));
+ const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+ QCOMPARE(preferredDestination(primary, primary, secondary, fallback).toCode(), secondary.toCode());
+ // a territory variant of the primary is still the primary -> secondary
+ QCOMPARE(preferredDestination(Language(QLocale(QStringLiteral("en_US"))), primary, secondary, fallback).toCode(),
+ secondary.toCode());
+ // source equal to the secondary -> primary
+ QCOMPARE(preferredDestination(secondary, primary, secondary, fallback).toCode(), primary.toCode());
+}
+
+void TranslationLogicTest::preferredDestinationFallsBackToSystem()
+{
+ using TranslationLogic::preferredDestination;
+ const Language primary(QStringLiteral("en"));
+ const Language fallback(QLocale(QStringLiteral("fr_FR")));
+
+ // unset primary and secondary -> fallback
+ QCOMPARE(preferredDestination(primary, Language::autoLanguage(), Language::autoLanguage(), fallback).toCode(),
+ fallback.toCode());
+ // source equals both primary and secondary -> fallback
+ QCOMPARE(preferredDestination(primary, primary, primary, fallback).toCode(), fallback.toCode());
+}
+
+QTEST_GUILESS_MAIN(TranslationLogicTest)
+#include "test_translationlogic.moc"
\ No newline at end of file