mirror of https://github.com/cutefishos/settings
feat(keyboard): add key repeat and layout settings
parent
919d36d184
commit
7073fe4263
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue