mirror of
https://github.com/logos-co/logos-app.git
synced 2026-08-27 17:31:13 +00:00
* ci(tests): run shutdown-test in the PR gate and enforce the combined suite time budget
* test(harness): add shared fixtures (LGX generator, G-ERR/G-ALIVE/G-EXIT gates, xfail runner) and rea
* test(ui): assert welcome page state on first launch before any interaction (A1)
* fix(tests): retry the A1 greeting assertion so it cannot race the async launcherApps refresh
* ci(tests): tee the app log into BASECAMP_APP_LOG so the ui-tests G-ERR gate scans real output
* fix(tests): typename
* fix(tests): shutdown test to follow behavior from 3a73d33
* fix(tests): reduce comments
* fix: potential flakiness
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix: replace full QML tree serialization
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* fix: file URL construction
* fix: use bash explicitly
* test(qml): add spec §4.1 automation objectNames to QML sources
Add the stable automation handles enumerated in spec §4.1, following
the existing sidebar.app.<name> / confirmationDialog.<mode>.<button>
convention: welcomePage.installNow, appManager.{searchField,
category.<name>,emptyView}, appContextMenu root,
addApplicationDialog.{primaryButton,closeButton,errorText,
resolutionBanner}, settings.searchField,
pluginInterface.{call.<method>,result,back}, repositories.{urlField,
addButton,refreshButton,errorBanner,errorDismiss,row.<url>,
removeConfirm.{confirm,cancel}}, and sidebar.buildLabel.
workspace.dock.<name> is deliberately not applied: the dock root's
objectName is pinned to the bare module name by
workspace_area_test.cpp and needs a coordinated rename (recorded as
a finding).
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
578 lines
22 KiB
C++
578 lines
22 KiB
C++
#include "window.h"
|
|
#include <QApplication>
|
|
#include <QGuiApplication>
|
|
#include <QScreen>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QSystemTrayIcon>
|
|
#include <QMenu>
|
|
#include <QMenuBar>
|
|
#include <QAction>
|
|
#include <QKeySequence>
|
|
#include <QShortcut>
|
|
#include <QCloseEvent>
|
|
#include <QIcon>
|
|
#include <QPixmap>
|
|
#include <QStandardPaths>
|
|
#include <QTimer>
|
|
#include <QWindow>
|
|
#include <QPointer>
|
|
#include <QScopedValueRollback>
|
|
#include "LogosBasecampPaths.h"
|
|
#include "MainUIBackend.h"
|
|
#include "ShellHostAdapter.h"
|
|
#include "IShellHost.h"
|
|
#include "IShellView.h"
|
|
#include "logos_api.h"
|
|
#include "win_dll_search.h"
|
|
#include <QPluginLoader>
|
|
#ifdef Q_OS_MAC
|
|
#include "trafficLightsTitleBar.h"
|
|
#include "macWindowStyle.h"
|
|
#endif
|
|
|
|
Window::Window(LogosAPI* logosAPI, logos::qt::QtLogosCore* core, QWidget *parent)
|
|
: QMainWindow(parent)
|
|
, m_logosAPI(logosAPI)
|
|
, m_core(core)
|
|
, m_trayIcon(nullptr)
|
|
, m_trayIconMenu(nullptr)
|
|
, m_showHideAction(nullptr)
|
|
, m_quitAction(nullptr)
|
|
{
|
|
setObjectName(QStringLiteral("logosMainWindow"));
|
|
setupUi();
|
|
createTrayIcon();
|
|
#ifdef Q_OS_MAC
|
|
createMenuBar();
|
|
#endif
|
|
#ifdef Q_OS_LINUX
|
|
// GNOME/KDE convention: Ctrl+Q quits. QKeySequence::Quit maps to Ctrl+Q
|
|
// on X11/Wayland and is empty on Windows.
|
|
auto* quitShortcut = new QShortcut(QKeySequence::Quit, this);
|
|
quitShortcut->setObjectName(QStringLiteral("logosQuitShortcut"));
|
|
connect(quitShortcut, &QShortcut::activated, this, &Window::quitApplication);
|
|
#endif
|
|
}
|
|
|
|
Window::~Window()
|
|
{
|
|
// Ordered teardown, stated as a contract instead of left to Qt's reverse
|
|
// child destruction:
|
|
// 1. beginShutdown() unmounts every in-process UI plugin widget while the
|
|
// shell's widget tree is still intact — those widgets are docked in
|
|
// the shell, so the shell must outlive this step.
|
|
// 2. destroyShell() detaches the observer before deleting the shell, so
|
|
// no late host callback reaches a destroyed observer.
|
|
// 3. only then the backend: PackageCoordinator -> UIPluginManager ->
|
|
// CoreModuleManager, the order MainUIBackend.h documents.
|
|
if (m_backend) {
|
|
m_backend->beginShutdown();
|
|
}
|
|
|
|
if (m_shellView) {
|
|
// Out of the central-widget slot before handing it back, or QMainWindow
|
|
// deletes it too.
|
|
QWidget* shell = takeCentralWidget();
|
|
m_shellView->destroyShell(shell);
|
|
// NOT deleted: m_shellView is QPluginLoader's root instance, destroyed
|
|
// by Qt in QLibraryStore::cleanup() at process exit — deleting it here
|
|
// double-frees.
|
|
m_shellView = nullptr;
|
|
}
|
|
|
|
delete m_hostAdapter;
|
|
m_hostAdapter = nullptr;
|
|
|
|
delete m_backend;
|
|
m_backend = nullptr;
|
|
|
|
if (m_trayIcon) {
|
|
delete m_trayIcon;
|
|
}
|
|
}
|
|
|
|
void Window::setupUi()
|
|
{
|
|
// The UI shell is a Qt plugin — plugins/main_ui/main_ui.{so,dylib,dll} —
|
|
// reached through IShellView / IShellHost. It gets a QWidget* out and eight
|
|
// named operations in, holds no host privilege, and links no logos runtime;
|
|
// nix/symbol-gate.nix enforces that rather than trusting it.
|
|
//
|
|
// Ownership stays HERE: the backend and its adapter belong to the Window and
|
|
// the shell only borrows them. Deliberately not parented to `this`; ~Window
|
|
// destroys them explicitly and in order.
|
|
m_backend = new MainUIBackend(m_logosAPI, m_core, nullptr);
|
|
m_hostAdapter = new ShellHostAdapter(m_backend, nullptr);
|
|
|
|
QString pluginExtension;
|
|
#if defined(Q_OS_WIN)
|
|
pluginExtension = ".dll";
|
|
#elif defined(Q_OS_MAC)
|
|
pluginExtension = ".dylib";
|
|
#else // Linux and other Unix-like systems
|
|
pluginExtension = ".so";
|
|
#endif
|
|
|
|
// Embedded (pre-installed at build time) first, then the user-writable dir.
|
|
const QString embeddedPath = LogosBasecampPaths::embeddedPluginsDirectory()
|
|
+ "/main_ui/main_ui" + pluginExtension;
|
|
const QString mainUiPluginPath =
|
|
QFile::exists(embeddedPath)
|
|
? embeddedPath
|
|
: LogosBasecampPaths::pluginsDirectory() + "/main_ui/main_ui" + pluginExtension;
|
|
|
|
// main_ui lives in its own plugins/main_ui/ directory, so anything vendored
|
|
// beside it is invisible to Windows' loader without this. No-op elsewhere;
|
|
// the reference is intentionally held for the process lifetime.
|
|
ModuleLib::preloadPluginWithOwnDirSearch(mainUiPluginPath);
|
|
|
|
QPluginLoader loader(mainUiPluginPath);
|
|
if (!loader.load()) {
|
|
qFatal("Failed to load the main UI plugin from %s: %s",
|
|
qUtf8Printable(mainUiPluginPath), qUtf8Printable(loader.errorString()));
|
|
}
|
|
|
|
// A real cast, not QMetaObject::invokeMethod("createWidget"): the string
|
|
// form is a silent-failure path — change an argument type and both sides
|
|
// still compile, then miss at runtime.
|
|
m_shellView = qobject_cast<IShellView*>(loader.instance());
|
|
if (!m_shellView) {
|
|
qFatal("main_ui at %s does not implement IShellView",
|
|
qUtf8Printable(mainUiPluginPath));
|
|
}
|
|
|
|
// All that stands between a stale plugin build and a jump through a
|
|
// mismatched vtable slot. Both sides take IShellHost_abi from the one copy
|
|
// in app/interfaces/, so a difference means the plugin was built against a
|
|
// different revision of that header.
|
|
if (m_shellView->hostAbiVersion() != IShellHost_abi) {
|
|
qFatal("main_ui was built against IShellHost ABI %d, host is %d",
|
|
m_shellView->hostAbiVersion(), IShellHost_abi);
|
|
}
|
|
|
|
// Deliberately no "No main UI module found" fallback widget: an error-label
|
|
// widget reads as a working app with an empty window. A missing or
|
|
// mismatched shell is a broken install, and the qFatal paths above say
|
|
// which.
|
|
setCentralWidget(m_shellView->createShell(m_hostAdapter));
|
|
|
|
// The launch width is sized for the Package Manager's full table (category
|
|
// sidebar through the Action and Description columns) without horizontal
|
|
// scrolling; below it those rightmost columns clip off-screen. Still
|
|
// resizable down to MainContainer's 800x600 minimum.
|
|
setWindowTitle("Logos Basecamp");
|
|
{
|
|
// ...but never larger than the screen offers: an oversized window opens
|
|
// with its right and bottom edges off-screen and, on Windows, cannot be
|
|
// dragged back, so whatever hangs off -- here the sidebar's
|
|
// bottom-anchored system buttons -- is unreachable. Measured on a
|
|
// 1271x839-logical RDP session: 1600x900 overflowed right by ~342 and
|
|
// bottom by ~97 logical px.
|
|
//
|
|
// Clamp to availableGeometry EXACTLY, not to a fraction of it, so this
|
|
// is a no-op on any display big enough for the design size; otherwise it
|
|
// silently undoes the width above. Qt raises the result back to
|
|
// MainContainer's 800x600 minimum on its own.
|
|
//
|
|
// Skipped under the offscreen platform: QOffscreenScreen hardcodes its
|
|
// geometry to 800x600, so clamping to it would shrink every headless UI
|
|
// doctest window and break qt-mcp's scene-position clicks.
|
|
//
|
|
// A screen that shrinks AFTER launch -- an RDP session with
|
|
// /dynamic-resolution -- is handled by rebindScreenWatch() +
|
|
// scheduleFitToScreen() from showEvent: here the window has not landed
|
|
// on a screen yet and its frame margins are not knowable.
|
|
QSize target(1600, 900);
|
|
m_desiredSize = target; // what to grow back to when room returns
|
|
const QScreen* windowScreen = screen();
|
|
if (windowScreen && QGuiApplication::platformName() != QLatin1String("offscreen"))
|
|
target = target.boundedTo(windowScreen->availableGeometry().size());
|
|
resize(target);
|
|
}
|
|
|
|
setAutoFillBackground(true);
|
|
{
|
|
QPalette p = palette();
|
|
p.setColor(QPalette::Window, QColor("#171717"));
|
|
setPalette(p);
|
|
}
|
|
|
|
#ifdef Q_OS_MAC
|
|
setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
|
|
setupMacOSDockReopen();
|
|
// Create title bar after resize() so it gets full width from the start
|
|
m_trafficLightsTitleBar = new TrafficLightsTitleBar(this);
|
|
m_trafficLightsTitleBar->setGeometry(0, 0, width(), TrafficLightsTitleBar::kTitleBarHeight);
|
|
m_trafficLightsTitleBar->show();
|
|
m_trafficLightsTitleBar->raise();
|
|
#endif
|
|
}
|
|
|
|
void Window::changeEvent(QEvent* event)
|
|
{
|
|
QMainWindow::changeEvent(event);
|
|
// Leaving maximized/fullscreen/minimized restores a normal-state size that
|
|
// may predate a screen change, so re-check. Scheduled rather than immediate:
|
|
// the macOS branch below deliberately resizes to w-1,h-1 and back via
|
|
// singleShot(0), and the settle delay lands after that has finished.
|
|
if (event->type() == QEvent::WindowStateChange)
|
|
scheduleFitToScreen();
|
|
#ifdef Q_OS_MAC
|
|
if (event->type() == QEvent::WindowStateChange) {
|
|
const bool fullScreen = (windowState() & Qt::WindowFullScreen) != 0;
|
|
if (m_trafficLightsTitleBar) {
|
|
if (fullScreen)
|
|
m_trafficLightsTitleBar->hide();
|
|
else
|
|
m_trafficLightsTitleBar->show();
|
|
}
|
|
applyMacWindowRoundedCorners(this, !fullScreen);
|
|
// This is needed to fix squared corners after exiting fullscreen mode
|
|
if (!fullScreen) {
|
|
const int w = width();
|
|
const int h = height();
|
|
resize(w - 1, h - 1);
|
|
QTimer::singleShot(0, this, [this, w, h]() {
|
|
resize(w, h);
|
|
applyMacWindowRoundedCorners(this, true);
|
|
});
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void Window::resizeEvent(QResizeEvent* event)
|
|
{
|
|
QMainWindow::resizeEvent(event);
|
|
// A resize the USER performed redefines what the window is entitled to when
|
|
// room returns; one WE performed must not, or the fit becomes
|
|
// min(previous, available) and converges on the smallest work area seen all
|
|
// session. isVisible() keeps Qt's own pre-show sizing out. Maximized and
|
|
// fullscreen sizes belong to the window manager, not to the user's intent:
|
|
// recording one would grow the window to full screen on un-maximize.
|
|
if (!m_applyingFit && isVisible()
|
|
&& !(windowState() & (Qt::WindowMaximized | Qt::WindowFullScreen)))
|
|
m_desiredSize = size();
|
|
#ifdef Q_OS_MAC
|
|
if (m_trafficLightsTitleBar && m_trafficLightsTitleBar->isVisible())
|
|
m_trafficLightsTitleBar->setGeometry(0, 0, width(), TrafficLightsTitleBar::kTitleBarHeight);
|
|
#endif
|
|
}
|
|
|
|
void Window::showEvent(QShowEvent* event)
|
|
{
|
|
QMainWindow::showEvent(event);
|
|
// Both idempotent, so no one-shot flag: that is what makes a tray restore
|
|
// re-fit if -- and only if -- the screen shrank while the window was hidden,
|
|
// since the fit is skipped while !isVisible(). A process-wide `static` would
|
|
// gate every Window ever constructed on the first one.
|
|
rebindScreenWatch();
|
|
fitFrameToAvailableGeometry();
|
|
#ifdef Q_OS_MAC
|
|
applyMacWindowRoundedCorners(this);
|
|
#endif
|
|
}
|
|
|
|
void Window::scheduleFitToScreen()
|
|
{
|
|
if (!m_fitTimer) {
|
|
m_fitTimer = new QTimer(this);
|
|
m_fitTimer->setSingleShot(true);
|
|
// An RDP client with /dynamic-resolution resizes the desktop
|
|
// continuously while its own window is dragged. Fitting each
|
|
// intermediate would chase the drag; wait for it to settle. Long enough
|
|
// to outlive a drag frame, short enough to be invisible.
|
|
m_fitTimer->setInterval(150);
|
|
connect(m_fitTimer, &QTimer::timeout, this, &Window::fitFrameToAvailableGeometry);
|
|
}
|
|
m_fitTimer->start(); // restart, collapsing a burst into one fit
|
|
}
|
|
|
|
void Window::rebindScreenWatch()
|
|
{
|
|
QWindow* handle = windowHandle();
|
|
if (!handle)
|
|
return; // not mapped yet; showEvent calls back
|
|
|
|
if (handle != m_watchedWindow) {
|
|
QObject::disconnect(m_screenChangedConnection);
|
|
m_watchedWindow = handle;
|
|
m_screenChangedConnection = connect(handle, &QWindow::screenChanged, this,
|
|
[this](QScreen*) { rebindScreenWatch(); });
|
|
}
|
|
|
|
QScreen* current = handle->screen();
|
|
if (current == m_watchedScreen)
|
|
return;
|
|
// Re-point exactly, by handle: disconnect(sender, ...) would also tear down
|
|
// unrelated connections, and reconnecting without disconnecting would
|
|
// double-fire.
|
|
QObject::disconnect(m_availableGeometryConnection);
|
|
m_watchedScreen = current;
|
|
if (current) {
|
|
m_availableGeometryConnection =
|
|
connect(current, &QScreen::availableGeometryChanged, this,
|
|
[this](const QRect&) { scheduleFitToScreen(); });
|
|
}
|
|
// Deliberately NO fit here. screenChanged fires when the window MOVES to
|
|
// another monitor -- which the user did on purpose -- and, on Windows, when
|
|
// Qt migrates windows onto the 1024x768 lock screen on RDP disconnect.
|
|
// Fitting then would shrink the window to lock-screen size with no way back.
|
|
}
|
|
|
|
void Window::fitFrameToAvailableGeometry()
|
|
{
|
|
// QOffscreenScreen hardcodes its geometry, so honouring it would shrink
|
|
// every headless UI-doctest window and break qt-mcp's scene-position clicks.
|
|
if (QGuiApplication::platformName() == QLatin1String("offscreen"))
|
|
return;
|
|
// Basecamp hides to the tray on close, on EVERY platform (see closeEvent),
|
|
// and hide() does not set Qt::WindowMinimized -- so the state check below
|
|
// does not cover it. A hidden window has no meaningful frame geometry and
|
|
// nothing to fit; showEvent re-runs this on restore.
|
|
if (!isVisible())
|
|
return;
|
|
// Maximized and fullscreen sizes belong to the window manager, and a
|
|
// minimized window's geometry is not its restored geometry.
|
|
if (windowState() & (Qt::WindowMaximized | Qt::WindowFullScreen | Qt::WindowMinimized))
|
|
return;
|
|
const QWindow* handle = windowHandle();
|
|
if (!handle)
|
|
return;
|
|
const QScreen* windowScreen = handle->screen();
|
|
if (!windowScreen)
|
|
return;
|
|
|
|
// Compare SIZES, not rectangles, and never touch the position.
|
|
// QRect::contains() is wrong here twice over: on Windows frameGeometry()
|
|
// comes from GetWindowRect, which includes DWM's INVISIBLE resize border
|
|
// (~13px per side at 192dpi), so a perfectly placed window never reports as
|
|
// contained and this would run on every launch; on macOS Qt's frame title
|
|
// bar sits above availableGeometry's origin, and acting on that moved the
|
|
// window 66px down at launch. Measured both.
|
|
const QSize avail = windowScreen->availableGeometry().size();
|
|
|
|
// resize() sets the CLIENT size while availableGeometry() bounds the FRAME,
|
|
// so the constructor's clamp is short by the decoration margins, knowable
|
|
// only once the platform window exists. Measured on a 3456x1826 work area:
|
|
// the clamped client filled it exactly and the frame still hung 72px below,
|
|
// putting the sidebar's bottom-anchored system buttons under the taskbar.
|
|
const QSize decoration = frameGeometry().size() - size();
|
|
|
|
// Bounded by what the window is ENTITLED to, not by its current size: the
|
|
// latter makes this min(previous, available) on every event, ratcheting down
|
|
// to the smallest work area seen all session -- an auto-hiding taskbar, or
|
|
// an RDP window dragged smaller and back, would shrink Basecamp for good.
|
|
// m_desiredSize is the launch design size, replaced by any resize the USER
|
|
// performs (see resizeEvent), so growing back never exceeds what was asked.
|
|
const QSize want = m_desiredSize.isValid() ? m_desiredSize : size();
|
|
const QSize target = (avail - decoration).boundedTo(want).expandedTo(minimumSize());
|
|
|
|
// The whole idempotence of this function, and why no re-entrancy flag is
|
|
// needed: our own resize re-enters with the same inputs and stops here.
|
|
if (target == size())
|
|
return;
|
|
|
|
QScopedValueRollback<bool> applying(m_applyingFit, true);
|
|
resize(target);
|
|
}
|
|
|
|
#ifdef Q_OS_MAC
|
|
void Window::setupMacOSDockReopen()
|
|
{
|
|
connect(qApp, &QApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) {
|
|
if (state == Qt::ApplicationActive && !isWindowShown())
|
|
restoreWindow();
|
|
});
|
|
}
|
|
|
|
// Override Qt's default macOS "Quit" (which posts a close event and gets
|
|
// swallowed by hide-to-tray). QuitRole promotes this action into the native
|
|
// app menu so Cmd+Q actually terminates the process.
|
|
void Window::createMenuBar()
|
|
{
|
|
QAction* quit = menuBar()->addMenu(tr("File"))->addAction(tr("Quit"));
|
|
quit->setObjectName(QStringLiteral("logosQuitAction"));
|
|
quit->setShortcut(QKeySequence::Quit);
|
|
quit->setMenuRole(QAction::QuitRole);
|
|
connect(quit, &QAction::triggered, this, &Window::quitApplication);
|
|
|
|
// Frameless on macOS means no native title bar and no Window menu, so
|
|
// nothing is bound to Cmd+M — route it to the traffic light's action.
|
|
// Qt maps Ctrl to Command here (see Qt::AA_MacDontSwapCtrlAndMeta).
|
|
QAction* minimize = menuBar()->addMenu(tr("Window"))->addAction(tr("Minimize"));
|
|
minimize->setObjectName(QStringLiteral("logosMinimizeAction"));
|
|
minimize->setShortcut(QKeySequence(QStringLiteral("Ctrl+M"))); // Cmd+M on macOS
|
|
minimize->setMenuRole(QAction::NoRole);
|
|
connect(minimize, &QAction::triggered, this, &QWidget::showMinimized);
|
|
}
|
|
#endif
|
|
|
|
void Window::createTrayIcon()
|
|
{
|
|
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
|
|
qWarning() << "System tray is not available on this system";
|
|
return;
|
|
}
|
|
|
|
// Create tray icon
|
|
m_trayIcon = new QSystemTrayIcon(this);
|
|
setIcon();
|
|
m_trayIcon->setToolTip("Logos Basecamp");
|
|
|
|
// Create context menu
|
|
m_trayIconMenu = new QMenu(this);
|
|
|
|
m_showHideAction = m_trayIconMenu->addAction(tr("Show/Hide"));
|
|
m_showHideAction->setObjectName(QStringLiteral("logosTrayShowHideAction"));
|
|
m_showHideAction->setCheckable(false);
|
|
connect(m_showHideAction, &QAction::triggered, this, &Window::showHideWindow);
|
|
|
|
m_trayIconMenu->addSeparator();
|
|
|
|
m_quitAction = m_trayIconMenu->addAction(tr("Quit"));
|
|
m_quitAction->setObjectName(QStringLiteral("logosTrayQuitAction"));
|
|
connect(m_quitAction, &QAction::triggered, this, &Window::quitApplication);
|
|
|
|
m_trayIcon->setContextMenu(m_trayIconMenu);
|
|
|
|
// Connect icon activation signal
|
|
connect(m_trayIcon, &QSystemTrayIcon::activated, this, &Window::iconActivated);
|
|
|
|
// Show the tray icon
|
|
m_trayIcon->show();
|
|
}
|
|
|
|
void Window::setIcon()
|
|
{
|
|
if (!m_trayIcon) {
|
|
return;
|
|
}
|
|
|
|
QIcon icon(":/icons/logos.png");
|
|
if (icon.isNull()) {
|
|
qWarning() << "Failed to load tray icon from resource";
|
|
// Create a simple fallback icon
|
|
icon = QIcon::fromTheme("application-x-executable");
|
|
if (icon.isNull()) {
|
|
// Last resort: create a minimal icon
|
|
QPixmap pixmap(16, 16);
|
|
pixmap.fill(Qt::blue);
|
|
icon = QIcon(pixmap);
|
|
}
|
|
}
|
|
m_trayIcon->setIcon(icon);
|
|
}
|
|
|
|
void Window::closeEvent(QCloseEvent *event)
|
|
{
|
|
#ifdef Q_OS_MAC
|
|
// In full screen, close only exits full screen (Discord-style); do not hide
|
|
if (windowState() & Qt::WindowFullScreen) {
|
|
setWindowState(Qt::WindowNoState);
|
|
event->ignore();
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
if (m_trayIcon && m_trayIcon->isVisible()) {
|
|
// Hide the window instead of closing
|
|
hide();
|
|
event->ignore();
|
|
|
|
// Show a message to inform the user
|
|
if (m_trayIcon->supportsMessages()) {
|
|
m_trayIcon->showMessage(
|
|
tr("Logos Basecamp"),
|
|
tr("The application will continue to run in the system tray. "
|
|
"Click the tray icon to restore the window."),
|
|
QSystemTrayIcon::Information,
|
|
2000
|
|
);
|
|
}
|
|
} else {
|
|
// No tray: close only hides — setQuitOnLastWindowClosed(false) keeps the app running.
|
|
event->accept();
|
|
}
|
|
}
|
|
|
|
bool Window::isWindowShown() const
|
|
{
|
|
// isVisible() alone is not enough: it stays true when minimized (#268), and
|
|
// on Wayland only exposure reveals that. Exposure can also drop for an
|
|
// occluded window, which then raises instead of hiding — the harmless miss.
|
|
const QWindow* handle = windowHandle();
|
|
if (!isVisible() || isMinimized() || (handle && !handle->isExposed()))
|
|
return false;
|
|
#ifdef Q_OS_MAC
|
|
return !macAppIsHidden();
|
|
#else
|
|
return true;
|
|
#endif
|
|
}
|
|
|
|
void Window::restoreWindow()
|
|
{
|
|
// macOS: order back in first, or show() re-applies WindowMinimized.
|
|
if (!isVisible())
|
|
show();
|
|
// Clear only the minimized bit, so maximized/fullscreen survives.
|
|
if (isMinimized())
|
|
setWindowState(windowState() & ~Qt::WindowMinimized);
|
|
|
|
#ifdef Q_OS_MAC
|
|
macDeminiaturize(this);
|
|
macActivateApp();
|
|
#else
|
|
// The state bit is advisory on X11 and a no-op on Wayland (no unminimize
|
|
// request exists), so force a fresh map — that every display server honours.
|
|
if (windowHandle() && !windowHandle()->isExposed()) {
|
|
hide();
|
|
show();
|
|
}
|
|
#endif
|
|
raise();
|
|
activateWindow();
|
|
}
|
|
|
|
void Window::showHideWindow()
|
|
{
|
|
if (isWindowShown()) {
|
|
hide();
|
|
} else {
|
|
restoreWindow();
|
|
}
|
|
}
|
|
|
|
void Window::iconActivated(QSystemTrayIcon::ActivationReason reason)
|
|
{
|
|
switch (reason) {
|
|
case QSystemTrayIcon::Trigger:
|
|
// Single click - toggle window visibility
|
|
showHideWindow();
|
|
break;
|
|
case QSystemTrayIcon::DoubleClick:
|
|
// Double click - also toggle (some platforms use double click)
|
|
showHideWindow();
|
|
break;
|
|
case QSystemTrayIcon::MiddleClick:
|
|
// Middle click - toggle as well
|
|
showHideWindow();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void Window::quitApplication()
|
|
{
|
|
// Hide tray icon first
|
|
if (m_trayIcon) {
|
|
m_trayIcon->hide();
|
|
}
|
|
|
|
// Quit the application
|
|
QApplication::quit();
|
|
}
|