Files
yourls-ui/mainwindow.cpp
2026-10-01 12:21:40 -04:00

411 lines
14 KiB
C++

#include "mainwindow.h"
#include "clipboardresultdialog.h"
#include "clipboardurl.h"
#include "ui_mainwindow.h"
#include "yourlsclient.h"
#include <QAction>
#include <QApplication>
#include <QClipboard>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDialog>
#include <QDialogButtonBox>
#include <QGuiApplication>
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QPushButton>
#include <QSettings>
#include <QStatusBar>
#include <QStyle>
#include <QTimer>
#include <QVBoxLayout>
namespace {
/**
* @brief Builds a clickable HTML link for dialogs.
* @param url URL to render.
* @return Rich text string.
*/
QString makeLinkHtml(const QUrl &url) {
const QString urlText = url.toDisplayString();
return QStringLiteral("<a href=\"%1\">%2</a>")
.arg(url.toString(QUrl::FullyEncoded), urlText.toHtmlEscaped());
}
/**
* @brief Returns the organization name used by QSettings.
* @return Organization string.
*/
QString settingsOrganization() {
return QStringLiteral("deeaitch");
}
/**
* @brief Returns the application name used by QSettings.
* @return Application string.
*/
QString settingsApplication() {
return QStringLiteral("yourls-ui");
}
}// namespace
/**
* @brief Constructs the main window.
* @param parent Parent widget.
*/
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), ui(new Ui::MainWindow), m_trayIcon(nullptr), m_trayMenu(nullptr), m_cutUrlAction(nullptr), m_settingsAction(nullptr), m_helpAction(nullptr), m_aboutAction(nullptr), m_exitAction(nullptr), m_yourlsClient(new YourlsClient(this)) {
ui->setupUi(this);
#ifndef QT_NO_DEBUG
qDebug() << "[yourls-ui] debug build, executable:" << QCoreApplication::applicationFilePath()
<< "Qt:" << qVersion() << "platform:" << QGuiApplication::platformName();
#endif
setWindowTitle(QStringLiteral("yourls-ui settings"));
ui->apiKeyLineEdit->setEchoMode(QLineEdit::Password);
statusBar()->showMessage(QStringLiteral("Configure YOURLS API settings and save."));
setupMenuActions();
setupTray();
setupConnections();
loadSettings();
connect(m_yourlsClient, &YourlsClient::shortenSucceeded, this, &MainWindow::onShortenSucceeded);
connect(m_yourlsClient, &YourlsClient::shortenFailed, this, &MainWindow::onShortenFailed);
if (hasValidSettings()) {
if (m_trayIcon != nullptr)
m_trayIcon->show();
QTimer::singleShot(0, this, &MainWindow::hideToTray);
}
}
/**
* @brief Destroys the main window.
*/
MainWindow::~MainWindow() {
delete ui;
}
/**
* @brief Handles close requests.
* @param event Close event.
*/
void MainWindow::closeEvent(QCloseEvent *event) {
if (hasValidSettings() && m_trayIcon != nullptr && m_trayIcon->isVisible()) {
hideToTray();
event->ignore();
return;
}
QMainWindow::closeEvent(event);
}
/**
* @brief Saves settings from the UI.
*/
void MainWindow::saveSettings() {
const QString apiUrl = ui->urlLineEdit->text().trimmed();
const QString apiKey = ui->apiKeyLineEdit->text().trimmed();
if (apiUrl.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("Missing API URL"), QStringLiteral("Please enter the YOURLS API URL."));
return;
}
if (apiKey.isEmpty()) {
QMessageBox::warning(this, QStringLiteral("Missing API key"), QStringLiteral("Please enter the YOURLS API signature."));
return;
}
const QUrl parsedApiUrl(apiUrl);
if (!parsedApiUrl.isValid() || parsedApiUrl.scheme().isEmpty() || parsedApiUrl.host().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Invalid API URL"),
QStringLiteral("The configured API URL does not look valid."));
return;
}
QSettings settings(settingsOrganization(), settingsApplication());
settings.setValue(QStringLiteral("api/url"), apiUrl);
settings.setValue(QStringLiteral("api/signature"), apiKey);
settings.sync();
if (m_trayIcon != nullptr)
m_trayIcon->show();
statusBar()->showMessage(QStringLiteral("Settings saved."), 2000);
hideToTray();
}
/**
* @brief Shows About text.
*/
void MainWindow::showAbout() {
QMessageBox::about(this,
QStringLiteral("About yourls-ui"),
QStringLiteral("yourls-ui\n\n"
"Small tray utility for shortening clipboard URLs with a YOURLS API.\n\n"
"Creator: deeaitch\n"
"Created: 2026-03-16"));
}
/**
* @brief Shows Help text.
*/
void MainWindow::showHelp() {
QMessageBox::information(this,
QStringLiteral("Help"),
QStringLiteral("1. Open Settings and enter the YOURLS API URL and signature.\n"
"2. Save the settings. The application will stay in the tray.\n"
"3. Copy a full URL to the clipboard.\n"
"4. Right-click the tray icon and choose \"Cut URL\".\n"
"5. The short URL will be copied back to the clipboard and shown in a popup.\n\n"
"If something fails, the program shows detailed error information."));
}
/**
* @brief Shows the settings window.
*/
void MainWindow::showSettingsWindow() {
showNormal();
raise();
activateWindow();
}
/**
* @brief Hides the settings window to tray when possible.
*/
void MainWindow::hideToTray() {
if (!hasValidSettings() || m_trayIcon == nullptr || !QSystemTrayIcon::isSystemTrayAvailable() || !m_trayIcon->isVisible()) {
showSettingsWindow();
return;
}
hide();
if (m_trayIcon != nullptr && m_trayIcon->isVisible()) {
m_trayIcon->showMessage(QStringLiteral("yourls-ui"),
QStringLiteral("The application is running in the system tray."),
QSystemTrayIcon::Information,
2000);
}
}
/**
* @brief Exits the application.
*/
void MainWindow::exitApplication() {
QApplication::quit();
}
/**
* @brief Handles tray icon activation.
* @param reason Activation reason.
*/
void MainWindow::onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) {
if (reason == QSystemTrayIcon::DoubleClick || reason == QSystemTrayIcon::Trigger)
showSettingsWindow();
}
/**
* @brief Reads a URL from the clipboard and shortens it.
*/
void MainWindow::cutUrlFromClipboard() {
if (!hasValidSettings()) {
QMessageBox::warning(this,
QStringLiteral("Settings required"),
QStringLiteral("Please configure the API URL and signature first."));
showSettingsWindow();
return;
}
QUrl longUrl;
if (QGuiApplication::platformName().startsWith(QStringLiteral("wayland")) && !isActiveWindow()) {
// Wayland delivers clipboard offers to clients with keyboard focus.
// A tray menu belongs to the shell and does not focus our application.
QDialog clipboardDialog(this);
clipboardDialog.setWindowTitle(QStringLiteral("Read clipboard URL"));
auto *layout = new QVBoxLayout(&clipboardDialog);
layout->addWidget(new QLabel(
QStringLiteral("Activate this window to read the URL from the clipboard."), &clipboardDialog));
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, &clipboardDialog);
auto *readButton = buttons->addButton(QStringLiteral("Read clipboard"), QDialogButtonBox::AcceptRole);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::rejected, &clipboardDialog, &QDialog::reject);
const auto readClipboard = [&]() {
longUrl = ClipboardUrl::fromMimeData(QGuiApplication::clipboard()->mimeData());
clipboardDialog.accept();
};
connect(readButton, &QPushButton::clicked, &clipboardDialog, readClipboard);
// Wait for focus and the clipboard offer, without blocking the event loop.
QTimer pollTimer(&clipboardDialog);
int activePolls = 0;
connect(&pollTimer, &QTimer::timeout, &clipboardDialog, [&]() {
if (!clipboardDialog.isActiveWindow())
return;
const auto *mimeData = QGuiApplication::clipboard()->mimeData();
if ((mimeData != nullptr && !mimeData->formats().isEmpty()) || ++activePolls >= 20)
readClipboard();
});
#ifndef QT_NO_DEBUG
qDebug() << "[clipboard] Wayland: waiting for focused window and clipboard offer";
#endif
pollTimer.start(100);
if (clipboardDialog.exec() != QDialog::Accepted)
return;
pollTimer.stop();
} else {
longUrl = ClipboardUrl::fromMimeData(QGuiApplication::clipboard()->mimeData());
}
#ifndef QT_NO_DEBUG
qDebug() << "[clipboard] final URL:" << longUrl << "valid:" << longUrl.isValid();
#endif
if (!longUrl.isValid()) {
#ifndef QT_NO_DEBUG
qDebug() << "[clipboard] rejected locally; no YOURLS request sent";
#endif
QMessageBox::warning(this,
QStringLiteral("Invalid clipboard content"),
QStringLiteral("The clipboard does not contain a valid URL."));
return;
}
YourlsSettings settings;
settings.apiUrl = ui->urlLineEdit->text().trimmed();
settings.signature = ui->apiKeyLineEdit->text().trimmed();
if (m_trayIcon != nullptr) {
m_trayIcon->showMessage(QStringLiteral("yourls-ui"),
QStringLiteral("Requesting short URL..."),
QSystemTrayIcon::Information,
1200);
}
m_yourlsClient->shortenUrl(settings, longUrl);
}
/**
* @brief Handles successful shortening.
* @param shortUrl Generated short URL.
*/
void MainWindow::onShortenSucceeded(const QUrl &shortUrl) {
showShortUrlPopup(shortUrl);
}
/**
* @brief Handles shortening failure.
* @param details Detailed error text.
*/
void MainWindow::onShortenFailed(const QString &details) {
QMessageBox::critical(this, QStringLiteral("Failed to create short URL"), details);
}
/**
* @brief Creates tray icon and tray menu.
*/
void MainWindow::setupTray() {
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
QMessageBox::warning(this,
QStringLiteral("System tray unavailable"),
QStringLiteral("No system tray was detected. The application will stay as a normal window."));
return;
}
m_trayIcon = new QSystemTrayIcon(this);
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_DialogApplyButton));
m_trayMenu = new QMenu(this);
m_trayMenu->addAction(m_cutUrlAction);
m_trayMenu->addSeparator();
m_trayMenu->addAction(m_settingsAction);
m_trayMenu->addAction(m_helpAction);
m_trayMenu->addAction(m_aboutAction);
m_trayMenu->addSeparator();
m_trayMenu->addAction(m_exitAction);
m_trayIcon->setContextMenu(m_trayMenu);
}
/**
* @brief Connects UI signals to slots.
*/
void MainWindow::setupConnections() {
connect(ui->saveBtn, &QPushButton::clicked, this, &MainWindow::saveSettings);
connect(ui->actionHelp, &QAction::triggered, this, &MainWindow::showHelp);
connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::showAbout);
connect(ui->actionExit, &QAction::triggered, this, &MainWindow::exitApplication);
connect(m_cutUrlAction, &QAction::triggered, this, &MainWindow::cutUrlFromClipboard);
connect(m_settingsAction, &QAction::triggered, this, &MainWindow::showSettingsWindow);
connect(m_helpAction, &QAction::triggered, this, &MainWindow::showHelp);
connect(m_aboutAction, &QAction::triggered, this, &MainWindow::showAbout);
connect(m_exitAction, &QAction::triggered, this, &MainWindow::exitApplication);
if (m_trayIcon != nullptr)
connect(m_trayIcon, &QSystemTrayIcon::activated, this, &MainWindow::onTrayIconActivated);
}
/**
* @brief Loads persisted settings into the UI.
*/
void MainWindow::loadSettings() {
QSettings settings(settingsOrganization(), settingsApplication());
ui->urlLineEdit->setText(settings.value(QStringLiteral("api/url")).toString());
ui->apiKeyLineEdit->setText(settings.value(QStringLiteral("api/signature")).toString());
}
/**
* @brief Returns true when all required settings are present.
* @return True when the application can work from the tray.
*/
bool MainWindow::hasValidSettings() const {
return !ui->urlLineEdit->text().trimmed().isEmpty() && !ui->apiKeyLineEdit->text().trimmed().isEmpty();
}
/**
* @brief Shows a popup with the generated short URL.
* @param shortUrl Generated short URL.
*/
void MainWindow::showShortUrlPopup(const QUrl &shortUrl) {
const bool wayland = QGuiApplication::platformName().startsWith(QStringLiteral("wayland"));
ClipboardResultDialog dialog(shortUrl, wayland, this);
dialog.setWindowTitle(QStringLiteral("Short URL created"));
auto *layout = new QVBoxLayout(&dialog);
auto *label = new QLabel(QStringLiteral("Generated short URL:<br>%1").arg(makeLinkHtml(shortUrl)), &dialog);
label->setTextFormat(Qt::RichText);
label->setTextInteractionFlags(Qt::TextBrowserInteraction);
label->setOpenExternalLinks(true);
layout->addWidget(label);
layout->addWidget(dialog.copyStatusLabel());
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok, &dialog);
QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog]() {
// An OK click supplies focus/input even when automatic activation was denied.
if (dialog.copyShortUrl())
dialog.accept();
});
layout->addWidget(buttons);
if (!wayland)
dialog.copyShortUrl();
dialog.exec();
}
/**
* @brief Creates tray/menu actions.
*/
void MainWindow::setupMenuActions() {
m_cutUrlAction = new QAction(QStringLiteral("Cut URL"), this);
m_settingsAction = new QAction(QStringLiteral("Settings"), this);
m_helpAction = new QAction(QStringLiteral("Help"), this);
m_aboutAction = new QAction(QStringLiteral("About"), this);
m_exitAction = new QAction(QStringLiteral("Exit"), this);
}