Floating polish: visibility, resize, saved geometry, D-Bus, desktop entry

- VisibilityController: show on field focus, dismiss until next focus,
  manual show/hide/toggle overrides
- FloatingWindow: resize from the corner grip, clamp to screen
- WindowSettings: geometry per screen, debounced saves
- DBusControl: org.sloposk.Keyboard Show/Hide/Toggle for global shortcuts
- org.sloposk.keyboard.desktop so System Settings lists it as a virtual keyboard
- README with build/run/layout docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:02:25 +02:00
co-authored by Claude Opus 5.5
parent 92c132c66f
commit c6c977eb4c
19 changed files with 489 additions and 40 deletions
+7 -1
View File
@@ -17,6 +17,8 @@ qt_add_library(sloposk_core STATIC
layout/LayoutRepository.h layout/LayoutRepository.cpp
layout/KeyGeometry.h layout/KeyGeometry.cpp
layout/LayoutModel.h layout/LayoutModel.cpp
window/VisibilityController.h window/VisibilityController.cpp
window/WindowSettings.h window/WindowSettings.cpp
)
# QML lives in the top-level qml/ dir; alias each file so the module sees it at its root.
@@ -26,6 +28,7 @@ set(SLOPOSK_QML_FILES
Key.qml
AlternatesPopup.qml
DragHandle.qml
ResizeHandle.qml
Theme.qml
)
list(TRANSFORM SLOPOSK_QML_FILES PREPEND ${PROJECT_SOURCE_DIR}/qml/ OUTPUT_VARIABLE sloposk_qml_paths)
@@ -59,7 +62,8 @@ target_link_libraries(sloposk_core PUBLIC Qt6::Quick PkgConfig::XKB)
qt_add_executable(sloposk
main.cpp
DevHooks.h DevHooks.cpp
app/DevHooks.h app/DevHooks.cpp
app/DBusControl.h app/DBusControl.cpp
backend/InputMethodProtocol.h
backend/KWinInputMethod.h backend/KWinInputMethod.cpp
backend/KWinInputContext.h backend/KWinInputContext.cpp
@@ -76,9 +80,11 @@ set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/qwayland-input-method-un
target_link_libraries(sloposk PRIVATE
sloposk_core
sloposk_coreplugin
Qt6::DBus
Qt6::WaylandClient
LayerShellQt::Interface
PkgConfig::WAYLAND
)
install(TARGETS sloposk)
install(FILES ${PROJECT_SOURCE_DIR}/data/org.sloposk.keyboard.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
+22
View File
@@ -0,0 +1,22 @@
#include "DBusControl.h"
#include "window/VisibilityController.h"
#include <QDBusConnection>
DBusControl::DBusControl(VisibilityController &visibility, QObject *parent)
: QObject(parent)
, m_visibility(visibility)
{
}
bool DBusControl::registerOnSessionBus()
{
QDBusConnection bus = QDBusConnection::sessionBus();
return bus.registerService(QStringLiteral("org.sloposk.Keyboard"))
&& bus.registerObject(QStringLiteral("/Keyboard"), this, QDBusConnection::ExportScriptableSlots);
}
void DBusControl::Show() { m_visibility.show(); }
void DBusControl::Hide() { m_visibility.hide(); }
void DBusControl::Toggle() { m_visibility.toggle(); }
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <QObject>
class VisibilityController;
// Session-bus remote control, e.g. for a global shortcut:
// qdbus org.sloposk.Keyboard /Keyboard org.sloposk.Keyboard.Toggle
class DBusControl : public QObject
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.sloposk.Keyboard")
public:
explicit DBusControl(VisibilityController &visibility, QObject *parent = nullptr);
// False if the bus is unavailable or another instance owns the name.
bool registerOnSessionBus();
public Q_SLOTS:
Q_SCRIPTABLE void Show();
Q_SCRIPTABLE void Hide();
Q_SCRIPTABLE void Toggle();
private:
VisibilityController &m_visibility;
};
+43 -17
View File
@@ -1,10 +1,13 @@
#include "DevHooks.h"
#include "app/DBusControl.h"
#include "app/DevHooks.h"
#include "backend/KWinInputMethod.h"
#include "engine/DirectEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutModel.h"
#include "layout/LayoutRepository.h"
#include "window/FloatingWindow.h"
#include "window/VisibilityController.h"
#include "window/WindowSettings.h"
#include <QGuiApplication>
#include <QLocale>
@@ -26,6 +29,36 @@ void loadLayouts(LayoutRepository &layouts)
layouts.finalize(QLocale::system().name());
}
void restoreAndPersistGeometry(FloatingWindow &placement, WindowSettings &settings)
{
if (const auto geometry = settings.load(placement.screenName()))
placement.setGeometry(*geometry);
QObject::connect(&placement, &FloatingWindow::geometryChanged, &settings,
[&] { settings.save(placement.screenName(), placement.geometry()); });
}
void connectVisibility(VisibilityController &visibility, KWinInputMethod &backend,
KeyboardController &controller, FloatingWindow &placement)
{
// Without the input-method global (run outside KWin's control, e.g. for UI
// 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(&backend, &TextBackend::activeChanged, &visibility,
[&] { visibility.setFieldActive(backend.isActive()); });
QObject::connect(&controller, &KeyboardController::dismissRequested, &visibility, &VisibilityController::hide);
QObject::connect(&visibility, &VisibilityController::visibleChanged, &placement,
[&](bool visible) { visible ? placement.show() : placement.hide(); });
updateStandalone();
}
void connectTextContext(KWinInputMethod &backend, KeyboardController &controller)
{
QObject::connect(&backend, &TextBackend::activeChanged, &controller, &KeyboardController::resetContext);
QObject::connect(&backend, &TextBackend::contextChanged, &controller,
[&] { controller.setContext(backend.context()); });
}
} // namespace
int main(int argc, char *argv[])
@@ -45,6 +78,7 @@ int main(int argc, char *argv[])
DirectEngine engine(backend);
LayoutModel model;
KeyboardController controller(engine, model, layouts);
connectTextContext(backend, controller);
QQmlApplicationEngine qml;
qml.setInitialProperties({
@@ -60,24 +94,16 @@ int main(int argc, char *argv[])
FloatingWindow placement(window);
window->setProperty("placement", QVariant::fromValue(&placement));
WindowSettings settings;
restoreAndPersistGeometry(placement, settings);
// Show while a text field is focused; with no input-method global (e.g. run
// standalone for UI work) stay visible so the keyboard can still be tried out.
auto updateVisibility = [&] {
if (backend.isActive() || !backend.isAvailable())
placement.show();
else
placement.hide();
};
QObject::connect(&backend, &TextBackend::activeChanged, &app, updateVisibility);
QObject::connect(&backend, &KWinInputMethod::availableChanged, &app, updateVisibility);
QObject::connect(&controller, &KeyboardController::dismissRequested, &placement, &FloatingWindow::hide);
QObject::connect(&backend, &TextBackend::activeChanged, &controller, &KeyboardController::resetContext);
QObject::connect(&backend, &TextBackend::contextChanged, &controller,
[&] { controller.setContext(backend.context()); });
updateVisibility();
VisibilityController visibility;
connectVisibility(visibility, backend, controller, placement);
DBusControl dbus(visibility);
if (!dbus.registerOnSessionBus())
qWarning("D-Bus control unavailable (is another instance running?)");
installDevHooks(backend, *window);
return app.exec();
}
+45 -15
View File
@@ -36,19 +36,44 @@ FloatingWindow::FloatingWindow(QWindow *window, QObject *parent)
apply();
}
void FloatingWindow::setPosition(QPoint position)
QRect FloatingWindow::geometry() const
{
position = clamped(position);
if (position == m_position)
return;
m_position = position;
return {m_position, m_window->size()};
}
void FloatingWindow::setGeometry(const QRect &geometry)
{
const QSize size = geometry.size().expandedTo(m_window->minimumSize()).boundedTo(screenArea().size());
m_window->resize(size);
m_position = clamped(geometry.topLeft(), size);
apply();
Q_EMIT positionChanged();
}
QString FloatingWindow::screenName() const
{
return m_window->screen()->name();
}
void FloatingWindow::moveBy(qreal dx, qreal dy)
{
setPosition(m_position + QPoint(qRound(dx), qRound(dy)));
const QPoint position = clamped(m_position + QPoint(qRound(dx), qRound(dy)), m_window->size());
if (position == m_position)
return;
m_position = position;
apply();
Q_EMIT geometryChanged();
}
void FloatingWindow::resizeBy(qreal dw, qreal dh)
{
const QSize maximum = screenArea().size() - QSize(m_position.x(), m_position.y());
const QSize size = (m_window->size() + QSize(qRound(dw), qRound(dh)))
.expandedTo(m_window->minimumSize())
.boundedTo(maximum);
if (size == m_window->size())
return;
m_window->resize(size);
Q_EMIT geometryChanged();
}
void FloatingWindow::setInputRegion(const QRectF &region)
@@ -66,19 +91,24 @@ void FloatingWindow::hide()
m_window->setVisible(false);
}
QPoint FloatingWindow::clamped(QPoint position) const
QRect FloatingWindow::screenArea() const
{
const QRect area = m_window->screen()->geometry();
const int maxX = qMax(0, area.width() - m_window->width());
const int maxY = qMax(0, area.height() - m_window->height());
return m_window->screen()->geometry();
}
QPoint FloatingWindow::clamped(QPoint position, QSize size) const
{
const QRect area = screenArea();
const int maxX = qMax(0, area.width() - size.width());
const int maxY = qMax(0, area.height() - size.height());
return {qBound(0, position.x(), maxX), qBound(0, position.y(), maxY)};
}
QPoint FloatingWindow::defaultPosition() const
{
const QRect area = m_window->screen()->geometry();
return clamped({(area.width() - m_window->width()) / 2,
area.height() - m_window->height() - BottomGap});
const QRect area = screenArea();
return clamped({(area.width() - m_window->width()) / 2, area.height() - m_window->height() - BottomGap},
m_window->size());
}
void FloatingWindow::apply()
@@ -86,5 +116,5 @@ void FloatingWindow::apply()
if (m_layerSurface)
m_layerSurface->setMargins({m_position.x(), m_position.y(), 0, 0});
else
m_window->setPosition(m_window->screen()->geometry().topLeft() + m_position);
m_window->setPosition(screenArea().topLeft() + m_position);
}
+11 -6
View File
@@ -1,7 +1,7 @@
#pragma once
#include <QObject>
#include <QPoint>
#include <QRect>
#include <QRectF>
class QWindow;
@@ -10,29 +10,34 @@ namespace LayerShellQt { class Window; }
// Places a keyboard window as a free-floating overlay that never takes focus.
// On Wayland: a layer-shell surface anchored top-left, positioned via margins.
// On X11: a frameless, non-focusable, always-on-top tool window.
// Geometry is relative to the window's screen and always kept on it.
class FloatingWindow : public QObject
{
Q_OBJECT
Q_PROPERTY(QPoint position READ position WRITE setPosition NOTIFY positionChanged)
public:
// Must be called before the window is shown for the first time.
explicit FloatingWindow(QWindow *window, QObject *parent = nullptr);
QPoint position() const { return m_position; }
void setPosition(QPoint position);
QRect geometry() const;
void setGeometry(const QRect &geometry);
QString screenName() const;
Q_INVOKABLE void moveBy(qreal dx, qreal dy);
// Grows from the top-left corner; respects the window's minimum size.
Q_INVOKABLE void resizeBy(qreal dw, qreal dh);
// Only this part of the window takes input; the rest passes through.
Q_INVOKABLE void setInputRegion(const QRectF &region);
Q_INVOKABLE void show();
Q_INVOKABLE void hide();
Q_SIGNALS:
void positionChanged();
// Only for user-initiated changes (move/resize), not for restores.
void geometryChanged();
private:
QPoint clamped(QPoint position) const;
QRect screenArea() const;
QPoint clamped(QPoint position, QSize size) const;
QPoint defaultPosition() const;
void apply();
+43
View File
@@ -0,0 +1,43 @@
#include "VisibilityController.h"
void VisibilityController::setFieldActive(bool active)
{
if (active == m_fieldActive)
return;
m_fieldActive = active;
m_override.reset();
update();
}
void VisibilityController::setStandalone(bool standalone)
{
m_standalone = standalone;
update();
}
void VisibilityController::show()
{
m_override = true;
update();
}
void VisibilityController::hide()
{
m_override = false;
update();
}
void VisibilityController::toggle()
{
m_override = !m_visible;
update();
}
void VisibilityController::update()
{
const bool visible = m_override.value_or(m_standalone || m_fieldActive);
if (visible == m_visible)
return;
m_visible = visible;
Q_EMIT visibleChanged(visible);
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <QObject>
#include <optional>
// Decides whether the keyboard is shown:
// - while a text field is focused
// - always, when running standalone (no input-method connection)
// - show()/hide()/toggle() override that until the focus changes
class VisibilityController : public QObject
{
Q_OBJECT
public:
using QObject::QObject;
bool isVisible() const { return m_visible; }
void setFieldActive(bool active);
void setStandalone(bool standalone);
void show();
void hide();
void toggle();
Q_SIGNALS:
void visibleChanged(bool visible);
private:
void update();
bool m_fieldActive = false;
bool m_standalone = false;
std::optional<bool> m_override;
bool m_visible = false;
};
+52
View File
@@ -0,0 +1,52 @@
#include "WindowSettings.h"
#include <QSettings>
namespace {
constexpr int DebounceMs = 500;
QString key(const QString &screen)
{
// Screen names may contain '/', which QSettings treats as a group separator.
return QStringLiteral("geometry/") + QString(screen).replace(u'/', u'_');
}
} // namespace
WindowSettings::WindowSettings(QObject *parent)
: QObject(parent)
{
m_debounce.setSingleShot(true);
m_debounce.setInterval(DebounceMs);
connect(&m_debounce, &QTimer::timeout, this, &WindowSettings::flush);
}
WindowSettings::~WindowSettings()
{
if (m_debounce.isActive())
flush();
}
std::optional<QRect> WindowSettings::load(const QString &screen) const
{
const QRect geometry = QSettings().value(key(screen)).toRect();
if (!geometry.isValid())
return std::nullopt;
return geometry;
}
void WindowSettings::save(const QString &screen, const QRect &geometry)
{
if (m_debounce.isActive() && screen != m_pendingScreen)
flush();
m_pendingScreen = screen;
m_pendingGeometry = geometry;
m_debounce.start();
}
void WindowSettings::flush()
{
m_debounce.stop();
QSettings().setValue(key(m_pendingScreen), m_pendingGeometry);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <QObject>
#include <QRect>
#include <QTimer>
#include <optional>
// Remembers the keyboard's geometry per screen (~/.config/sloposk.conf).
// Saving is debounced so dragging doesn't hammer the disk.
class WindowSettings : public QObject
{
Q_OBJECT
public:
explicit WindowSettings(QObject *parent = nullptr);
~WindowSettings() override;
std::optional<QRect> load(const QString &screen) const;
void save(const QString &screen, const QRect &geometry);
private:
void flush();
QTimer m_debounce;
QString m_pendingScreen;
QRect m_pendingGeometry;
};