feat(keyboard): add key repeat and layout settings

main
reionwong 1 month ago
parent 919d36d184
commit 7073fe4263

@ -61,6 +61,7 @@ set(SRCS
src/defaultapplications.cpp
src/mimeapps.cpp
src/inputmethod.cpp
src/keyboard.cpp
src/mock/networkmockmodel.cpp
)

@ -15,6 +15,7 @@
#include "fonts/fonts.h"
#include "fontsmodel.h"
#include "inputmethod.h"
#include "keyboard.h"
#include "language.h"
#include "networkproxy.h"
#include "notifications.h"
@ -85,6 +86,7 @@ Application::Application(int &argc, char **argv)
qmlRegisterType<About>(uri, 1, 0, "About");
qmlRegisterType<Language>(uri, 1, 0, "Language");
qmlRegisterType<InputMethod>(uri, 1, 0, "InputMethod");
qmlRegisterType<Keyboard>(uri, 1, 0, "Keyboard");
qmlRegisterType<Fonts>(uri, 1, 0, "Fonts");
qmlRegisterType<PowerManager>(uri, 1, 0, "PowerManager");
qmlRegisterType<Mouse>(uri, 1, 0, "Mouse");

@ -0,0 +1,210 @@
#include "keyboard.h"
#include <QCollator>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDBusServiceWatcher>
#include <QDebug>
#include <QFile>
#include <QXmlStreamReader>
#include <algorithm>
#include <libintl.h>
namespace {
constexpr auto service = "com.cutefish.Keyboard1";
constexpr auto path = "/com/cutefish/Keyboard1";
constexpr auto rulesFile = "/usr/share/X11/xkb/rules/evdev.xml";
QString translate(const QString &description)
{
return QString::fromUtf8(dgettext("xkeyboard-config", description.toUtf8().constData()));
}
QVariantList readLayouts()
{
QFile file(QString::fromLatin1(rulesFile));
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Cannot read keyboard layouts from" << file.fileName();
return {};
}
bind_textdomain_codeset("xkeyboard-config", "UTF-8");
QVariantList layouts;
QXmlStreamReader xml(&file);
bool inLayoutList = false;
bool inVariant = false;
QString layout;
QString name;
QString description;
while (!xml.atEnd()) {
xml.readNext();
if (xml.isStartElement()) {
if (xml.name() == u"layoutList") {
inLayoutList = true;
} else if (!inLayoutList) {
continue;
} else if (xml.name() == u"variant") {
inVariant = true;
} else if (xml.name() == u"configItem") {
name.clear();
description.clear();
} else if (xml.name() == u"name") {
name = xml.readElementText();
} else if (xml.name() == u"description") {
description = xml.readElementText();
}
} else if (xml.isEndElement() && inLayoutList) {
if (xml.name() == u"layoutList") {
inLayoutList = false;
} else if (xml.name() == u"variant") {
inVariant = false;
} else if (xml.name() == u"configItem" && !name.isEmpty()) {
if (!inVariant)
layout = name;
// A placeholder for a layout file the user writes by hand.
if (layout == QLatin1String("custom"))
continue;
const QString id = inVariant ? layout + QLatin1Char('-') + name : layout;
layouts << QVariantMap {{QStringLiteral("id"), id},
{QStringLiteral("name"), translate(description)}};
}
}
}
if (xml.hasError())
qWarning() << "Cannot parse" << file.fileName() << xml.errorString();
QCollator collator;
collator.setCaseSensitivity(Qt::CaseInsensitive);
std::sort(layouts.begin(), layouts.end(), [&collator](const QVariant &a, const QVariant &b) {
return collator.compare(a.toMap().value(QStringLiteral("name")).toString(),
b.toMap().value(QStringLiteral("name")).toString())
< 0;
});
return layouts;
}
} // namespace
Keyboard::Keyboard(QObject *parent)
: QObject(parent)
{
auto *watcher = new QDBusServiceWatcher(QLatin1String(service), QDBusConnection::sessionBus(),
QDBusServiceWatcher::WatchForOwnerChange, this);
connect(watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, &Keyboard::refreshInterface);
refreshInterface();
}
void Keyboard::refreshInterface()
{
delete m_interface;
m_interface = new QDBusInterface(QLatin1String(service), QLatin1String(path),
QLatin1String(service), QDBusConnection::sessionBus(), this);
if (m_interface->isValid()) {
connect(m_interface, SIGNAL(repeatEnabledChanged()), this, SIGNAL(repeatEnabledChanged()));
connect(m_interface, SIGNAL(repeatDelayChanged()), this, SIGNAL(repeatDelayChanged()));
connect(m_interface, SIGNAL(repeatIntervalChanged()), this,
SIGNAL(repeatIntervalChanged()));
connect(m_interface, SIGNAL(layoutChanged()), this, SIGNAL(layoutChanged()));
}
notifyChanged();
}
void Keyboard::notifyChanged()
{
emit availableChanged();
emit repeatEnabledChanged();
emit repeatDelayChanged();
emit repeatIntervalChanged();
emit layoutChanged();
}
void Keyboard::call(const QString &method, const QVariant &value)
{
if (!m_interface->isValid())
return;
auto *watcher = new QDBusPendingCallWatcher(m_interface->asyncCall(method, value), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this,
[this, method](QDBusPendingCallWatcher *reply) {
const QDBusPendingReply<> result = *reply;
if (result.isError()) {
qWarning() << "Cannot update keyboard setting" << method
<< result.error().message();
notifyChanged();
}
reply->deleteLater();
});
}
bool Keyboard::available() const
{
return m_interface->isValid();
}
bool Keyboard::repeatEnabled() const
{
return m_interface->isValid() ? m_interface->property("repeatEnabled").toBool() : true;
}
void Keyboard::setRepeatEnabled(bool enabled)
{
call(QStringLiteral("setRepeatEnabled"), enabled);
}
int Keyboard::repeatDelay() const
{
return m_interface->property("repeatDelay").toInt();
}
void Keyboard::setRepeatDelay(int milliseconds)
{
call(QStringLiteral("setRepeatDelay"), milliseconds);
}
int Keyboard::repeatInterval() const
{
return m_interface->property("repeatInterval").toInt();
}
void Keyboard::setRepeatInterval(int milliseconds)
{
call(QStringLiteral("setRepeatInterval"), milliseconds);
}
QString Keyboard::layout() const
{
return m_interface->property("layout").toString();
}
QString Keyboard::layoutName() const
{
const QString id = layout();
for (const auto &entry : layouts()) {
const QVariantMap map = entry.toMap();
if (map.value(QStringLiteral("id")).toString() == id)
return map.value(QStringLiteral("name")).toString();
}
return id;
}
QVariantList Keyboard::layouts() const
{
if (m_layouts.isEmpty())
m_layouts = readLayouts();
return m_layouts;
}
void Keyboard::selectLayout(const QString &id)
{
if (!m_interface->isValid())
return;
auto *watcher = new QDBusPendingCallWatcher(
m_interface->asyncCall(QStringLiteral("setLayout"), id), this);
connect(watcher, &QDBusPendingCallWatcher::finished, this,
[this, id](QDBusPendingCallWatcher *call) {
const QDBusPendingReply<bool> reply = *call;
if (reply.isError() || !reply.value())
qWarning() << "Cannot select keyboard layout" << id << reply.error().message();
emit layoutChanged();
call->deleteLater();
});
}

