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 {
+271 -257
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,259 +76,267 @@ Kirigami.ScrollablePage {
} }
} }
// --- the list ------------------------------------------------------------ Kirigami.PlaceholderMessage {
anchors.centerIn: parent
ListView { width: parent.width - Kirigami.Units.gridUnit * 4
id: permsView visible: !page.hasSelection
model: page.controller icon.name: "preferences-desktop"
text: i18n("Select an application")
Kirigami.PlaceholderMessage { explanation: i18n("Choose an application to review and adjust its permissions.")
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: !page.hasSelection
icon.name: "preferences-desktop"
text: i18n("Select an application")
explanation: i18n("Choose an application to review and adjust its permissions.")
}
section.property: "categoryTitle"
section.delegate: Kirigami.ListSectionHeader {
required property string section
width: ListView.view.width
text: section
}
delegate: DelegateChooser {
role: "rowType"
// Toggle (share/sockets/devices/features/filesystem presets)
DelegateChoice {
roleValue: 0
delegate: QQC2.ItemDelegate {
id: toggleDelegate
required property int index
required property string label
required property string example
required property bool value
required property int status
width: ListView.view.width
hoverEnabled: true
onClicked: toggleSwitch.toggle()
contentItem: RowLayout {
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
DelegateChoice {
roleValue: 1
delegate: page.entryDelegate
}
// 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 string value
required property string secondary
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: varDelegate.value
placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varDelegate.index, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varDelegate.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varDelegate.index, text)
}
StatusBadge {
badgeStatus: varDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varDelegate.removable
onClicked: page.controller.removeEntry(varDelegate.index)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: QQC2.ItemDelegate {
id: busDelegate
required property int index
required property string value
required property string secondary
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: busDelegate.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busDelegate.index, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busDelegate.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busDelegate.index, currentValue)
}
StatusBadge {
badgeStatus: busDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busDelegate.removable
onClicked: page.controller.removeEntry(busDelegate.index)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: QQC2.ItemDelegate {
id: portalDelegate
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
width: ListView.view.width
hoverEnabled: true
background: null
readonly property var states: [2, 4, 3] // Unset, Allowed, Disallowed
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")]
currentIndex: portalDelegate.value === 4 ? 1 : (portalDelegate.value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(portalDelegate.index, portalDelegate.states[currentIndex])
}
}
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: QQC2.ItemDelegate {
id: addDelegate
required property string categoryId
width: ListView.view.width
icon.name: "list-add"
text: i18nc("@action", "Add…")
onClicked: page.controller.addEntry(categoryId)
}
}
}
} }
// Shared delegate for path / relative-path entries. ColumnLayout {
property Component entryDelegate: Component { spacing: Kirigami.Units.largeSpacing
QQC2.ItemDelegate { visible: page.hasSelection
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 { Repeater {
spacing: Kirigami.Units.smallSpacing model: page.controller.categories
QQC2.TextField {
text: pathDelegate.value delegate: ColumnLayout {
placeholderText: pathDelegate.categoryId === "persistent" ? i18n("e.g. .thunderbird") : i18n("e.g. ~/games:ro") id: section
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(pathDelegate.index, text) required property var modelData
Layout.fillWidth: true
spacing: 0
FormCard.FormHeader {
title: section.modelData.title
} }
StatusBadge {
badgeStatus: pathDelegate.status FormCard.FormCard {
} KItemModels.KSortFilterProxyModel {
QQC2.ToolButton { id: catModel
icon.name: "edit-delete-remove" sourceModel: page.controller
visible: pathDelegate.removable filterRoleName: "categoryId"
onClicked: page.controller.removeEntry(pathDelegate.index) // No category id is a substring of another, so this is an exact match.
filterString: section.modelData.id
}
Repeater {
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Toggle
DelegateChoice {
roleValue: 0
delegate: FormCard.FormSwitchDelegate {
id: toggleD
required property int index
required property string label
required property string example
required property bool value
required property int status
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
text: label
description: example
checked: value
icon.name: status === 2 ? "document-edit-symbolic" : (status === 1 ? "globe-symbolic" : "")
onToggled: page.controller.setToggleValue(sourceRow, checked)
}
}
// Filesystem path (with access mode + Browse)
DelegateChoice {
roleValue: 1
delegate: FormCard.AbstractFormDelegate {
id: fsD
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
FolderDialog {
id: folderDialog
onAccepted: {
const p = decodeURIComponent(selectedFolder.toString().replace(/^file:\/\//, ""));
fsField.text = p;
page.controller.setEntryPrimary(fsD.sourceRow, p);
}
}
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
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")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varD.sourceRow, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varD.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varD.sourceRow, text)
}
StatusBadge {
state: varD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varD.removable
onClicked: page.controller.removeEntry(varD.sourceRow)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: FormCard.AbstractFormDelegate {
id: busD
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: busD.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busD.sourceRow, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busD.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busD.sourceRow, currentValue)
}
StatusBadge {
state: busD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busD.removable
onClicked: page.controller.removeEntry(busD.sourceRow)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: FormCard.FormComboBoxDelegate {
id: portalD
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
readonly property var states: [2, 4, 3]
text: label
description: supported ? example : unsupportedReason
enabled: supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: value === 4 ? 1 : (value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(sourceRow, states[currentIndex])
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: FormCard.FormButtonDelegate {
id: addD
required property string categoryId
text: i18nc("@action", "Add…")
icon.name: "list-add"
onClicked: page.controller.addEntry(categoryId)
}
}
}
}
} }
} }
} }