Files
qbz/crates/qbz-qt/qml/settings/LibFolderEditModal.qml
T
vicrodh b1dba198c9 feat(qt): kiosk profile release hardening
Integrate the Kiosk profile hardening round: 12 dedicated Kiosk views plus
22 shared opt-in hosts, shared navigation history with Back/Forward, Albums
as the default library tab, native paged Local Library (artists/tracks),
lightweight MyQBZ grid and detail, Ephemeral lifecycle, bounded lists and
artwork sized to the physical frame, static skeletons, 64px transport bar,
Settings touch reflow and a persistent Immersive exit.

Desktop keeps its prior behavior: every shared component defaults to the
desktop path and only opts into Kiosk behavior through explicit properties
(origin-relative ScrollMemory, viewport-bounded artwork requests).

Regression coverage: kiosk navigation and artwork Node tests added to the
runtime gate, scroll-origin fixtures for fixed and grouped rows, i18n
strings for all eight locales.

Verified by 750 release tests, six QML audits, Kiosk smokes at 800x480 and
1280x800 and a Desktop A/B with seven pixel-identical captures.

cc
2026-09-07 08:23:11 -06:00

492 lines
21 KiB
QML

// Per-folder settings for a registered Local Library folder — QML port of
// crates/qbz-ui/ui/settings/LibFolderEditModal.slint (itself the Tauri
// FolderSettingsModal). Alias, enabled, network override + fs-type,
// accessibility status, change-path, last-scanned, scan-this-folder.
//
// THE FIELD THAT MATTERS: `userOverrideNetwork`. It has a real consumer in
// the SHARED scanner (`qbz-library/src/scan.rs:164`), where it suppresses
// per-scan network re-detection. Until this modal existed the Qt build had no
// writer for it, so a user who classified a folder by hand had that decision
// silently overwritten by detection on the next scan. This is not a cosmetic
// port.
//
// State rides the settings document (`doc.library.folderEdit`) rather than a
// bridge of its own — it is a surface of the Local Library panel and all its
// actions already land in settings_qt/library.rs. Draft edits are LOCAL to
// this file until Save, so Cancel really cancels; only the accessibility
// probe and the path change write back before then (both come FROM Rust).
//
// MOUNTED AT THE SETTINGS VIEW ROOT, beside SettingsConfirmHost — the panels
// are Columns inside a Flickable, so a modal mounted in one would be sized by
// the scrolled content and ride the scroll. ADR-009: z >= 3000.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
property bool kioskHost: false
id: root
property var doc: ({})
readonly property var st: (doc.library && doc.library.folderEdit) || ({})
anchors.fill: parent
z: 3100
visible: root.st.open === true
enabled: root.visible
QbzTheme { id: theme }
// The option list and its stored labels, in the reference's order
// (LibFolderEditModal.slint:266-278 / :37-48). Index 0 = auto-detect.
readonly property var fsTypeValues: [
"auto", "cifs", "nfs", "sshfs", "rclone", "webdav", "glusterfs", "ceph", "other"
]
// --- Draft state ----------------------------------------------------
// Seeded on every open, never bound: a binding onto the document would
// snap the user's half-typed alias back on the next publish (the probe
// alone republishes twice per open).
property string draftAlias: ""
property bool draftEnabled: true
property bool draftIsNetwork: false
property bool draftOverride: false
property int draftFsIndex: 0
onVisibleChanged: {
if (!visible)
return
root.draftAlias = root.st.alias || ""
root.draftEnabled = root.st.enabled === true
root.draftIsNetwork = root.st.isNetwork === true
root.draftOverride = root.st.userOverrideNetwork === true
root.draftFsIndex = root.st.fsTypeIndex || 0
aliasField.text = root.draftAlias
closeButton.forceActiveFocus()
}
function close() {
QbzBridge.settingsString("library-folder-edit-close", "")
}
function save() {
QbzBridge.settingsString("library-folder-edit-save", JSON.stringify({
id: root.st.folderId || 0,
alias: root.draftAlias,
enabled: root.draftEnabled,
isNetwork: root.draftIsNetwork,
fsType: root.fsTypeValues[root.draftFsIndex] || "auto",
userOverrideNetwork: root.draftOverride
}))
}
Keys.onEscapePressed: function (event) {
root.close()
event.accepted = true
}
// Scrim — click dismisses.
Rectangle {
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.close()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
// Faked 32px drop shadow (#00000080) — the LoginScreen/QbzConfirmModal
// precedent; a real blurred shadow owes its own parity pass.
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: "#80000000"
z: -1
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(root.width - 80, 520)
height: Math.min(root.height - 80, body.implicitHeight + 44)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// Swallow clicks so they never reach the scrim.
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Flickable {
anchors.fill: parent
clip: root.kioskHost
interactive: root.kioskHost
contentWidth: width
contentHeight: body.implicitHeight + 44
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
x: 22
y: 22
width: parent.width - 44
spacing: 16
// --- Title + close ------------------------------------------
Item {
width: parent.width
height: root.kioskHost ? 44 : 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Folder settings", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: root.kioskHost ? (theme.fontHeading) * 1.2 : (theme.fontHeading)
font.weight: theme.weightSemibold
}
Rectangle {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: root.kioskHost ? 44 : 28
height: root.kioskHost ? 44 : 28
radius: theme.radiusSm
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
activeFocusOnTab: root.visible
border.width: activeFocus ? 2 : 0
border.color: theme.accent
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Close", QbzSession.trRev)
Accessible.onPressAction: root.close()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.close()
event.accepted = true
}
}
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: closeButton.forceActiveFocus()
onClicked: root.close()
}
}
}
// --- Folder location + Change + accessibility ---------------
Column {
width: parent.width
spacing: 6
Text {
text: QbzSession.tr("Folder location", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
}
Item {
width: parent.width
height: 32
QbzIcon {
id: locIcon
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
name: root.draftIsNetwork ? "network" : "hard-drive"
width: 18
height: 18
tintName: root.draftIsNetwork ? "accent" : "secondary"
}
Text {
anchors.left: locIcon.right
anchors.leftMargin: 10
anchors.right: changeBtn.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.st.path || ""
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
elide: Text.ElideMiddle
}
SettingsButton { kioskHost: root.kioskHost;
id: changeBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
iconName: "folder-open"
text: QbzSession.tr("Change", QbzSession.trRev)
onClicked: QbzBridge.settingsString(
"library-folder-change-path", String(root.st.folderId || 0))
}
}
// Accessibility status. `checkingAccessible` is true while the
// Rust probe is out — it starts that way on every open, so a
// dead mount does not hold the modal closed.
Row {
spacing: 8
QbzIcon {
visible: root.st.checkingAccessible !== true
anchors.verticalCenter: parent.verticalCenter
name: root.st.accessible === true ? "check" : "x"
width: 14
height: 14
// The reference hard-codes #3fae6a / #e0564f. QbzIcon
// tints from a baked SET, not an arbitrary colour, so
// these go through the port's two documented
// substitutions: "green" (#22c55e) and "favorite"
// (#ef4444, which icon_tint_qt.rs:63 already records
// as the port-wide stand-in for #e0564f danger).
tintName: root.st.accessible === true ? "green" : "favorite"
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.st.checkingAccessible === true
? QbzSession.tr("Checking...", QbzSession.trRev)
: (root.st.accessible === true
? QbzSession.tr("Folder is accessible", QbzSession.trRev)
: QbzSession.tr("Folder is not accessible", QbzSession.trRev))
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
}
}
}
// --- Display name -------------------------------------------
Column {
width: parent.width
spacing: 6
Text {
text: QbzSession.tr("Display name", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
}
QbzLineEdit { kioskHost: root.kioskHost;
id: aliasField
width: parent.width
placeholder: QbzSession.tr("Optional friendly name", QbzSession.trRev)
onEdited: function (v) { root.draftAlias = v }
onCommitted: function (v) { root.draftAlias = v }
}
Text {
text: QbzSession.tr("Shown instead of the full path in the folder list.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
}
}
// --- Enabled -------------------------------------------------
Item {
width: parent.width
height: 40
Column {
anchors.left: parent.left
anchors.right: enabledToggle.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: QbzSession.tr("Enabled", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
}
Text {
width: parent.width
text: QbzSession.tr("Disabled folders are skipped during scans.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
wrapMode: Text.WordWrap
}
}
QbzToggle { kioskHost: root.kioskHost;
id: enabledToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.draftEnabled
onToggled: function (v) { root.draftEnabled = v }
}
}
// --- Network override ----------------------------------------
// Flipping this sets userOverrideNetwork, which is what tells the
// shared scanner to stop re-detecting (scan.rs:164).
Item {
width: parent.width
height: 40
Column {
anchors.left: parent.left
anchors.right: netToggle.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
text: QbzSession.tr("Network folder", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
}
Text {
width: parent.width
text: QbzSession.tr("Mark this folder as a network share (excluded when offline).", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
wrapMode: Text.WordWrap
}
}
QbzToggle { kioskHost: root.kioskHost;
id: netToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.draftIsNetwork
onToggled: function (v) {
root.draftIsNetwork = v
root.draftOverride = true
}
}
}
// --- FS type (network only) ----------------------------------
Column {
width: parent.width
spacing: 6
visible: root.draftIsNetwork
Text {
text: QbzSession.tr("Network filesystem type", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
font.weight: theme.weightSemibold
}
QbzSelect { kioskHost: root.kioskHost;
menuWidth: 200
options: [
QbzSession.tr("Auto-detect", QbzSession.trRev),
"CIFS / SMB", "NFS", "SSHFS", "rclone", "WebDAV",
"GlusterFS", "Ceph",
QbzSession.tr("Other", QbzSession.trRev)
]
currentIndex: root.draftFsIndex
onSelected: function (i) { root.draftFsIndex = i }
}
}
// --- Last scanned --------------------------------------------
Item {
width: parent.width
height: 18
Text {
anchors.left: parent.left
text: QbzSession.tr("Last scanned", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
}
Text {
anchors.right: parent.right
text: (root.st.lastScan || 0) > 0
? new Date((root.st.lastScan || 0) * 1000).toLocaleString(Qt.locale())
: QbzSession.tr("Never", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: root.kioskHost ? (theme.fontLegal) * 1.2 : (theme.fontLegal)
}
}
Rectangle {
width: parent.width
height: 1
color: theme.borderSubtle
}
// --- Footer ---------------------------------------------------
Item {
width: parent.width
height: 36
// Scan needs the folder to be both enabled AND reachable —
// scanning a disabled or dead folder is a guaranteed no-op.
SettingsButton { kioskHost: root.kioskHost;
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
iconName: "refresh-cw"
text: QbzSession.tr("Scan this folder", QbzSession.trRev)
enabled: root.draftEnabled && root.st.accessible === true
onClicked: {
QbzBridge.settingsString("library-scan", String(root.st.folderId || 0))
root.close()
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
SettingsButton { kioskHost: root.kioskHost;
text: QbzSession.tr("Cancel", QbzSession.trRev)
onClicked: root.close()
}
// Accent-FILLED primary. Built inline rather than by
// adding an `accent` arm to the shared SettingsButton:
// that control is outlined-only everywhere it is used
// today, and QbzConfirmModal already draws its primary
// this same way (:218-232), including the
// `accentGlyphColor` selector for the label.
Rectangle {
id: saveButton
anchors.verticalCenter: parent.verticalCenter
width: saveLabel.implicitWidth + 36
height: 36
radius: theme.radiusSm
color: saveArea.containsMouse ? theme.accentHover : theme.accent
activeFocusOnTab: root.visible
border.width: activeFocus ? 2 : 0
border.color: theme.accentGlyphColor
Accessible.role: Accessible.Button
Accessible.name: QbzSession.tr("Save", QbzSession.trRev)
Accessible.onPressAction: root.save()
Keys.onPressed: function (event) {
if (!event.isAutoRepeat
&& (event.key === Qt.Key_Space
|| event.key === Qt.Key_Return
|| event.key === Qt.Key_Enter)) {
root.save()
event.accepted = true
}
}
Text {
id: saveLabel
anchors.centerIn: parent
text: QbzSession.tr("Save", QbzSession.trRev)
color: theme.accentGlyphColor
font.pixelSize: root.kioskHost ? (theme.fontBody) * 1.2 : (theme.fontBody)
font.weight: theme.weightSemibold
}
MouseArea {
id: saveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPressed: saveButton.forceActiveFocus()
onClicked: root.save()
}
}
}
}
}
}
}
}