@ -0,0 +1,57 @@
#pragma once
#include <QDBusInterface>
#include <QObject>
#include <QVariantList>
// Client of the compositor's com.cutefish.Keyboard1 service.
class Keyboard : public QObject
{
Q_OBJECT
Q_PROPERTY(bool available READ available NOTIFY availableChanged)
Q_PROPERTY(bool repeatEnabled READ repeatEnabled WRITE setRepeatEnabled NOTIFY
repeatEnabledChanged)
Q_PROPERTY(int repeatDelay READ repeatDelay WRITE setRepeatDelay NOTIFY repeatDelayChanged)
Q_PROPERTY(int repeatInterval READ repeatInterval WRITE setRepeatInterval NOTIFY
repeatIntervalChanged)
// "layout" or "layout-variant", as xkeyboard-config names them.
Q_PROPERTY(QString layout READ layout NOTIFY layoutChanged)
Q_PROPERTY(QString layoutName READ layoutName NOTIFY layoutChanged)
// Every layout and variant as {id, name}, sorted by name. Read on first use.
Q_PROPERTY(QVariantList layouts READ layouts CONSTANT)
public:
explicit Keyboard(QObject *parent = nullptr);
bool available() const;
bool repeatEnabled() const;
void setRepeatEnabled(bool enabled);
int repeatDelay() const;
void setRepeatDelay(int milliseconds);
int repeatInterval() const;
void setRepeatInterval(int milliseconds);
QString layout() const;
QString layoutName() const;
QVariantList layouts() const;
Q_INVOKABLE void selectLayout(const QString &id);
signals:
void availableChanged();
void repeatEnabledChanged();
void repeatDelayChanged();
void repeatIntervalChanged();
void layoutChanged();
private:
void refreshInterface();
void notifyChanged();
void call(const QString &method, const QVariant &value);
QDBusInterface *m_interface = nullptr;
mutable QVariantList m_layouts;
};

