Files
qbz/crates/qbz-qt/qml/controls/FolderModals.qml
T
vicrodh 461238bd6f fix(qt): modal forms open clean, never with the last session's text
QbzLineEdit re-seeds its box only when `text` CHANGES and only while the
box is not focused; QbzTextArea only while unfocused. Modals that reset
their drafts on the close edge (the box still holds focus for that
frame) or reopen with the seed they closed on produced neither, so the
box kept the previous input while the draft said otherwise. Both
controls gain `reset(value)`, which writes the box directly, and every
modal form calls it from its open handler: playlist edit and create,
MyQBZ create / rename / description, folder create and edit, the link
resolver, the rip wizard and the library folder editor. The MyQBZ
create name now resets on the open edge, and its edit panel re-seeds on
every open, also for the same collection.

Renaming a Qobuz playlist also patches the playlist manager's warm cache,
which the editor seeds from before any fetch: reopening the editor right
after Save showed the pre-save name until the background reload landed,
and forever while offline.

cc
2026-09-14 05:22:44 -06:00

401 lines
17 KiB
QML

// FolderModals — the TWO folder panels and their delete confirm, in one file,
// as sibling self-gating overlays (the MyQbzModals.qml shape):
//
// CREATE primitives/CreateFolderModal.slint (109 lines) — a name field and
// a Create button, opened from the sidebar's "..." -> New folder.
// Panel width min(host - 80, 400).
// EDITOR primitives/FolderEditModal.slint (277) — the full editor, opened
// from every folder card / chip / tree row (D7) and from the
// sidebar row menu, and by the manager toolbar's own "New folder"
// in CREATE mode (`id == ""`). Panel width min(host - 80, 480). Its
// body is FolderEditPanel.qml; this file owns the chrome.
// CONFIRM QbzConfirmModal, declared LAST so it stacks over the editor, with
// its own z: 3100.
//
// Mounted ONCE in qml/shell/AppShell.qml as a direct child of the root, after
// MyQbzModals and before QbzToast, with no `z` — that file's convention is
// declaration order, and ADR-009's ">= 3000" is satisfied structurally
// (AppShell.qml:533-563). 05 §5.4's "mount the create modal inside Sidebar.qml"
// is rejected: the sidebar animates to width 0 with clip: true and already
// force-closes its own flyout on a state change, so a modal parented into it
// disappears with the sidebar.
//
// ── TWO DRAFTS, NOT ONE ────────────────────────────────────────────────────
// `draftCreateName` belongs to the CREATE panel and is reset on its OWN open
// transition; the editor's four drafts live inside FolderEditPanel. Sharing
// one draft set between them (04 §5.6) makes "New folder" reopen holding
// whatever was last typed in the editor.
//
// ── `busy` HOLDS THE PANEL OPEN ────────────────────────────────────────────
// Neither panel closes itself. Rust closes them, and only after the write
// landed (contract D22) — a failed create keeps the name on screen and toasts,
// where the reference closes first and loses both the name and the error.
//
// ── ESCAPE IS A WRAPPING FocusScope, NOT A SIBLING ONE ─────────────────────
// QbzConfirmModal gets away with a sibling FocusScope because it has no text
// field. Here the name input takes active focus, and QML propagates an
// unhandled key up the focused item's PARENT chain — a sibling scope is never
// on that chain, so Escape would be dead the moment the field is focused.
// Each panel is therefore wrapped in its scope. QbzLineEdit's TextInput leaves
// Escape unaccepted on the plain arm (QbzLineEdit.qml:177-184), so it reaches
// us. Escape closes the innermost open thing: while the confirm is up it holds
// focus and handles its own.
//
// ── ENTER ──────────────────────────────────────────────────────────────────
// Through QbzLineEdit's `accepted`, NEVER `committed`: committed also fires on
// focus-out, and closing the modal blurs the field, which would submit the
// same value twice (MyQbzModals.qml:34-36).
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Documents. Guarded parses: a raw JSON.parse in a binding throws on
// the pre-publish frame (PlaylistView.qml:55-61).
readonly property var createDoc: {
try { return JSON.parse(QbzFolderEdit.createJson) } catch (e) { return ({}) }
}
readonly property var editDoc: {
try { return JSON.parse(QbzFolderEdit.editJson) } catch (e) { return ({}) }
}
readonly property bool createOpen: root.createDoc.open === true
readonly property bool creating: root.createDoc.creating === true
readonly property bool editOpen: root.editDoc.open === true
/// Rust REFUSES `createClose()` / `editClose()` while a write is in flight
/// (D22 — `busy` holds the panel open). The close X must therefore SHOW
/// that it is inert for that window, or it is a control that renders and
/// no-ops. Same treatment the panel's own Delete button already carries.
readonly property bool editBusy: root.editDoc.busy === true
visible: root.createOpen || root.editOpen
enabled: root.visible
// §1.4.3 (2026-08-03 hotkeys-port contract): both FocusScopes grab focus
// on open and Qt strands it on the now-invisible scope at close, which
// kills the AppShell key dispatcher until the next click. Restore the
// shell root — the dispatcher's fallback focus item — on the close edge.
function _restoreShellFocus() {
var p = root
while (p.parent) {
if (p.parent.isQbzShellRoot === true) {
p.parent.forceActiveFocus()
return
}
p = p.parent
}
}
// ==================================================================
// PANEL 1 — Create folder (CreateFolderModal.slint)
// ==================================================================
property string draftCreateName: ""
readonly property bool canCreate: root.draftCreateName.trim() !== "" && !root.creating
onCreateOpenChanged: {
if (root.createOpen) {
// Its OWN draft, reset on its OWN open transition.
root.draftCreateName = ""
createField.reset("")
createScope.forceActiveFocus()
createField.focusField()
} else {
root._restoreShellFocus()
}
}
FocusScope {
id: createScope
anchors.fill: parent
visible: root.createOpen
enabled: root.createOpen
// Taken HERE rather than in the open handler: an invisible item cannot
// hold active focus, and a handler on the document property is not
// ordered against the `visible` binding it shares a frame with.
onVisibleChanged: if (createScope.visible) createScope.forceActiveFocus()
Keys.onEscapePressed: function (event) {
QbzFolderEdit.createClose()
event.accepted = true
}
// `radius` is load-bearing: this Item fills AppShell's ROUNDED content
// frame and Qt Quick's clip is a rectangular scissor, so an opaque
// full-bleed child paints into the frame's four bezel corners
// (AppShell.qml:246-310). #bf000000 is Slint's #000000bf CONVERTED —
// Slint is #RRGGBBAA, Qt is #AARRGGBB.
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzFolderEdit.createClose()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
width: Math.min(root.width - 80, 400)
height: createCol.implicitHeight + 40
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// FIRST child: swallows clicks so they never reach the scrim.
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: createCol
x: 20
y: 20
width: parent.width - 40
spacing: 16
// --- Title + close X.
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 28)
text: root.t("New folder")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
opacity: root.creating ? 0.5 : 1.0
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: "x"
tintName: createCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: createCloseArea
anchors.fill: parent
// `createClose()` is refused mid-create, so the
// affordance goes with it rather than lying.
enabled: !root.creating
hoverEnabled: !root.creating
cursorShape: root.creating ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzFolderEdit.createClose()
}
}
}
// --- Name. No label above it — the reference has none here.
QbzLineEdit {
id: createField
width: parent.width
text: root.draftCreateName
placeholder: root.t("Folder name")
onEdited: function (value) { root.draftCreateName = value }
onAccepted: function (value) {
root.draftCreateName = value
if (root.canCreate)
QbzFolderEdit.createSubmit(root.draftCreateName)
}
}
// --- Footer.
Item {
width: parent.width
height: 36
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: root.creating ? root.t("Creating...") : root.t("Create")
btnHeight: 36
labelSize: 15
btnEnabled: root.canCreate
onClicked: QbzFolderEdit.createSubmit(root.draftCreateName)
}
}
}
}
}
// ==================================================================
// PANEL 2 — The folder editor (FolderEditModal.slint)
// ==================================================================
// Seeds are consumed ONCE per open, and again when the editor is pointed
// at a different folder without closing. A plain republish (a busy flip, a
// picked image) must NOT reseed — it would throw away what the user typed.
readonly property string editSeedKey:
(root.editOpen ? "1" : "0") + "|" + (root.editDoc.id || "")
onEditSeedKeyChanged: {
if (root.editOpen) {
editPanel.seed()
editScope.forceActiveFocus()
editPanel.focusName()
}
}
onEditOpenChanged: {
// A sub-modal must never outlive its host panel.
if (!root.editOpen)
deleteConfirm.close()
if (!root.editOpen)
root._restoreShellFocus()
}
FocusScope {
id: editScope
anchors.fill: parent
visible: root.editOpen
enabled: root.editOpen
onVisibleChanged: if (editScope.visible) editScope.forceActiveFocus()
Keys.onEscapePressed: function (event) {
// The confirm holds focus while it is up and handles its own Esc,
// so reaching here means the panel is the innermost open thing.
QbzFolderEdit.editClose()
event.accepted = true
}
Rectangle {
anchors.fill: parent
radius: theme.radiusMd
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: QbzFolderEdit.editClose()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
width: Math.min(root.width - 80, 480)
height: editCol.implicitHeight + 40
x: Math.round((root.width - width) / 2)
y: Math.round((root.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea {
anchors.fill: parent
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
Column {
id: editCol
x: 20
y: 20
width: parent.width - 40
spacing: 16
// --- Title + close X.
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 28)
// "" is CREATE mode — the manager toolbar's New folder
// opens this same editor with no id.
text: (root.editDoc.id || "") === "" ? root.t("New folder")
: root.t("Edit folder")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
opacity: root.editBusy ? 0.5 : 1.0
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: "x"
tintName: editCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: editCloseArea
anchors.fill: parent
// `editClose()` is refused while a save or delete
// is in flight — see `editBusy`.
enabled: !root.editBusy
hoverEnabled: !root.editBusy
cursorShape: root.editBusy ? Qt.ArrowCursor
: Qt.PointingHandCursor
onClicked: QbzFolderEdit.editClose()
}
}
}
FolderEditPanel {
id: editPanel
width: parent.width
doc: root.editDoc
onDeleteRequested: deleteConfirm.open()
}
}
}
}
// ==================================================================
// The delete confirm — LAST, so it stacks over the editor (D21).
// ==================================================================
// The reference raises a NATIVE rfd message box with untranslated &str
// (main.rs:5724); this is the in-app surface the port already has, and its
// two strings are new msgids. The body interpolates the LIVE draft name,
// matching the reference's `get_name()` — rename the folder, then delete
// it without saving, and the prompt still names what is on screen.
QbzConfirmModal {
id: deleteConfirm
anchors.fill: parent
danger: true
title: root.t("Delete folder")
body: root.t("Delete the folder “{}”? Its playlists move back to the root.")
.replace("{}", editPanel.draftName)
confirmLabel: root.t("Delete")
onConfirmed: {
QbzFolderEdit.editDeleteConfirmed()
// Hand focus back so Esc keeps working on the panel that is still
// up while the delete is in flight.
if (root.editOpen)
editScope.forceActiveFocus()
}
onCancelled: {
if (root.editOpen)
editScope.forceActiveFocus()
}
}
}