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
+3
View File
@@ -36,6 +36,7 @@ set(SLOPOSK_QML_FILES
Key.qml
AlternatesPopup.qml
DragHandle.qml
FloatingButton.qml
ResizeHandle.qml
SectionHeader.qml
SettingsButton.qml
@@ -91,7 +92,9 @@ qt_add_executable(sloposk
backend/KWinInputContext.h backend/KWinInputContext.cpp
backend/KWinFakeInput.h backend/KWinFakeInput.cpp
backend/WaylandKeymap.h backend/WaylandKeymap.cpp
window/CollapseController.h window/CollapseController.cpp
window/FloatingWindow.h window/FloatingWindow.cpp
window/KWinScript.h window/KWinScript.cpp
window/KWinRule.h window/KWinRule.cpp
)
+23 -7
View File
@@ -13,6 +13,7 @@
#include "layout/LayoutLoader.h"
#include "layout/LayoutModel.h"
#include "settings/Settings.h"
#include "window/CollapseController.h"
#include "window/FloatingWindow.h"
#include "window/KWinRule.h"
#include "window/VisibilityController.h"
@@ -82,12 +83,15 @@ void connectKeySettings(Settings &settings, LayoutModel &model, FloatingWindow &
[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()))
placement.setGeometry(*geometry);
QObject::connect(&placement, &FloatingWindow::geometryChanged, &settings,
[&] { settings.save(placement.screenName(), placement.geometry()); });
QObject::connect(&placement, &FloatingWindow::geometryChanged, &settings, [&] {
if (!collapse.isCollapsed())
settings.save(placement.screenName(), placement.geometry());
});
}
// 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,
KeyboardController &controller, FloatingWindow &placement)
FloatingWindow &placement)
{
// First, so the initial state below reaches the window too.
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.
const auto updateStandalone = [&] { visibility.setStandalone(!backend.isAvailable()); };
QObject::connect(&backend, &KWinInputMethod::availableChanged, &visibility, updateStandalone);
QObject::connect(&controller, &KeyboardController::dismissRequested, &visibility, &VisibilityController::hide);
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)
{
QObject::connect(&backend, &TextBackend::activeChanged, &controller,
@@ -191,14 +204,17 @@ int main(int argc, char *argv[])
installKWinRule(QGuiApplication::desktopFileName());
FloatingWindow placement(window);
window->setProperty("placement", QVariant::fromValue(&placement));
CollapseController collapse(*window, placement);
window->setProperty("collapse", QVariant::fromValue(&collapse));
WindowSettings windowSettings;
restoreAndPersistGeometry(placement, windowSettings);
restoreAndPersistGeometry(placement, windowSettings, collapse);
connectKeySettings(settings, model, placement, *window, rebuildLayout);
VisibilityController visibility;
KWinVirtualKeyboard virtualKeyboardPolicy;
connectFieldFocus(visibility, backend, virtualKeyboardPolicy);
connectVisibility(visibility, backend, settings, controller, placement);
connectVisibility(visibility, backend, settings, placement);
connectDismiss(controller, settings, collapse, visibility);
DBusControl dbus(visibility);
reportDBusControl(diagnostics, dbus.registerOnSessionBus());
+11
View File
@@ -11,6 +11,7 @@ constexpr auto HoldKeysKey = "typing/holdKeys";
constexpr auto VisibleSectionsKey = "layout/visibleSections";
constexpr auto FullKeyboardKey = "layout/fullKeyboard";
constexpr auto AlwaysVisibleKey = "window/alwaysVisible";
constexpr auto CollapseToButtonKey = "window/collapseToButton";
constexpr auto BackgroundOpacityKey = "appearance/backgroundOpacity";
constexpr auto BlurBackgroundKey = "appearance/blurBackground";
}
@@ -28,6 +29,7 @@ Settings::Settings(QObject *parent)
m_visibleSections = {MainSectionId};
m_fullKeyboard = store.value(FullKeyboardKey, 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_blurBackground = store.value(BlurBackgroundKey, true).toBool();
}
@@ -60,6 +62,15 @@ void Settings::setAlwaysVisible(bool always)
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)
{
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)
// Shown even without a focused text field, to type into any window.
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.
Q_PROPERTY(qreal backgroundOpacity READ backgroundOpacity WRITE setBackgroundOpacity NOTIFY appearanceChanged)
Q_PROPERTY(qreal minBackgroundOpacity READ minBackgroundOpacity CONSTANT)
@@ -49,6 +51,8 @@ public:
void setFullKeyboard(bool full);
bool alwaysVisible() const { return m_alwaysVisible; }
void setAlwaysVisible(bool always);
bool collapseToButton() const { return m_collapseToButton; }
void setCollapseToButton(bool collapse);
static constexpr qreal MinBackgroundOpacity = 0.1;
static qreal minBackgroundOpacity() { return MinBackgroundOpacity; }
@@ -62,6 +66,7 @@ Q_SIGNALS:
void visibleSectionsChanged();
void fullKeyboardChanged();
void alwaysVisibleChanged();
void collapseToButtonChanged();
void appearanceChanged();
private:
@@ -72,6 +77,7 @@ private:
QStringList m_visibleSections;
bool m_fullKeyboard = false;
bool m_alwaysVisible = false;
bool m_collapseToButton = false;
qreal m_backgroundOpacity = 1.0;
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 "KWinScript.h"
#include <KWindowEffects>
#include <QCursor>
#include <QGuiApplication>
#include <QMouseEvent>
#include <QPainterPath>
#include <QScreen>
#include <QWindow>
@@ -12,6 +17,18 @@ FloatingWindow::FloatingWindow(QWindow *window, QObject *parent)
window->setFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus);
for (const auto changed : {&QWindow::xChanged, &QWindow::yChanged, &QWindow::widthChanged, &QWindow::heightChanged})
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
@@ -38,6 +55,30 @@ void FloatingWindow::resizeBy(qreal dw, qreal dh)
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)
{
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()));
}
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()
{
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
// focus. The window manager places it and moves it (Super+drag included);
// 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
{
Q_OBJECT
@@ -25,18 +27,30 @@ public:
// Grows from the top-left corner; respects the window's minimum size.
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.
Q_INVOKABLE void setInputRegion(const QRectF &region);
// Asks the compositor to blur behind this rounded rect; an empty rect turns blur off.
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 hide();
Q_SIGNALS:
void geometryChanged();
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
private:
QRect screenArea() const;
void releaseMouse();
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);