@ -0,0 +1,139 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import FishUI 1.0 as FishUI
import Cutefish.Settings 1.0
import "../"
ItemPage {
id: page
headerTitle: qsTr("Layout")
Keyboard {
id: keyboard
}
readonly property var filteredLayouts: {
var query = searchField.text.trim().toLowerCase()
if (query.length === 0)
return keyboard.layouts
return keyboard.layouts.filter(function(entry) {
return entry.name.toLowerCase().indexOf(query) >= 0
|| entry.id.toLowerCase().indexOf(query) >= 0
})
}
FishUI.RoundedRectangle {
anchors.fill: parent
// The window header floats over the top of the page.
anchors.topMargin: rootWindow.header.height + FishUI.Units.largeSpacing
+ FishUI.Units.smallSpacing
anchors.leftMargin: FishUI.Units.largeSpacing * 2
anchors.rightMargin: FishUI.Units.largeSpacing * 2
anchors.bottomMargin: FishUI.Units.largeSpacing * 2
color: FishUI.Theme.secondBackgroundColor
radius: FishUI.Theme.bigRadius
// Text fields are drawn for card surfaces; on the page background
// they all but disappear.
FishUI.TextField {
id: searchField
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: FishUI.Units.largeSpacing
placeholderText: qsTr("Search layouts")
selectByMouse: true
onTextChanged: layoutList.positionViewAtBeginning()
}
ListView {
id: layoutList
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchField.bottom
anchors.bottom: parent.bottom
anchors.margins: FishUI.Units.smallSpacing
anchors.topMargin: FishUI.Units.smallSpacing
clip: true
model: page.filteredLayouts
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
FishUI.ScrollBar.vertical: FishUI.ScrollBar {}
FishUI.WheelHandler { target: layoutList }
delegate: ItemDelegate {
id: row
required property var modelData
required property int index
readonly property bool current: modelData.id === keyboard.layout
width: ListView.view.width
height: 40
leftPadding: FishUI.Units.largeSpacing * 1.5 - FishUI.Units.smallSpacing
rightPadding: leftPadding
Accessible.role: Accessible.RadioButton
Accessible.name: modelData.name
Accessible.checked: current
background: FishUI.RoundedRectangle {
radius: FishUI.Theme.smallRadius
color: row.hovered ? Qt.alpha(FishUI.Theme.textColor, 0.05) : "transparent"
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: row.leftPadding
anchors.rightMargin: row.rightPadding
height: 1
color: FishUI.Theme.disabledTextColor
opacity: FishUI.Theme.darkMode ? 0.3 : 0.1
visible: row.index < layoutList.count - 1
}
contentItem: RowLayout {
spacing: FishUI.Units.largeSpacing
FishUI.Label {
Layout.fillWidth: true
text: row.modelData.name
textFormat: Text.PlainText
color: FishUI.Theme.textColor
elide: Text.ElideRight
}
FishUI.Icon {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
name: "check"
color: FishUI.Theme.highlightColor
visible: row.current
}
}
onClicked: keyboard.selectLayout(modelData.id)
}
FishUI.Label {
anchors.centerIn: parent
visible: layoutList.count === 0
text: qsTr("No layouts found")
color: FishUI.Theme.disabledTextColor
}
Component.onCompleted: {
for (var i = 0; i < count; ++i) {
if (page.filteredLayouts[i].id === keyboard.layout) {
positionViewAtIndex(i, ListView.Center)
break
}
}
}
}
}
}

