A float button in every window's title bar

Frametop's own window decoration (decoration/), a QML decoration for KWin's
Aurorae engine drawn like Breeze, puts a float button left of Close. It's the
Keep Below button with its own glyph: the KWin script floats a window when
keep-below is set and docks it when it's cleared, and keeps the flag set on
every floating window, so the button shows "back to the desktop" there. The
session script installs the decoration for the Frametop desktop only;
decoration/apply.sh switches a running desktop to it or back to Breeze.

Not yet tried in a running KWin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 22:03:41 -06:00
1 parent 86c89f90af
commit f32187824a
9 files changed
+498 -5

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Switch the running Frametop desktop's window decoration without restarting it: Frametop's
# own (this folder, with the float button) or back to Breeze. The session script does the
# same at every desktop start (session/frametop-session.sh), so this is for trying changes.
# decoration/apply.sh install this folder's copy and use it
# decoration/apply.sh --off back to Breeze (until the next desktop start)
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
deco=kwin4_decoration_qml_frametop
cfg=$HOME/.config/frametop
kwinrc=$cfg/kwinrc
# The desktop's KWin: the one using Frametop's config folder. Its D-Bus is the session's.
bus=
for pid in $(pgrep -x kwin_wayland); do
env=$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null) || continue
grep -qx "XDG_CONFIG_HOME=$cfg" <<<"$env" || continue
bus=$(sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p' <<<"$env")
done
[ -n "$bus" ] || { echo "the Frametop desktop isn't running" >&2; exit 1; }
if [ "${1:-}" = --off ]; then
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key library --delete
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --delete
else
dir=${XDG_DATA_HOME:-$HOME/.local/share}/kwin/decorations/$deco
rm -rf "$dir"
mkdir -p "$(dirname "$dir")"
cp -r "$here" "$dir"
rm -f "$dir/apply.sh"
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key library org.kde.kwin.aurorae
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme "$deco"
fi
DBUS_SESSION_BUS_ADDRESS=$bus gdbus call --session -d org.kde.KWin -o /KWin -m org.kde.KWin.reconfigure >/dev/null
echo "decoration: $(kreadconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --default Breeze)"
+120
View File
@@ -0,0 +1,120 @@
/*
A title bar button drawn the way Breeze draws its own: a glyph in the title's colour,
a soft circle behind it on hover, and a red circle for Close.
The float button is KWin's Keep Below button with a glyph of its own: Frametop's KWin
script (float/frametop-float.js) floats a window when keep-below is set on it and docks
it when it's cleared, and keeps the flag set on every floating window, so "toggled" here
means "floating" and the glyph turns into "back to the desktop".
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import QtQuick.Shapes
import org.kde.kwin.decoration
DecorationButton {
id: button
property real size: 20
property color fg: "white"
property color bg: "black"
readonly property bool isClose: buttonType === DecorationOptions.DecorationButtonClose
readonly property bool isFloat: buttonType === DecorationOptions.DecorationButtonKeepBelow
// The glyph, as polylines in an 18 x 18 box (Breeze's own sizes).
readonly property var glyph: {
switch (buttonType) {
case DecorationOptions.DecorationButtonClose:
return [[[5, 5], [13, 13]], [[13, 5], [5, 13]]];
case DecorationOptions.DecorationButtonMaximizeRestore:
return decoration.client.maximized ? [[[4.5, 9], [9, 4.5], [13.5, 9], [9, 13.5], [4.5, 9]]]
: [[[4, 11.5], [9, 6.5], [14, 11.5]]];
case DecorationOptions.DecorationButtonMinimize:
return [[[4, 7], [9, 12], [14, 7]]];
case DecorationOptions.DecorationButtonKeepAbove:
return [[[4, 9], [9, 4], [14, 9]], [[4, 14], [9, 9], [14, 14]]];
case DecorationOptions.DecorationButtonShade:
return [[[4, 5], [14, 5]], [[4, 13], [9, 8], [14, 13]]];
case DecorationOptions.DecorationButtonApplicationMenu:
return [[[3.5, 5], [14.5, 5]], [[3.5, 9], [14.5, 9]], [[3.5, 13], [14.5, 13]]];
case DecorationOptions.DecorationButtonKeepBelow:
// A box with an arrow leaving it (float in VR), or coming back into it (put it back).
return button.toggled
? [[[8, 3.5], [3.5, 3.5], [3.5, 14.5], [14.5, 14.5], [14.5, 10]], [[14.5, 3.5], [8.5, 9.5]],
[[8.5, 5], [8.5, 9.5], [13, 9.5]]]
: [[[8, 3.5], [3.5, 3.5], [3.5, 14.5], [14.5, 14.5], [14.5, 10]], [[8.5, 9.5], [14.5, 3.5]],
[[10, 3.5], [14.5, 3.5], [14.5, 8]]];
}
return [];
}
readonly property string tip: isFloat ? (toggled ? "Back to Desktop" : "Float in VR") : ""
width: size
height: size
Rectangle {
anchors.fill: parent
radius: width / 2
visible: button.hovered || button.pressed || (button.toggled && !button.isFloat)
color: button.isClose ? (button.pressed ? "#c0392b" : "#da4453")
: Qt.rgba(button.fg.r, button.fg.g, button.fg.b,
button.pressed ? 0.35 : button.hovered ? 0.2 : 0.12)
}
// On all desktops: a dot, filled while the window is on all of them. Help: a question mark.
Rectangle {
visible: button.buttonType === DecorationOptions.DecorationButtonOnAllDesktops
anchors.centerIn: parent
width: button.size * 0.3
height: width
radius: width / 2
color: button.toggled ? button.fg : "transparent"
border.width: Math.max(1, button.size / 18)
border.color: button.fg
}
Text {
visible: button.buttonType === DecorationOptions.DecorationButtonQuickHelp
anchors.centerIn: parent
text: "?"
color: button.fg
font.pixelSize: button.size * 0.65
font.bold: true
}
// Up to three strokes per glyph.
function stroke(i) {
const k = button.size / 18;
return i < glyph.length ? glyph[i].map(p => Qt.point(p[0] * k, p[1] * k)) : [];
}
readonly property color strokeColor: isClose && (hovered || pressed) ? "white" : fg
readonly property real strokeWidth: Math.max(1, size / 18 * 1.1)
// (An inline component can't see the ids around it, so everything comes in as properties.)
component Stroke: ShapePath {
property var points: []
fillColor: "transparent"
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
PathPolyline { path: points }
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
Stroke { points: button.stroke(0); strokeColor: button.strokeColor; strokeWidth: button.strokeWidth }
Stroke { points: button.stroke(1); strokeColor: button.strokeColor; strokeWidth: button.strokeWidth }
Stroke { points: button.stroke(2); strokeColor: button.strokeColor; strokeWidth: button.strokeWidth }
}
onHoveredChanged: {
if (!tip || typeof decoration.requestShowToolTip !== "function")
return;
if (hovered)
decoration.requestShowToolTip(tip);
else
decoration.requestHideToolTip();
}
Component.onCompleted: {
if (buttonType === DecorationOptions.DecorationButtonQuickHelp)
visible = Qt.binding(() => decoration.client.providesContextHelp);
if (buttonType === DecorationOptions.DecorationButtonApplicationMenu)
visible = Qt.binding(() => decoration.client.hasApplicationMenu);
}
}
+270
View File
@@ -0,0 +1,270 @@
/*
Frametop's window decoration: Breeze's flat title bar, drawn in QML for KWin's Aurorae
engine, plus a button left of Close that floats the window in VR (docs/floating-windows.md,
decision 25). Aurorae loads QML without compiling anything, so this keeps working across
SteamOS's KWin updates, which a C++ decoration wouldn't.
The float button is the Keep Below button (FtButton.qml): Frametop's KWin script floats a
window when keep-below is set and docks it when it's cleared. A Keep Below button in the
configured button order is left out, since the float button stands in for it.
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import org.kde.kwin.decoration
Decoration {
id: root
alpha: false
DecorationOptions {
id: options
deco: decoration
}
TextMetrics {
id: metrics
font: options.titleFont
text: "Mj"
}
readonly property bool maximized: decoration.client.maximized
readonly property int buttonSize: Math.max(16, Math.round(metrics.height * 1.25))
readonly property int titleHeight: buttonSize + 8
readonly property int borderSize: decorationSettings.borderSize
readonly property int side: {
switch (borderSize) {
case DecorationOptions.BorderNone:
case DecorationOptions.BorderNoSides: return 0;
case DecorationOptions.BorderTiny: return 2;
case DecorationOptions.BorderLarge: return 6;
case DecorationOptions.BorderVeryLarge: return 8;
case DecorationOptions.BorderHuge: return 12;
case DecorationOptions.BorderVeryHuge: return 18;
case DecorationOptions.BorderOversized: return 27;
default: return 4;
}
}
readonly property int bottom: borderSize === DecorationOptions.BorderNone ? 0
: borderSize === DecorationOptions.BorderNoSides ? 4 : side
readonly property color outline: Qt.tint(options.titleBarColor, Qt.rgba(0, 0, 0, 0.35))
function applyBorders() {
borders.left = side;
borders.right = side;
borders.bottom = bottom;
borders.top = titleHeight;
maximizedBorders.top = titleHeight;
// Without visible side borders, keep a strip to grab for resizing.
extendedBorders.left = side ? 0 : 4;
extendedBorders.right = side ? 0 : 4;
extendedBorders.bottom = bottom ? 0 : 4;
}
onTitleHeightChanged: applyBorders()
onSideChanged: applyBorders()
onBottomChanged: applyBorders()
Component.onCompleted: applyBorders()
// The configured buttons, with the float button left of Close (or first on the right
// when there's no Close), and no Keep Below of their own.
function order(list, right) {
const out = [];
let placed = false;
for (let i = 0; i < list.length; ++i) {
const t = list[i];
if (t === DecorationOptions.DecorationButtonKeepBelow)
continue;
if (t === DecorationOptions.DecorationButtonClose && !placed) {
if (right) {
out.push(DecorationOptions.DecorationButtonKeepBelow, t);
} else {
out.push(t, DecorationOptions.DecorationButtonKeepBelow);
}
placed = true;
continue;
}
out.push(t);
}
return {buttons: out, placed: placed};
}
readonly property var leftOrder: order(options.titleButtonsLeft || [], false)
readonly property var rightOrder: {
const r = order(options.titleButtonsRight || [], true);
if (!r.placed && !leftOrder.placed)
r.buttons.unshift(DecorationOptions.DecorationButtonKeepBelow);
return r;
}
function componentFor(t) {
switch (t) {
case DecorationOptions.DecorationButtonMenu: return menuButton;
case DecorationOptions.DecorationButtonExplicitSpacer: return spacer;
case DecorationOptions.DecorationButtonClose: return closeButton;
case DecorationOptions.DecorationButtonMaximizeRestore: return maximizeButton;
case DecorationOptions.DecorationButtonMinimize: return minimizeButton;
case DecorationOptions.DecorationButtonKeepBelow: return floatButton;
case DecorationOptions.DecorationButtonKeepAbove: return keepAboveButton;
case DecorationOptions.DecorationButtonShade: return shadeButton;
case DecorationOptions.DecorationButtonOnAllDesktops: return allDesktopsButton;
case DecorationOptions.DecorationButtonQuickHelp: return helpButton;
case DecorationOptions.DecorationButtonApplicationMenu: return appMenuButton;
}
return null;
}
Rectangle {
anchors.fill: parent
color: options.titleBarColor
border.width: root.maximized ? 0 : 1
border.color: root.outline
}
Item {
id: titleBar
x: root.maximized ? 0 : Math.max(root.side, 1)
y: root.maximized ? 0 : 1
width: root.width - 2 * x
height: root.titleHeight - y
Row {
id: leftButtons
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Repeater {
model: root.leftOrder.buttons
delegate: Loader {
required property var modelData
sourceComponent: root.componentFor(modelData)
}
}
}
Row {
id: rightButtons
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
spacing: 4
layoutDirection: Qt.LeftToRight
Repeater {
model: root.rightOrder.buttons
delegate: Loader {
required property var modelData
sourceComponent: root.componentFor(modelData)
}
}
}
// Centred over the whole bar, like Breeze, but never under the buttons.
Text {
id: caption
readonly property real free: rightButtons.x - (leftButtons.x + leftButtons.width) - 16
width: Math.min(implicitWidth, free)
x: Math.max(leftButtons.x + leftButtons.width + 8,
Math.min((parent.width - width) / 2, rightButtons.x - 8 - width))
anchors.verticalCenter: parent.verticalCenter
text: decoration.client.caption
textFormat: Text.PlainText
font: options.titleFont
color: options.fontColor
elide: Text.ElideRight
renderType: Text.NativeRendering
}
Component.onCompleted: decoration.installTitleItem(titleBar)
}
Component {
id: menuButton
MenuButton {
width: root.buttonSize
height: root.buttonSize
}
}
Component {
id: spacer
Item {
width: root.buttonSize
height: root.buttonSize
}
}
Component {
id: closeButton
FtButton {
buttonType: DecorationOptions.DecorationButtonClose
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: maximizeButton
FtButton {
buttonType: DecorationOptions.DecorationButtonMaximizeRestore
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: minimizeButton
FtButton {
buttonType: DecorationOptions.DecorationButtonMinimize
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: floatButton
FtButton {
buttonType: DecorationOptions.DecorationButtonKeepBelow
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: keepAboveButton
FtButton {
buttonType: DecorationOptions.DecorationButtonKeepAbove
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: shadeButton
FtButton {
buttonType: DecorationOptions.DecorationButtonShade
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: allDesktopsButton
FtButton {
buttonType: DecorationOptions.DecorationButtonOnAllDesktops
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: helpButton
FtButton {
buttonType: DecorationOptions.DecorationButtonQuickHelp
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
Component {
id: appMenuButton
FtButton {
buttonType: DecorationOptions.DecorationButtonApplicationMenu
size: root.buttonSize
fg: options.fontColor
bg: options.titleBarColor
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"KPackageStructure": "KWin/Decoration",
"KPlugin": {
"Authors": [
{
"Name": "Frametop"
}
],
"Description": "Breeze-style window decoration with a button that floats the window in VR (Frametop desktop)",
"Id": "kwin4_decoration_qml_frametop",
"License": "GPL",
"Name": "Frametop"
}
}