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
394 lines
18 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|
|
}
|