Files
qbz/crates/qbz-qt/qml/shell/WhatsNewModal.qml
T
vicrodh 083e6ea172 fix(qt): format the blacklist Added date and the What's New release date in the user's locale
Both were rendered from a hardcoded English month table in Rust, invisible
to i18n. Rust now hands QML the raw timestamp / civil date and QML formats
it with Qt.formatDate(..., Locale.LongFormat) like the purchase views
already do.

cc
2026-09-13 03:48:02 -06:00

381 lines
17 KiB
QML

// What's New — QML port of crates/qbz-ui/ui/shell/WhatsNewModal.slint.
//
// Opened from the header hamburger menu ("What's New"). src/whats_new_qt.rs
// fetches the GitHub release matching the running version on every open,
// renders its markdown body into a FLAT BLOCK MODEL, and publishes it as
// QbzAbout.whatsNewJson: `{ open, loading, version, date, hasBody, toc[],
// blocks[] }`. The block render is the centrepiece of this file.
//
// ── WHY THIS IS NOT `Text.MarkdownText` ────────────────────────────────────
//
// The renderer's subset is NOT Markdown, and Qt's is a DIFFERENT subset. Off
// the same release body they disagree visibly:
//
// * `**bold**` and `` `code` `` are STRIPPED by the reference, styled by Qt;
// * an indent-0 `- item` is a level-0 SECTION + a TOC entry in the
// reference, a bullet in Qt;
// * `#` and `##` collapse to ONE level in the reference, two in Qt;
// * a whole-line `[text](url)` is a KIND_LINK BLOCK in the reference, an
// inline <a> needing onLinkActivated in Qt.
//
// So the parse stays in Rust (whats_new_qt.rs, unit-tested against a real
// TAG_DETAILS.md body) and this file only lays out blocks. Kinds: 0 section,
// 1 bullet, 2 paragraph, 3 whole-line link.
//
// ── THE TOC IS DECORATIVE, AS IT IS IN THE REFERENCE ───────────────────────
//
// WhatsNewModal.slint:122-126 records that Slint cannot scroll a Flickable to
// an arbitrary nested child, so the chips are an OVERVIEW, not jump links.
// Qt could do the real thing (the slug id is already in the block model), but
// that is a behaviour change, not a port — it is flagged in the delivery doc
// rather than smuggled in here.
//
// Structure, scrim, shadow and self-gate follow LogViewerModal.qml; z: 3000
// explicitly, per ADR-009 as this port spells it. Colour literals are
// `Qt.rgba(...)` because Slint is #RRGGBBAA and Qt is #AARRGGBB (see
// AboutModal.qml's header for the five modals that shipped an invisible scrim
// or shadow from exactly that copy, fixed 2026-08-14).
//
// No pills (ADR-008): the TOC chips are bordered radiusSm rows.
import QtQuick
import com.blitzfc.qbz
import "../theme"
Item {
id: root
readonly property var doc: {
try {
return JSON.parse(QbzAbout.whatsNewJson)
} catch (e) {
return ({})
}
}
anchors.fill: parent
z: 3000
visible: root.doc.open === true
enabled: root.visible
QbzTheme { id: theme }
onVisibleChanged: {
if (visible)
keyScope.forceActiveFocus()
}
FocusScope {
id: keyScope
anchors.fill: parent
Keys.onEscapePressed: QbzAbout.whatsNewClose()
}
Rectangle {
anchors.fill: parent
// Slint `#000000bf` CONVERTED.
color: Qt.rgba(0, 0, 0, 0.75)
MouseArea {
anchors.fill: parent
onClicked: QbzAbout.whatsNewClose()
// Wheel-lock (the DiscoverConfigModal rule).
onWheel: function (wheel) { wheel.accepted = true }
}
}
// Faked 32px drop shadow (`drop-shadow-color: #00000080`).
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: Qt.rgba(0, 0, 0, 0.5)
}
Rectangle {
id: panel
anchors.centerIn: parent
// 820x700 capped, minus an 80px window margin — WhatsNewModal.slint:36-37.
width: Math.min(root.width - 80, 820)
height: Math.min(root.height - 80, 700)
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 }
}
Item {
id: stack
anchors.fill: parent
anchors.margins: 24
// ---- Header: title + close X ------------------------------
Item {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 28
Text {
anchors.left: parent.left
anchors.right: wnClose.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: {
var v = root.doc.version || ""
if (v === "")
return QbzSession.tr("What's new", QbzSession.trRev)
var d = root.doc.date || ""
// `date` is the release's civil date as written
// (YYYY-MM-DD); render it in the user's locale.
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(d)
if (m)
d = Qt.formatDate(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])),
Locale.LongFormat)
return QbzSession.tr("What's new in v{}", QbzSession.trRev).replace("{}", v)
+ (d === "" ? "" : " (" + d + ")")
}
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Item {
id: wnClose
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: wnCloseArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: wnCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzAbout.whatsNewClose()
}
}
}
// ---- Body region (stretches between header and footer) ----
Item {
id: bodyBox
anchors.top: headerRow.bottom
anchors.topMargin: 16
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: footerRow.top
anchors.bottomMargin: 16
// Loading state.
Text {
anchors.centerIn: parent
visible: root.doc.loading === true
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
// Empty state — no release body available. Reached on every
// DEV build: the fetch returns nothing for a draft or
// prerelease tag (whats_new_qt.rs), by design.
Text {
anchors.centerIn: parent
visible: root.doc.loading !== true && root.doc.hasBody !== true
width: Math.min(parent.width, 360)
text: QbzSession.tr("Release notes are not available.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
// Rendered release body.
Flickable {
id: flick
anchors.fill: parent
visible: root.doc.loading !== true && root.doc.hasBody === true
clip: true
contentWidth: width
contentHeight: bodyCol.height
boundsBehavior: Flickable.StopAtBounds
Column {
id: bodyCol
width: flick.width
// ---- TOC: the level-0 headings as a bordered index.
// Stacked, not wrapped, and NOT clickable — see the
// file header.
Column {
width: parent.width
spacing: 6
visible: (root.doc.toc || []).length > 0
Repeater {
model: root.doc.toc || []
delegate: Rectangle {
id: tocChip
required property var modelData
height: 26
width: tocLabel.implicitWidth + 24
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
Text {
id: tocLabel
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: tocChip.modelData.label || ""
color: theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
}
}
}
Item { width: 1; height: 8 }
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 8 }
}
// ---- Body blocks.
Repeater {
model: root.doc.blocks || []
delegate: Item {
id: blk
required property var modelData
readonly property int kind: modelData.kind || 0
readonly property int level: modelData.level || 0
// Vertical rhythm comes from the paddings, as
// in the reference: sections get extra top
// space, everything else a uniform 7px below.
readonly property int padTop: blk.kind === 0 ? 16 : 0
readonly property int padBottom: blk.kind === 0 ? 6 : 7
width: bodyCol.width
height: line.implicitHeight + blk.padTop + blk.padBottom
Row {
id: line
y: blk.padTop
// Bullet indentation: level 1 = first nesting.
x: blk.kind === 1 ? blk.level * 16 : 0
width: blk.width - x
spacing: 8
Text {
id: glyph
visible: blk.kind === 1
width: visible ? implicitWidth : 0
text: blk.level >= 2 ? "◦" : "•"
color: blk.level >= 2 ? theme.textMuted : theme.textSecondary
font.pixelSize: blk.level >= 2 ? 11 : 12
}
Text {
id: blockText
width: line.width
- (glyph.visible ? glyph.width + line.spacing : 0)
text: blk.modelData.text || ""
wrapMode: Text.WordWrap
color: blk.kind === 3
? (linkArea.containsMouse ? theme.accentHover : theme.accent)
: blk.kind === 0
? theme.textPrimary
: blk.kind === 1
? (blk.level >= 2 ? theme.textMuted : theme.textSecondary)
: theme.textSecondary
font.pixelSize: blk.kind === 0
? 14
: blk.kind === 1
? (blk.level >= 2 ? 11 : 12)
: 13
font.weight: blk.kind === 0
? theme.weightBold
: blk.kind === 3
? theme.weightMedium
: theme.weightRegular
// Whole-line link (kind 3) — the whole
// line is the hit target, matching the
// reference's TouchArea over the text.
MouseArea {
id: linkArea
anchors.fill: parent
enabled: blk.kind === 3
visible: enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.openExternalUrl(blk.modelData.url || "")
}
}
}
}
}
Item { width: 1; height: 8 }
}
}
QbzScrollBar {
target: flick
visible: flick.visible && flick.contentHeight > flick.height
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
// ---- Footer: the accent Close button ----------------------
Item {
id: footerRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 36
Rectangle {
anchors.right: parent.right
height: 36
width: closeLabel.implicitWidth + 36
radius: theme.radiusSm
color: footerArea.containsMouse ? theme.accentHover : theme.accent
Text {
id: closeLabel
anchors.centerIn: parent
text: QbzSession.tr("Close", QbzSession.trRev)
// The measured on-accent selector, not a raw
// accent-text (theme/QbzTheme.qml, "ON AN ACCENT
// FILL") — the port-wide rule for a label on an accent
// fill, and the one place the reference's
// Theme.accent-text is knowingly diverged from.
color: theme.accentGlyphColor
font.pixelSize: 14
font.weight: theme.weightMedium
}
MouseArea {
id: footerArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzAbout.whatsNewClose()
}
}
}
}
}
}