diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..8bba429 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,122 @@ +name: Windows build + +# Builds the portable Windows x64 package so the translate fix can be tested +# without a local Qt/MSVC toolchain. PDFium is vendored in third_party/; +# OpenSSL, QPDF and ZLIB come from vcpkg; Tesseract is left out, so OCR is +# disabled in this build (the app builds and runs fine without it). + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: windows-2022 + + env: + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + QT_VERSION: '6.8.1' + + steps: + - uses: actions/checkout@v4 + + - name: Export GitHub Actions cache env for vcpkg + uses: actions/github-script@v7 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + host: windows + target: desktop + arch: win64_msvc2022_64 + modules: 'qtimageformats' + cache: true + + - name: Install vcpkg dependencies (OpenSSL, QPDF, ZLIB, Tesseract) + shell: pwsh + run: | + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install ` + openssl:x64-windows qpdf:x64-windows zlib:x64-windows tesseract:x64-windows + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Configure + shell: pwsh + run: | + cmake -B build -S . -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows 2>&1 | Tee-Object -Variable cfg + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # CMake only warns when Tesseract is missing and builds an OCR stub, which + # is far too quiet a way to ship a package with a headline feature gone. + if ($cfg -match 'Tesseract khong tim thay') { + throw "Tesseract was not found — OCR would be silently disabled in this build" + } + + - name: Build + run: cmake --build build --config Release --parallel + + - name: Deploy Qt runtime + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + Get-ChildItem $out + & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations "$out\TorReader.exe" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # windeployqt's --compiler-runtime is a silent no-op on the runner, and + # the shipped portable build carries the CRT locally rather than assuming + # the VC++ redistributable is installed. Copy it in by hand. + $crt = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\2022\*\VC\Redist\MSVC" ` + -Directory -Filter 'Microsoft.VC*.CRT' -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $crt) { throw "MSVC redistributable CRT not found on the runner" } + Write-Host "CRT from $($crt.FullName)" + Copy-Item "$($crt.FullName)\*.dll" $out -Force + # Vendored PDFium and the vcpkg runtime DLLs sit outside windeployqt's view. + Copy-Item third_party\pdfium\bin\pdfium.dll $out -Force + $vcbin = "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows\bin" + if (Test-Path $vcbin) { + Copy-Item "$vcbin\*.dll" $out -Force -ErrorAction SilentlyContinue + } + Get-ChildItem $out | Select-Object Name, Length + + - name: Verify package contents + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + $missing = @() + foreach ($f in 'TorReader.exe','Qt6Network.dll','pdfium.dll', + 'msvcp140.dll','vcruntime140.dll','tls\qschannelbackend.dll') { + if (-not (Test-Path "$out\$f")) { $missing += $f } + } + if (-not (Get-ChildItem "$out" -Filter 'tesseract*.dll' -ErrorAction SilentlyContinue)) { + $missing += 'tesseract*.dll' + } + $langs = @(Get-ChildItem "$out\tessdata" -Filter *.traineddata -ErrorAction SilentlyContinue) + Write-Host "tessdata language files: $($langs.Count)" + if ($langs.Count -lt 10) { $missing += "tessdata/*.traineddata (found $($langs.Count), expected 10+)" } + if ($missing) { throw "Incomplete package, missing: $($missing -join ', ')" } + Write-Host "Package verified." + + - name: Package + shell: pwsh + run: | + $out = "build\bin\Release" + if (-not (Test-Path $out)) { $out = "build\bin" } + Compress-Archive -Path "$out\*" -DestinationPath TorReaderPDF-win64.zip -Force + + - uses: actions/upload-artifact@v4 + with: + name: TorReaderPDF-win64 + path: TorReaderPDF-win64.zip + retention-days: 14 diff --git a/CMakeLists.txt b/CMakeLists.txt index f29c80f..403b614 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -187,6 +187,7 @@ set(SOURCES src/ui/UpdateBanner.cpp src/ui/GateDialog.cpp src/ui/TranslationPopup.cpp + src/ui/LanguagePickerPopup.cpp src/ui/NotificationBar.cpp > ) @@ -231,6 +232,7 @@ set(HEADERS src/ui/UpdateBanner.h src/ui/GateDialog.h src/ui/TranslationPopup.h + src/ui/LanguagePickerPopup.h src/ui/NotificationBar.h > ) diff --git a/src/core/Translator.cpp b/src/core/Translator.cpp index a4776fc..5fd9212 100755 --- a/src/core/Translator.cpp +++ b/src/core/Translator.cpp @@ -15,23 +15,60 @@ #include #include #include +#include #include -static constexpr const char* kTargetLang = "vi"; static constexpr const char* kGeminiModel = "gemini-2.5-flash"; static constexpr const char* kCacheFileName = "translate_cache.json"; static constexpr int kMaxMemoryEntries = 500; static constexpr int kMaxDiskEntries = 2000; static constexpr int kCacheSaveDebounceMs = 2000; +static constexpr int kRequestTimeoutMs = 15000; -static QString makeCacheKey(const QString& lang, const QString& text) { +static QString makeCacheKey(const QString& srcLang, const QString& dstLang, + const QString& text) { return QString::fromLatin1( QCryptographicHash::hash( - (lang + QLatin1String("\x1f") + text).toUtf8(), + (srcLang + QLatin1String("\x1f") + dstLang + + QLatin1String("\x1f") + text).toUtf8(), QCryptographicHash::Sha1) .toHex()); } +const QList>& Translator::languages() { + static const QList> kLangs = { + { QStringLiteral("auto"), QStringLiteral("Detect language") }, + { QStringLiteral("en"), QStringLiteral("English") }, + { QStringLiteral("vi"), QStringLiteral("Vietnamese") }, + { QStringLiteral("zh-CN"),QStringLiteral("Chinese (Simplified)") }, + { QStringLiteral("zh-TW"),QStringLiteral("Chinese (Traditional)") }, + { QStringLiteral("ja"), QStringLiteral("Japanese") }, + { QStringLiteral("ko"), QStringLiteral("Korean") }, + { QStringLiteral("fr"), QStringLiteral("French") }, + { QStringLiteral("de"), QStringLiteral("German") }, + { QStringLiteral("es"), QStringLiteral("Spanish") }, + { QStringLiteral("pt"), QStringLiteral("Portuguese") }, + { QStringLiteral("it"), QStringLiteral("Italian") }, + { QStringLiteral("ru"), QStringLiteral("Russian") }, + { QStringLiteral("th"), QStringLiteral("Thai") }, + { QStringLiteral("id"), QStringLiteral("Indonesian") }, + { QStringLiteral("ms"), QStringLiteral("Malay") }, + { QStringLiteral("hi"), QStringLiteral("Hindi") }, + { QStringLiteral("ar"), QStringLiteral("Arabic") }, + { QStringLiteral("nl"), QStringLiteral("Dutch") }, + { QStringLiteral("pl"), QStringLiteral("Polish") }, + { QStringLiteral("tr"), QStringLiteral("Turkish") }, + }; + return kLangs; +} + +QString Translator::languageName(const QString& code) { + for (const auto& p : languages()) + if (p.first.compare(code, Qt::CaseInsensitive) == 0) + return p.second; + return code; +} + Translator::Translator(QObject* parent) : QObject(parent) , m_nam(new QNetworkAccessManager(this)) @@ -45,13 +82,15 @@ Translator::Translator(QObject* parent) m_diskCachePath = appData + QLatin1Char('/') + QLatin1String(kCacheFileName); } -void Translator::translate(const QString& text) { - const QString lang = QString::fromLatin1(kTargetLang); - const QString key = makeCacheKey(lang, text); +void Translator::translate(const QString& text, const QString& srcLangIn, + const QString& dstLangIn) { + const QString srcLang = srcLangIn.isEmpty() ? QStringLiteral("auto") : srcLangIn; + const QString dstLang = dstLangIn.isEmpty() ? QStringLiteral("vi") : dstLangIn; + const QString key = makeCacheKey(srcLang, dstLang, text); - auto emitCached = [this, text](const QString& translation) { - QMetaObject::invokeMethod(this, [this, text, translation]() { - emit finished(text, translation); + auto emitCached = [this, text, srcLang, dstLang](const QString& translation) { + QMetaObject::invokeMethod(this, [this, text, translation, srcLang, dstLang]() { + emit finished(text, translation, srcLang, dstLang); }, Qt::QueuedConnection); }; @@ -70,26 +109,74 @@ void Translator::translate(const QString& text) { } } - QUrl url(QStringLiteral("https://translate.googleapis.com/translate_a/single" - "?client=gtx&sl=auto&tl=%1&dt=t").arg(lang)); + // A Qt build shipped without its TLS backend plugin cannot open *any* https + // connection, and the resulting reply error is opaque. Say so plainly. + if (!QSslSocket::supportsSsl()) { + emit failed(QStringLiteral( + "No TLS backend available, so https requests cannot be made. " + "The Qt 'tls' plugin folder is missing next to the executable " + "(re-run windeployqt on the build).")); + return; + } - QNetworkRequest req(url); + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/false); +} + +// Google is asked twice before falling back to Gemini: first as the POST the +// endpoint documents, then as a plain GET. Some proxies and captive networks +// drop the POST body and hand back an empty 200, which used to look exactly +// like "no response at all" from the UI. +void Translator::sendGoogleRequest(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + bool useGet) +{ + QString urlStr = QStringLiteral("https://translate.googleapis.com/translate_a/single" + "?client=gtx&sl=%1&tl=%2&dt=t") + .arg(QString::fromLatin1(QUrl::toPercentEncoding(srcLang)), + QString::fromLatin1(QUrl::toPercentEncoding(dstLang))); + if (useGet) + urlStr += QStringLiteral("&q=") + + QString::fromLatin1(QUrl::toPercentEncoding(text)); + + QNetworkRequest req((QUrl(urlStr))); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); req.setHeader(QNetworkRequest::UserAgentHeader, "Mozilla/5.0 (compatible; TorReaderPDF/2.0.0)"); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + // Without a timeout a stalled connection never emits finished(), so the UI + // would sit on "Translating…" forever with no error. + req.setTransferTimeout(kRequestTimeoutMs); + + QNetworkReply* rep = useGet + ? m_nam->get(req) + : m_nam->post(req, QByteArray("q=") + QUrl::toPercentEncoding(text)); + + connect(rep, &QNetworkReply::finished, this, + [this, rep, text, srcLang, dstLang, key, useGet]() { + rep->deleteLater(); - QByteArray body = "q=" + QUrl::toPercentEncoding(text); - QNetworkReply* rep = m_nam->post(req, body); + const int http = rep->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray raw = rep->readAll(); + + qWarning("Translator: %s %s -> http=%d qterr=%d(%s) bytes=%lld", + useGet ? "GET" : "POST", + qPrintable(QStringLiteral("%1->%2").arg(srcLang, dstLang)), + http, int(rep->error()), qPrintable(rep->errorString()), + qint64(raw.size())); - connect(rep, &QNetworkReply::finished, [this, rep, text, lang, key]() { - rep->deleteLater(); if (rep->error() != QNetworkReply::NoError) { - tryGeminiFallback(text, lang, key, rep->errorString()); + const QString why = QStringLiteral("%1 (HTTP %2)") + .arg(rep->errorString()).arg(http); + if (!useGet) + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/true); + else + tryGeminiFallback(text, srcLang, dstLang, key, why); return; } - QByteArray raw = rep->readAll(); QJsonDocument doc = QJsonDocument::fromJson(raw); QString translation; @@ -126,17 +213,26 @@ void Translator::translate(const QString& text) { } if (translation.trimmed().isEmpty()) { - tryGeminiFallback(text, lang, key, - QStringLiteral("No translation returned")); + // An empty 200 usually means the body never reached Google; retry + // as a GET before giving up on the endpoint entirely. + if (!useGet) { + sendGoogleRequest(text, srcLang, dstLang, key, /*useGet=*/true); + return; + } + tryGeminiFallback( + text, srcLang, dstLang, key, + QStringLiteral("Google returned HTTP %1 with %2 bytes and no " + "translation").arg(http).arg(raw.size())); } else { addToCache(key, translation.trimmed()); - emit finished(text, translation.trimmed()); + emit finished(text, translation.trimmed(), srcLang, dstLang); } }); } -void Translator::tryGeminiFallback(const QString& text, const QString& lang, - const QString& key, const QString& primaryError) +void Translator::tryGeminiFallback(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + const QString& primaryError) { if (!m_geminiKeyChecked) { m_geminiApiKey = qEnvironmentVariable("GEMINI_API_KEY"); @@ -149,7 +245,7 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, if (m_geminiApiKey.isEmpty()) { emit failed(primaryError + - QStringLiteral(" (Gemini fallback: chưa cấu hình GEMINI_API_KEY)")); + QStringLiteral(" (Gemini fallback unavailable: GEMINI_API_KEY is not configured)")); return; } @@ -161,12 +257,17 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::UserAgentHeader, "Mozilla/5.0 (compatible; TorReaderPDF/2.0.0)"); - req.setTransferTimeout(15000); + req.setTransferTimeout(kRequestTimeoutMs); + + const QString fromPart = (srcLang == QLatin1String("auto")) + ? QString() + : QStringLiteral("from %1 ").arg(languageName(srcLang)); QJsonObject partObj; partObj[QStringLiteral("text")] = - QStringLiteral("Translate the following text to %1. Return ONLY the translation, no explanations:\n\n%2") - .arg(lang, text); + QStringLiteral("Translate the following text %1to %2. " + "Return ONLY the translation, no explanations:\n\n%3") + .arg(fromPart, languageName(dstLang), text); QJsonArray parts; parts.append(partObj); @@ -183,8 +284,16 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, QByteArray body = QJsonDocument(bodyObj).toJson(QJsonDocument::Compact); QNetworkReply* rep = m_nam->post(req, body); - connect(rep, &QNetworkReply::finished, [this, rep, text, key, primaryError]() { + connect(rep, &QNetworkReply::finished, this, + [this, rep, text, srcLang, dstLang, key, primaryError]() { rep->deleteLater(); + const int http = rep->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray raw = rep->readAll(); + qWarning("Translator: Gemini -> http=%d qterr=%d(%s) bytes=%lld", + http, int(rep->error()), qPrintable(rep->errorString()), + qint64(raw.size())); + if (rep->error() != QNetworkReply::NoError) { emit failed(primaryError + QStringLiteral(" (Gemini fallback: ") + rep->errorString() + @@ -192,7 +301,6 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, return; } - QByteArray raw = rep->readAll(); QJsonDocument doc = QJsonDocument::fromJson(raw); QString translation; @@ -214,7 +322,7 @@ void Translator::tryGeminiFallback(const QString& text, const QString& lang, QStringLiteral(" (Gemini fallback: empty response)")); } else { addToCache(key, translation); - emit finished(text, translation); + emit finished(text, translation, srcLang, dstLang); } }); } diff --git a/src/core/Translator.h b/src/core/Translator.h index a82cd2e..6f34fb2 100755 --- a/src/core/Translator.h +++ b/src/core/Translator.h @@ -2,6 +2,8 @@ #include #include #include +#include +#include class QNetworkAccessManager; class QTimer; @@ -15,18 +17,31 @@ class Translator : public QObject { Q_OBJECT public: explicit Translator(QObject* parent = nullptr); - void translate(const QString& text); + + // srcLang may be "auto"; dstLang must be a concrete language code. + void translate(const QString& text, + const QString& srcLang = QStringLiteral("en"), + const QString& dstLang = QStringLiteral("vi")); + + // Shared language table: . "auto" is the first entry. + static const QList>& languages(); + static QString languageName(const QString& code); signals: - void finished(const QString& original, const QString& translation); + void finished(const QString& original, const QString& translation, + const QString& srcLang, const QString& dstLang); void failed(const QString& error); private: + void sendGoogleRequest(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + bool useGet); void addToCache(const QString& key, const QString& translation); void loadCacheFromDisk(); void saveCacheToDisk(); - void tryGeminiFallback(const QString& text, const QString& lang, - const QString& key, const QString& primaryError); + void tryGeminiFallback(const QString& text, const QString& srcLang, + const QString& dstLang, const QString& key, + const QString& primaryError); QNetworkAccessManager* m_nam; QHash m_cache; diff --git a/src/ui/ContinuousView.cpp b/src/ui/ContinuousView.cpp index 1152bb0..5f3b68f 100755 --- a/src/ui/ContinuousView.cpp +++ b/src/ui/ContinuousView.cpp @@ -1347,8 +1347,10 @@ void ContinuousView::wheelEvent(QWheelEvent* event) void ContinuousView::mousePressEvent(QMouseEvent* event) { - // Ctrl+Left drag = text selection for translation (giu nguyen, SPEC phan 2). - if ((event->modifiers() & Qt::ControlModifier) + // Ctrl+Left (or Alt+Left) drag = text selection for translation. + // Both modifiers are accepted so the gesture matches PdfGpuView and still + // works when the window manager eats Alt+drag. + if ((event->modifiers() & (Qt::ControlModifier | Qt::AltModifier)) && event->button() == Qt::LeftButton) { m_selecting = true; diff --git a/src/ui/LanguagePickerPopup.cpp b/src/ui/LanguagePickerPopup.cpp new file mode 100644 index 0000000..1c9276c --- /dev/null +++ b/src/ui/LanguagePickerPopup.cpp @@ -0,0 +1,184 @@ +#include "LanguagePickerPopup.h" +#include "../core/Translator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr const char* kKeySrc = "translate/srcLang"; +static constexpr const char* kKeyDst = "translate/dstLang"; +static constexpr const char* kKeyRemember = "translate/rememberLangs"; + +QString LanguagePickerPopup::savedSource() { + QSettings s; + return s.value(QLatin1String(kKeySrc), QStringLiteral("en")).toString(); +} + +QString LanguagePickerPopup::savedTarget() { + QSettings s; + return s.value(QLatin1String(kKeyDst), QStringLiteral("vi")).toString(); +} + +bool LanguagePickerPopup::rememberChoice() { + QSettings s; + return s.value(QLatin1String(kKeyRemember), false).toBool(); +} + +LanguagePickerPopup::LanguagePickerPopup(QWidget* parent) + : QDialog(parent, Qt::Tool | Qt::FramelessWindowHint) +{ + setAttribute(Qt::WA_TranslucentBackground); + setWindowTitle(tr("Translate")); + setFixedWidth(360); + + auto* title = new QLabel(tr("Translate selection"), this); + title->setStyleSheet("font-weight:bold; font-size:11pt; color:#1a1a1a;"); + + m_lblPreview = new QLabel(this); + m_lblPreview->setWordWrap(true); + m_lblPreview->setStyleSheet("color:#666; font-size:9pt; font-style:italic;"); + + m_cbFrom = new QComboBox(this); + m_cbTo = new QComboBox(this); + for (const auto& lang : Translator::languages()) { + m_cbFrom->addItem(lang.second, lang.first); + if (lang.first != QLatin1String("auto")) // no "auto" as a target + m_cbTo->addItem(lang.second, lang.first); + } + selectCode(m_cbFrom, savedSource()); + selectCode(m_cbTo, savedTarget()); + + m_btnSwap = new QToolButton(this); + m_btnSwap->setText(QString::fromUtf8("\xE2\x87\x84")); // ⇄ + m_btnSwap->setToolTip(tr("Swap languages")); + m_btnSwap->setCursor(Qt::PointingHandCursor); + m_btnSwap->setAutoRaise(true); + + auto* lblFrom = new QLabel(tr("From"), this); + auto* lblTo = new QLabel(tr("Into"), this); + lblFrom->setStyleSheet("color:#444; font-size:9pt;"); + lblTo->setStyleSheet("color:#444; font-size:9pt;"); + + auto* grid = new QGridLayout; + grid->setHorizontalSpacing(8); + grid->setVerticalSpacing(4); + grid->addWidget(lblFrom, 0, 0); + grid->addWidget(lblTo, 0, 2); + grid->addWidget(m_cbFrom, 1, 0); + grid->addWidget(m_btnSwap,1, 1); + grid->addWidget(m_cbTo, 1, 2); + grid->setColumnStretch(0, 1); + grid->setColumnStretch(2, 1); + + m_chkRemember = new QCheckBox(tr("Always use this pair (don't ask again)"), this); + m_chkRemember->setStyleSheet("color:#444; font-size:9pt;"); + m_chkRemember->setChecked(rememberChoice()); + + m_btnTranslate = new QPushButton(tr("Translate"), this); + m_btnTranslate->setDefault(true); + m_btnTranslate->setCursor(Qt::PointingHandCursor); + m_btnCancel = new QPushButton(tr("Cancel"), this); + m_btnCancel->setCursor(Qt::PointingHandCursor); + + auto* btns = new QHBoxLayout; + btns->addStretch(); + btns->addWidget(m_btnCancel); + btns->addWidget(m_btnTranslate); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(14, 12, 14, 12); + layout->setSpacing(8); + layout->addWidget(title); + layout->addWidget(m_lblPreview); + layout->addLayout(grid); + layout->addWidget(m_chkRemember); + layout->addLayout(btns); + + connect(m_btnSwap, &QToolButton::clicked, this, &LanguagePickerPopup::onSwap); + connect(m_btnTranslate, &QPushButton::clicked, this, &LanguagePickerPopup::onTranslate); + connect(m_btnCancel, &QPushButton::clicked, this, &QDialog::reject); +} + +void LanguagePickerPopup::selectCode(QComboBox* box, const QString& code) { + int idx = box->findData(code); + if (idx < 0) + idx = box->findData(box == m_cbTo ? QStringLiteral("vi") : QStringLiteral("en")); + if (idx >= 0) + box->setCurrentIndex(idx); +} + +void LanguagePickerPopup::onSwap() { + const QString from = m_cbFrom->currentData().toString(); + const QString to = m_cbTo->currentData().toString(); + // "auto" cannot be a target, so swapping out of it falls back to English. + selectCode(m_cbTo, from == QLatin1String("auto") ? QStringLiteral("en") : from); + selectCode(m_cbFrom, to); +} + +void LanguagePickerPopup::onTranslate() { + const QString src = m_cbFrom->currentData().toString(); + const QString dst = m_cbTo->currentData().toString(); + + QSettings s; + s.setValue(QLatin1String(kKeySrc), src); + s.setValue(QLatin1String(kKeyDst), dst); + s.setValue(QLatin1String(kKeyRemember), m_chkRemember->isChecked()); + s.sync(); + + hide(); + emit translateRequested(m_text, src, dst); +} + +void LanguagePickerPopup::askFor(const QString& selectedText, const QPoint& globalPos) { + m_text = selectedText; + + QString preview = selectedText.simplified(); + if (preview.size() > 160) + preview = preview.left(157) + QStringLiteral("..."); + // Escaped rather than literal so the file stays ASCII, like the rest of the UI code. + m_lblPreview->setText(QString::fromUtf8("\xE2\x80\x9C%1\xE2\x80\x9D").arg(preview)); + + // Re-read persisted defaults in case they changed elsewhere. + selectCode(m_cbFrom, savedSource()); + selectCode(m_cbTo, savedTarget()); + m_chkRemember->setChecked(rememberChoice()); + + adjustSize(); + + // Place near the cursor, clamped to the screen the cursor is actually on. + QScreen* scr = QGuiApplication::screenAt(globalPos); + if (!scr) scr = QGuiApplication::primaryScreen(); + const QRect avail = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080); + + // qBound asserts when min > max, which happens if the popup is wider than + // the screen — clamp the upper bound first. + const int xMax = qMax(avail.left(), avail.right() - width()); + int x = qBound(avail.left(), globalPos.x() - width() / 2, xMax); + int y = globalPos.y() + 14; + if (y + height() > avail.bottom()) + y = qMax(avail.top(), globalPos.y() - height() - 14); + move(x, y); + + show(); + raise(); + activateWindow(); + m_btnTranslate->setFocus(); +} + +void LanguagePickerPopup::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(QColor(255, 255, 255, 250)); + p.setPen(QPen(QColor(190, 190, 190), 1)); + p.drawRoundedRect(rect().adjusted(1, 1, -1, -1), 8, 8); +} diff --git a/src/ui/LanguagePickerPopup.h b/src/ui/LanguagePickerPopup.h new file mode 100644 index 0000000..60eccb6 --- /dev/null +++ b/src/ui/LanguagePickerPopup.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include + +class QComboBox; +class QLabel; +class QPushButton; +class QToolButton; +class QCheckBox; + +// Small floating panel shown right after the user marquee-selects text. +// Lets them confirm "translate FROM INTO " before the request +// goes out. Defaults to English -> Vietnamese and remembers the last choice. +class LanguagePickerPopup : public QDialog { + Q_OBJECT +public: + explicit LanguagePickerPopup(QWidget* parent = nullptr); + + // Shows the panel near globalPos with a preview of the selected text. + void askFor(const QString& selectedText, const QPoint& globalPos); + + static QString savedSource(); + static QString savedTarget(); + // True when the user ticked "don't ask again" - callers should then + // translate straight away using savedSource()/savedTarget(). + static bool rememberChoice(); + +signals: + // Emitted when the user confirms. src may be "auto". + void translateRequested(const QString& text, const QString& srcLang, + const QString& dstLang); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + void selectCode(QComboBox* box, const QString& code); + void onSwap(); + void onTranslate(); + + QLabel* m_lblPreview; + QComboBox* m_cbFrom; + QComboBox* m_cbTo; + QToolButton* m_btnSwap; + QCheckBox* m_chkRemember; + QPushButton* m_btnTranslate; + QPushButton* m_btnCancel; + QString m_text; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 2f49570..3146fd9 100755 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -26,6 +26,7 @@ #include "core/GoogleAuth.h" #include "core/Translator.h" #include "TranslationPopup.h" +#include "LanguagePickerPopup.h" #include "NoteInputDialog.h" #include "core/UpdateChecker.h" #include "GateDialog.h" @@ -785,7 +786,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { // Permanent hint bar — shows shortcut hints on the right side of the status bar auto* hintLabel = new QLabel( - "Ctrl+Scroll: Zoom · Alt+Drag: Translate " + "Ctrl+Scroll: Zoom · Ctrl/Alt+Drag: Translate " "· Scroll: Flip page · Right-click thumbnail: Page options"); m_hintLabel = hintLabel; hintLabel->setStyleSheet(QStringLiteral("color:%1; font-size:10px; padding-right:8px;") @@ -798,14 +799,36 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { m_googleAuth = new GoogleAuth(this); m_translator = new Translator(this); m_transPopup = new TranslationPopup(nullptr); // top-level floating window + m_langPicker = new LanguagePickerPopup(nullptr); + + // The picker asks "from X into Y" before anything goes over the network. + connect(m_langPicker, &LanguagePickerPopup::translateRequested, + this, [this](const QString& text, const QString& src, const QString& dst) { + statusBar()->showMessage("Translating\xE2\x80\xA6", 2000); + m_translator->translate(text, src, dst); + }); connect(m_translator, &Translator::finished, - this, [this](const QString& orig, const QString& trans) { - m_transPopup->showTranslation(orig, trans, m_lastTransPos); + this, [this](const QString& orig, const QString& trans, + const QString& src, const QString& dst) { + const QString pair = QStringLiteral("%1 \xE2\x86\x92 %2") + .arg(Translator::languageName(src), + Translator::languageName(dst)); + m_transPopup->showTranslation(orig, trans, m_lastTransPos, pair); }); connect(m_translator, &Translator::failed, this, [this](const QString& err) { - statusBar()->showMessage("Translation failed: " + err, 4000); + // A 4-second status flash was too easy to miss, which made every + // failure look like "nothing happened". Show the reason where the + // translation itself would have appeared. + statusBar()->showMessage("Translation failed: " + err, 10000); + m_transPopup->showTranslation( + QString(), + QStringLiteral("Translation failed" + "
%1") + .arg(err.toHtmlEscaped()), + m_lastTransPos, + QStringLiteral("Error")); }); connect(m_continuousView, &ContinuousView::textRegionSelected, @@ -1231,7 +1254,8 @@ void MainWindow::setupActionBar() { // Translate — nut tren toolbar (giua About va Share app). m_translateAct = new QAction("Translate", this); m_translateAct->setToolTip( - "Enable Google Translate — hold Ctrl and drag over text to select & translate\n" + "Enable Google Translate — hold Ctrl (or Alt) and drag over text to select & translate\n" + "A pop-up asks which language to translate from and into (English \xE2\x86\x92 Vietnamese by default)\n" "Works in both Single and Continuous modes\n" "Right-click to reset consent"); m_translateAct->setShortcut(QKeySequence("Ctrl+Shift+T")); @@ -1241,9 +1265,11 @@ void MainWindow::setupActionBar() { mb.setWindowTitle("Google Translate Enabled"); mb.setText( "Translation is enabled.

