From 08e67845a5901945c2e5d71f1899b24f88d0b635 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Wed, 26 Aug 2026 22:26:29 +1200 Subject: [PATCH] Add focused window controls to top bar Introduces a new `WindowControls` component in the desktop top bar with minimize, maximize/restore, and close actions for the focused window. `WindowService` now exposes focused-window presence/maximized state plus focused-window convenience actions to support this UI. Also extends Nebula UI with a reusable `NebulaToolTip`, keyboard/pressed-state support in `NebulaIconButton`, new window-control glyphs in `NebulaIcon`, and theme tokens for pressed/danger hover states, with CMake and README updates to register/document the new components. --- core/windows/WindowService.cpp | 48 ++++++++- core/windows/WindowService.hpp | 8 ++ packages/nebula-ui/CMakeLists.txt | 1 + packages/nebula-ui/README.md | 2 +- .../nebula-ui/qml/Nebula/UI/NebulaIcon.qml | 47 +++++++++ .../qml/Nebula/UI/NebulaIconButton.qml | 22 +++++ .../nebula-ui/qml/Nebula/UI/NebulaToolTip.qml | 98 +++++++++++++++++++ packages/nebula-ui/qml/Nebula/UI/Theme.qml | 3 + shells/desktop/shell/CMakeLists.txt | 1 + .../desktop/shell/qml/components/TopBar.qml | 27 ++++- .../shell/qml/components/WindowControls.qml | 76 ++++++++++++++ 11 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 packages/nebula-ui/qml/Nebula/UI/NebulaToolTip.qml create mode 100644 shells/desktop/shell/qml/components/WindowControls.qml diff --git a/core/windows/WindowService.cpp b/core/windows/WindowService.cpp index 09a3ab4..4dce015 100644 --- a/core/windows/WindowService.cpp +++ b/core/windows/WindowService.cpp @@ -67,6 +67,11 @@ int WindowService::windowCount() const return m_model->count(); } +bool WindowService::hasFocusedWindow() const +{ + return !m_focusedWindowId.isEmpty(); +} + QString WindowService::focusedWindowId() const { return m_focusedWindowId; @@ -87,6 +92,11 @@ QString WindowService::focusedApplicationName() const return m_focusedApplicationName; } +bool WindowService::focusedWindowMaximized() const +{ + return m_focusedWindowMaximized; +} + bool WindowService::focusWindow(const QString &windowId) { return m_backend->focusWindow(parseWindowId(windowId)); @@ -112,6 +122,38 @@ bool WindowService::minimizeWindow(const QString &windowId) return m_backend->minimizeWindow(parseWindowId(windowId)); } +bool WindowService::minimizeFocusedWindow() +{ + if (m_focusedWindowId.isEmpty()) + return false; + return minimizeWindow(m_focusedWindowId); +} + +bool WindowService::toggleMaximizeFocusedWindow() +{ + if (m_focusedWindowId.isEmpty()) + return false; + + const bool restore = m_focusedWindowMaximized; + const bool ok = restore ? restoreWindow(m_focusedWindowId) + : maximizeWindow(m_focusedWindowId); + if (!ok) + return false; + + if (m_focusedWindowMaximized == restore) { + m_focusedWindowMaximized = !restore; + emit focusChanged(); + } + return true; +} + +bool WindowService::closeFocusedWindow() +{ + if (m_focusedWindowId.isEmpty()) + return false; + return closeWindow(m_focusedWindowId); +} + bool WindowService::setWindowFloating(const QString &windowId, bool floating) { return m_backend->setWindowFloating(parseWindowId(windowId), floating); @@ -154,6 +196,7 @@ void WindowService::syncFromBackend() QString title; QString appId; QString appName; + bool maximized = false; if (const WindowInfo *focused = m_model->windowById(focusedId)) { if (!focused->minimized) { @@ -161,17 +204,20 @@ void WindowService::syncFromBackend() title = focused->title; appId = focused->appId.isEmpty() ? focused->windowClass : focused->appId; appName = focused->displayName; + maximized = focused->maximized; } } if (m_focusedWindowId != windowId || m_focusedWindowTitle != title || m_focusedApplicationId != appId - || m_focusedApplicationName != appName) { + || m_focusedApplicationName != appName + || m_focusedWindowMaximized != maximized) { m_focusedWindowId = windowId; m_focusedWindowTitle = title; m_focusedApplicationId = appId; m_focusedApplicationName = appName; + m_focusedWindowMaximized = maximized; emit focusChanged(); } } diff --git a/core/windows/WindowService.hpp b/core/windows/WindowService.hpp index 7685246..7817538 100644 --- a/core/windows/WindowService.hpp +++ b/core/windows/WindowService.hpp @@ -21,10 +21,12 @@ class WindowService : public QObject Q_PROPERTY(WindowModel *model READ model CONSTANT) Q_PROPERTY(WorkspaceModel *workspaceModel READ workspaceModel CONSTANT) Q_PROPERTY(int windowCount READ windowCount NOTIFY windowCountChanged) + Q_PROPERTY(bool hasFocusedWindow READ hasFocusedWindow NOTIFY focusChanged) Q_PROPERTY(QString focusedWindowId READ focusedWindowId NOTIFY focusChanged) Q_PROPERTY(QString focusedWindowTitle READ focusedWindowTitle NOTIFY focusChanged) Q_PROPERTY(QString focusedApplicationId READ focusedApplicationId NOTIFY focusChanged) Q_PROPERTY(QString focusedApplicationName READ focusedApplicationName NOTIFY focusChanged) + Q_PROPERTY(bool focusedWindowMaximized READ focusedWindowMaximized NOTIFY focusChanged) public: enum SnapEdge { @@ -42,16 +44,21 @@ public: WorkspaceModel *workspaceModel() const; int windowCount() const; + bool hasFocusedWindow() const; QString focusedWindowId() const; QString focusedWindowTitle() const; QString focusedApplicationId() const; QString focusedApplicationName() const; + bool focusedWindowMaximized() const; Q_INVOKABLE bool focusWindow(const QString &windowId); Q_INVOKABLE bool closeWindow(const QString &windowId); Q_INVOKABLE bool maximizeWindow(const QString &windowId); Q_INVOKABLE bool restoreWindow(const QString &windowId); Q_INVOKABLE bool minimizeWindow(const QString &windowId); + Q_INVOKABLE bool minimizeFocusedWindow(); + Q_INVOKABLE bool toggleMaximizeFocusedWindow(); + Q_INVOKABLE bool closeFocusedWindow(); Q_INVOKABLE bool setWindowFloating(const QString &windowId, bool floating); Q_INVOKABLE bool moveWindow(const QString &windowId, int x, int y); Q_INVOKABLE bool resizeWindow(const QString &windowId, int width, int height); @@ -79,4 +86,5 @@ private: QString m_focusedWindowTitle; QString m_focusedApplicationId; QString m_focusedApplicationName; + bool m_focusedWindowMaximized = false; }; diff --git a/packages/nebula-ui/CMakeLists.txt b/packages/nebula-ui/CMakeLists.txt index 95db608..5798d22 100644 --- a/packages/nebula-ui/CMakeLists.txt +++ b/packages/nebula-ui/CMakeLists.txt @@ -16,6 +16,7 @@ set(NEBULA_UI_QML_FILES NebulaCard.qml NebulaIconButton.qml NebulaTextField.qml + NebulaToolTip.qml FocusFrame.qml NebulaIcon.qml ) diff --git a/packages/nebula-ui/README.md b/packages/nebula-ui/README.md index 8dd85e1..15d1827 100644 --- a/packages/nebula-ui/README.md +++ b/packages/nebula-ui/README.md @@ -11,7 +11,7 @@ import Nebula.UI This module contains reusable visual language only: - colour, spacing, type, and motion tokens (`Theme`) -- generic controls (`NebulaButton`, `NebulaCard`, `NebulaIconButton`, `NebulaTextField`) +- generic controls (`NebulaButton`, `NebulaCard`, `NebulaIconButton`, `NebulaTextField`, `NebulaToolTip`) - focus treatment (`FocusFrame`) - compact vector icons (`NebulaIcon`) diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml index f53500e..b6c8835 100644 --- a/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaIcon.qml @@ -82,6 +82,18 @@ Item { case "application": paintApplication(ctx) break + case "minimize": + paintMinimize(ctx) + break + case "maximize": + paintMaximize(ctx) + break + case "restore": + paintRestore(ctx) + break + case "close": + paintClose(ctx) + break default: paintApplication(ctx) break @@ -192,6 +204,41 @@ Item { ctx.stroke() } + function paintMinimize(ctx) { + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.moveTo(6.4, 16.2) + ctx.lineTo(17.6, 16.2) + ctx.stroke() + } + + function paintMaximize(ctx) { + ctx.lineWidth = 1.5 + ctx.beginPath() + roundedRect(ctx, 6.2, 6.2, 11.6, 11.6, 2) + ctx.stroke() + } + + function paintRestore(ctx) { + ctx.lineWidth = 1.45 + ctx.beginPath() + roundedRect(ctx, 8.6, 5.6, 9.4, 9.4, 1.7) + ctx.stroke() + ctx.beginPath() + roundedRect(ctx, 5.6, 8.6, 9.4, 9.4, 1.7) + ctx.stroke() + } + + function paintClose(ctx) { + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.moveTo(7.3, 7.3) + ctx.lineTo(16.7, 16.7) + ctx.moveTo(16.7, 7.3) + ctx.lineTo(7.3, 16.7) + ctx.stroke() + } + function paintApplication(ctx) { ctx.lineWidth = 1.5 ctx.beginPath() diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml index c77e679..6fdc240 100644 --- a/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaIconButton.qml @@ -7,6 +7,7 @@ Item { property bool active: false property bool danger: false readonly property bool hovered: hover.containsMouse + readonly property bool pressed: hover.pressed readonly property color iconColor: { if (danger && hovered) return Theme.danger @@ -23,16 +24,32 @@ Item { implicitWidth: Theme.controlHeight implicitHeight: Theme.controlHeight + activeFocusOnTab: true Accessible.role: Accessible.Button Accessible.name: root.label Accessible.onPressAction: root.clicked() + Keys.onReturnPressed: { + event.accepted = true + root.clicked() + } + Keys.onSpacePressed: { + event.accepted = true + root.clicked() + } + Rectangle { anchors.fill: parent radius: Theme.radiusSmall color: { + if (root.danger && hover.pressed) + return Theme.dangerSoftStrong + if (root.danger && hover.containsMouse) + return Theme.dangerSoft if (root.active) return Theme.accentSoft + if (hover.pressed) + return Theme.pressedFill if (hover.containsMouse) return Theme.hoverFill return "transparent" @@ -63,4 +80,9 @@ Item { FocusFrame { radius: Theme.radiusSmall } + + NebulaToolTip { + text: root.label + active: hover.containsMouse && !hover.pressed && root.label.length > 0 + } } diff --git a/packages/nebula-ui/qml/Nebula/UI/NebulaToolTip.qml b/packages/nebula-ui/qml/Nebula/UI/NebulaToolTip.qml new file mode 100644 index 0000000..b9c3aca --- /dev/null +++ b/packages/nebula-ui/qml/Nebula/UI/NebulaToolTip.qml @@ -0,0 +1,98 @@ +import QtQuick +import QtQuick.Window + +Item { + id: root + + property string text: "" + property bool active: false + property int delay: 550 + property int timeout: 4000 + + width: 0 + height: 0 + enabled: false + Accessible.ignored: true + + Timer { + id: showTimer + interval: root.delay + repeat: false + onTriggered: root.open() + } + + Timer { + id: hideTimer + interval: root.timeout + repeat: false + onTriggered: tip.visible = false + } + + Window { + id: tip + flags: Qt.Popup | Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowDoesNotAcceptFocus + color: "transparent" + visible: false + width: Math.ceil(tipText.implicitWidth) + 16 + height: Math.ceil(tipText.implicitHeight) + 10 + + Rectangle { + anchors.fill: parent + radius: Theme.radiusSmall + color: Theme.surfaceRaised + border.color: Theme.borderStrong + border.width: 1 + + Text { + id: tipText + anchors.centerIn: parent + text: root.text + color: Theme.textPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + onActiveChanged: root.sync() + onTextChanged: { + if (tip.visible) + root.place() + else + root.sync() + } + + function sync() { + if (root.active && root.text.length > 0) { + showTimer.restart() + return + } + showTimer.stop() + hideTimer.stop() + tip.visible = false + } + + function open() { + if (!root.active || root.text.length === 0) + return + const host = root.parent + if (!host) + return + const win = host.Window.window + if (win) + tip.transientParent = win + root.place() + tip.visible = true + hideTimer.restart() + } + + function place() { + const host = root.parent + if (!host) + return + const pos = host.mapToGlobal(host.width / 2, host.height + 6) + const screenWidth = Screen.width + tip.x = Math.round(Math.max(8, Math.min(pos.x - tip.width / 2, screenWidth - tip.width - 8))) + tip.y = Math.round(pos.y) + } +} diff --git a/packages/nebula-ui/qml/Nebula/UI/Theme.qml b/packages/nebula-ui/qml/Nebula/UI/Theme.qml index 648de6e..f0f3bb0 100644 --- a/packages/nebula-ui/qml/Nebula/UI/Theme.qml +++ b/packages/nebula-ui/qml/Nebula/UI/Theme.qml @@ -26,6 +26,9 @@ QtObject { readonly property color focus: Qt.rgba(126 / 255, 160 / 255, 196 / 255, 0.55) readonly property color hoverFill: Qt.rgba(1, 1, 1, 0.06) + readonly property color pressedFill: Qt.rgba(1, 1, 1, 0.10) + readonly property color dangerSoft: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.18) + readonly property color dangerSoftStrong: Qt.rgba(201 / 255, 136 / 255, 136 / 255, 0.28) readonly property color tileHover: Qt.rgba(1, 1, 1, 0.05) readonly property color iconWell: Qt.rgba(1, 1, 1, 0.05) readonly property color iconWellHighlight: Qt.rgba(1, 1, 1, 0.06) diff --git a/shells/desktop/shell/CMakeLists.txt b/shells/desktop/shell/CMakeLists.txt index 00b367b..68c6cb9 100644 --- a/shells/desktop/shell/CMakeLists.txt +++ b/shells/desktop/shell/CMakeLists.txt @@ -48,6 +48,7 @@ set(NEBULA_SHELL_QML_FILES qml/surfaces/TopBarSurface.qml qml/surfaces/LauncherSurface.qml qml/components/TopBar.qml + qml/components/WindowControls.qml qml/components/NebulaButton.qml qml/components/Clock.qml qml/components/SystemArea.qml diff --git a/shells/desktop/shell/qml/components/TopBar.qml b/shells/desktop/shell/qml/components/TopBar.qml index 904e93c..844033d 100644 --- a/shells/desktop/shell/qml/components/TopBar.qml +++ b/shells/desktop/shell/qml/components/TopBar.qml @@ -23,15 +23,19 @@ Item { id: left anchors.left: parent.left anchors.leftMargin: 6 + anchors.right: right.left + anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter spacing: 8 NebulaButton { + id: nebulaButton checked: ShellState.launcherOpen onClicked: ShellState.toggleLauncher() } Rectangle { + id: leftDivider anchors.verticalCenter: parent.verticalCenter width: 1 height: 14 @@ -40,7 +44,7 @@ Item { Text { anchors.verticalCenter: parent.verticalCenter - width: Math.min(implicitWidth, root.width * 0.4) + width: Math.min(implicitWidth, Math.max(0, left.width - nebulaButton.width - leftDivider.width - left.spacing * 2)) text: ShellState.activeApplication color: Theme.textSecondary font.family: Theme.fontFamily @@ -50,9 +54,28 @@ Item { } } - SystemArea { + Row { + id: right anchors.right: parent.right anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter + spacing: windowControls.visible ? 8 : 0 + + WindowControls { + id: windowControls + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + visible: windowControls.visible + width: visible ? 1 : 0 + height: 14 + color: Theme.borderStrong + anchors.verticalCenter: parent.verticalCenter + } + + SystemArea { + anchors.verticalCenter: parent.verticalCenter + } } } diff --git a/shells/desktop/shell/qml/components/WindowControls.qml b/shells/desktop/shell/qml/components/WindowControls.qml new file mode 100644 index 0000000..8fd92a8 --- /dev/null +++ b/shells/desktop/shell/qml/components/WindowControls.qml @@ -0,0 +1,76 @@ +import QtQuick +import Nebula.UI +import Nebula.Windows + +Item { + id: root + + readonly property bool maximized: WindowService.focusedWindowMaximized + + visible: WindowService.hasFocusedWindow + implicitWidth: well.implicitWidth + implicitHeight: Theme.controlHeight + width: visible ? implicitWidth : 0 + Accessible.role: Accessible.Grouping + Accessible.name: qsTr("Window controls") + + Rectangle { + id: well + anchors.fill: parent + implicitWidth: row.implicitWidth + 2 + implicitHeight: Theme.controlHeight + radius: Theme.radiusSmall + color: Theme.iconWell + border.width: 1 + border.color: Theme.border + + Row { + id: row + anchors.centerIn: parent + spacing: 0 + + NebulaIconButton { + id: minimizeButton + width: 26 + height: 26 + label: qsTr("Minimise") + onClicked: WindowService.minimizeFocusedWindow() + + NebulaIcon { + name: "minimize" + size: 14 + color: minimizeButton.iconColor + } + } + + NebulaIconButton { + id: maximizeButton + width: 26 + height: 26 + label: root.maximized ? qsTr("Restore") : qsTr("Maximise") + onClicked: WindowService.toggleMaximizeFocusedWindow() + + NebulaIcon { + name: root.maximized ? "restore" : "maximize" + size: 14 + color: maximizeButton.iconColor + } + } + + NebulaIconButton { + id: closeButton + width: 26 + height: 26 + label: qsTr("Close") + danger: true + onClicked: WindowService.closeFocusedWindow() + + NebulaIcon { + name: "close" + size: 14 + color: closeButton.iconColor + } + } + } + } +}