@ -7,12 +7,30 @@ import Cutefish.Settings 1.0
import "../"
ItemPage {
id: page
headerTitle: qsTr("Keyboard")
// Slider stops, slowest first, in milliseconds.
readonly property var repeatDelays: [1500, 1000, 750, 500, 400, 300, 250, 200, 150]
readonly property var repeatIntervals: [500, 250, 100, 67, 50, 40, 33, 25, 20]
function nearestStop(stops, value) {
var best = 0
for (var i = 1; i < stops.length; ++i) {
if (Math.abs(stops[i] - value) < Math.abs(stops[best] - value))
best = i
}
return best
}
InputMethod {
id: inputMethod
}
Keyboard {
id: keyboard
}
Scrollable {
anchors.fill: parent
contentHeight: layout.implicitHeight
@ -82,7 +100,68 @@ ItemPage {
opacity: 0.8
}
RoundedItem {
visible: keyboard.available
GridLayout {
columns: 2
columnSpacing: FishUI.Units.largeSpacing * 1.5
rowSpacing: FishUI.Units.largeSpacing * 2
FishUI.Label {
text: qsTr("Key repeat")
Layout.fillWidth: true
}
FishUI.Switch {
Layout.fillHeight: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
checked: keyboard.repeatEnabled
rightPadding: 0
rightInset: 0
onToggled: keyboard.repeatEnabled = checked
}
FishUI.Label {
text: qsTr("Delay until repeat")
enabled: keyboard.repeatEnabled
}
FishUI.Slider {
Layout.fillWidth: true
enabled: keyboard.repeatEnabled
from: 0
to: page.repeatDelays.length - 1
stepSize: 1
value: page.nearestStop(page.repeatDelays, keyboard.repeatDelay)
onMoved: keyboard.repeatDelay = page.repeatDelays[Math.round(value)]
}
FishUI.Label {
text: qsTr("Repeat rate")
enabled: keyboard.repeatEnabled
}
FishUI.Slider {
Layout.fillWidth: true
enabled: keyboard.repeatEnabled
from: 0
to: page.repeatIntervals.length - 1
stepSize: 1
value: page.nearestStop(page.repeatIntervals, keyboard.repeatInterval)
onMoved: keyboard.repeatInterval = page.repeatIntervals[Math.round(value)]
}
}
}
ListCard {
NavigationItem {
visible: keyboard.available
text: qsTr("Layout")
value: keyboard.layoutName
onClicked: rootWindow.pushPage("qrc:/qml/Keyboard/LayoutPage.qml")
}
NavigationItem {
text: qsTr("Keyboard Shortcuts")
onClicked: rootWindow.pushPage("qrc:/qml/Shortcuts/Main.qml")

@ -6,6 +6,18 @@ import FishUI 1.0 as FishUI
ListRow {
id: control
// The current choice, shown before the chevron.
property string value
FishUI.Label {
Layout.maximumWidth: control.width / 2
visible: control.value.length > 0
text: control.value
textFormat: Text.PlainText
color: FishUI.Theme.disabledTextColor
elide: Text.ElideRight
}
FishUI.Icon {
Layout.preferredWidth: 16
Layout.preferredHeight: 16

@ -108,6 +108,7 @@
<file>qml/Cursor/Main.qml</file>
<file>qml/Keyboard/Main.qml</file>
<file>qml/Keyboard/InputMethodRow.qml</file>
<file>qml/Keyboard/LayoutPage.qml</file>
<file>images/sidebar/dark/cursor.svg</file>
<file>images/sidebar/dark/fonts.svg</file>
<file>images/sidebar/dark/icontheme.svg</file>

Loading…
Cancel
Save