v0.2.0: data-safety, unsaved-changes guard, FormCard UI, filesystem UX

- Preserve override keys we do not model so Save never drops data
- Prompt before discarding unsaved changes when switching applications
- Native FormCard layout for the permission list (grouped cards)
- Filesystem entries gain an access-mode selector and a folder browser
- Add resolution autotests (toggle render/save, filesystem split, key preservation)
This commit is contained in:
2026-07-01 14:46:24 -05:00
parent 3419a1acac
commit 5f37debd01
9 changed files with 559 additions and 265 deletions
+6 -1
View File
@@ -3,7 +3,7 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(flatkontrol VERSION 0.1.0 LANGUAGES CXX) project(flatkontrol VERSION 0.2.0 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0) set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0) set(REQUIRED_KF_VERSION 6.5.0)
@@ -30,6 +30,7 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
Quick Quick
QuickControls2 QuickControls2
DBus DBus
Test
) )
find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
@@ -47,6 +48,10 @@ ecm_setup_version(${PROJECT_VERSION}
add_subdirectory(src) add_subdirectory(src)
if(BUILD_TESTING)
add_subdirectory(autotests)
endif()
install(PROGRAMS io.github.toservetheking.FlatKontrol.desktop install(PROGRAMS io.github.toservetheking.FlatKontrol.desktop
DESTINATION ${KDE_INSTALL_APPDIR}) DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES io.github.toservetheking.FlatKontrol.metainfo.xml install(FILES io.github.toservetheking.FlatKontrol.metainfo.xml
+22
View File
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: GPL-3.0-or-later
add_executable(resolutiontest
resolutiontest.cpp
../src/keyfile.cpp
../src/flatpakinstallations.cpp
../src/permissioncatalog.cpp
../src/portalsbackend.cpp
../src/permissionscontroller.cpp
)
target_include_directories(resolutiontest PRIVATE ../src)
target_link_libraries(resolutiontest PRIVATE
Qt6::Core
Qt6::Qml
Qt6::DBus
Qt6::Test
KF6::I18n
)
add_test(NAME resolutiontest COMMAND resolutiontest)
+145
View File
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "keyfile.h"
#include "permissionscontroller.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
using PC = PermissionsController;
class ResolutionTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir m_dir;
QString userBase() const
{
return m_dir.path() + QStringLiteral("/user");
}
void makeApp(const QString &appId, const QString &metadata)
{
const QString d = userBase() + QStringLiteral("/app/") + appId + QStringLiteral("/current/active");
QVERIFY(QDir().mkpath(d));
QFile f(d + QStringLiteral("/metadata"));
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(metadata.toUtf8());
}
void writeOverride(const QString &appId, const QString &content)
{
const QString d = userBase() + QStringLiteral("/overrides");
QVERIFY(QDir().mkpath(d));
QFile f(d + QLatin1Char('/') + appId);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(content.toUtf8());
}
KeyFile readOverride(const QString &appId)
{
KeyFile kf;
kf.load(userBase() + QStringLiteral("/overrides/") + appId);
return kf;
}
static int findToggle(PC &c, const QString &catId, const QString &option)
{
for (int i = 0; i < c.rowCount(); ++i) {
const QModelIndex idx = c.index(i);
if (c.data(idx, PC::RowTypeRole).toInt() == PC::Toggle && c.data(idx, PC::CategoryIdRole).toString() == catId
&& c.data(idx, PC::OptionKeyRole).toString() == option) {
return i;
}
}
return -1;
}
private Q_SLOTS:
void initTestCase()
{
QVERIFY(m_dir.isValid());
qputenv("FLATPAK_USER_DIR", userBase().toUtf8());
qputenv("FLATPAK_SYSTEM_DIR", (m_dir.path() + QStringLiteral("/system")).toUtf8());
// Point config at an empty dir so host custom installations don't interfere.
qputenv("FLATPAK_CONFIG_DIR", (m_dir.path() + QStringLiteral("/config")).toUtf8());
}
void toggleRenderAndSave()
{
makeApp(QStringLiteral("org.test.Toggle"), QStringLiteral("[Context]\nshared=network;ipc\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Toggle"));
const int net = findToggle(c, QStringLiteral("share"), QStringLiteral("network"));
QVERIFY(net >= 0);
QCOMPARE(c.data(c.index(net), PC::ValueRole).toBool(), true);
QCOMPARE(c.data(c.index(net), PC::StatusRole).toInt(), int(PC::Original));
QVERIFY(!c.modified());
c.setToggleValue(net, false);
QVERIFY(c.modified());
QCOMPARE(c.data(c.index(net), PC::StatusRole).toInt(), int(PC::User));
c.save();
QVERIFY(!c.modified());
const QStringList shared = readOverride(QStringLiteral("org.test.Toggle")).value(QStringLiteral("Context"), QStringLiteral("shared")).split(QLatin1Char(';'), Qt::SkipEmptyParts);
QVERIFY(shared.contains(QStringLiteral("!network")));
QVERIFY(!shared.contains(QStringLiteral("!ipc"))); // unchanged options are not written
}
void filesystemsSplitByMode()
{
makeApp(QStringLiteral("org.test.Fs"), QStringLiteral("[Context]\nfilesystems=home;~/Docs:ro;xdg-download\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Fs"));
// "home" is a preset toggle, on.
const int home = findToggle(c, QStringLiteral("filesystems-presets"), QStringLiteral("home"));
QVERIFY(home >= 0);
QCOMPARE(c.data(c.index(home), PC::ValueRole).toBool(), true);
// The non-preset paths become entries split into path + access mode.
QMap<QString, QString> paths;
for (int i = 0; i < c.rowCount(); ++i) {
const QModelIndex idx = c.index(i);
if (c.data(idx, PC::RowTypeRole).toInt() == PC::PathEntry && c.data(idx, PC::CategoryIdRole).toString() == QStringLiteral("filesystems-other")) {
paths.insert(c.data(idx, PC::ValueRole).toString(), c.data(idx, PC::SecondaryRole).toString());
}
}
QCOMPARE(paths.value(QStringLiteral("~/Docs")), QStringLiteral("ro"));
QCOMPARE(paths.value(QStringLiteral("xdg-download")), QStringLiteral("rw"));
}
void unknownKeysPreserved()
{
makeApp(QStringLiteral("org.test.Unknown"), QStringLiteral("[Context]\nshared=network\n"));
writeOverride(QStringLiteral("org.test.Unknown"),
QStringLiteral("[Context]\nshared=!network\nunknownkey=foo\n\n[Weird Group]\na=b\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Unknown"));
// Loading an existing file must be idempotent (no spurious "modified").
QVERIFY(!c.modified());
const int net = findToggle(c, QStringLiteral("share"), QStringLiteral("network"));
QVERIFY(net >= 0);
QCOMPARE(c.data(c.index(net), PC::ValueRole).toBool(), false); // overridden off
c.setToggleValue(net, true); // back to the metadata default -> override removed
QVERIFY(c.modified());
c.save();
const KeyFile kf = readOverride(QStringLiteral("org.test.Unknown"));
// Keys we do not model must survive the round-trip.
QCOMPARE(kf.value(QStringLiteral("Context"), QStringLiteral("unknownkey")), QStringLiteral("foo"));
QCOMPARE(kf.value(QStringLiteral("Weird Group"), QStringLiteral("a")), QStringLiteral("b"));
}
};
QTEST_GUILESS_MAIN(ResolutionTest)
#include "resolutiontest.moc"
@@ -61,6 +61,16 @@
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>
<releases> <releases>
<release version="0.2.0" date="2026-07-01">
<description>
<ul>
<li>Preserve override keys that are not modelled, so saving never drops data</li>
<li>Ask before discarding unsaved changes when switching applications</li>
<li>Native FormCard layout for the permission list</li>
<li>Filesystem entries now have an access-mode selector and a folder browser</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-06-30"> <release version="0.1.0" date="2026-06-30">
<description> <description>
<p>Initial release.</p> <p>Initial release.</p>
+1 -1
View File
@@ -36,7 +36,7 @@ modules:
# For Flathub / release builds, pin to a tag AND commit: # For Flathub / release builds, pin to a tag AND commit:
- type: git - type: git
url: https://github.com/toservetheking/FlatKontrol.git url: https://github.com/toservetheking/FlatKontrol.git
tag: v0.1.0 tag: v0.2.0
# commit: <fill in the exact commit SHA the tag points at> # commit: <fill in the exact commit SHA the tag points at>
# #
# For local testing before the repo is pushed, replace the source above with: # For local testing before the repo is pushed, replace the source above with:
+74 -2
View File
@@ -29,6 +29,33 @@ QString joinSorted(const QStringList &tokens)
return copy.join(QLatin1Char(';')); return copy.join(QLatin1Char(';'));
} }
// Split a filesystem token like "~/games:ro" into its access mode and path.
// A missing/unknown suffix means read-write (flatpak's default).
bool isFsMode(const QString &s)
{
return s == QLatin1String("ro") || s == QLatin1String("rw") || s == QLatin1String("create");
}
QString fsModeOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.mid(idx + 1);
}
return QStringLiteral("rw");
}
QString fsPathOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.left(idx);
}
return token;
}
QString fsToken(const QString &path, const QString &mode)
{
return (mode.isEmpty() || mode == QLatin1String("rw")) ? path : path + QLatin1Char(':') + mode;
}
// Normalise a KeyFile to a canonical string so two override sets can be // Normalise a KeyFile to a canonical string so two override sets can be
// compared regardless of group/key/token ordering. // compared regardless of group/key/token ordering.
QString normalize(const KeyFile &kf) QString normalize(const KeyFile &kf)
@@ -54,6 +81,23 @@ PermissionsController::PermissionsController(QObject *parent)
{ {
} }
QVariantList PermissionsController::categories() const
{
QVariantList list;
for (const CategoryDef &cat : catalog()) {
if (cat.type == RowType::Portal && isGlobal()) {
continue; // portals are not applicable to the global override file
}
list.append(QVariantMap{
{QStringLiteral("id"), cat.id},
{QStringLiteral("title"), cat.title},
{QStringLiteral("description"), cat.description},
{QStringLiteral("type"), static_cast<int>(cat.type)},
});
}
return list;
}
bool PermissionsController::isFilesystemPreset(const QString &bareToken) bool PermissionsController::isFilesystemPreset(const QString &bareToken)
{ {
static const QSet<QString> presets = { static const QSet<QString> presets = {
@@ -319,7 +363,8 @@ void PermissionsController::rebuild()
Row r; Row r;
r.catIndex = i; r.catIndex = i;
r.kind = PathEntry; r.kind = PathEntry;
r.primary = t; r.primary = fsPathOf(t);
r.secondary = fsModeOf(t);
r.removable = true; r.removable = true;
r.entryId = m_nextEntryId++; r.entryId = m_nextEntryId++;
m_rows.append(r); m_rows.append(r);
@@ -730,7 +775,7 @@ KeyFile PermissionsController::buildOverrides() const
QSet<QString> desired; QSet<QString> desired;
for (const Row &r : m_rows) { for (const Row &r : m_rows) {
if (r.catIndex == i && !r.primary.trimmed().isEmpty()) { if (r.catIndex == i && !r.primary.trimmed().isEmpty()) {
desired.insert(r.primary.trimmed()); desired.insert(fsToken(r.primary.trimmed(), r.secondary));
} }
} }
// Port of filesystemsOther.updateFromProxyProperty (presets separated). // Port of filesystemsOther.updateFromProxyProperty (presets separated).
@@ -834,6 +879,33 @@ KeyFile PermissionsController::buildOverrides() const
kf.setValue(group, key, tokens.join(QLatin1Char(';'))); kf.setValue(group, key, tokens.join(QLatin1Char(';')));
} }
// Preserve any keys we do not model (e.g. newer flatpak options, or entries
// written by other tools) so saving never drops data. We claim the six
// Context keys and the whole Environment / bus-policy groups; everything else
// is copied through verbatim from what was on disk.
static const QSet<QString> claimedContextKeys = {
QStringLiteral("shared"),
QStringLiteral("sockets"),
QStringLiteral("devices"),
QStringLiteral("features"),
QStringLiteral("filesystems"),
QStringLiteral("persistent"),
};
static const QSet<QString> claimedGroups = {
QStringLiteral("Environment"),
QStringLiteral("Session Bus Policy"),
QStringLiteral("System Bus Policy"),
};
for (const QString &group : m_savedSnapshot.groups()) {
const bool wholeGroupClaimed = claimedGroups.contains(group);
for (const QString &key : m_savedSnapshot.keys(group)) {
const bool claimed = wholeGroupClaimed || (group == QStringLiteral("Context") && claimedContextKeys.contains(key));
if (!claimed) {
kf.setValue(group, key, m_savedSnapshot.value(group, key));
}
}
}
return kf; return kf;
} }
+4
View File
@@ -11,6 +11,7 @@
#include <QQmlEngine> #include <QQmlEngine>
#include <QSet> #include <QSet>
#include <QString> #include <QString>
#include <QVariantList>
/** /**
* Owns the editable permission state for one application (or the global * Owns the editable permission state for one application (or the global
@@ -34,6 +35,8 @@ class PermissionsController : public QAbstractListModel
Q_PROPERTY(bool isGlobal READ isGlobal NOTIFY appIdChanged) Q_PROPERTY(bool isGlobal READ isGlobal NOTIFY appIdChanged)
Q_PROPERTY(bool modified READ modified NOTIFY modifiedChanged) Q_PROPERTY(bool modified READ modified NOTIFY modifiedChanged)
Q_PROPERTY(bool canUndo READ canUndo NOTIFY canUndoChanged) Q_PROPERTY(bool canUndo READ canUndo NOTIFY canUndoChanged)
// One entry per visible category: {id, title, description, type}.
Q_PROPERTY(QVariantList categories READ categories NOTIFY appIdChanged)
public: public:
enum RowKind { enum RowKind {
@@ -119,6 +122,7 @@ public:
{ {
return m_canUndo; return m_canUndo;
} }
QVariantList categories() const;
// --- Editing API, called from QML --- // --- Editing API, called from QML ---
Q_INVOKABLE void setToggleValue(int row, bool value); Q_INVOKABLE void setToggleValue(int row, bool value);
+26 -4
View File
@@ -45,12 +45,37 @@ Kirigami.ApplicationWindow {
detailsDialog.open(); detailsDialog.open();
} }
function requestSelect(appId: string): void {
if (appId === controller.appId) {
root.pageStack.currentIndex = 1;
return;
}
if (controller.modified) {
discardDialog.pendingAppId = appId;
discardDialog.open();
} else {
selectApp(appId);
}
}
function selectApp(appId: string): void { function selectApp(appId: string): void {
controller.appId = appId; controller.appId = appId;
// On narrow layouts, reveal the permissions column. // On narrow layouts, reveal the permissions column.
root.pageStack.currentIndex = 1; root.pageStack.currentIndex = 1;
} }
Kirigami.PromptDialog {
id: discardDialog
property string pendingAppId
title: i18nc("@title", "Unsaved changes")
subtitle: i18n("You have unsaved changes. Discard them and switch applications?")
standardButtons: QQC2.Dialog.Discard | QQC2.Dialog.Cancel
onDiscarded: {
close();
root.selectApp(discardDialog.pendingAppId);
}
}
Component { Component {
id: sidebarComponent id: sidebarComponent
@@ -85,10 +110,7 @@ Kirigami.ApplicationWindow {
subtitle: appDelegate.isGlobal ? i18n("Default settings for all apps") : appDelegate.appId subtitle: appDelegate.isGlobal ? i18n("Default settings for all apps") : appDelegate.appId
} }
onClicked: { onClicked: root.requestSelect(appId)
appList.currentIndex = index;
root.selectApp(appId);
}
} }
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
+170 -156
View File
@@ -4,8 +4,11 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls as QQC2 import QtQuick.Controls as QQC2
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs
import Qt.labs.qmlmodels import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.FlatKontrol import io.github.toservetheking.FlatKontrol
Kirigami.ScrollablePage { Kirigami.ScrollablePage {
@@ -17,20 +20,23 @@ Kirigami.ScrollablePage {
title: hasSelection ? controller.appName : i18nc("@title", "Permissions") title: hasSelection ? controller.appName : i18nc("@title", "Permissions")
// --- shared bits --------------------------------------------------------- leftPadding: 0
rightPadding: 0
topPadding: Kirigami.Units.gridUnit
bottomPadding: Kirigami.Units.gridUnit
// A small status marker used across delegates.
component StatusBadge: Kirigami.Icon { component StatusBadge: Kirigami.Icon {
property int badgeStatus: 0 property int state: 0
visible: badgeStatus > 0 visible: state > 0
implicitWidth: Kirigami.Units.iconSizes.small implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small implicitHeight: Kirigami.Units.iconSizes.small
source: badgeStatus === 2 ? "document-edit" : "globe" source: state === 2 ? "document-edit-symbolic" : "globe-symbolic"
HoverHandler { HoverHandler {
id: hover id: badgeHover
} }
QQC2.ToolTip.visible: hover.hovered QQC2.ToolTip.visible: badgeHover.hovered
QQC2.ToolTip.text: badgeStatus === 2 ? i18n("Set by you") : i18n("Set by a global override") QQC2.ToolTip.text: state === 2 ? i18n("Set by you") : i18n("Set by a global override")
} }
actions: [ actions: [
@@ -70,12 +76,6 @@ Kirigami.ScrollablePage {
} }
} }
// --- the list ------------------------------------------------------------
ListView {
id: permsView
model: page.controller
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4 width: parent.width - Kirigami.Units.gridUnit * 4
@@ -85,111 +85,181 @@ Kirigami.ScrollablePage {
explanation: i18n("Choose an application to review and adjust its permissions.") explanation: i18n("Choose an application to review and adjust its permissions.")
} }
section.property: "categoryTitle" ColumnLayout {
section.delegate: Kirigami.ListSectionHeader { spacing: Kirigami.Units.largeSpacing
required property string section visible: page.hasSelection
width: ListView.view.width
text: section Repeater {
model: page.controller.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
FormCard.FormHeader {
title: section.modelData.title
} }
FormCard.FormCard {
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: page.controller
filterRoleName: "categoryId"
// No category id is a substring of another, so this is an exact match.
filterString: section.modelData.id
}
Repeater {
model: catModel
delegate: DelegateChooser { delegate: DelegateChooser {
role: "rowType" role: "rowType"
// Toggle (share/sockets/devices/features/filesystem presets) // Toggle
DelegateChoice { DelegateChoice {
roleValue: 0 roleValue: 0
delegate: QQC2.ItemDelegate { delegate: FormCard.FormSwitchDelegate {
id: toggleDelegate id: toggleD
required property int index required property int index
required property string label required property string label
required property string example required property string example
required property bool value required property bool value
required property int status required property int status
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
width: ListView.view.width text: label
hoverEnabled: true description: example
onClicked: toggleSwitch.toggle() checked: value
icon.name: status === 2 ? "document-edit-symbolic" : (status === 1 ? "globe-symbolic" : "")
contentItem: RowLayout { onToggled: page.controller.setToggleValue(sourceRow, checked)
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: toggleDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: toggleDelegate.example
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
elide: Text.ElideRight
}
}
StatusBadge {
badgeStatus: toggleDelegate.status
}
QQC2.Switch {
id: toggleSwitch
checked: toggleDelegate.value
onToggled: page.controller.setToggleValue(toggleDelegate.index, checked)
}
}
} }
} }
// Filesystem path entry // Filesystem path (with access mode + Browse)
DelegateChoice { DelegateChoice {
roleValue: 1 roleValue: 1
delegate: page.entryDelegate delegate: FormCard.AbstractFormDelegate {
} id: fsD
// Persistent (relative path) entry
DelegateChoice {
roleValue: 2
delegate: page.entryDelegate
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: QQC2.ItemDelegate {
id: varDelegate
required property int index required property int index
required property string value required property string value
required property string secondary required property string secondary
required property int status required property int status
required property bool removable required property bool removable
width: ListView.view.width readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
hoverEnabled: true
background: null background: null
FolderDialog {
id: folderDialog
onAccepted: {
const p = decodeURIComponent(selectedFolder.toString().replace(/^file:\/\//, ""));
fsField.text = p;
page.controller.setEntryPrimary(fsD.sourceRow, p);
}
}
contentItem: RowLayout { contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
QQC2.TextField { QQC2.TextField {
text: varDelegate.value id: fsField
text: fsD.value
placeholderText: i18n("e.g. ~/Downloads")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(fsD.sourceRow, text)
}
QQC2.ComboBox {
readonly property var modes: ["ro", "rw", "create"]
model: [i18n("Read-only"), i18n("Read/write"), i18n("Create")]
currentIndex: Math.max(0, modes.indexOf(fsD.secondary))
onActivated: page.controller.setEntrySecondary(fsD.sourceRow, modes[currentIndex])
}
QQC2.ToolButton {
icon.name: "document-open-folder"
QQC2.ToolTip.text: i18n("Browse…")
QQC2.ToolTip.visible: hovered
onClicked: folderDialog.open()
}
StatusBadge {
state: fsD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: fsD.removable
onClicked: page.controller.removeEntry(fsD.sourceRow)
}
}
}
}
// Persistent (home-relative) path
DelegateChoice {
roleValue: 2
delegate: FormCard.AbstractFormDelegate {
id: relD
required property int index
required property string value
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: relD.value
placeholderText: i18n("e.g. .thunderbird")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(relD.sourceRow, text)
}
StatusBadge {
state: relD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: relD.removable
onClicked: page.controller.removeEntry(relD.sourceRow)
}
}
}
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: FormCard.AbstractFormDelegate {
id: varD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: varD.value
placeholderText: i18n("VARIABLE") placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10 Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varDelegate.index, text) onTextEdited: page.controller.setEntryPrimary(varD.sourceRow, text)
} }
QQC2.Label { QQC2.Label {
text: "=" text: "="
} }
QQC2.TextField { QQC2.TextField {
text: varDelegate.secondary text: varD.secondary
placeholderText: i18n("value") placeholderText: i18n("value")
Layout.fillWidth: true Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varDelegate.index, text) onTextEdited: page.controller.setEntrySecondary(varD.sourceRow, text)
} }
StatusBadge { StatusBadge {
badgeStatus: varDelegate.status state: varD.status
} }
QQC2.ToolButton { QQC2.ToolButton {
icon.name: "edit-delete-remove" icon.name: "edit-delete-remove"
visible: varDelegate.removable visible: varD.removable
onClicked: page.controller.removeEntry(varDelegate.index) onClicked: page.controller.removeEntry(varD.sourceRow)
} }
} }
} }
@@ -198,37 +268,35 @@ Kirigami.ScrollablePage {
// D-Bus name policy // D-Bus name policy
DelegateChoice { DelegateChoice {
roleValue: 4 roleValue: 4
delegate: QQC2.ItemDelegate { delegate: FormCard.AbstractFormDelegate {
id: busDelegate id: busD
required property int index required property int index
required property string value required property string value
required property string secondary required property string secondary
required property int status required property int status
required property bool removable required property bool removable
width: ListView.view.width readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
hoverEnabled: true
background: null background: null
contentItem: RowLayout { contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
QQC2.TextField { QQC2.TextField {
text: busDelegate.value text: busD.value
placeholderText: i18n("e.g. org.freedesktop.Notifications") placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busDelegate.index, text) onTextEdited: page.controller.setEntryPrimary(busD.sourceRow, text)
} }
QQC2.ComboBox { QQC2.ComboBox {
model: ["talk", "own"] model: ["talk", "own"]
currentIndex: busDelegate.secondary === "own" ? 1 : 0 currentIndex: busD.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busDelegate.index, currentValue) onActivated: page.controller.setEntrySecondary(busD.sourceRow, currentValue)
} }
StatusBadge { StatusBadge {
badgeStatus: busDelegate.status state: busD.status
} }
QQC2.ToolButton { QQC2.ToolButton {
icon.name: "edit-delete-remove" icon.name: "edit-delete-remove"
visible: busDelegate.removable visible: busD.removable
onClicked: page.controller.removeEntry(busDelegate.index) onClicked: page.controller.removeEntry(busD.sourceRow)
} }
} }
} }
@@ -237,92 +305,38 @@ Kirigami.ScrollablePage {
// Portal (tri-state) // Portal (tri-state)
DelegateChoice { DelegateChoice {
roleValue: 5 roleValue: 5
delegate: QQC2.ItemDelegate { delegate: FormCard.FormComboBoxDelegate {
id: portalDelegate id: portalD
required property int index required property int index
required property string label required property string label
required property string example required property string example
required property int value required property int value
required property bool supported required property bool supported
required property string unsupportedReason required property string unsupportedReason
width: ListView.view.width readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
hoverEnabled: true readonly property var states: [2, 4, 3]
background: null text: label
description: supported ? example : unsupportedReason
readonly property var states: [2, 4, 3] // Unset, Allowed, Disallowed enabled: supported
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: portalDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: portalDelegate.supported ? portalDelegate.example : portalDelegate.unsupportedReason
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
QQC2.ComboBox {
enabled: portalDelegate.supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")] model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: portalDelegate.value === 4 ? 1 : (portalDelegate.value === 3 ? 2 : 0) currentIndex: value === 4 ? 1 : (value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(portalDelegate.index, portalDelegate.states[currentIndex]) onActivated: page.controller.setPortalValue(sourceRow, states[currentIndex])
}
}
} }
} }
// "Add entry" affordance // "Add entry" affordance
DelegateChoice { DelegateChoice {
roleValue: 6 roleValue: 6
delegate: QQC2.ItemDelegate { delegate: FormCard.FormButtonDelegate {
id: addDelegate id: addD
required property string categoryId required property string categoryId
width: ListView.view.width
icon.name: "list-add"
text: i18nc("@action", "Add…") text: i18nc("@action", "Add…")
icon.name: "list-add"
onClicked: page.controller.addEntry(categoryId) onClicked: page.controller.addEntry(categoryId)
} }
} }
} }
} }
// Shared delegate for path / relative-path entries.
property Component entryDelegate: Component {
QQC2.ItemDelegate {
id: pathDelegate
required property int index
required property string value
required property string categoryId
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: pathDelegate.value
placeholderText: pathDelegate.categoryId === "persistent" ? i18n("e.g. .thunderbird") : i18n("e.g. ~/games:ro")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(pathDelegate.index, text)
}
StatusBadge {
badgeStatus: pathDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: pathDelegate.removable
onClicked: page.controller.removeEntry(pathDelegate.index)
} }
} }
} }