Shrink to a floating button instead of hiding

With "Shrink to Button" on, the dismiss key shrinks the window to just
itself: a see-through button that can be dragged anywhere. Tapping it
grows the keyboard back around it, kept on screen. KWin moves and
resizes the window through a one-off script, as Wayland clients can't
place themselves.

Also moves the window back on screen whenever it appears (KWin remembers
where the button was), and releases the mouse after a window manager
move or resize, which used to eat the next press on the grab bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 01:05:37 +01:00
co-authored by Claude Opus 5.5
parent ac5f43ca5f
commit 032fbd8ba4
15 changed files with 401 additions and 14 deletions
+1
View File
@@ -14,6 +14,7 @@ iPad-style floating on-screen keyboard for Plasma Wayland. Qt 6, C++ core, QML U
- Alt Gr (right of space in full mode) types the keymap's AltGr symbols (sk: Alt Gr+J = '), sticky like the others - Alt Gr (right of space in full mode) types the keymap's AltGr symbols (sk: Alt Gr+J = '), sticky like the others
- Optional PC key blocks: function row, navigation (ins/home/pg up, del/end/pg dn, arrows), numeric keypad; any mix, typing keys included (⚙) - Optional PC key blocks: function row, navigation (ins/home/pg up, del/end/pg dn, arrows), numeric keypad; any mix, typing keys included (⚙)
- Floating: drag the bottom bar, resize from the corner; geometry is remembered per screen - Floating: drag the bottom bar, resize from the corner; geometry is remembered per screen
- "Shrink to Button" (⚙): ⌨ leaves just itself as a see-through floating button (drag it anywhere); tap it for the keyboard back, around the button and kept on screen
- Follows Plasma's virtual keyboard setting (tray toggle): off never pops up on focus, the default only after touch or a pen; "Always Show" and D-Bus `Show`/`Toggle` still show it - Follows Plasma's virtual keyboard setting (tray toggle): off never pops up on focus, the default only after touch or a pen; "Always Show" and D-Bus `Show`/`Toggle` still show it
- See-through background (opacity slider in ⚙) with optional blur behind it (KWin's blur effect) - See-through background (opacity slider in ⚙) with optional blur behind it (KWin's blur effect)
+46
View File
@@ -0,0 +1,46 @@
import QtQuick
// The dismiss key left floating while the keyboard is shrunk; see-through so
// it hides less. Tap to bring the keyboard back; dragging is up to the owner
// (the window manager's move).
Item {
id: button
signal tapped()
signal dragStarted()
opacity: 0.6
Accessible.role: Accessible.Button
Accessible.name: qsTr("Show Keyboard")
Rectangle {
anchors.fill: parent
radius: Theme.keyRadius
color: tap.pressed ? Theme.characterKey : Theme.functionKey
}
Text {
anchors.centerIn: parent
text: "⌨"
font.pixelSize: button.height * 0.4
color: Theme.text
}
TapHandler {
id: tap
// The move ends with a release that would otherwise count as a tap.
property bool moved: false
onPressedChanged: if (pressed) moved = false
onTapped: if (!moved) button.tapped()
}
DragHandler {
target: null
onActiveChanged: {
if (!active)
return;
tap.moved = true;
button.dragStarted();
}
}
}
+11
View File
@@ -4,7 +4,18 @@ import Sloposk
KeyboardSurface { KeyboardSurface {
id: surface id: surface
// Window-relative rect of the first key of `type`; empty if there is none.
function keyRect(type) {
for (let i = 0; i < keys.count; ++i) {
const key = keys.itemAt(i);
if (key?.keyType === type)
return key.mapToItem(null, 0, 0, key.width, key.height);
}
return Qt.rect(0, 0, 0, 0);
}
Repeater { Repeater {
id: keys
model: surface.model model: surface.model
delegate: Key { delegate: Key {
shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off shiftMode: surface.controller ? surface.controller.shiftMode : KeyboardController.Off
+30 -6
View File
@@ -9,6 +9,8 @@ Window {
required property Settings settings required property Settings settings
required property Diagnostics diagnostics required property Diagnostics diagnostics
property var placement: null // FloatingWindow, set from C++ property var placement: null // FloatingWindow, set from C++
property var collapse: null // CollapseController, set from C++
readonly property bool collapsed: collapse?.collapsed ?? false
property bool settingsOpen: false property bool settingsOpen: false
// Height of the key area, for C++ keeping key sizes when rows change. // Height of the key area, for C++ keeping key sizes when rows change.
readonly property real keysHeight: keyboard.height readonly property real keysHeight: keyboard.height
@@ -16,23 +18,31 @@ Window {
// Transparent room above the keyboard for long-press popups on the top row. // Transparent room above the keyboard for long-press popups on the top row.
readonly property real popupMargin: 90 readonly property real popupMargin: 90
// Outside this, input passes through to whatever is below the window. // Outside this, input passes through to whatever is below the window.
readonly property rect inputRegion: keyboard.alternates.open readonly property rect inputRegion: collapsed
? Qt.rect(floatingButton.x, floatingButton.y, floatingButton.width, floatingButton.height)
: keyboard.alternates.open
? Qt.rect(0, 0, width, height) ? Qt.rect(0, 0, width, height)
: Qt.rect(panel.x, panel.y, panel.width, panel.height) : Qt.rect(panel.x, panel.y, panel.width, panel.height)
// Empty when nothing shows through, so the compositor skips the blur. // Empty when nothing shows through, so the compositor skips the blur.
readonly property rect blurRegion: settings.blurBackground && settings.backgroundOpacity < 1 readonly property rect blurRegion: !collapsed && settings.blurBackground && settings.backgroundOpacity < 1
? Qt.rect(panel.x, panel.y, panel.width, panel.height) ? Qt.rect(panel.x, panel.y, panel.width, panel.height)
: Qt.rect(0, 0, 0, 0) : Qt.rect(0, 0, 0, 0)
width: 1000 width: 1000
height: 340 + popupMargin height: 340 + popupMargin
minimumWidth: 480 // None while shrunk to the floating button.
minimumHeight: 180 + popupMargin minimumWidth: collapsed ? 0 : 480
minimumHeight: collapsed ? 0 : 180 + popupMargin
visible: false visible: false
color: "transparent" color: "transparent"
onInputRegionChanged: placement?.setInputRegion(inputRegion) onInputRegionChanged: placement?.setInputRegion(inputRegion)
onBlurRegionChanged: placement?.setBlurRegion(blurRegion, Theme.windowRadius) onBlurRegionChanged: placement?.setBlurRegion(blurRegion, Theme.windowRadius)
// For CollapseController.
function dismissKeyRect() {
return keyboard.keyRect("dismiss");
}
onPlacementChanged: { onPlacementChanged: {
placement?.setInputRegion(inputRegion); placement?.setInputRegion(inputRegion);
placement?.setBlurRegion(blurRegion, Theme.windowRadius); placement?.setBlurRegion(blurRegion, Theme.windowRadius);
@@ -41,6 +51,7 @@ Window {
Rectangle { Rectangle {
id: panel id: panel
anchors { fill: parent; topMargin: root.popupMargin } anchors { fill: parent; topMargin: root.popupMargin }
visible: !root.collapsed
radius: Theme.windowRadius radius: Theme.windowRadius
color: Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, root.settings.backgroundOpacity) color: Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, root.settings.backgroundOpacity)
@@ -66,7 +77,7 @@ Window {
id: handle id: handle
anchors { left: parent.left; right: parent.right; bottom: parent.bottom } anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
pillWidth: Math.min(120, width - 2 * (quickToggles.x + quickToggles.width + 12)) pillWidth: Math.min(120, width - 2 * (quickToggles.x + quickToggles.width + 12))
onDragStarted: root.startSystemMove() onDragStarted: root.placement.startMove()
} }
SettingsButton { SettingsButton {
@@ -86,7 +97,20 @@ Window {
ResizeHandle { ResizeHandle {
anchors { right: parent.right; bottom: parent.bottom } anchors { right: parent.right; bottom: parent.bottom }
onDragStarted: root.startSystemResize(Qt.RightEdge | Qt.BottomEdge) onDragStarted: root.placement.startResize(Qt.RightEdge | Qt.BottomEdge)
} }
} }
FloatingButton {
id: floatingButton
readonly property rect keyRect: root.collapse?.keyRect ?? Qt.rect(0, 0, 0, 0)
// Over the key while the window is full size, then in the shrunk window.
x: Math.max(0, Math.min(keyRect.x, root.width - width))
y: Math.max(0, Math.min(keyRect.y, root.height - height))
width: keyRect.width
height: keyRect.height
visible: root.collapsed
onTapped: root.collapse.expand()
onDragStarted: root.placement.startMove()
}
} }
+8
View File
@@ -63,6 +63,14 @@ Flickable {
onToggled: checked => panel.settings.alwaysVisible = checked onToggled: checked => panel.settings.alwaysVisible = checked
} }
ToggleRow {
width: parent.width
text: qsTr("Shrink to Button")
detail: qsTr("⌨ leaves a floating button instead of hiding; tap it to bring the keyboard back")
checked: panel.settings.collapseToButton
onToggled: checked => panel.settings.collapseToButton = checked
}
ToggleRow { ToggleRow {
width: parent.width width: parent.width
text: qsTr("“.” Shortcut") text: qsTr("“.” Shortcut")
+3
View File
@@ -36,6 +36,7 @@ set(SLOPOSK_QML_FILES
Key.qml Key.qml
AlternatesPopup.qml AlternatesPopup.qml
DragHandle.qml DragHandle.qml
FloatingButton.qml
ResizeHandle.qml ResizeHandle.qml
SectionHeader.qml SectionHeader.qml
SettingsButton.qml SettingsButton.qml
@@ -91,7 +92,9 @@ qt_add_executable(sloposk
backend/KWinInputContext.h backend/KWinInputContext.cpp backend/KWinInputContext.h backend/KWinInputContext.cpp
backend/KWinFakeInput.h backend/KWinFakeInput.cpp backend/KWinFakeInput.h backend/KWinFakeInput.cpp
backend/WaylandKeymap.h backend/WaylandKeymap.cpp backend/WaylandKeymap.h backend/WaylandKeymap.cpp
window/CollapseController.h window/CollapseController.cpp
window/FloatingWindow.h window/FloatingWindow.cpp window/FloatingWindow.h window/FloatingWindow.cpp
window/KWinScript.h window/KWinScript.cpp
window/KWinRule.h window/KWinRule.cpp window/KWinRule.h window/KWinRule.cpp
) )
+23 -7
View File
@@ -13,6 +13,7 @@
#include "layout/LayoutLoader.h" #include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h" #include "layout/LayoutModel.h"
#include "settings/Settings.h" #include "settings/Settings.h"
#include "window/CollapseController.h"
#include "window/FloatingWindow.h" #include "window/FloatingWindow.h"
#include "window/KWinRule.h" #include "window/KWinRule.h"
#include "window/VisibilityController.h" #include "window/VisibilityController.h"
@@ -82,12 +83,15 @@ void connectKeySettings(Settings &settings, LayoutModel &model, FloatingWindow &
[keepingKeySize, rebuild] { keepingKeySize(rebuild); }); [keepingKeySize, rebuild] { keepingKeySize(rebuild); });
} }
void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings) // The shrunk button's geometry isn't the keyboard's: not saved.
void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings, const CollapseController &collapse)
{ {
if (const auto geometry = settings.load(placement.screenName())) if (const auto geometry = settings.load(placement.screenName()))
placement.setGeometry(*geometry); placement.setGeometry(*geometry);
QObject::connect(&placement, &FloatingWindow::geometryChanged, &settings, QObject::connect(&placement, &FloatingWindow::geometryChanged, &settings, [&] {
[&] { settings.save(placement.screenName(), placement.geometry()); }); if (!collapse.isCollapsed())
settings.save(placement.screenName(), placement.geometry());
});
} }
// A focused field shows the keyboard only if KWin's policy wants one now; // A focused field shows the keyboard only if KWin's policy wants one now;
@@ -112,7 +116,7 @@ void connectFieldFocus(VisibilityController &visibility, KWinInputMethod &backen
} }
void connectVisibility(VisibilityController &visibility, KWinInputMethod &backend, const Settings &settings, void connectVisibility(VisibilityController &visibility, KWinInputMethod &backend, const Settings &settings,
KeyboardController &controller, FloatingWindow &placement) FloatingWindow &placement)
{ {
// First, so the initial state below reaches the window too. // First, so the initial state below reaches the window too.
QObject::connect(&visibility, &VisibilityController::visibleChanged, &placement, QObject::connect(&visibility, &VisibilityController::visibleChanged, &placement,
@@ -124,10 +128,19 @@ void connectVisibility(VisibilityController &visibility, KWinInputMethod &backen
// work) there is no focus signal: stay visible so it can be tried out. // work) there is no focus signal: stay visible so it can be tried out.
const auto updateStandalone = [&] { visibility.setStandalone(!backend.isAvailable()); }; const auto updateStandalone = [&] { visibility.setStandalone(!backend.isAvailable()); };
QObject::connect(&backend, &KWinInputMethod::availableChanged, &visibility, updateStandalone); QObject::connect(&backend, &KWinInputMethod::availableChanged, &visibility, updateStandalone);
QObject::connect(&controller, &KeyboardController::dismissRequested, &visibility, &VisibilityController::hide);
updateStandalone(); updateStandalone();
} }
// The dismiss key hides the keyboard, or shrinks it to a floating button.
void connectDismiss(KeyboardController &controller, const Settings &settings, CollapseController &collapse,
VisibilityController &visibility)
{
QObject::connect(&controller, &KeyboardController::dismissRequested, &visibility, [&] {
if (!settings.collapseToButton() || !collapse.collapse())
visibility.hide();
});
}
void connectTextContext(KWinInputMethod &backend, KeyboardController &controller) void connectTextContext(KWinInputMethod &backend, KeyboardController &controller)
{ {
QObject::connect(&backend, &TextBackend::activeChanged, &controller, QObject::connect(&backend, &TextBackend::activeChanged, &controller,
@@ -191,14 +204,17 @@ int main(int argc, char *argv[])
installKWinRule(QGuiApplication::desktopFileName()); installKWinRule(QGuiApplication::desktopFileName());
FloatingWindow placement(window); FloatingWindow placement(window);
window->setProperty("placement", QVariant::fromValue(&placement)); window->setProperty("placement", QVariant::fromValue(&placement));
CollapseController collapse(*window, placement);
window->setProperty("collapse", QVariant::fromValue(&collapse));
WindowSettings windowSettings; WindowSettings windowSettings;
restoreAndPersistGeometry(placement, windowSettings); restoreAndPersistGeometry(placement, windowSettings, collapse);
connectKeySettings(settings, model, placement, *window, rebuildLayout); connectKeySettings(settings, model, placement, *window, rebuildLayout);
VisibilityController visibility; VisibilityController visibility;
KWinVirtualKeyboard virtualKeyboardPolicy; KWinVirtualKeyboard virtualKeyboardPolicy;
connectFieldFocus(visibility, backend, virtualKeyboardPolicy); connectFieldFocus(visibility, backend, virtualKeyboardPolicy);
connectVisibility(visibility, backend, settings, controller, placement); connectVisibility(visibility, backend, settings, placement);
connectDismiss(controller, settings, collapse, visibility);
DBusControl dbus(visibility); DBusControl dbus(visibility);
reportDBusControl(diagnostics, dbus.registerOnSessionBus()); reportDBusControl(diagnostics, dbus.registerOnSessionBus());
+11
View File
@@ -11,6 +11,7 @@ constexpr auto HoldKeysKey = "typing/holdKeys";
constexpr auto VisibleSectionsKey = "layout/visibleSections"; constexpr auto VisibleSectionsKey = "layout/visibleSections";
constexpr auto FullKeyboardKey = "layout/fullKeyboard"; constexpr auto FullKeyboardKey = "layout/fullKeyboard";
constexpr auto AlwaysVisibleKey = "window/alwaysVisible"; constexpr auto AlwaysVisibleKey = "window/alwaysVisible";
constexpr auto CollapseToButtonKey = "window/collapseToButton";
constexpr auto BackgroundOpacityKey = "appearance/backgroundOpacity"; constexpr auto BackgroundOpacityKey = "appearance/backgroundOpacity";
constexpr auto BlurBackgroundKey = "appearance/blurBackground"; constexpr auto BlurBackgroundKey = "appearance/blurBackground";
} }
@@ -28,6 +29,7 @@ Settings::Settings(QObject *parent)
m_visibleSections = {MainSectionId}; m_visibleSections = {MainSectionId};
m_fullKeyboard = store.value(FullKeyboardKey, false).toBool(); m_fullKeyboard = store.value(FullKeyboardKey, false).toBool();
m_alwaysVisible = store.value(AlwaysVisibleKey, false).toBool(); m_alwaysVisible = store.value(AlwaysVisibleKey, false).toBool();
m_collapseToButton = store.value(CollapseToButtonKey, false).toBool();
m_backgroundOpacity = qBound(MinBackgroundOpacity, store.value(BackgroundOpacityKey, 1.0).toReal(), 1.0); m_backgroundOpacity = qBound(MinBackgroundOpacity, store.value(BackgroundOpacityKey, 1.0).toReal(), 1.0);
m_blurBackground = store.value(BlurBackgroundKey, true).toBool(); m_blurBackground = store.value(BlurBackgroundKey, true).toBool();
} }
@@ -60,6 +62,15 @@ void Settings::setAlwaysVisible(bool always)
Q_EMIT alwaysVisibleChanged(); Q_EMIT alwaysVisibleChanged();
} }
void Settings::setCollapseToButton(bool collapse)
{
if (collapse == m_collapseToButton)
return;
m_collapseToButton = collapse;
QSettings().setValue(CollapseToButtonKey, collapse);
Q_EMIT collapseToButtonChanged();
}
void Settings::setFullKeyboard(bool full) void Settings::setFullKeyboard(bool full)
{ {
if (full == m_fullKeyboard) if (full == m_fullKeyboard)
+6
View File
@@ -23,6 +23,8 @@ class Settings : public QObject
Q_PROPERTY(bool fullKeyboard READ fullKeyboard WRITE setFullKeyboard NOTIFY fullKeyboardChanged) Q_PROPERTY(bool fullKeyboard READ fullKeyboard WRITE setFullKeyboard NOTIFY fullKeyboardChanged)
// Shown even without a focused text field, to type into any window. // Shown even without a focused text field, to type into any window.
Q_PROPERTY(bool alwaysVisible READ alwaysVisible WRITE setAlwaysVisible NOTIFY alwaysVisibleChanged) Q_PROPERTY(bool alwaysVisible READ alwaysVisible WRITE setAlwaysVisible NOTIFY alwaysVisibleChanged)
// The dismiss key shrinks the keyboard to a floating button instead of hiding it.
Q_PROPERTY(bool collapseToButton READ collapseToButton WRITE setCollapseToButton NOTIFY collapseToButtonChanged)
// Opacity of the panel behind the keys (MinBackgroundOpacity–1); keys stay opaque. // Opacity of the panel behind the keys (MinBackgroundOpacity–1); keys stay opaque.
Q_PROPERTY(qreal backgroundOpacity READ backgroundOpacity WRITE setBackgroundOpacity NOTIFY appearanceChanged) Q_PROPERTY(qreal backgroundOpacity READ backgroundOpacity WRITE setBackgroundOpacity NOTIFY appearanceChanged)
Q_PROPERTY(qreal minBackgroundOpacity READ minBackgroundOpacity CONSTANT) Q_PROPERTY(qreal minBackgroundOpacity READ minBackgroundOpacity CONSTANT)
@@ -49,6 +51,8 @@ public:
void setFullKeyboard(bool full); void setFullKeyboard(bool full);
bool alwaysVisible() const { return m_alwaysVisible; } bool alwaysVisible() const { return m_alwaysVisible; }
void setAlwaysVisible(bool always); void setAlwaysVisible(bool always);
bool collapseToButton() const { return m_collapseToButton; }
void setCollapseToButton(bool collapse);
static constexpr qreal MinBackgroundOpacity = 0.1; static constexpr qreal MinBackgroundOpacity = 0.1;
static qreal minBackgroundOpacity() { return MinBackgroundOpacity; } static qreal minBackgroundOpacity() { return MinBackgroundOpacity; }
@@ -62,6 +66,7 @@ Q_SIGNALS:
void visibleSectionsChanged(); void visibleSectionsChanged();
void fullKeyboardChanged(); void fullKeyboardChanged();
void alwaysVisibleChanged(); void alwaysVisibleChanged();
void collapseToButtonChanged();
void appearanceChanged(); void appearanceChanged();
private: private:
@@ -72,6 +77,7 @@ private:
QStringList m_visibleSections; QStringList m_visibleSections;
bool m_fullKeyboard = false; bool m_fullKeyboard = false;
bool m_alwaysVisible = false; bool m_alwaysVisible = false;
bool m_collapseToButton = false;
qreal m_backgroundOpacity = 1.0; qreal m_backgroundOpacity = 1.0;
bool m_blurBackground = true; bool m_blurBackground = true;
}; };
+74
View File
@@ -0,0 +1,74 @@
#include "CollapseController.h"
#include "FloatingWindow.h"
#include <QQuickWindow>
namespace {
constexpr int ExpandTimeoutMs = 500;
bool roughlyEqual(QSize a, QSize b)
{
return qAbs(a.width() - b.width()) <= 1 && qAbs(a.height() - b.height()) <= 1;
}
} // namespace
CollapseController::CollapseController(QQuickWindow &window, FloatingWindow &placement, QObject *parent)
: QObject(parent)
, m_window(window)
, m_placement(placement)
{
m_expanding.setSingleShot(true);
m_expanding.setInterval(ExpandTimeoutMs);
connect(&m_expanding, &QTimer::timeout, this, &CollapseController::finishExpanding);
connect(&window, &QWindow::widthChanged, this, &CollapseController::windowResized);
connect(&window, &QWindow::heightChanged, this, &CollapseController::windowResized);
}
bool CollapseController::collapse()
{
if (m_collapsed)
return true;
QVariant rect;
QMetaObject::invokeMethod(&m_window, "dismissKeyRect", Q_RETURN_ARG(QVariant, rect));
if (rect.toRectF().isEmpty())
return false;
m_keyRect = rect.toRectF();
m_expandedSize = m_window.size();
m_collapsed = true;
Q_EMIT collapsedChanged();
// After the next frame, once the compositor knows the lower minimum size.
connect(&m_window, &QQuickWindow::frameSwapped, this, [this] {
if (m_collapsed)
m_placement.moveResize(m_keyRect, false);
}, Qt::SingleShotConnection);
return true;
}
void CollapseController::expand()
{
if (!m_collapsed || m_expanding.isActive())
return;
if (roughlyEqual(m_window.size(), m_expandedSize)) {
finishExpanding(); // never shrank: nothing to move
return;
}
// Stays collapsed until the window has grown, so the keys don't flash squeezed.
m_placement.moveResize(QRectF(-m_keyRect.topLeft(), m_expandedSize), true);
m_expanding.start();
}
void CollapseController::windowResized()
{
if (m_expanding.isActive() && roughlyEqual(m_window.size(), m_expandedSize))
finishExpanding();
}
void CollapseController::finishExpanding()
{
m_expanding.stop();
if (!roughlyEqual(m_window.size(), m_expandedSize))
m_window.resize(m_expandedSize); // no KWin to do it
m_collapsed = false;
Q_EMIT collapsedChanged();
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <QObject>
#include <QRectF>
#include <QSize>
#include <QTimer>
class FloatingWindow;
class QQuickWindow;
// Shrinks the keyboard window to just its dismiss key, floating on its own,
// and grows it back with the key under the button again (kept on screen).
// The window itself shrinks, so it can be dragged anywhere and covers
// nothing else. QML draws from `collapsed` and `keyRect`.
class CollapseController : public QObject
{
Q_OBJECT
Q_PROPERTY(bool collapsed READ isCollapsed NOTIFY collapsedChanged)
// The key in the full window; the button stays there until the window shrinks.
Q_PROPERTY(QRectF keyRect READ keyRect NOTIFY collapsedChanged)
public:
CollapseController(QQuickWindow &window, FloatingWindow &placement, QObject *parent = nullptr);
bool isCollapsed() const { return m_collapsed; }
QRectF keyRect() const { return m_keyRect; }
// False if the keyboard has no dismiss key to shrink to.
bool collapse();
Q_INVOKABLE void expand();
Q_SIGNALS:
void collapsedChanged();
private:
void windowResized();
void finishExpanding();
QQuickWindow &m_window;
FloatingWindow &m_placement;
bool m_collapsed = false;
QRectF m_keyRect;
QSize m_expandedSize;
QTimer m_expanding; // waits for KWin; runs out without it
};
+65
View File
@@ -1,6 +1,11 @@
#include "FloatingWindow.h" #include "FloatingWindow.h"
#include "KWinScript.h"
#include <KWindowEffects> #include <KWindowEffects>
#include <QCursor>
#include <QGuiApplication>
#include <QMouseEvent>
#include <QPainterPath> #include <QPainterPath>
#include <QScreen> #include <QScreen>
#include <QWindow> #include <QWindow>
@@ -12,6 +17,18 @@ FloatingWindow::FloatingWindow(QWindow *window, QObject *parent)
window->setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus); window->setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus);
for (const auto changed : {&QWindow::xChanged, &QWindow::yChanged, &QWindow::widthChanged, &QWindow::heightChanged}) for (const auto changed : {&QWindow::xChanged, &QWindow::yChanged, &QWindow::widthChanged, &QWindow::heightChanged})
connect(window, changed, this, &FloatingWindow::geometryChanged); connect(window, changed, this, &FloatingWindow::geometryChanged);
window->installEventFilter(this);
}
bool FloatingWindow::eventFilter(QObject *watched, QEvent *event)
{
// Exposed means mapped, so KWin knows the window by now.
if (watched == m_window && event->type() == QEvent::Expose) {
if (m_window->isExposed() && !m_exposed)
moveResize(QRectF(QPointF(), m_window->size()), true);
m_exposed = m_window->isExposed();
}
return false;
} }
QRect FloatingWindow::geometry() const QRect FloatingWindow::geometry() const
@@ -38,6 +55,30 @@ void FloatingWindow::resizeBy(qreal dw, qreal dh)
m_window->resize(size); m_window->resize(size);
} }
void FloatingWindow::moveResize(const QRectF &relative, bool keepOnScreen)
{
runKWinScript(QStringLiteral(R"(
for (const w of workspace.windowList()) {
if (w.resourceClass !== "%1")
continue;
const width = %4, height = %5;
let x = w.frameGeometry.x + %2, y = w.frameGeometry.y + %3;
if (%6) {
const area = workspace.clientArea(KWin.MaximizeArea, w);
x = Math.max(area.x, Math.min(x, area.x + area.width - width));
y = Math.max(area.y, Math.min(y, area.y + area.height - height));
}
w.frameGeometry = {x: x, y: y, width: width, height: height};
}
)")
.arg(QGuiApplication::desktopFileName())
.arg(relative.x())
.arg(relative.y())
.arg(relative.width())
.arg(relative.height())
.arg(keepOnScreen ? QStringLiteral("true") : QStringLiteral("false")));
}
void FloatingWindow::setInputRegion(const QRectF &region) void FloatingWindow::setInputRegion(const QRectF &region)
{ {
m_window->setMask(QRegion(region.toAlignedRect())); m_window->setMask(QRegion(region.toAlignedRect()));
@@ -54,6 +95,30 @@ void FloatingWindow::setBlurRegion(const QRectF &region, qreal radius)
KWindowEffects::enableBlurBehind(m_window, true, QRegion(path.toFillPolygon().toPolygon())); KWindowEffects::enableBlurBehind(m_window, true, QRegion(path.toFillPolygon().toPolygon()));
} }
void FloatingWindow::startMove()
{
if (m_window->startSystemMove())
releaseMouse();
}
void FloatingWindow::startResize(Qt::Edges edges)
{
if (m_window->startSystemResize(edges))
releaseMouse();
}
// The window manager keeps the button's release: without one, the pressed
// item holds on to the mouse and eats the next press.
void FloatingWindow::releaseMouse()
{
if (!(QGuiApplication::mouseButtons() & Qt::LeftButton))
return; // touch
const QPointF global = QCursor::pos(m_window->screen());
QMouseEvent release(QEvent::MouseButtonRelease, m_window->mapFromGlobal(global), global, Qt::LeftButton,
Qt::NoButton, QGuiApplication::keyboardModifiers());
QCoreApplication::sendEvent(m_window, &release);
}
void FloatingWindow::show() void FloatingWindow::show()
{ {
m_window->setVisible(true); m_window->setVisible(true);
+15 -1
View File
@@ -9,7 +9,9 @@ class QWindow;
// Makes a keyboard window a frameless, always-on-top window that never takes // Makes a keyboard window a frameless, always-on-top window that never takes
// focus. The window manager places it and moves it (Super+drag included); // focus. The window manager places it and moves it (Super+drag included);
// on KWin Wayland that relies on the rule from installKWinRule(). // on KWin Wayland that relies on the rule from installKWinRule().
// Geometry is relative to the window's screen. // Each time it appears, it's moved back on screen: KWin remembers where it
// was, which may be somewhere it was smaller. Geometry is relative to the
// window's screen.
class FloatingWindow : public QObject class FloatingWindow : public QObject
{ {
Q_OBJECT Q_OBJECT
@@ -25,18 +27,30 @@ public:
// Grows from the top-left corner; respects the window's minimum size. // Grows from the top-left corner; respects the window's minimum size.
Q_INVOKABLE void resizeBy(qreal dw, qreal dh); Q_INVOKABLE void resizeBy(qreal dw, qreal dh);
// Moves the window by `relative`'s offset and resizes it to its size,
// optionally clamped to the screen's free area. Done by KWin (Wayland
// clients can't place themselves), so it applies later.
void moveResize(const QRectF &relative, bool keepOnScreen);
// Only this part of the window takes input; the rest passes through. // Only this part of the window takes input; the rest passes through.
Q_INVOKABLE void setInputRegion(const QRectF &region); Q_INVOKABLE void setInputRegion(const QRectF &region);
// Asks the compositor to blur behind this rounded rect; an empty rect turns blur off. // Asks the compositor to blur behind this rounded rect; an empty rect turns blur off.
Q_INVOKABLE void setBlurRegion(const QRectF &region, qreal radius); Q_INVOKABLE void setBlurRegion(const QRectF &region, qreal radius);
// The window manager's move and resize, for mouse and touch alike.
Q_INVOKABLE void startMove();
Q_INVOKABLE void startResize(Qt::Edges edges);
Q_INVOKABLE void show(); Q_INVOKABLE void show();
Q_INVOKABLE void hide(); Q_INVOKABLE void hide();
Q_SIGNALS: Q_SIGNALS:
void geometryChanged(); void geometryChanged();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private: private:
QRect screenArea() const; QRect screenArea() const;
void releaseMouse();
QWindow *m_window; QWindow *m_window;
bool m_exposed = false;
}; };
+56
View File
@@ -0,0 +1,56 @@
#include "KWinScript.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDir>
#include <QTemporaryFile>
#include <QTimer>
#include <memory>
namespace {
const QString Service = QStringLiteral("org.kde.KWin");
const QString ScriptingPath = QStringLiteral("/Scripting");
const QString ScriptingInterface = QStringLiteral("org.kde.kwin.Scripting");
// KWin reads the file on a worker thread some time after `run`.
constexpr int KeepFileMs = 10'000;
QDBusMessage kwinCall(const QString &path, const QString &interface, const QString &method, const QVariantList &args = {})
{
QDBusMessage call = QDBusMessage::createMethodCall(Service, path, interface, method);
call.setArguments(args);
return call;
}
} // namespace
void runKWinScript(const QString &source)
{
static int count = 0;
const QString plugin = QStringLiteral("sloposk-%1").arg(++count);
// Unloading from within: `stop` right after `run` could beat the script.
const QString unload = QStringLiteral("\ncallDBus(\"%1\", \"%2\", \"%3\", \"unloadScript\", \"%4\");\n")
.arg(Service, ScriptingPath, ScriptingInterface, plugin);
auto file = std::make_shared<QTemporaryFile>(QDir::tempPath() + QStringLiteral("/sloposk-XXXXXX.js"));
if (!file->open() || file->write((source + unload).toUtf8()) < 0 || !file->flush()) {
qWarning("Can't write a KWin script: %s", qPrintable(file->errorString()));
return;
}
auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(
kwinCall(ScriptingPath, ScriptingInterface, QStringLiteral("loadScript"), {file->fileName(), plugin})));
QObject::connect(watcher, &QDBusPendingCallWatcher::finished, [file](QDBusPendingCallWatcher *watcher) {
watcher->deleteLater();
const QDBusPendingReply<int> reply = *watcher;
if (reply.isError() || reply.value() < 0) {
qWarning("KWin didn't load a script: %s", qPrintable(reply.error().message()));
return;
}
const QString path = ScriptingPath + QStringLiteral("/Script") + QString::number(reply.value());
QDBusConnection::sessionBus().asyncCall(kwinCall(path, QStringLiteral("org.kde.kwin.Script"), QStringLiteral("run")));
QTimer::singleShot(KeepFileMs, [file] {});
});
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <QString>
// Runs a one-off KWin script (JavaScript) over D-Bus; it unloads itself when
// done. Asynchronous; failures are only logged.
void runKWinScript(const QString &source);