" - "Hold Ctrl and drag over text to select it.
" + "Hold Ctrl (or Alt) and drag over text to select it.
" "Works in both Single page and Continuous modes.
" - "Release to automatically translate the selected text to Vietnamese.

" + "On release a pop-up asks which language to translate " + "from and into \xE2\x80\x94 English \xE2\x86\x92 Vietnamese by default.
" + "Tick Always use this pair there to skip the pop-up next time.

" "To disable/reset: right-click the Translate button."); mb.setIcon(QMessageBox::Information); mb.setStandardButtons(QMessageBox::Ok); @@ -4533,16 +4559,25 @@ void MainWindow::onTextRegionSelected(int pageIdx, QRectF rectPts, QPoint global FPDF_DOCUMENT rawDoc = t->doc->raw(); m_lastTransPos = globalPos; - statusBar()->showMessage("Translating…", 3000); + statusBar()->showMessage("Extracting text…", 3000); auto* watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, - [this, watcher]() { + [this, watcher, globalPos]() { watcher->deleteLater(); QString text = watcher->result(); - if (!text.isEmpty()) - m_translator->translate(text); - else + if (!text.isEmpty()) { + // Ask "from X into Y" first, unless the user ticked "don't ask again". + if (LanguagePickerPopup::rememberChoice()) { + statusBar()->showMessage("Translating…", 2000); + m_translator->translate(text, + LanguagePickerPopup::savedSource(), + LanguagePickerPopup::savedTarget()); + } else { + statusBar()->clearMessage(); + m_langPicker->askFor(text, globalPos); + } + } else statusBar()->showMessage( "No selectable text in this area. " "Scanned pages may require OCR.", 4000); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 3c79724..d3b631f 100755 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -34,6 +34,7 @@ class ContinuousView; class QSplitter; class Translator; class TranslationPopup; +class LanguagePickerPopup; class GoogleAuth; class UpdateChecker; class QTimer; @@ -246,6 +247,7 @@ private slots: // Translation feature Translator* m_translator = nullptr; TranslationPopup* m_transPopup = nullptr; + LanguagePickerPopup* m_langPicker = nullptr; GoogleAuth* m_googleAuth = nullptr; UpdateChecker* m_updateChecker = nullptr; QPoint m_lastTransPos; diff --git a/src/ui/PdfGpuView.cpp b/src/ui/PdfGpuView.cpp index 45827e3..d865753 100755 --- a/src/ui/PdfGpuView.cpp +++ b/src/ui/PdfGpuView.cpp @@ -1441,6 +1441,18 @@ void PdfGpuView::mousePressEvent(QMouseEvent* e) { update(); return; } + // Ctrl/Alt + Left drag = marquee selection for translation. Checked here, + // before every tool branch below: those branches return unconditionally, + // so anywhere further down the gesture would simply never arm. + if ((e->modifiers() & (Qt::AltModifier | Qt::ControlModifier)) + && e->button() == Qt::LeftButton && m_hasImage) { + m_selecting = true; + m_selStart = e->position(); + m_selEnd = e->position(); + setCursor(Qt::IBeamCursor); + update(); + return; + } // Link: chi khi tool la Pan hoac Select, khong bat Ctrl. Bam vao link thi // di theo link truoc khi bat dau pan/chon chu (SPEC_PDF_LINKS muc 3). if ((m_tool == ViewTool::Pan || m_tool == ViewTool::SelectText) @@ -1507,15 +1519,6 @@ void PdfGpuView::mousePressEvent(QMouseEvent* e) { beginTextSelection(e->position(), 1); return; } - // Alt+Left drag = text selection for translation - if ((e->modifiers() & Qt::AltModifier) && e->button() == Qt::LeftButton) { - m_selecting = true; - m_selStart = e->position(); - m_selEnd = e->position(); - setCursor(Qt::IBeamCursor); - update(); - return; - } if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { m_panning = true; m_lastMousePos = e->position(); diff --git a/src/ui/TranslationPopup.cpp b/src/ui/TranslationPopup.cpp index 4c8ce03..e81e879 100755 --- a/src/ui/TranslationPopup.cpp +++ b/src/ui/TranslationPopup.cpp @@ -18,6 +18,10 @@ TranslationPopup::TranslationPopup(QWidget* parent) setAttribute(Qt::WA_ShowWithoutActivating); setFixedWidth(340); + m_lblLangPair = new QLabel(this); + m_lblLangPair->setStyleSheet("color:#2563EB; font-size:8pt; font-weight:bold;"); + m_lblLangPair->setVisible(false); + m_lblOriginal = new QLabel(this); m_lblOriginal->setWordWrap(true); m_lblOriginal->setStyleSheet("color:#666; font-size:9pt; font-style:italic;"); @@ -52,6 +56,7 @@ TranslationPopup::TranslationPopup(QWidget* parent) auto* layout = new QVBoxLayout(this); layout->setContentsMargins(14, 12, 14, 12); layout->setSpacing(6); + layout->addWidget(m_lblLangPair); layout->addWidget(m_lblOriginal); layout->addWidget(m_lblTranslated); layout->addLayout(hdr); @@ -66,9 +71,13 @@ TranslationPopup::TranslationPopup(QWidget* parent) void TranslationPopup::showTranslation(const QString& original, const QString& translation, - const QPoint& globalPos) + const QPoint& globalPos, + const QString& langPair) { + m_lblLangPair->setText(langPair); + m_lblLangPair->setVisible(!langPair.isEmpty()); m_lblOriginal->setText(original); + m_lblOriginal->setVisible(!original.isEmpty()); m_lblTranslated->setText(translation.isEmpty() ? "(no translation)" : translation); m_translation = translation; @@ -76,7 +85,9 @@ void TranslationPopup::showTranslation(const QString& original, adjustSize(); - QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + QScreen* scr = QGuiApplication::screenAt(globalPos); + if (!scr) scr = QGuiApplication::primaryScreen(); + QRect screen = scr ? scr->availableGeometry() : QRect(0, 0, 1920, 1080); int x = qBound(screen.left(), globalPos.x() - width() / 2, screen.right() - width()); int y = globalPos.y() - height() - 12; diff --git a/src/ui/TranslationPopup.h b/src/ui/TranslationPopup.h index 100e16a..5b53414 100755 --- a/src/ui/TranslationPopup.h +++ b/src/ui/TranslationPopup.h @@ -11,7 +11,8 @@ class TranslationPopup : public QWidget { public: explicit TranslationPopup(QWidget* parent = nullptr); void showTranslation(const QString& original, const QString& translation, - const QPoint& globalPos); + const QPoint& globalPos, + const QString& langPair = QString()); protected: void paintEvent(QPaintEvent* event) override; @@ -20,6 +21,7 @@ private slots: void onCopy(); private: + QLabel* m_lblLangPair; QLabel* m_lblOriginal; QLabel* m_lblTranslated; QLabel* m_lblCopied;