Files
qbz/crates/qbz-qt/qml/controls/FolderEditPanel.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

394 lines
18 KiB
QML

// FolderEditPanel — the BODY of the folder editor
// (primitives/FolderEditModal.slint:66-266): name field, the seven icon-preset
// tiles plus the custom-image tile, the eleven colour swatches, the "Hide from
// sidebar" row and the Delete / Save footer.
//
// The chrome around it — scrim, card, title, close X, Escape scope and the
// delete confirm — belongs to FolderModals.qml, which mounts this. The split
// is declared up front (rule 2): 04 §5.2's single ~380-line FolderModals is
// rejected because the two panels plus the confirm plus the drafts land nearer
// 600, and the port's precedents for "one file, several panels" are
// MyQbzModals.qml at 523 lines for three SIMPLER panels and
// PlaylistPickerModal.qml at 685.
//
// ── THE DRAFTS LIVE HERE ───────────────────────────────────────────────────
// Name, preset, colour and the hidden switch are QML-LOCAL and reach Rust only
// as arguments to `editSave(name, preset, color, hidden)`. That is the port's
// standing rule (MyQbzModals.qml's header: "TEXT STATE IS QML-LOCAL ... every
// submit invokable takes the value as an argument") and it is why the bridge
// has no `selectPreset` / `selectColor` / `toggleHidden` invokables: a colour
// click has no business making a round trip.
//
// Preset selection is the ONE draft write with a Rust side effect — it also
// calls `QbzFolderEdit.clearImage()`, because picking a preset means dropping
// the custom image, and the image is Rust-owned (the file dialog is native).
// That call is also what makes the reference's declared-but-never-called
// `clear-image` callback (main.rs:5611-5619) live.
//
// ── SEEDING ────────────────────────────────────────────────────────────────
// `doc.name` / `iconPreset` / `iconColor` / `isHidden` are SEEDS, consumed
// once on the open transition. They are NOT bound: Rust republishes editJson
// on every `busy` flip and on every image pick, and a bound field would throw
// away whatever the user had typed each time. `hasCustomImage` /
// `customImagePath` / `busy` ARE read live, because Rust owns them.
//
// ── REUSED, NOT REDRAWN ────────────────────────────────────────────────────
// QbzLineEdit the name field (its width MUST be set — the plain arm is
// a fixed 240, where the Slint LineEdit stretches).
// QbzToggle the hidden row. The reference uses QbzToggle too
// (FolderEditModal.slint:214). A second switch is exactly
// the fork rule 5 forbids.
// QbzPrimaryButton the Save button. The reference's `#ffffffd6` hover on an
// accent fill and its hardcoded `#ffffff` label are NOT
// copied — that white-wash is the white-button-on-artwork
// treatment and it washes out on an accent fill (illegible
// on the 11 light themes). The sibling
// LibFolderEditModal.slint:376 already uses accent-hover.
// Contract §10 Q5 records the exact literals if parity is
// ever wanted.
// PmFolderIcon NOT reused for the preset tiles: a tile is a
// surface-elevated square with a theme-tinted 17px glyph,
// where PmFolderIcon is a saturated colour tile with a
// fixed-white glyph at half its own size. Only the id ->
// glyph chain is common, and it is seven ternary arms; a
// shared .js would need its own build.rs entry for that.
// Keep the two in step — PmFolderIcon.qml carries the
// same chain.
// Delete button hand-drawn, because it is a GHOST danger button and the
// port's filled danger control (QbzPrimaryButton
// `destructive`) is a different affordance. SettingsButton
// has `danger`, but it is a settings-row control with its
// own leading-glyph geometry.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
/// The parsed QbzFolderEdit.editJson (§4.5). The HOST parses it once; a
/// second `JSON.parse` in here would double the work on every republish.
property var doc: ({})
/// The panel never raises the confirm itself — FolderModals owns it, so
/// the confirm can stack over this panel with its own z (D21).
signal deleteRequested()
QbzTheme { id: theme }
function t(s) { return QbzSession.tr(s, QbzSession.trRev) }
// --- Drafts -----------------------------------------------------------
property string draftName: ""
property string draftPreset: "folder"
property string draftColor: ""
property bool draftHidden: false
readonly property bool busy: root.doc.busy === true
readonly property bool isCreate: (root.doc.id || "") === ""
readonly property bool canSave: root.draftName.trim() !== "" && !root.busy
/// Consume the seeds. Called by the host on the open transition — never on
/// a plain republish.
function seed() {
root.draftName = root.doc.name || ""
root.draftPreset = root.doc.iconPreset || "folder"
root.draftColor = root.doc.iconColor || ""
root.draftHidden = root.doc.isHidden === true
// The box as well as the draft (QbzLineEdit.reset()).
nameField.reset()
}
/// Focus the name field. Split out so the host can call it AFTER the panel
/// is actually visible — an invisible item cannot take active focus.
function focusName() { nameField.focusField() }
/// The id -> glyph chain, shared with PmFolderIcon.qml. "folder" is not a
/// case in the reference's chain at all and falls through to the default
/// arm, which is also the unknown-id arm; "headphones" draws audio-lines.
/// Both are upstream intent (§4.5) — do not "fix" either.
function presetGlyph(id) {
return id === "heart" ? "heart"
: id === "star" ? "star"
: id === "music" ? "music"
: id === "disc" ? "disc"
: id === "library" ? "library"
: id === "headphones" ? "audio-lines"
: "folder"
}
implicitHeight: col.implicitHeight
Column {
id: col
width: parent.width
spacing: 16
// ================= Name =========================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Folder name")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
QbzLineEdit {
id: nameField
// MUST be explicit: the plain arm is a fixed `width: 240`
// (QbzLineEdit.qml:62), where the Slint LineEdit stretches to
// the panel.
width: parent.width
text: root.draftName
placeholder: root.t("Enter folder name")
onEdited: function (value) { root.draftName = value }
// `accepted`, NEVER `committed`: committed also fires on blur,
// and closing the modal blurs the field, which would submit a
// second time (MyQbzModals.qml:34-36 documents the incident).
onAccepted: function (value) {
root.draftName = value
if (root.draftName.trim() !== "" && !root.busy)
QbzFolderEdit.editSave(root.draftName, root.draftPreset,
root.draftColor, root.draftHidden)
}
}
}
// ================= Icon =========================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Icon")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
Row {
spacing: 8
Repeater {
model: root.doc.presets || []
delegate: Rectangle {
id: presetTile
required property var modelData
// A preset is only "the" selection while there is no
// custom image — the image wins, exactly as in the
// reference (`custom-image-path == "" && ...`).
readonly property bool active:
root.doc.hasCustomImage !== true
&& root.draftPreset === presetTile.modelData
width: 40
height: 40
radius: 8
color: presetTile.active
? theme.accent
: (presetArea.containsMouse ? theme.surfaceElevatedHover
: theme.surfaceElevated)
QbzIcon {
anchors.centerIn: parent
width: 17
height: 17
name: root.presetGlyph(presetTile.modelData)
// On an accent fill the port goes through the
// measured on-accent selector, not a hardcoded
// #ffffff (QbzTheme.qml "ON AN ACCENT FILL").
tintName: presetTile.active ? theme.accentGlyphTint : "secondary"
}
MouseArea {
id: presetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.draftPreset = presetTile.modelData
// The image is RUST-owned (native dialog), so
// dropping it is the one draft write that has
// to make a round trip.
QbzFolderEdit.clearImage()
}
}
}
}
// --- The custom-image tile.
Rectangle {
id: imageTile
readonly property bool active: root.doc.hasCustomImage === true
width: 40
height: 40
radius: 8
color: imageTile.active
? theme.accent
: (imageArea.containsMouse ? theme.surfaceElevatedHover
: theme.surfaceElevated)
// No `clip: true` — it is a rectangular scissor and does
// not follow `radius`. RoundedImage masks its own corners.
RoundedImage {
anchors.fill: parent
visible: imageTile.active
source: imageTile.active ? (root.doc.customImagePath || "") : ""
radius: 8
fit: "crop"
}
QbzIcon {
anchors.centerIn: parent
visible: !imageTile.active
width: 17
height: 17
name: "image-plus"
tintName: "secondary"
}
MouseArea {
id: imageArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzFolderEdit.pickImage()
}
}
}
}
// ================= Colour =======================================
Column {
width: parent.width
spacing: 6
Text {
text: root.t("Color")
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
// ELEVEN swatches, ONE row — 11 * 28 + 10 * 8 = 388 px. The
// .slint comment at :167 saying "Two rows" is wrong about its own
// grid. The wrap arithmetic is kept anyway, so a longer constant
// list wraps instead of overflowing the card.
Item {
id: swatchGrid
readonly property int columns: 11
readonly property int sw: 28
readonly property int gap: 8
readonly property int count: (root.doc.swatches || []).length
readonly property int rows: Math.ceil(swatchGrid.count / swatchGrid.columns)
width: parent.width
height: swatchGrid.rows * swatchGrid.sw
+ Math.max(0, swatchGrid.rows - 1) * swatchGrid.gap
Repeater {
model: root.doc.swatches || []
delegate: Rectangle {
id: swatch
required property int index
required property var modelData
readonly property string value: swatch.modelData.value || ""
readonly property bool active: root.draftColor === swatch.value
x: (swatch.index % swatchGrid.columns) * (swatchGrid.sw + swatchGrid.gap)
y: Math.floor(swatch.index / swatchGrid.columns)
* (swatchGrid.sw + swatchGrid.gap)
width: swatchGrid.sw
height: swatchGrid.sw
radius: swatchGrid.sw / 2
color: swatch.modelData.isAccent === true ? theme.accent : swatch.value
// A fixed white ring: the host is a saturated swatch
// under every theme, so a theme-following border would
// vanish on half of them.
border.width: swatch.active ? 2 : 0
border.color: "#ffffff"
QbzIcon {
anchors.centerIn: parent
visible: swatch.active
width: 14
height: 14
name: "check"
tintName: "white"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// "" IS a value ("use the theme accent") and it
// reaches the DB as "" — see contract D24.
onClicked: root.draftColor = swatch.value
}
}
}
}
}
// ================= Hidden =======================================
Item {
width: parent.width
height: 22
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 50)
text: root.t("Hide from sidebar")
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
QbzToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.draftHidden
enabled: !root.busy
// QbzToggle never self-flips; the owner of the state does.
onToggled: function (value) { root.draftHidden = value }
}
}
// ================= Footer =======================================
Item {
width: parent.width
height: 36
// Delete — edit mode only. A ghost danger button: fill on hover
// only, 1px danger border, danger label. theme.danger /
// theme.dangerHover ARE the reference's #ef4444 / #ef444433 under
// the default theme (the engine defaults danger to rgb(ef,44,44)
// and derives danger_hover as a 0.2 tint, i.e. alpha 0x33) and the
// correct colours under the other 35.
Rectangle {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: !root.isCreate
width: delLabel.implicitWidth + 28
height: 36
radius: 8
color: (delArea.containsMouse && !root.busy) ? theme.dangerHover : "transparent"
border.width: 1
border.color: theme.danger
opacity: root.busy ? 0.5 : 1.0
Text {
id: delLabel
anchors.centerIn: parent
text: root.t("Delete")
color: theme.danger
font.pixelSize: 15
font.weight: theme.weightMedium
}
MouseArea {
id: delArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: if (!root.busy) root.deleteRequested()
}
}
// Save. The label stays "Save" in create mode too — that is the
// reference (FolderEditModal.slint:246); the small sidebar create
// panel is the one that says "Create".
QbzPrimaryButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: root.t("Save")
btnHeight: 36
labelSize: 15
btnEnabled: root.canSave
onClicked: QbzFolderEdit.editSave(root.draftName, root.draftPreset,
root.draftColor, root.draftHidden)
}
}
}
}