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.
This commit is contained in:
2026-08-26 22:26:29 +12:00
parent e96a22eb9e
commit 08e67845a5
11 changed files with 329 additions and 4 deletions
+47 -1
View File
@@ -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();
}
}
+8
View File
@@ -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;
};
+1
View File
@@ -16,6 +16,7 @@ set(NEBULA_UI_QML_FILES
NebulaCard.qml
NebulaIconButton.qml
NebulaTextField.qml
NebulaToolTip.qml
FocusFrame.qml
NebulaIcon.qml
)
+1 -1
View File
@@ -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`)
@@ -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()
@@ -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
}
}
@@ -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)
}
}
@@ -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)
+1
View File
@@ -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
+25 -2
View File
@@ -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
}
}
}
@@ -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
}
}
}
}
}