diff --git a/storybook/pages/AssetsDetailsHeaderPage.qml b/storybook/pages/AssetsDetailsHeaderPage.qml
index c0a220ec20..bfd708dbc8 100644
--- a/storybook/pages/AssetsDetailsHeaderPage.qml
+++ b/storybook/pages/AssetsDetailsHeaderPage.qml
@@ -45,7 +45,6 @@ SplitView {
formatBalance: function(balance){
return LocaleUtils.currencyAmountToLocaleString(currencyStore.getCurrencyAmount(balance, "ETH"))
}
- communityTag.visible: false
}
ColumnLayout {
diff --git a/storybook/qmlTests/tests/tst_AccountOrderSync.qml b/storybook/qmlTests/tests/tst_AccountOrderSync.qml
index 1ae19642b3..cff8e27743 100644
--- a/storybook/qmlTests/tests/tst_AccountOrderSync.qml
+++ b/storybook/qmlTests/tests/tst_AccountOrderSync.qml
@@ -120,8 +120,9 @@ Item {
if (listView.count !== expectedTitles.length)
return false
for (let i = 0; i < expectedTitles.length; ++i) {
- const item = listView.itemAtIndex(i)
- if (!item || item.title !== expectedTitles[i])
+ // the delegate root is a shell; the title is on the row it incubates
+ const row = listView.itemAtIndex(i)?.contentItem
+ if (!row || row.title !== expectedTitles[i])
return false
}
return true
diff --git a/storybook/qmlTests/tests/tst_AssetsView.qml b/storybook/qmlTests/tests/tst_AssetsView.qml
index cadb44ccde..969215603b 100644
--- a/storybook/qmlTests/tests/tst_AssetsView.qml
+++ b/storybook/qmlTests/tests/tst_AssetsView.qml
@@ -517,12 +517,21 @@ Item {
return comboBox
}
+ // The rows are TokenDelegateShells; the token row itself lives behind
+ // the shell's async Loader, so it is not there the instant the shell is.
+ function tokenRowAt(listView, index) {
+ const shell = listView.itemAtIndex(index)
+ verify(!!shell, "no row shell at index %1".arg(index))
+ tryVerify(() => shell.contentReady)
+ return shell.contentItem
+ }
+
function verifyAssetOrder(expectedTitles) {
const listView = getListView(controlUnderTest)
waitForRendering(listView)
compare(listView.count, expectedTitles.length)
for (let i = 0; i < expectedTitles.length; ++i)
- compare(listView.itemAtIndex(i).title, expectedTitles[i])
+ compare(tokenRowAt(listView, i).title, expectedTitles[i])
}
function verifyComboBoxDisplay(comboBox, optionText, ascending) {
@@ -771,8 +780,9 @@ Item {
if (listView.count !== expectedTitles.length)
return false
for (let i = 0; i < expectedTitles.length; ++i) {
- const item = listView.itemAtIndex(i)
- if (!item || item.title !== expectedTitles[i])
+ const shell = listView.itemAtIndex(i)
+ if (!shell || !shell.contentReady
+ || shell.contentItem.title !== expectedTitles[i])
return false
}
return true
diff --git a/storybook/qmlTests/tests/tst_LeftTabView.qml b/storybook/qmlTests/tests/tst_LeftTabView.qml
index acb3eb9c65..c0638fbd58 100644
--- a/storybook/qmlTests/tests/tst_LeftTabView.qml
+++ b/storybook/qmlTests/tests/tst_LeftTabView.qml
@@ -87,8 +87,9 @@ Item {
if (listView.count !== expectedTitles.length)
return false
for (let i = 0; i < expectedTitles.length; ++i) {
- const item = listView.itemAtIndex(i)
- if (!item || item.title !== expectedTitles[i])
+ // the delegate root is a shell; the title is on the row it incubates
+ const row = listView.itemAtIndex(i)?.contentItem
+ if (!row || row.title !== expectedTitles[i])
return false
}
return true
diff --git a/storybook/qmlTests/tests/tst_SectionPanelGeometryChurn.qml b/storybook/qmlTests/tests/tst_SectionPanelGeometryChurn.qml
new file mode 100644
index 0000000000..06eba5e6e5
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelGeometryChurn.qml
@@ -0,0 +1,315 @@
+import QtQuick
+import QtQuick.Controls
+import QtTest
+
+import StatusQ.Components
+import StatusQ.Layout
+
+/*
+ Characterises every geometry a section panel is put through by
+ StatusSectionLayout, and attributes each transition to a mechanism.
+
+ Two metrics are reported per scenario:
+ * sizes - every (w,h) the panel reported, in order. Width and height
+ changes are separate signals, so this over-counts.
+ * SETTLED - one sample per event-loop turn, i.e. per layout pass. This is
+ the count that corresponds to a real relayout of the panel's
+ subtree; intra-turn intermediates never reach a frame.
+
+ Companion files:
+ tst_SectionPanelGeometryRemedies.qml - A/B of the candidate fixes
+ tst_SectionPanelIncubationCost.qml - cost of the unparented phase
+ tst_SectionPanelIndexing.qml - SwipeView index bug (RED)
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ component GeoRecorder: QtObject {
+ id: rec
+ required property Item watched
+ property var sizes: []
+ property var events: []
+ property var settled: []
+ property bool _pending: false
+
+ readonly property Connections conn: Connections {
+ target: rec.watched
+ function onWidthChanged() { rec.record("w") }
+ function onHeightChanged() { rec.record("h") }
+ function onParentChanged() { rec.record("P") }
+ }
+ function record(why) {
+ const s = watched.width + "x" + watched.height
+ events.push(why + ":" + s + (watched.parent ? "" : "/orphan"))
+ if (sizes.length === 0 || sizes[sizes.length - 1] !== s)
+ sizes.push(s)
+ if (!rec._pending) {
+ rec._pending = true
+ Qt.callLater(rec.sample)
+ }
+ }
+ function sample() {
+ rec._pending = false
+ const s = watched.width + "x" + watched.height
+ if (settled.length === 0 || settled[settled.length - 1] !== s)
+ settled.push(s)
+ }
+ function dump(tag) {
+ console.info(tag, "| sizes:", JSON.stringify(sizes),
+ "| SETTLED:", JSON.stringify(settled),
+ "| events:", JSON.stringify(events))
+ }
+ }
+ Component { id: recorderComponent; GeoRecorder {} }
+
+ Component { id: panelComponent; Rectangle { color: "grey" } }
+ Component { id: footerComponent; Rectangle { color: "green"; implicitHeight: 61 } }
+ Component { id: headerContentComponent; Item { property real want: 20
+ implicitHeight: want; implicitWidth: 80 } }
+ Component { id: toolBarComponent; StatusToolBar { width: 390 } }
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent; currentIndex: 1 }
+ }
+
+ // A section that builds its panels as unparented property Items, as
+ // WalletLayout and ChatView do.
+ component UnboundSection: Item {
+ id: sect
+ readonly property Item leftPanel: Loader {
+ sourceComponent: Rectangle { color: "red"; implicitWidth: 900; implicitHeight: 6000 }
+ }
+ readonly property Item centerPanel: Loader {
+ sourceComponent: Rectangle { color: "red"; implicitWidth: 1440; implicitHeight: 4000 }
+ }
+ }
+ Component { id: unboundSectionComponent; UnboundSection {} }
+
+ TestCase {
+ name: "SectionPanelGeometryChurn"
+ when: windowShown
+
+ function cleanup() { root.width = 390; root.height = 844 }
+
+ function makeChrome() {
+ const c = createTemporaryObject(chromeComponent, root)
+ verify(!!c)
+ return c
+ }
+
+ // ------------------------------------------------------------------
+ // 1. The handoff itself
+ // ------------------------------------------------------------------
+
+ // A panel handed over with no visual parent reports its *content's*
+ // implicit size until the chrome adopts it, then jumps once.
+ function test_handoffIsASingleSettledTransition() {
+ const chrome = makeChrome()
+ waitForRendering(chrome); wait(20)
+
+ const section = createTemporaryObject(unboundSectionComponent, root)
+ console.info("PREHANDOFF center:",
+ section.centerPanel.width + "x" + section.centerPanel.height,
+ "parent:", !!section.centerPanel.parent)
+ compare(!!section.centerPanel.parent, false,
+ "a panel declared as a property value has no visual parent")
+
+ const rec = createTemporaryObject(recorderComponent, root,
+ {watched: section.centerPanel})
+ chrome.centerPanel = section.centerPanel
+ chrome.leftPanel = section.leftPanel
+ waitForRendering(chrome); wait(30)
+ rec.dump("HANDOFF center")
+ // Two: the implicit-size phase the panel spends unparented, then
+ // the box the chrome gives it. The chrome adds nothing beyond that.
+ compare(rec.settled.length, 2,
+ "the handoff is the implicit-size phase plus one adoption")
+ }
+
+ // ------------------------------------------------------------------
+ // 2. Terms that move the panel AFTER it is adopted
+ // ------------------------------------------------------------------
+
+ // The host's height reaches the panel 1:1 - no amplification.
+ function test_hostHeightReachesThePanelOneForOne() {
+ const chrome = makeChrome()
+ const panel = createTemporaryObject(panelComponent, root)
+ chrome.centerPanel = panel
+ waitForRendering(chrome); wait(20)
+
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ root.height = 800
+ waitForRendering(chrome); wait(20)
+ root.height = 772
+ waitForRendering(chrome); wait(20)
+ rec.dump("HOSTHEIGHT")
+ compare(rec.settled.length, 2, "one panel size per host height")
+ }
+
+ // The toolbar's implicit height is not constant: the back button adds
+ // 20px, its label another 10.
+ function test_toolBarImplicitHeightTerms() {
+ const tb = createTemporaryObject(toolBarComponent, root,
+ {backButtonVisible: false})
+ waitForRendering(tb); wait(20)
+ const hidden = tb.implicitHeight
+ tb.backButtonVisible = true
+ waitForRendering(tb); wait(20)
+ const iconOnly = tb.implicitHeight
+ tb.backButtonName = "Some account"
+ waitForRendering(tb); wait(20)
+ const withLabel = tb.implicitHeight
+ console.info("TOOLBAR hidden=" + hidden + " iconOnly=" + iconOnly
+ + " withLabel=" + withLabel)
+ verify(hidden !== iconOnly && iconOnly !== withLabel,
+ "toolbar height depends on the back button and its label")
+ }
+
+ // ...and the portrait chrome subtracts it from the centre panel, so a
+ // panel *arriving* in another slot resizes the centre panel: the left
+ // panel's page makes index 1 reachable, which shows the back button.
+ function test_leftPanelArrivalResizesTheCentrePanel() {
+ const chrome = makeChrome()
+ const center = createTemporaryObject(panelComponent, root)
+ chrome.centerPanel = center
+ waitForRendering(chrome); wait(20)
+ const before = center.height
+
+ const rec = createTemporaryObject(recorderComponent, root, {watched: center})
+ chrome.leftPanel = createTemporaryObject(panelComponent, root)
+ waitForRendering(chrome); wait(20)
+ console.info("LEFTARRIVES centre height " + before + " -> " + center.height)
+ rec.dump("LEFTARRIVES")
+ verify(center.height !== before,
+ "the centre panel is resized by a panel arriving elsewhere")
+ }
+
+ // headerContent's implicit height passes straight through to the panel.
+ function test_headerContentHeightMovesTheCentrePanel() {
+ const chrome = makeChrome()
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ const hc = createTemporaryObject(headerContentComponent, root, {want: 20})
+ chrome.headerContent = hc
+ waitForRendering(chrome); wait(30)
+ const a = center.height
+ hc.want = 49
+ waitForRendering(chrome); wait(30)
+ console.info("HEADERCONTENT centre height " + a + " -> " + center.height
+ + " (headerContent 20 -> 49)")
+ compare(a - center.height, 29,
+ "headerContent's height is subtracted from the centre panel 1:1")
+ }
+
+ // The footer slot: its implicit height moves the centre panel, but the
+ // *target's* visibility does not release the space.
+ function test_footerTerms() {
+ const chrome = makeChrome()
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ const footer = createTemporaryObject(footerComponent, root)
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ chrome.footer = footer
+ waitForRendering(chrome); wait(30)
+ const withFooter = center.height
+
+ footer.visible = false
+ waitForRendering(chrome); wait(30)
+ const targetHidden = center.height
+
+ footer.visible = true
+ chrome.showFooter = false
+ waitForRendering(chrome); wait(30)
+ const slotOff = center.height
+
+ chrome.showFooter = true
+ footer.implicitHeight = 90
+ waitForRendering(chrome); wait(30)
+ const taller = center.height
+
+ console.info("FOOTER centre height: withFooter=" + withFooter
+ + " targetHidden=" + targetHidden
+ + " showFooter=false -> " + slotOff
+ + " implicitHeight 61->90 -> " + taller)
+ compare(targetHidden, withFooter,
+ "hiding the footer *target* does not release the slot")
+ verify(slotOff !== withFooter, "showFooter releases the slot")
+ verify(taller !== withFooter, "the footer's implicit height moves the panel")
+ }
+
+ // ------------------------------------------------------------------
+ // 3. Reparenting churn
+ // ------------------------------------------------------------------
+
+ // Rotation hands the panel from the portrait chrome to the landscape
+ // one and back. Each swap also starts the landscape left-panel width
+ // animation (StatusSectionLayoutLandscape.qml, `Behavior on
+ // d.effectiveLeftPanelWidth`), which used to walk the centre panel
+ // through a width per animation frame - a full relayout each.
+ //
+ // StatusSectionLayout brackets both, so a rotation costs one relayout
+ // per orientation. (Was: > 2 per rotation, hence this file's name.)
+ //
+ // coalesceResizes is what a phone runs with: there, every window resize
+ // is the system rotating/splitting the screen, never a user drag.
+ function test_rotationCostsOneRelayoutPerOrientation() {
+ const chrome = createTemporaryObject(chromeComponent, root,
+ {coalesceResizes: true})
+ verify(!!chrome)
+ const center = createTemporaryObject(panelComponent, root)
+ const left = createTemporaryObject(panelComponent, root)
+ chrome.centerPanel = center
+ chrome.leftPanel = left
+ waitForRendering(chrome); wait(600)
+
+ const rec = createTemporaryObject(recorderComponent, root, {watched: center})
+ root.width = 1200
+ waitForRendering(chrome); wait(600)
+ root.width = 390
+ waitForRendering(chrome); wait(600)
+ rec.dump("ROTATION isPortrait=" + chrome.isPortrait)
+ // At most three: one per settled orientation, plus one the outgoing
+ // layout gets in before the bracket engages - QQuickItem cascades a
+ // geometry change to its children's anchors before emitting its own
+ // widthChanged, so the first size of a burst is always live. Every
+ // further size in the burst, and every frame of the left-column
+ // animation, is coalesced away.
+ verify(rec.settled.length <= 3,
+ "a rotation must not walk the panel through the left-column "
+ + "animation, got " + JSON.stringify(rec.settled))
+ compare(center.width, center.parent.width,
+ "and the panel must end on its slot's box")
+ compare(center.height, center.parent.height)
+ }
+
+ // Showing/hiding the right panel inserts and takes its SwipeView page;
+ // the panel is orphaned on the way out.
+ function test_rightPanelToggleOrphansIt() {
+ const chrome = makeChrome()
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ const right = createTemporaryObject(panelComponent, root)
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ chrome.rightPanel = right
+ waitForRendering(chrome); wait(20)
+
+ const recC = createTemporaryObject(recorderComponent, root, {watched: center})
+ const recR = createTemporaryObject(recorderComponent, root, {watched: right})
+ chrome.showRightPanel = true
+ waitForRendering(chrome); wait(20)
+ chrome.showRightPanel = false
+ waitForRendering(chrome); wait(20)
+ recC.dump("RIGHTTOGGLE centre")
+ recR.dump("RIGHTTOGGLE right")
+ compare(recC.settled.length, 0,
+ "toggling the right panel must not resize the centre panel")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelGeometryRemedies.qml b/storybook/qmlTests/tests/tst_SectionPanelGeometryRemedies.qml
new file mode 100644
index 0000000000..c0fa37d025
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelGeometryRemedies.qml
@@ -0,0 +1,370 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtTest
+
+import StatusQ.Layout
+
+/*
+ A/B of the candidate remedies against what the chrome does today.
+
+ REJECTED here (kept as evidence): replacing LayoutItemProxy with a guarded,
+ coalescing "panel slot" that refuses to write degenerate geometry and
+ collapses same-frame writes. It cleans up the signal trace but changes
+ nothing in the SETTLED trace, because Qt's layout pass already collapses
+ those intermediates - they never reach a frame.
+
+ ACCEPTED here: pre-sizing the incoming panel to the exact geometry the slot
+ already imposes (test_slotExactPreSizingIsResizeFree) makes the handoff cost
+ zero resizes.
+
+ REFUTED here: any feedback from the panel's own implicit size back into the
+ box the chrome gives it.
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ component GeoRecorder: QtObject {
+ id: rec
+ required property Item watched
+ property var sizes: []
+ property var events: []
+ property var settled: []
+ property bool _pending: false
+
+ readonly property Connections conn: Connections {
+ target: rec.watched
+ function onWidthChanged() { rec.record("w") }
+ function onHeightChanged() { rec.record("h") }
+ function onParentChanged() { rec.record("P") }
+ }
+ function record(why) {
+ const s = watched.width + "x" + watched.height
+ events.push(why + ":" + s + (watched.parent ? "" : "/orphan"))
+ if (sizes.length === 0 || sizes[sizes.length - 1] !== s)
+ sizes.push(s)
+ if (!rec._pending) {
+ rec._pending = true
+ Qt.callLater(rec.sample)
+ }
+ }
+ function sample() {
+ rec._pending = false
+ const s = watched.width + "x" + watched.height
+ if (settled.length === 0 || settled[settled.length - 1] !== s)
+ settled.push(s)
+ }
+ function resizeCount() {
+ let n = 0
+ for (let i = 0; i < events.length; ++i)
+ if (events[i][0] !== "P") ++n
+ return n
+ }
+ function dump(tag) {
+ console.info(tag, "| sizes:", JSON.stringify(sizes),
+ "| SETTLED:", JSON.stringify(settled),
+ "| resizes:", resizeCount(),
+ "| events:", JSON.stringify(events))
+ }
+ }
+ Component { id: recorderComponent; GeoRecorder {} }
+ Component { id: panelComponent; Rectangle { color: "grey" } }
+ Component { id: footerComponent; Rectangle { color: "green"; implicitHeight: 61 } }
+ Component {
+ id: bigPanelComponent
+ Rectangle { color: "red"; implicitWidth: 1440; implicitHeight: 4000 }
+ }
+ Component {
+ id: elasticPanelComponent
+ Rectangle {
+ color: "grey"
+ property real want: 100
+ implicitHeight: want
+ implicitWidth: want
+ }
+ }
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent; currentIndex: 1 }
+ }
+
+ // A section that pre-sizes its still-unparented panel to the slot geometry.
+ component SlotBoundSection: Item {
+ id: sbs
+ property Item slotRef: null
+ readonly property Item centerPanel: Loader {
+ width: sbs.slotRef ? sbs.slotRef.width : 0
+ height: sbs.slotRef ? sbs.slotRef.height : 0
+ sourceComponent: Rectangle { color: "red"; implicitWidth: 1440; implicitHeight: 4000 }
+ }
+ }
+ Component { id: slotBoundSectionComponent; SlotBoundSection {} }
+
+ // ---------------- the rejected alternative ----------------
+ component PanelSlot: Item {
+ id: slot
+
+ property Item target: null
+
+ // The slot owns the target only while it can describe a real box for it.
+ readonly property bool live: slot.visible && slot.width > 0 && slot.height > 0
+
+ onLiveChanged: slot.schedule()
+ onWidthChanged: slot.schedule()
+ onHeightChanged: slot.schedule()
+ onTargetChanged: {
+ if (d.owned && d.owned !== slot.target)
+ d.release(d.owned)
+ slot.schedule()
+ }
+
+ QtObject {
+ id: d
+ property Item owned: null
+ property bool pending: false
+ function release(item) {
+ if (item && item.parent === slot)
+ item.parent = null
+ if (d.owned === item)
+ d.owned = null
+ }
+ }
+
+ function schedule() {
+ if (d.pending)
+ return
+ d.pending = true
+ Qt.callLater(slot.apply)
+ }
+
+ function apply() {
+ d.pending = false
+ const t = slot.target
+ if (!t || !slot.live)
+ return
+ d.owned = t
+ if (t.parent !== slot) {
+ t.parent = slot
+ t.x = 0
+ t.y = 0
+ }
+ if (t.width !== slot.width)
+ t.width = slot.width
+ if (t.height !== slot.height)
+ t.height = slot.height
+ }
+
+ Component.onCompleted: slot.schedule()
+ }
+
+ // Two "chromes" that swap visibility, as LayoutChooser does.
+ component ProxyPair: Item {
+ id: pp
+ anchors.fill: parent
+ property Item panel: null
+ property bool portrait: true
+ property real headerHeight: 52
+
+ Item {
+ anchors.fill: parent
+ visible: pp.portrait
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.fillWidth: true; Layout.preferredHeight: pp.headerHeight }
+ LayoutItemProxy {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ target: pp.panel
+ }
+ }
+ }
+ Item {
+ anchors.fill: parent
+ visible: !pp.portrait
+ RowLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.preferredWidth: 306; Layout.fillHeight: true }
+ LayoutItemProxy {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ target: pp.panel
+ }
+ }
+ }
+ }
+
+ component SlotPair: Item {
+ id: sp
+ anchors.fill: parent
+ property Item panel: null
+ property bool portrait: true
+ property real headerHeight: 52
+
+ Item {
+ anchors.fill: parent
+ visible: sp.portrait
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.fillWidth: true; Layout.preferredHeight: sp.headerHeight }
+ PanelSlot {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ target: sp.panel
+ }
+ }
+ }
+ Item {
+ anchors.fill: parent
+ visible: !sp.portrait
+ RowLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.preferredWidth: 306; Layout.fillHeight: true }
+ PanelSlot {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ target: sp.panel
+ }
+ }
+ }
+ }
+
+ Component { id: proxyPairComponent; ProxyPair {} }
+ Component { id: slotPairComponent; SlotPair {} }
+
+ TestCase {
+ name: "SectionPanelGeometryRemedies"
+ when: windowShown
+
+ function cleanup() { root.width = 390; root.height = 844 }
+
+ function rotate(host) {
+ root.width = 1200; host.portrait = false
+ waitForRendering(host); wait(30)
+ root.width = 390; host.portrait = true
+ waitForRendering(host); wait(30)
+ }
+
+ // ---------------- accepted ----------------
+
+ // Pre-size the incoming panel to the geometry the slot already imposes
+ // (read here off the skeleton occupying it): the handoff resizes
+ // nothing at all.
+ function test_slotExactPreSizingIsResizeFree() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ const leftSkel = createTemporaryObject(panelComponent, root)
+ const centerSkel = createTemporaryObject(panelComponent, root)
+ chrome.leftPanel = leftSkel
+ chrome.centerPanel = centerSkel
+ chrome.footer = createTemporaryObject(footerComponent, root)
+ waitForRendering(chrome); wait(20)
+
+ const section = createTemporaryObject(slotBoundSectionComponent, root,
+ {slotRef: centerSkel})
+ waitForRendering(chrome); wait(20)
+ console.info("SLOTEXACT pre-handoff panel:",
+ section.centerPanel.width + "x" + section.centerPanel.height,
+ "| slot:", centerSkel.width + "x" + centerSkel.height)
+
+ const rec = createTemporaryObject(recorderComponent, root,
+ {watched: section.centerPanel})
+ chrome.centerPanel = section.centerPanel
+ waitForRendering(chrome); wait(20)
+ rec.dump("SLOTEXACT handoff")
+ compare(rec.resizeCount(), 0,
+ "handoff must not resize the panel: " + JSON.stringify(rec.events))
+ }
+
+ // ---------------- refuted ----------------
+
+ // The centre slot mirrors the panel's implicit size
+ // (StatusSectionLayoutPortrait.qml, centerPanelProxy), which looks like
+ // a feedback loop. It is not: the box the panel gets is unaffected.
+ function test_noFeedbackFromThePanelsOwnImplicitSize() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(elasticPanelComponent, root, {want: 100})
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ chrome.footer = createTemporaryObject(footerComponent, root)
+ waitForRendering(chrome); wait(30)
+
+ const wants = [100, 1000, 4000, 20000, 400, 100]
+ const heights = []
+ for (let i = 0; i < wants.length; ++i) {
+ center.want = wants[i]
+ waitForRendering(chrome); wait(30)
+ heights.push(center.height)
+ }
+ console.info("FEEDBACK want:", JSON.stringify(wants),
+ "-> given height:", JSON.stringify(heights))
+ for (let j = 1; j < heights.length; ++j)
+ compare(heights[j], heights[0],
+ "the panel's implicit size must not change its own box")
+ }
+
+ // ---------------- rejected: the guarded slot ----------------
+
+ function test_rotation_layoutItemProxy() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(proxyPairComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ rotate(host)
+ rec.dump("PROXY rotation")
+ verify(true)
+ }
+
+ function test_rotation_panelSlot() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(slotPairComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ rotate(host)
+ rec.dump("SLOT rotation")
+ verify(true)
+ }
+
+ function test_handoff_layoutItemProxy() {
+ const host = createTemporaryObject(proxyPairComponent, root, {panel: null})
+ waitForRendering(host); wait(30)
+ const panel = createTemporaryObject(bigPanelComponent, root)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ host.panel = panel
+ waitForRendering(host); wait(30)
+ rec.dump("PROXY handoff")
+ verify(true)
+ }
+
+ function test_handoff_panelSlot() {
+ const host = createTemporaryObject(slotPairComponent, root, {panel: null})
+ waitForRendering(host); wait(30)
+ const panel = createTemporaryObject(bigPanelComponent, root)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ host.panel = panel
+ waitForRendering(host); wait(30)
+ rec.dump("SLOT handoff")
+ verify(true)
+ }
+
+ // Same-frame writes cost nothing extra either way: Qt's layout pass
+ // already collapses them.
+ function test_sameFrameWritesCollapse_layoutItemProxy() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(proxyPairComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ host.headerHeight = 32
+ host.headerHeight = 52
+ waitForRendering(host); wait(30)
+ rec.dump("PROXY sameFrameWrites")
+ compare(rec.settled.length, 0)
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelGeometrySlot.qml b/storybook/qmlTests/tests/tst_SectionPanelGeometrySlot.qml
new file mode 100644
index 0000000000..4aec5ecbf2
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelGeometrySlot.qml
@@ -0,0 +1,460 @@
+import QtQuick
+import QtQuick.Controls
+import QtTest
+
+import StatusQ.Layout
+
+/*
+ Acceptance tests for the bracketed panel slot (SectionPanelSlot), driven
+ through the real StatusSectionLayout rather than a prototype.
+
+ The baseline these are measured against lives in
+ tst_SectionPanelResizeStorm.qml: replaying the width sequence a device
+ rotation actually produced through a raw LayoutItemProxy costs one full
+ relayout of the panel per step (9 for 9 window sizes), plus a multi-frame
+ hold at a degenerate box across the portrait/landscape handoff.
+
+ Covered here:
+ * the same sequence through the real chrome, coalesced vs not;
+ * portrait -> landscape -> portrait round trips, repeated, in both
+ directions - state that survives one transition can still be wrong on
+ the way back;
+ * arbitration: the same panel is bound into BOTH sub-layouts at once, so
+ two slots target it and exactly one may own it.
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ // Widths measured on device across an Android rotation, both times
+ // (.plan/panelgeo-device-capture.txt, episodes 7 and 9). The first four
+ // are the platform animating the window in portrait; the crossing at 1048
+ // is the orientation swap; the tail is our own left-column animation.
+ readonly property var rotationWidths: [480, 416, 371, 435, 1048, 972, 908, 863, 908]
+
+ readonly property int portraitWidth: 390
+ readonly property int portraitHeight: 844
+ readonly property int landscapeWidth: 1048
+ readonly property int landscapeHeight: 416
+
+ component GeoRecorder: QtObject {
+ id: rec
+ required property Item watched
+ property var settled: []
+ property bool _pending: false
+ property var events: []
+ readonly property Connections conn: Connections {
+ target: rec.watched
+ function onWidthChanged() { rec.record("w") }
+ function onHeightChanged() { rec.record("h") }
+ function onParentChanged() { rec.record("P") }
+ }
+ function record(why) {
+ rec.events.push(why + ":" + rec.watched.width + "x" + rec.watched.height)
+ if (rec._pending)
+ return
+ rec._pending = true
+ Qt.callLater(rec.sample)
+ }
+ function resizeCount() {
+ let n = 0
+ for (let i = 0; i < rec.events.length; ++i)
+ if (rec.events[i][0] !== "P") ++n
+ return n
+ }
+ function sample() {
+ rec._pending = false
+ const s = watched.width + "x" + watched.height
+ if (settled.length === 0 || settled[settled.length - 1] !== s)
+ settled.push(s)
+ }
+ function reset() { settled = [] }
+ }
+ Component { id: recorderComponent; GeoRecorder {} }
+ Component { id: panelComponent; Rectangle { color: "grey" } }
+ Component { id: footerComponent; Rectangle { color: "green"; implicitHeight: 61 } }
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent; currentIndex: 1 }
+ }
+
+ // A section built the way the loader-owned ones are: the panel is a
+ // property value, so it has no visual parent until the chrome adopts it,
+ // and it sizes itself from the chrome's published slot geometry meanwhile.
+ component SlotPreSizedSection: Item {
+ id: sect
+ property StatusSectionLayout sectionLayout: null
+ readonly property Item centerPanel: Loader {
+ width: sect.sectionLayout?.centerPanelSlotWidth ?? 0
+ height: sect.sectionLayout?.centerPanelSlotHeight ?? 0
+ sourceComponent: Rectangle { color: "red"; implicitWidth: 1440; implicitHeight: 4000 }
+ }
+ readonly property Item leftPanel: Loader {
+ width: sect.sectionLayout?.leftPanelSlotWidth ?? 0
+ height: sect.sectionLayout?.leftPanelSlotHeight ?? 0
+ sourceComponent: Rectangle { color: "red"; implicitWidth: 900; implicitHeight: 6000 }
+ }
+ }
+ Component { id: slotPreSizedSectionComponent; SlotPreSizedSection {} }
+
+ TestCase {
+ name: "SectionPanelGeometrySlot"
+ when: windowShown
+
+ function cleanup() {
+ root.width = root.portraitWidth
+ root.height = root.portraitHeight
+ }
+
+ function isDescendantOf(item, ancestor) {
+ let it = item
+ while (it) {
+ if (it === ancestor) return true
+ it = it.parent
+ }
+ return false
+ }
+
+ // A section shaped like the wallet's: a left panel and a centre panel
+ // handed to the chrome, plus a footer, so the centre slot is not simply
+ // the section's box.
+ function makeSection(coalesce) {
+ const chrome = createTemporaryObject(chromeComponent, root,
+ {coalesceResizes: coalesce})
+ verify(!!chrome)
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ chrome.footer = createTemporaryObject(footerComponent, root)
+ waitForRendering(chrome)
+ settle(chrome)
+ return {chrome: chrome, left: left, center: center}
+ }
+
+ // Long enough for the settle timer (48ms) plus the landscape
+ // left-column NumberAnimation (AnimationDuration.Slow = 400ms).
+ function settle(chrome) {
+ wait(600)
+ waitForRendering(chrome)
+ wait(60)
+ }
+
+ function storm(chrome) {
+ for (let i = 0; i < root.rotationWidths.length; ++i) {
+ root.width = root.rotationWidths[i]
+ root.height = root.rotationWidths[i] < 752 ? root.portraitHeight
+ : root.landscapeHeight
+ waitForRendering(chrome)
+ wait(16) // a frame apart, as on the device
+ }
+ settle(chrome)
+ }
+
+ // ------------------------------------------------------------------
+ // F1: the storm
+ // ------------------------------------------------------------------
+
+ // Live pass-through: every step of the storm is a relayout of the
+ // panel, as the raw LayoutItemProxy baseline is.
+ function test_rotationStorm_notCoalesced() {
+ const s = makeSection(false)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: s.center})
+ storm(s.chrome)
+ console.info("SLOT storm (coalesceResizes=false) settled:",
+ JSON.stringify(rec.settled))
+ verify(rec.settled.length > 2,
+ "without coalescing the storm should still reach the panel, got "
+ + JSON.stringify(rec.settled))
+ }
+
+ // Bracketed: the whole storm costs one relayout, and ends on the right
+ // box. This is the headline number.
+ function test_rotationStorm_coalesced() {
+ const s = makeSection(true)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: s.center})
+ storm(s.chrome)
+ console.info("SLOT storm (coalesceResizes=true) settled:",
+ JSON.stringify(rec.settled))
+ verify(rec.settled.length <= 2,
+ "a coalesced storm must cost at most two relayouts, got "
+ + JSON.stringify(rec.settled))
+ compare(s.center.width, s.center.parent.width,
+ "the panel must end on its slot's width")
+ compare(s.center.height, s.center.parent.height,
+ "the panel must end on its slot's height")
+ }
+
+ // A resize nobody bracketed still tracks live, or a desktop window drag
+ // would lag by the settle interval.
+ function test_unbracketedResizeTracksLive() {
+ const s = makeSection(false)
+ root.width = 500
+ waitForRendering(s.chrome); wait(30)
+ compare(s.center.width > 0, true)
+ compare(s.center.width, s.center.parent.width,
+ "outside a bracket the panel tracks its slot immediately")
+ }
+
+ // ------------------------------------------------------------------
+ // F1: the degenerate hold (design 1b - 148ms at 0x0 on device)
+ // ------------------------------------------------------------------
+
+ function test_degenerateHostBoxIsNotWrittenThrough() {
+ const s = makeSection(false)
+ const before = s.center.height
+ verify(before > 0)
+
+ root.height = 4 // collapses the centre slot
+ waitForRendering(s.chrome); wait(80)
+ const collapsed = s.center.height
+ root.height = root.portraitHeight
+ waitForRendering(s.chrome); wait(80)
+ console.info("SLOT degenerate: panel height while host collapsed =",
+ collapsed, "(was " + before + ")")
+ compare(collapsed, before, "the slot keeps the last good box")
+ }
+
+ // ------------------------------------------------------------------
+ // Orientation round trips - the user's explicit requirement
+ // ------------------------------------------------------------------
+
+ function toPortrait(chrome) {
+ root.width = root.portraitWidth
+ root.height = root.portraitHeight
+ settle(chrome)
+ }
+
+ function toLandscape(chrome) {
+ root.width = root.landscapeWidth
+ root.height = root.landscapeHeight
+ settle(chrome)
+ }
+
+ function assertPanelFitsItsSlot(panel, tag) {
+ verify(!!panel.parent, tag + ": panel must have a parent")
+ verify(panel.width > 0 && panel.height > 0,
+ tag + ": panel must have a real box, got "
+ + panel.width + "x" + panel.height)
+ compare(panel.width, panel.parent.width, tag + ": width must match the slot")
+ compare(panel.height, panel.parent.height, tag + ": height must match the slot")
+ }
+
+ function test_orientationRoundTrip_data() {
+ return [{tag: "desktop", coalesce: false},
+ {tag: "mobile", coalesce: true}]
+ }
+
+ // Three full round trips, checking both panels at every stop. State
+ // that survives one transition can still be wrong on the way back, so
+ // this asserts at every leg rather than only at the end.
+ function test_orientationRoundTrip(data) {
+ const s = makeSection(data.coalesce)
+ verify(s.chrome.isPortrait, "starts in portrait")
+ assertPanelFitsItsSlot(s.center, "start centre")
+ assertPanelFitsItsSlot(s.left, "start left")
+
+ for (let i = 0; i < 3; ++i) {
+ toLandscape(s.chrome)
+ compare(s.chrome.isPortrait, false, "round " + i + ": became landscape")
+ assertPanelFitsItsSlot(s.center, "round " + i + " landscape centre")
+ assertPanelFitsItsSlot(s.left, "round " + i + " landscape left")
+ verify(isDescendantOf(s.center, s.chrome.chosenLayout),
+ "round " + i + ": centre panel must live in the landscape layout")
+ verify(isDescendantOf(s.left, s.chrome.chosenLayout),
+ "round " + i + ": left panel must live in the landscape layout")
+
+ toPortrait(s.chrome)
+ compare(s.chrome.isPortrait, true, "round " + i + ": back to portrait")
+ assertPanelFitsItsSlot(s.center, "round " + i + " portrait centre")
+ assertPanelFitsItsSlot(s.left, "round " + i + " portrait left")
+ verify(isDescendantOf(s.center, s.chrome.chosenLayout),
+ "round " + i + ": centre panel must live in the portrait layout")
+ verify(isDescendantOf(s.left, s.chrome.chosenLayout),
+ "round " + i + ": left panel must live in the portrait layout")
+ }
+ }
+
+ // The bracket must never wedge: after every transition the panel is
+ // tracking its slot again, so an ordinary resize still moves it.
+ function test_bracketIsNeverLeftRaised() {
+ const s = makeSection(true)
+ toLandscape(s.chrome)
+ toPortrait(s.chrome)
+ toLandscape(s.chrome)
+
+ compare(s.chrome.geometryTransitionOngoing, false,
+ "the bracket must be down once everything has settled")
+
+ const before = s.center.height
+ root.height = root.landscapeHeight - 40
+ settle(s.chrome)
+ verify(s.center.height !== before,
+ "a resize after the transition must still reach the panel")
+ assertPanelFitsItsSlot(s.center, "after wedge check")
+ }
+
+ // The landscape left column also animates when something expands it
+ // through leftPanelWidthOverride - the Activity Center. That is a user
+ // action, not a system geometry transition, so it must NOT be
+ // bracketed: leftColumnAnimating only holds a bracket that is already
+ // up, it never raises one. The panels track the slide instead of
+ // snapping at the end of it.
+ function test_activityCentreSlideStillTracksLive_data() {
+ return [{tag: "desktop", coalesce: false},
+ {tag: "mobile", coalesce: true}]
+ }
+
+ function test_activityCentreSlideStillTracksLive(data) {
+ const s = makeSection(data.coalesce)
+ toLandscape(s.chrome)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: s.center})
+
+ s.chrome.leftPanelWidthOverride = 344
+ wait(200) // mid-slide: the animation is 400ms
+ const midway = rec.settled.length
+ const bracketed = s.chrome.geometryTransitionOngoing
+ settle(s.chrome)
+ console.info("AC slide (" + data.tag + ") settled by mid-slide:", midway,
+ "| bracketed:", bracketed,
+ "| full:", JSON.stringify(rec.settled))
+ compare(bracketed, false, "the AC slide must not raise the bracket")
+ verify(midway > 1,
+ "the centre panel must track the AC slide, got " + midway
+ + " sizes by mid-slide")
+ }
+
+ // ------------------------------------------------------------------
+ // F2: the published slot geometry
+ // ------------------------------------------------------------------
+
+ // The numbers must be the box the slot actually gives its panel, in
+ // whichever orientation is chosen - the centre one especially, which is
+ // short by the header and the footer and narrow by the left column.
+ function test_publishedSlotGeometryIsTheBoxThePanelGets_data() {
+ return [{tag: "portrait", landscape: false},
+ {tag: "landscape", landscape: true}]
+ }
+
+ function test_publishedSlotGeometryIsTheBoxThePanelGets(data) {
+ const s = makeSection(false)
+ if (data.landscape)
+ toLandscape(s.chrome)
+ console.info("SLOTGEO (" + data.tag + ") centre:",
+ s.chrome.centerPanelSlotWidth + "x" + s.chrome.centerPanelSlotHeight,
+ "| left:",
+ s.chrome.leftPanelSlotWidth + "x" + s.chrome.leftPanelSlotHeight,
+ "| section:", s.chrome.width + "x" + s.chrome.height)
+ compare(s.chrome.centerPanelSlotWidth, s.center.width)
+ compare(s.chrome.centerPanelSlotHeight, s.center.height)
+ compare(s.chrome.leftPanelSlotWidth, s.left.width)
+ compare(s.chrome.leftPanelSlotHeight, s.left.height)
+ verify(s.chrome.centerPanelSlotHeight < s.chrome.height,
+ "the centre slot is shorter than the section - header + footer")
+ }
+
+ // A panel pre-sized from those numbers is adopted without one resize;
+ // the only thing the handoff does is reparent it.
+ function test_slotPreSizedPanelIsAdoptedWithoutAResize_data() {
+ return [{tag: "portrait", landscape: false},
+ {tag: "landscape", landscape: true}]
+ }
+
+ function test_slotPreSizedPanelIsAdoptedWithoutAResize(data) {
+ const s = makeSection(false)
+ if (data.landscape)
+ toLandscape(s.chrome)
+
+ const section = createTemporaryObject(slotPreSizedSectionComponent, root,
+ {sectionLayout: s.chrome})
+ waitForRendering(s.chrome); wait(30)
+ compare(!!section.centerPanel.parent, false,
+ "a panel declared as a property value has no visual parent")
+
+ const rec = createTemporaryObject(recorderComponent, root,
+ {watched: section.centerPanel})
+ s.chrome.centerPanel = section.centerPanel
+ waitForRendering(s.chrome); wait(60)
+ console.info("SLOTPRESIZE (" + data.tag + ") handoff events:",
+ JSON.stringify(rec.events))
+ compare(rec.resizeCount(), 0,
+ "the handoff must not resize the panel: "
+ + JSON.stringify(rec.events))
+ }
+
+ // ...and the binding that pre-sized it does not survive the adoption.
+ // Left in place it would track the slot behind the slot's back and the
+ // bracket would buy nothing - the exact interaction between F1 and F2.
+ function test_slotPreSizingDoesNotSurviveAdoptionAndDefeatTheBracket() {
+ const s = makeSection(true)
+ const section = createTemporaryObject(slotPreSizedSectionComponent, root,
+ {sectionLayout: s.chrome})
+ waitForRendering(s.chrome); wait(30)
+ s.chrome.centerPanel = section.centerPanel
+ settle(s.chrome)
+
+ const rec = createTemporaryObject(recorderComponent, root,
+ {watched: section.centerPanel})
+ storm(s.chrome)
+ console.info("SLOTPRESIZE storm settled:", JSON.stringify(rec.settled))
+ verify(rec.settled.length <= 2,
+ "a pre-sized panel must still be bracketed, got "
+ + JSON.stringify(rec.settled))
+ }
+
+ // ------------------------------------------------------------------
+ // Arbitration: one panel, two slots
+ // ------------------------------------------------------------------
+
+ // The chrome binds the same panel into both sub-layouts at once. Only
+ // the slot in the chosen layout may own it; the other must not steal it
+ // back, or the two fight and the panel churns worse than before.
+ function test_onlyTheChosenLayoutOwnsThePanel_data() {
+ return [{tag: "portrait", landscape: false},
+ {tag: "landscape", landscape: true}]
+ }
+
+ function test_onlyTheChosenLayoutOwnsThePanel(data) {
+ const s = makeSection(false)
+ if (data.landscape)
+ toLandscape(s.chrome)
+
+ const rec = createTemporaryObject(recorderComponent, root, {watched: s.center})
+ // Nothing changes; if two slots were fighting they would trade the
+ // panel back and forth on their own.
+ wait(300)
+ waitForRendering(s.chrome)
+ console.info("SLOT arbitration idle churn (" + data.tag + "):",
+ JSON.stringify(rec.settled))
+ compare(rec.settled.length, 0,
+ "an idle chrome must not resize the panel: "
+ + JSON.stringify(rec.settled))
+ verify(isDescendantOf(s.center, s.chrome.chosenLayout),
+ "the panel must live in the chosen layout")
+ assertPanelFitsItsSlot(s.center, "arbitration " + data.tag)
+ }
+
+ // Rotating with the right panel showing exercises the third slot pair,
+ // whose portrait page is inserted and removed from the SwipeView.
+ function test_rightPanelSurvivesTheRoundTrip() {
+ const s = makeSection(false)
+ const right = createTemporaryObject(panelComponent, root)
+ s.chrome.rightPanel = right
+ s.chrome.showRightPanel = true
+ settle(s.chrome)
+ assertPanelFitsItsSlot(right, "portrait right")
+
+ toLandscape(s.chrome)
+ assertPanelFitsItsSlot(right, "landscape right")
+ verify(isDescendantOf(right, s.chrome.chosenLayout),
+ "the right panel must live in the landscape layout")
+
+ toPortrait(s.chrome)
+ assertPanelFitsItsSlot(right, "portrait right again")
+ verify(isDescendantOf(right, s.chrome.chosenLayout),
+ "the right panel must live in the portrait layout")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelIncubationCost.qml b/storybook/qmlTests/tests/tst_SectionPanelIncubationCost.qml
new file mode 100644
index 0000000000..35c5454859
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelIncubationCost.qml
@@ -0,0 +1,124 @@
+import QtQuick
+import QtQuick.Controls
+import QtTest
+
+import StatusQ.Layout
+
+/*
+ What the unparented phase actually costs.
+
+ A panel constructed as a property value has no visual parent, so it reports
+ its *content's* implicit size until the chrome adopts it. What that costs
+ depends entirely on the content:
+
+ * content with no implicit size (a bare ListView) reports 0x0, so nothing
+ is laid out and nothing is built - the phase is nearly free, and binding
+ the panel's geometry actually makes it build MORE work up front;
+ * content with a large implicit size (a Column/Layout over a full list, as
+ ChatView's contact column is) reports that size and builds against it,
+ which is the case the ChatView remedy was written for.
+
+ So "pre-size the panel while it is unparented" is not a universal win: it
+ pins the panel to the right box, which is what makes the later handoff free
+ (see tst_SectionPanelGeometryRemedies.qml), but it also front-loads a
+ screenful of content into the incubation.
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ property int delegatesCreated: 0
+
+ Component {
+ id: heavyList
+ ListView {
+ model: 500
+ delegate: Item {
+ width: ListView.view.width
+ height: 40
+ Component.onCompleted: root.delegatesCreated++
+ }
+ }
+ }
+
+ // Content whose implicit size *is* its whole content, like a Column over a
+ // list - this is the shape ChatView's contact column had.
+ Component {
+ id: heavyColumn
+ Column {
+ Repeater {
+ model: 500
+ delegate: Item {
+ width: 300
+ height: 40
+ Component.onCompleted: root.delegatesCreated++
+ }
+ }
+ }
+ }
+
+ Component { id: unboundLoader; Loader { asynchronous: true } }
+ Component {
+ id: boundLoader
+ Loader {
+ asynchronous: true
+ width: root.width
+ height: root.height
+ }
+ }
+
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent; currentIndex: 1 }
+ }
+
+ TestCase {
+ name: "SectionPanelIncubationCost"
+ when: windowShown
+
+ function init() { root.delegatesCreated = 0 }
+
+ function measure(loaderComponent, contentComponent, tag) {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ waitForRendering(chrome); wait(20)
+ root.delegatesCreated = 0
+
+ const panel = createTemporaryObject(loaderComponent, root,
+ {sourceComponent: contentComponent})
+ tryVerify(() => panel.status === Loader.Ready, 5000)
+ waitForRendering(chrome); wait(50)
+ const detachedSize = panel.width + "x" + panel.height
+ const builtDetached = root.delegatesCreated
+
+ chrome.centerPanel = panel
+ waitForRendering(chrome); wait(50)
+ console.info(tag, "| size while unparented:", detachedSize,
+ "| built while unparented:", builtDetached,
+ "| built in total:", root.delegatesCreated,
+ "| final size:", panel.width + "x" + panel.height)
+ return builtDetached
+ }
+
+ // A ListView has no implicit size, so an unbound loader sits at 0x0 and
+ // builds essentially nothing while unparented.
+ function test_unboundLoaderOverAListBuildsNothingWhileUnparented() {
+ const n = measure(unboundLoader, heavyList, "LIST unbound")
+ verify(n <= 2, "expected ~nothing to be built at 0x0, got " + n)
+ }
+
+ // Pre-sizing it front-loads a screenful into the incubation.
+ function test_boundLoaderOverAListBuildsAScreenfulWhileUnparented() {
+ const n = measure(boundLoader, heavyList, "LIST bound")
+ verify(n > 2 && n < 100, "expected a screenful, got " + n)
+ }
+
+ // Content that reports its whole size implicitly builds all of it
+ // regardless - the case the ChatView remedy exists for.
+ function test_columnContentBuildsEverythingWhileUnparented() {
+ const n = measure(unboundLoader, heavyColumn, "COLUMN unbound")
+ verify(n >= 500, "expected the whole column, got " + n)
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelIndexing.qml b/storybook/qmlTests/tests/tst_SectionPanelIndexing.qml
new file mode 100644
index 0000000000..9f333fd8a9
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelIndexing.qml
@@ -0,0 +1,90 @@
+import QtQuick
+import QtQuick.Controls
+import QtTest
+
+import StatusQ.Layout
+
+/*
+ StatusSectionLayoutPortrait.BaseProxyPanel inserts and removes its page by a
+ *fixed* implicitIndex (0 left, 1 centre, 2 right):
+
+ onInViewChanged: {
+ if (!inView && !!parent)
+ d.items.push(root.takeItem(baseProxyPanel.implicitIndex));
+ else if (inView && !parent)
+ root.insertItem(implicitIndex, baseProxyPanel)
+ }
+
+ SwipeView indices shift as pages come and go, so the fixed index is only
+ correct when every lower-numbered page is present. With no left panel the
+ right panel's page sits at index 1, and takeItem(2) is out of range: hiding
+ the right panel silently left its page in the view.
+
+ Both positions are now looked up over the pages actually in the view. These
+ cases guard that.
+*/
+Item {
+ id: root
+ width: 390
+ height: 844
+
+ Component { id: panelComponent; Rectangle { color: "grey" } }
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent }
+ }
+
+ TestCase {
+ name: "SectionPanelIndexing"
+ when: windowShown
+
+ function isDescendantOf(item, ancestor) {
+ let it = item
+ while (it) {
+ if (it === ancestor) return true
+ it = it.parent
+ }
+ return false
+ }
+
+ // Control: with all three pages present the indices line up.
+ function test_hidingTheRightPanelRemovesItsPage() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ const left = createTemporaryObject(panelComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ const right = createTemporaryObject(panelComponent, root)
+
+ chrome.leftPanel = left
+ chrome.centerPanel = center
+ chrome.rightPanel = right
+ chrome.showRightPanel = true
+ waitForRendering(chrome); wait(20)
+ chrome.showRightPanel = false
+ waitForRendering(chrome); wait(20)
+ verify(!isDescendantOf(right, chrome),
+ "with all three pages present the right page is removed")
+ }
+
+ // Same operation with no left panel: the right page sits at index 1,
+ // not at its implicitIndex of 2. It used to stay in the view.
+ function test_hidingTheRightPanelWithNoLeftPanel() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ const center = createTemporaryObject(panelComponent, root)
+ const right = createTemporaryObject(panelComponent, root)
+
+ chrome.centerPanel = center
+ chrome.rightPanel = right
+ chrome.showRightPanel = true
+ waitForRendering(chrome); wait(20)
+ chrome.showRightPanel = false
+ waitForRendering(chrome); wait(20)
+
+ console.info("INDEXBUG rightStillInView=", isDescendantOf(right, chrome),
+ "centreStillInView=", isDescendantOf(center, chrome))
+ verify(isDescendantOf(center, chrome),
+ "the centre panel must not be the one removed")
+ verify(!isDescendantOf(right, chrome),
+ "hiding the right panel should remove its page")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelLoaderGeometry.qml b/storybook/qmlTests/tests/tst_SectionPanelLoaderGeometry.qml
new file mode 100644
index 0000000000..16f67f711c
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelLoaderGeometry.qml
@@ -0,0 +1,193 @@
+import QtQuick
+import QtTest
+
+import QtQuick.Controls
+import StatusQ.Layout
+
+// A section may hand the chrome a Loader as a panel, so the panel can incubate
+// asynchronously. A Loader reports its item's implicit size as its own, so a
+// panel whose content is wider than the section would push the chrome's layout
+// past the screen edge unless the loader's geometry is bound.
+//
+// The wallet hit this: the center panel is a StackView whose content is wider
+// than a phone, and wrapping it in an unbound Loader overflowed to the right.
+Item {
+ id: root
+
+ width: 390
+ height: 700
+
+ Component {
+ id: layoutComponent
+
+ StatusSectionLayoutPortrait {
+ anchors.fill: parent
+ }
+ }
+
+ // Content deliberately wider than the section, as a StackView holding a
+ // desktop-width page is.
+ Component {
+ id: wideContent
+ Rectangle { implicitWidth: 5000; implicitHeight: 2000 }
+ }
+
+ // What the wallet did first: no geometry of its own.
+ Component {
+ id: unboundLoader
+ Loader { sourceComponent: wideContent }
+ }
+
+ // What it does now: bound to the chrome for the unparented phase.
+ Component {
+ id: boundLoader
+ Loader {
+ width: root.width
+ height: root.height
+ sourceComponent: wideContent
+ }
+ }
+
+ // What the wallet's center panel actually is: a StackView that fills its
+ // parent with side margins, wrapped in a Loader.
+ Component {
+ id: walletShapedLoader
+ Loader {
+ sourceComponent: Component {
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: 48
+ anchors.rightMargin: 48
+ implicitWidth: 5000
+ implicitHeight: 2000
+ }
+ }
+ }
+ }
+
+ // Host Item absorbs the loader's sizing; the StackView anchors inside it,
+ // so the margins resolve against a real parent instead of the loader.
+ Component {
+ id: hostedStackLoader
+ Loader {
+ sourceComponent: Component {
+ Item {
+ StackView {
+ anchors.fill: parent
+ anchors.leftMargin: 48
+ anchors.rightMargin: 48
+ initialItem: Rectangle { implicitWidth: 5000; implicitHeight: 2000 }
+ }
+ }
+ }
+ }
+ }
+
+ // Control padding on the stack itself, which does NOT inset the page:
+ // StackView sizes currentItem to the whole view. Kept as the evidence for
+ // why the wallet's inset lives on the page (RightTabBaseView) instead.
+ Component {
+ id: paddedStackLoader
+ Loader {
+ sourceComponent: Component {
+ StackView {
+ leftPadding: 48
+ rightPadding: 48
+ initialItem: Rectangle { implicitWidth: 5000; implicitHeight: 2000 }
+ }
+ }
+ }
+ }
+
+ TestCase {
+ name: "SectionPanelLoaderGeometry"
+ when: windowShown
+
+ property var layout: null
+
+ function init() {
+ layout = createTemporaryObject(layoutComponent, root)
+ verify(!!layout)
+ }
+
+ function test_boundLoaderPanelDoesNotOverflowTheSection() {
+ layout.centerPanel = createTemporaryObject(boundLoader, root)
+ waitForRendering(layout)
+
+ const panel = layout.centerPanel
+ verify(panel.width > 0, "panel should have been given a width")
+ compare(panel.width <= root.width, true,
+ `center panel is ${panel.width}px wide in a ${root.width}px section`)
+ }
+
+ // The decisive case: is the binding load-bearing at all? ChatView runs
+ // this same pattern with no geometry on its center panel loader.
+ function test_unboundLoaderAsPanelDoesNotOverflowEither() {
+ layout.centerPanel = createTemporaryObject(unboundLoader, root)
+ waitForRendering(layout)
+
+ const panel = layout.centerPanel
+ console.info("UNBOUND panel width=", panel.width, "implicitWidth=", panel.implicitWidth,
+ "section width=", root.width)
+ compare(panel.width <= root.width, true,
+ `unbound center panel is ${panel.width}px in a ${root.width}px section`)
+ }
+
+ function test_walletShapedPanelDoesNotOverflow() {
+ layout.centerPanel = createTemporaryObject(walletShapedLoader, root)
+ waitForRendering(layout)
+
+ const panel = layout.centerPanel
+ const inner = panel.item
+ console.info("WALLETSHAPE loader w=", panel.width, "x=", panel.x,
+ "| inner w=", inner ? inner.width : -1, "x=", inner ? inner.x : -1,
+ "| section w=", root.width)
+ compare(panel.width <= root.width, true, `loader ${panel.width}px in ${root.width}px`)
+ verify(!!inner)
+ compare(inner.width <= root.width, true, `inner ${inner.width}px in ${root.width}px`)
+ }
+
+ function test_hostedStackViewInsetsWithoutOverflowing() {
+ layout.centerPanel = createTemporaryObject(hostedStackLoader, root)
+ waitForRendering(layout)
+
+ const host = layout.centerPanel.item
+ const stack = host.children[0]
+ console.info("HOSTED host w=", host.width, "| stack w=", stack.width, "x=", stack.x,
+ "| section w=", root.width)
+ compare(host.width <= root.width, true, `host ${host.width}px in ${root.width}px`)
+ compare(stack.width, root.width - 96, "stack should be inset by both margins")
+ compare(stack.x, 48, "stack should start after the left margin")
+ }
+
+ // The stack itself does not overflow - but its padding buys nothing:
+ // StackView sizes currentItem to the view, padding and all. Anything
+ // that needs an inset has to carry it on the page.
+ function test_paddedStackViewIgnoresItsOwnPadding() {
+ layout.centerPanel = createTemporaryObject(paddedStackLoader, root)
+ waitForRendering(layout)
+
+ const panel = layout.centerPanel
+ const stack = panel.item
+ const content = stack.currentItem
+ console.info("PADDED stack w=", stack.width, "| content w=", content.width,
+ "x=", content.x, "| section w=", root.width)
+ compare(stack.width <= root.width, true, `stack ${stack.width}px in ${root.width}px`)
+ compare(content.width, stack.width,
+ "StackView sizes currentItem to itself, ignoring its padding")
+ compare(content.x, 0, "...and positions it at the origin")
+ }
+
+ // Guards the reason the binding exists: without it the loader adopts
+ // the content's implicit width. If this ever stops overflowing, the
+ // binding above is no longer load-bearing and the test should be
+ // revisited rather than deleted.
+ function test_unboundLoaderAdoptsItsContentWidth() {
+ const loader = createTemporaryObject(unboundLoader, root)
+ waitForRendering(loader)
+
+ compare(loader.implicitWidth, 5000,
+ "a Loader reports its item's implicit width as its own")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelResizeStorm.qml b/storybook/qmlTests/tests/tst_SectionPanelResizeStorm.qml
new file mode 100644
index 0000000000..be51f3a141
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelResizeStorm.qml
@@ -0,0 +1,335 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import QtTest
+
+import StatusQ.Layout
+
+/*
+ The device capture's dominant cost is not the cold open: it is a *resize
+ storm*. An Android rotation walks the window through a sequence of sizes
+ spread over hundreds of milliseconds (480 -> 416 -> 371 -> 435 -> 1048 in the
+ capture, ~430ms), and the portrait/landscape handoff parks the panel at a
+ degenerate size for 148ms on the way.
+
+ Unlike the same-turn intermediates in tst_SectionPanelGeometryRemedies.qml -
+ which Qt's polish phase collapses for free - these span many frames, so every
+ one is a real relayout of a populated panel.
+
+ These tests measure a storm, and A/B a slot that holds the panel's geometry
+ until the storm settles.
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ component GeoRecorder: QtObject {
+ id: rec
+ required property Item watched
+ property var settled: []
+ property bool _pending: false
+ readonly property Connections conn: Connections {
+ target: rec.watched
+ function onWidthChanged() { rec.record() }
+ function onHeightChanged() { rec.record() }
+ }
+ function record() {
+ if (rec._pending)
+ return
+ rec._pending = true
+ Qt.callLater(rec.sample)
+ }
+ function sample() {
+ rec._pending = false
+ const s = watched.width + "x" + watched.height
+ if (settled.length === 0 || settled[settled.length - 1] !== s)
+ settled.push(s)
+ }
+ }
+ Component { id: recorderComponent; GeoRecorder {} }
+ Component { id: panelComponent; Rectangle { color: "grey" } }
+
+ // A slot that holds the target's geometry while the host is still moving,
+ // and never writes a degenerate box. Applies once, when things go quiet.
+ component QuiescentPanelSlot: Item {
+ id: slot
+
+ property Item target: null
+ property int settleMs: 32
+
+ readonly property bool sane: slot.visible && slot.width > 0 && slot.height > 0
+
+ onWidthChanged: settleTimer.restart()
+ onHeightChanged: settleTimer.restart()
+ onSaneChanged: settleTimer.restart()
+ onTargetChanged: settleTimer.restart()
+
+ Timer {
+ id: settleTimer
+ interval: slot.settleMs
+ onTriggered: slot.apply()
+ }
+
+ function apply() {
+ const t = slot.target
+ if (!t || !slot.sane)
+ return
+ if (t.parent !== slot) {
+ t.parent = slot
+ t.x = 0
+ t.y = 0
+ }
+ if (t.width !== slot.width)
+ t.width = slot.width
+ if (t.height !== slot.height)
+ t.height = slot.height
+ }
+
+ Component.onCompleted: settleTimer.restart()
+ }
+
+ // The shape actually proposed: a pass-through slot that only holds the
+ // panel while the chrome says a geometry transition is in flight. No lag
+ // outside the bracket, so a desktop window drag still tracks live.
+ component BracketedPanelSlot: Item {
+ id: bslot
+
+ property Item target: null
+ property bool frozen: false
+
+ readonly property bool sane: bslot.visible && bslot.width > 0 && bslot.height > 0
+
+ onWidthChanged: bslot.apply()
+ onHeightChanged: bslot.apply()
+ onSaneChanged: bslot.apply()
+ onTargetChanged: bslot.apply()
+ onFrozenChanged: bslot.apply()
+
+ function apply() {
+ const t = bslot.target
+ if (!t || !bslot.sane || bslot.frozen)
+ return
+ if (t.parent !== bslot) {
+ t.parent = bslot
+ t.x = 0
+ t.y = 0
+ }
+ if (t.width !== bslot.width)
+ t.width = bslot.width
+ if (t.height !== bslot.height)
+ t.height = bslot.height
+ }
+
+ Component.onCompleted: bslot.apply()
+ }
+
+ component BracketHost: Item {
+ anchors.fill: parent
+ property alias panel: bs.target
+ property alias frozen: bs.frozen
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.fillWidth: true; Layout.preferredHeight: 52 }
+ BracketedPanelSlot {
+ id: bs
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+ }
+ }
+ Component { id: bracketHostComponent; BracketHost {} }
+
+ component ProxyHost: Item {
+ anchors.fill: parent
+ property alias panel: proxy.target
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.fillWidth: true; Layout.preferredHeight: 52 }
+ LayoutItemProxy {
+ id: proxy
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+ }
+ }
+
+ component SlotHost: Item {
+ anchors.fill: parent
+ property alias panel: slot.target
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Item { Layout.fillWidth: true; Layout.preferredHeight: 52 }
+ QuiescentPanelSlot {
+ id: slot
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+ }
+ }
+
+ Component { id: proxyHostComponent; ProxyHost {} }
+ Component { id: slotHostComponent; SlotHost {} }
+
+ // A Loader inside a sizing host, to establish which way causality runs.
+ component LoaderHolder: Item {
+ id: lh
+ property var order: []
+ readonly property Item host: sizer
+ Item {
+ id: sizer
+ width: 200
+ height: 200
+ Loader {
+ id: ldr
+ anchors.fill: parent
+ sourceComponent: Rectangle { color: "grey" }
+ }
+ Connections {
+ target: ldr
+ function onHeightChanged() { lh.order.push("loader") }
+ }
+ Connections {
+ target: ldr.item
+ function onHeightChanged() { lh.order.push("item") }
+ }
+ }
+ }
+ Component { id: loaderHolderComponent; LoaderHolder {} }
+
+ // The width sequence an Android rotation actually produced, from
+ // .plan/panelgeo-device-capture.txt (episodes 7 and 9, identical both times).
+ readonly property var rotationWidths: [480, 416, 371, 435, 1048, 972, 908, 863, 908]
+
+ TestCase {
+ name: "SectionPanelResizeStorm"
+ when: windowShown
+
+ function cleanup() { root.width = 390; root.height = 844 }
+
+ function storm(host) {
+ for (let i = 0; i < root.rotationWidths.length; ++i) {
+ root.width = root.rotationWidths[i]
+ waitForRendering(host)
+ wait(16) // a frame apart, as on the device
+ }
+ wait(120) // let everything settle
+ }
+
+ // Baseline: every step of the storm reaches the panel.
+ function test_stormReachesThePanel_layoutItemProxy() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(proxyHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ storm(host)
+ console.info("STORM proxy settled:", JSON.stringify(rec.settled))
+ compare(rec.settled.length, root.rotationWidths.length,
+ "every storm step is a separate relayout of the panel")
+ }
+
+ // With a settle gate the panel is laid out once.
+ function test_stormIsCoalesced_quiescentSlot() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(slotHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(60)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+ storm(host)
+ console.info("STORM slot settled:", JSON.stringify(rec.settled))
+ verify(rec.settled.length <= 2,
+ "the storm should cost one relayout, got "
+ + JSON.stringify(rec.settled))
+ compare(rec.settled[rec.settled.length - 1],
+ panel.parent.width + "x" + panel.parent.height,
+ "and it must end on the right box")
+ }
+
+ // Reading the capture depends on knowing which way causality runs. In
+ // the log the Loader's *item* (wallet.rightStack / wallet.leftTab) is
+ // always logged before the Loader itself, which looks bottom-up. It is
+ // not: QQuickLoader::geometryChange() resizes its item and only then
+ // lets the base class emit widthChanged/heightChanged, so a top-down
+ // resize always reports the item first.
+ function test_loaderResizesItsItemBeforeEmittingItsOwnChange() {
+ const holder = createTemporaryObject(loaderHolderComponent, root)
+ waitForRendering(holder); wait(20)
+ holder.order = []
+ holder.host.height = 300
+ waitForRendering(holder); wait(20)
+ console.info("ORDER", JSON.stringify(holder.order))
+ compare(holder.order[0], "item",
+ "the item reports first even though the change came from above")
+ compare(holder.order[1], "loader")
+ }
+
+ // The proposed shape: hold only while the chrome brackets a transition.
+ function test_stormIsCoalesced_bracketedSlot() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(bracketHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+
+ host.frozen = true
+ storm(host)
+ host.frozen = false
+ waitForRendering(host); wait(60)
+
+ console.info("STORM bracketed settled:", JSON.stringify(rec.settled))
+ compare(rec.settled.length, 1,
+ "a bracketed storm costs one relayout, got "
+ + JSON.stringify(rec.settled))
+ }
+
+ // ...and stays a live pass-through outside the bracket, so a desktop
+ // window drag is not laggy.
+ function test_unbracketedResizeStillTracksLive_bracketedSlot() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(bracketHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ root.width = 500
+ waitForRendering(host); wait(20)
+ compare(panel.width, 500, "outside the bracket the panel tracks the host")
+ }
+
+ // The device also parks the panel at a degenerate size for 148ms during
+ // the portrait/landscape handoff (0x0, then 306x0, then 306x416). Unlike
+ // same-turn intermediates, that spans frames.
+ function test_degenerateGeometryHeldAcrossFrames_layoutItemProxy() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(proxyHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(30)
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+
+ root.height = 52 // slot collapses to 0 high
+ waitForRendering(host); wait(80)
+ const collapsed = panel.height
+ root.height = 844
+ waitForRendering(host); wait(80)
+ console.info("DEGENERATE proxy: panel height while host collapsed =",
+ collapsed, "| settled:", JSON.stringify(rec.settled))
+ compare(collapsed, 0, "the proxy passes the degenerate box straight on")
+ }
+
+ function test_degenerateGeometryIsNotWritten_quiescentSlot() {
+ const panel = createTemporaryObject(panelComponent, root)
+ const host = createTemporaryObject(slotHostComponent, root, {panel: panel})
+ waitForRendering(host); wait(60)
+ const before = panel.height
+ const rec = createTemporaryObject(recorderComponent, root, {watched: panel})
+
+ root.height = 52
+ waitForRendering(host); wait(80)
+ const collapsed = panel.height
+ root.height = 844
+ waitForRendering(host); wait(80)
+ console.info("DEGENERATE slot: panel height while host collapsed =",
+ collapsed, "(was " + before + ") | settled:",
+ JSON.stringify(rec.settled))
+ compare(collapsed, before, "the slot keeps the last good box")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_SectionPanelThemeChurn.qml b/storybook/qmlTests/tests/tst_SectionPanelThemeChurn.qml
new file mode 100644
index 0000000000..074a686283
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_SectionPanelThemeChurn.qml
@@ -0,0 +1,87 @@
+import QtQuick
+import QtQuick.Controls
+import QtTest
+
+import StatusQ.Core.Theme
+import StatusQ.Layout
+
+/*
+ Theme is a QQuickAttachedPropertyPropagator: the value an item sees is
+ resolved by walking its *visual parent* chain. A panel constructed with no
+ visual parent therefore resolves against the engine-level fallback node, not
+ against the chrome it will end up in - so every Theme.palette / Theme.padding
+ binding in the panel is first evaluated against the wrong values and
+ re-evaluated when the chrome adopts it.
+
+ theme.cpp already guards the *unparenting* direction (StatusQ/src/theme.cpp,
+ Theme::attachedParentChange: a detached item defers restyling until it lands
+ in a window again, so a section switch does not round-trip the subtree
+ through Light). These tests cover what is left: the construction direction.
+*/
+Item {
+ id: root
+
+ width: 390
+ height: 844
+
+ Theme.style: Theme.Style.Dark
+
+ component ThemedPanel: Rectangle {
+ id: p
+ readonly property int seenStyle: p.Theme.style
+ readonly property color seenColor: p.Theme.palette.primaryColor1
+ readonly property real seenPadding: p.Theme.padding
+ color: seenColor
+ }
+ Component { id: themedPanelComponent; ThemedPanel {} }
+
+ Component {
+ id: chromeComponent
+ StatusSectionLayout { anchors.fill: parent; currentIndex: 1 }
+ }
+
+ TestCase {
+ name: "SectionPanelThemeChurn"
+ when: windowShown
+
+ // Baseline: a panel built directly inside the themed tree sees the
+ // final theme immediately.
+ function test_panelBuiltInPlaceSeesTheTreeTheme() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ waitForRendering(chrome); wait(20)
+ const panel = createTemporaryObject(themedPanelComponent, chrome)
+ waitForRendering(chrome); wait(30)
+ console.info("THEME inPlace style=" + panel.seenStyle
+ + " colour=" + panel.seenColor)
+ compare(panel.seenStyle, Theme.Style.Dark)
+ }
+
+ // The real shape: constructed unparented, handed to the chrome later.
+ function test_panelBuiltUnparentedIsRestyledOnAdoption() {
+ const chrome = createTemporaryObject(chromeComponent, root)
+ waitForRendering(chrome); wait(20)
+
+ // no visual parent, like `readonly property Item centerPanel:
+ // Loader { ... }` in WalletLayout / ChatView
+ const panel = themedPanelComponent.createObject(null)
+ verify(!!panel)
+ waitForRendering(chrome); wait(30)
+ const detachedStyle = panel.seenStyle
+ const detachedColor = panel.seenColor
+
+ chrome.centerPanel = panel
+ waitForRendering(chrome); wait(30)
+
+ console.info("THEME detached style=" + detachedStyle
+ + " colour=" + detachedColor
+ + " -> adopted style=" + panel.seenStyle
+ + " colour=" + panel.seenColor)
+ const changed = (detachedStyle !== panel.seenStyle)
+ || (detachedColor.toString() !== panel.seenColor.toString())
+ panel.destroy()
+ verify(changed,
+ "a panel built unparented resolves the wrong theme and is "
+ + "restyled when the chrome adopts it")
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_TokenDelegateShell.qml b/storybook/qmlTests/tests/tst_TokenDelegateShell.qml
new file mode 100644
index 0000000000..004ff32750
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_TokenDelegateShell.qml
@@ -0,0 +1,83 @@
+import QtQuick
+import QtTest
+
+import shared.controls
+
+// The shell carries the row's geometry before its content exists,
+// so the list's contentHeight and scroll position depend on the shell's own
+// height matching what the loaded row resolves to. These tests are that contract.
+Item {
+ id: root
+
+ width: 600
+ height: 400
+
+ Component {
+ id: shellComponent
+
+ TokenDelegateShell {
+ width: root.width
+
+ sourceComponent: TokenDelegate {
+ width: root.width
+
+ name: "Ethereum"
+ icon: "https://example.invalid/eth.png"
+ balance: "1.23 ETH"
+ marketBalance: "$4,321.00"
+ marketDetailsAvailable: true
+ marketCurrencyPrice: "$3,512.10"
+ marketChangePct24hour: 1.5
+ }
+ }
+ }
+
+ Component {
+ id: rowComponent
+
+ TokenDelegate {
+ width: root.width
+
+ name: "Ethereum"
+ icon: "https://example.invalid/eth.png"
+ balance: "1.23 ETH"
+ marketBalance: "$4,321.00"
+ marketDetailsAvailable: true
+ marketCurrencyPrice: "$3,512.10"
+ marketChangePct24hour: 1.5
+ }
+ }
+
+ TestCase {
+ name: "TokenDelegateShell"
+ when: windowShown
+
+ function test_placeholderHeightMatchesTheRow() {
+ const row = createTemporaryObject(rowComponent, root)
+ verify(!!row)
+ waitForRendering(row)
+
+ const shell = createTemporaryObject(shellComponent, root)
+ verify(!!shell)
+
+ compare(shell.placeholderHeight, row.implicitHeight,
+ "the shell's placeholder height no longer matches the token row - "
+ + "the list geometry will jump as rows fill in")
+ }
+
+ function test_heightDoesNotChangeWhenTheContentArrives() {
+ const shell = createTemporaryObject(shellComponent, root)
+ verify(!!shell)
+
+ verify(!shell.contentReady, "the content must not be built synchronously")
+ const placeholderHeight = shell.height
+
+ tryVerify(() => shell.contentReady)
+ waitForRendering(shell)
+
+ compare(shell.height, placeholderHeight,
+ "the row changed height when its content arrived")
+ compare(shell.contentItem.height, placeholderHeight)
+ }
+ }
+}
diff --git a/storybook/qmlTests/tests/tst_WalletAccountDelegateShell.qml b/storybook/qmlTests/tests/tst_WalletAccountDelegateShell.qml
new file mode 100644
index 0000000000..6a2b3947e9
--- /dev/null
+++ b/storybook/qmlTests/tests/tst_WalletAccountDelegateShell.qml
@@ -0,0 +1,83 @@
+import QtQuick
+import QtTest
+
+import StatusQ.Components
+import StatusQ.Core.Theme
+
+import AppLayouts.Wallet.controls
+
+// The shell carries the row's geometry before its content exists,
+// so the list's contentHeight and scroll position depend on the shell's own
+// height matching what the loaded row resolves to. These tests are that contract.
+Item {
+ id: root
+
+ width: 400
+ height: 400
+
+ // The account row as LeftTabView configures it: title, subtitle and a 40px
+ // identicon. StatusListItem's implicitHeight floor is what makes this 64.
+ component AccountRow: StatusListItem {
+ width: root.width
+
+ title: "Account 0"
+ subTitle: "1,234.56 USD"
+ asset.emoji: "😃"
+ asset.color: Theme.palette.primaryColor1
+ asset.width: 40
+ asset.height: 40
+ asset.letterSize: 14
+ asset.isLetterIdenticon: true
+ asset.bgColor: Theme.palette.primaryColor3
+ statusListItemTitle.font.weight: Font.Medium
+ }
+
+ Component {
+ id: shellComponent
+
+ WalletAccountDelegateShell {
+ width: root.width
+
+ sourceComponent: AccountRow {}
+ }
+ }
+
+ Component {
+ id: rowComponent
+
+ AccountRow {}
+ }
+
+ TestCase {
+ name: "WalletAccountDelegateShell"
+ when: windowShown
+
+ function test_placeholderHeightMatchesTheRow() {
+ const row = createTemporaryObject(rowComponent, root)
+ verify(!!row)
+ waitForRendering(row)
+
+ const shell = createTemporaryObject(shellComponent, root)
+ verify(!!shell)
+
+ compare(shell.placeholderHeight, row.implicitHeight,
+ "the shell's placeholder height no longer matches the account row - "
+ + "the list geometry will jump as rows fill in")
+ }
+
+ function test_heightDoesNotChangeWhenTheContentArrives() {
+ const shell = createTemporaryObject(shellComponent, root)
+ verify(!!shell)
+
+ verify(!shell.contentReady, "the content must not be built synchronously")
+ const placeholderHeight = shell.height
+
+ tryVerify(() => shell.contentReady)
+ waitForRendering(shell)
+
+ compare(shell.height, placeholderHeight,
+ "the row changed height when its content arrived")
+ compare(shell.contentItem.height, placeholderHeight)
+ }
+ }
+}
diff --git a/ui/StatusQ/src/StatusQ/Layout/SectionPanelSlot.qml b/ui/StatusQ/src/StatusQ/Layout/SectionPanelSlot.qml
new file mode 100644
index 0000000000..6c40a46022
--- /dev/null
+++ b/ui/StatusQ/src/StatusQ/Layout/SectionPanelSlot.qml
@@ -0,0 +1,107 @@
+import QtQuick
+import QtQuick.Window
+
+/*!
+ \qmltype SectionPanelSlot
+ \inqmlmodule StatusQ.Layout
+ \internal
+ \brief Holds one of a section's panels and gives it the slot's box.
+
+ A drop-in replacement for \c LayoutItemProxy in the places where
+ \l StatusSectionLayout hands a panel to its portrait or landscape
+ sub-layout. It differs in two ways, both of which exist to stop a burst of
+ host sizes turning into a burst of relayouts of a populated panel:
+
+ \list
+ \li it never writes a degenerate box — while the slot is collapsed or
+ detached the panel keeps its last good geometry instead of being
+ squashed to 0 and restored a few frames later;
+ \li while \l frozen it stops tracking the slot altogether, so a run of
+ intermediate sizes (a device rotation walks the window through nine of
+ them) costs one relayout instead of nine. Outside the bracket it is a
+ live pass-through, so a desktop window drag still tracks.
+ \endlist
+
+ \section2 Arbitration
+
+ The same panel is bound into both sub-layouts at once, so two slots target
+ it and exactly one may own it. The rule is claim-on-live, never release: a
+ slot takes the panel when it becomes \l live and does nothing when it stops
+ being live. \l live requires the slot to be effectively visible \e and in a
+ scene, which is what separates the two sub-layouts (\c LayoutChooser makes
+ the chosen one visible and the other not) and also excludes a slot sitting
+ in a \c SwipeView page that has been taken out of the view — such a page has
+ no parent, and a parentless item reports \c visible \c true.
+*/
+Item {
+ id: root
+
+ /*!
+ The panel this slot owns. Changing it releases the previous one.
+ */
+ property Item target: null
+
+ /*!
+ While true the slot stops writing geometry to \l target: the panel keeps
+ the last box it was given until the bracket is lifted. Reparenting still
+ happens, so the panel is never left out of the scene.
+ */
+ property bool frozen: false
+
+ /*!
+ True when this slot is the one that should own \l target and can
+ describe a real box for it.
+ */
+ readonly property bool live: root.visible && root.Window.window !== null
+ && root.width > 0 && root.height > 0
+
+ // A held panel can be larger than the slot for the length of a transition.
+ clip: root.frozen
+
+ onLiveChanged: root.apply()
+ onWidthChanged: root.apply()
+ onHeightChanged: root.apply()
+ onFrozenChanged: root.apply()
+
+ onTargetChanged: {
+ if (d.owned && d.owned !== root.target && d.owned.parent === root)
+ d.owned.parent = null
+ d.owned = null
+ root.apply()
+ }
+
+ QtObject {
+ id: d
+ property Item owned: null
+ }
+
+ function apply() {
+ const t = root.target
+ if (!t || !root.live)
+ return
+ const adopting = t.parent !== root
+ if (adopting) {
+ t.parent = root
+ t.x = 0
+ t.y = 0
+ }
+ d.owned = t
+ // Hold the last good box - but only if there is one. A panel that has
+ // never been sized has nothing to hold, so a bracket in flight when it
+ // arrives must not park it at 0x0 for the length of the transition.
+ if (root.frozen && t.width > 0 && t.height > 0)
+ return
+ // On adoption, write unconditionally even when the value is unchanged.
+ // A section that pre-sized its panel from the published slot geometry
+ // arrives already the right size, and leaving that binding in place
+ // would let the panel track the slot behind the slot's back - defeating
+ // the bracket. An assignment of an equal value breaks the binding
+ // without emitting a change.
+ if (adopting || t.width !== root.width)
+ t.width = root.width
+ if (adopting || t.height !== root.height)
+ t.height = root.height
+ }
+
+ Component.onCompleted: root.apply()
+}
diff --git a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayout.qml b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayout.qml
index fd5153330f..82124db3ee 100644
--- a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayout.qml
+++ b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayout.qml
@@ -284,6 +284,72 @@ LayoutChooser {
readonly property int windowWidth: root.Window?.width ?? Screen.width
+ /*!
+ \qmlproperty bool StatusSectionLayout::coalesceResizes
+ Whether a change of the section's own box should be treated as a
+ system-driven geometry transition (see \l geometryTransitionOngoing).
+
+ Defaults to true on mobile, where the window is only ever resized by the
+ system — a rotation, split screen or multi-window — and every such
+ resize arrives as a run of intermediate sizes over a few hundred
+ milliseconds. False on desktop, where a resize is the user dragging the
+ window edge and the panels must track it live.
+ */
+ property bool coalesceResizes: Qt.platform.os === "android" || Qt.platform.os === "ios"
+
+ /*!
+ \qmlproperty bool StatusSectionLayout::geometryTransitionOngoing
+ True while the section's geometry is being changed by something other
+ than the user: a device rotation, the portrait/landscape swap, or the
+ landscape left column animating to a new width.
+
+ The panel slots hold their last good box for the duration, so the whole
+ transition costs one relayout of the (populated, expensive) panel
+ subtrees instead of one per intermediate size. Outside a transition the
+ slots are live pass-throughs.
+
+ Derived from the settle timer rather than latched by hand: there is no
+ "end" to forget, so no path can leave a panel frozen at a stale size.
+ */
+ readonly property bool geometryTransitionOngoing: geometrySettleTimer.running
+
+ // Runs for as long as the bracket is up, and stopping it is the only way
+ // the bracket comes down. Started (never restarted - restart() would drop
+ // `running` for an instant and flush the very relayout being coalesced) by
+ // every input that can begin a transition; stops on the first tick that
+ // sees no input since the last one and no left-column animation still in
+ // flight, that animation outliving the resize that triggered it.
+ Timer {
+ id: geometrySettleTimer
+ interval: 48
+ repeat: true
+ onTriggered: {
+ if (d.geometryInputSeen || landscapeView.leftColumnAnimating) {
+ d.geometryInputSeen = false
+ return
+ }
+ geometrySettleTimer.stop()
+ }
+ }
+
+ onWidthChanged: if (root.coalesceResizes) d.beginGeometryTransition()
+ onHeightChanged: if (root.coalesceResizes) d.beginGeometryTransition()
+ // A swap between the two sub-layouts, not the initial choice: bracketing
+ // that one would only delay the section's first layout.
+ onChosenLayoutChanged: {
+ if (d.previousChosenLayout)
+ d.beginGeometryTransition()
+ d.previousChosenLayout = chosenLayout
+ }
+
+ // The one input that arrives *before* the window starts resizing, so the
+ // bracket is already up when the platform's rotation animation begins.
+ Connections {
+ target: root.Screen
+ function onOrientationChanged() { d.beginGeometryTransition() }
+ function onPrimaryOrientationChanged() { d.beginGeometryTransition() }
+ }
+
criteria: [
root.windowWidth < ThemeUtils.portraitBreakpoint.width, // Portrait mode
true // Defaults to landscape mode
@@ -296,6 +362,29 @@ LayoutChooser {
readonly property bool isPortrait: chosenLayout === portraitView
+ /*!
+ \qmlproperty real StatusSectionLayout::leftPanelSlotWidth
+ \qmlproperty real StatusSectionLayout::leftPanelSlotHeight
+ \qmlproperty real StatusSectionLayout::centerPanelSlotWidth
+ \qmlproperty real StatusSectionLayout::centerPanelSlotHeight
+ \qmlproperty real StatusSectionLayout::rightPanelSlotWidth
+ \qmlproperty real StatusSectionLayout::rightPanelSlotHeight
+
+ The box each slot will give its panel, taken from whichever sub-layout
+ is currently chosen. They are valid before a panel arrives, which is the
+ point: a section that builds its panels outside the tree (as the ones
+ behind an async Loader do) can size them to these and be adopted without
+ a single resize. The centre one is the one that cannot be guessed from
+ the section's own box - it is short by the header and the footer, and
+ narrow by the left column in landscape.
+ */
+ readonly property real leftPanelSlotWidth: chosenLayout ? chosenLayout.leftPanelSlotWidth : 0
+ readonly property real leftPanelSlotHeight: chosenLayout ? chosenLayout.leftPanelSlotHeight : 0
+ readonly property real centerPanelSlotWidth: chosenLayout ? chosenLayout.centerPanelSlotWidth : 0
+ readonly property real centerPanelSlotHeight: chosenLayout ? chosenLayout.centerPanelSlotHeight : 0
+ readonly property real rightPanelSlotWidth: chosenLayout ? chosenLayout.rightPanelSlotWidth : 0
+ readonly property real rightPanelSlotHeight: chosenLayout ? chosenLayout.rightPanelSlotHeight : 0
+
StatusSectionLayoutLandscape {
id: landscapeView
anchors.fill: parent
@@ -314,6 +403,7 @@ LayoutChooser {
backButtonName: root.backButtonName
headerContent: root.headerContent
backgroundColor: root.backgroundColor
+ panelsFrozen: root.geometryTransitionOngoing
onBackButtonClicked: root.backButtonClicked()
}
@@ -335,6 +425,7 @@ LayoutChooser {
headerContent: root.headerContent
backgroundColor: root.backgroundColor
invertedLayout: root.invertedLayout
+ panelsFrozen: root.geometryTransitionOngoing
property int currentIndexCache
@@ -363,6 +454,18 @@ LayoutChooser {
QtObject {
id: d
+ // Set by every geometry input, cleared by the settle tick that sees it.
+ // Only ever read from that tick, so a stale true costs one extra tick
+ // and can never wedge the bracket up.
+ property bool geometryInputSeen: false
+
+ property var previousChosenLayout: null
+
+ function beginGeometryTransition() {
+ d.geometryInputSeen = true
+ geometrySettleTimer.start()
+ }
+
// The emitted bracket pairs on the edges of this: a slide counts as
// a panel switch only while the portrait layout is the visible one.
// Rotating away mid-slide closes the bracket immediately (nothing
diff --git a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutLandscape.qml b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutLandscape.qml
index 9cb1a659a9..22c02ba215 100644
--- a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutLandscape.qml
+++ b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutLandscape.qml
@@ -144,6 +144,50 @@ Control {
*/
property color backgroundColor: Theme.palette.statusAppLayout.rightPanelBackgroundColor
+ /*!
+ \qmlproperty bool StatusSectionLayoutLandscape::panelsFrozen
+ While true the panel slots stop tracking their box: each panel keeps the
+ last geometry it was given. See \l StatusSectionLayout::panelsFrozen.
+ */
+ property bool panelsFrozen: false
+
+ /*!
+ \qmlproperty real StatusSectionLayoutLandscape::leftPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutLandscape::leftPanelSlotHeight
+ The box the left panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real leftPanelSlotWidth: leftPanelProxy.width
+ readonly property real leftPanelSlotHeight: leftPanelProxy.height
+ /*!
+ \qmlproperty real StatusSectionLayoutLandscape::centerPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutLandscape::centerPanelSlotHeight
+ The box the centre panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real centerPanelSlotWidth: centerPanelProxy.width
+ readonly property real centerPanelSlotHeight: centerPanelProxy.height
+ /*!
+ \qmlproperty real StatusSectionLayoutLandscape::rightPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutLandscape::rightPanelSlotHeight
+ The box the right panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real rightPanelSlotWidth: rightPanelProxy.width
+ readonly property real rightPanelSlotHeight: rightPanelProxy.height
+
+ /*!
+ \qmlproperty bool StatusSectionLayoutLandscape::leftColumnAnimating
+ True while the left column is animating to a new width. The centre
+ panel's width is the complement, so this walks both panels through a
+ run of sizes; \l StatusSectionLayout holds its geometry bracket open
+ until it clears.
+ */
+ readonly property bool leftColumnAnimating: leftPanelWidthAnimation.running
+
/*!
\qmlsignal
This signal is emitted when the back button of the header component
@@ -169,6 +213,7 @@ Control {
Behavior on effectiveLeftPanelWidth {
NumberAnimation {
+ id: leftPanelWidthAnimation
duration: ThemeUtils.AnimationDuration.Slow
easing.type: Easing.InOutCubic
}
@@ -192,8 +237,10 @@ Control {
background: Rectangle {
color: root.Theme.palette.baseColor4
}
- contentItem: LayoutItemProxy {
+ contentItem: SectionPanelSlot {
+ id: leftPanelProxy
target: d.effectiveLeftPanel
+ frozen: root.panelsFrozen
}
}
@@ -226,13 +273,14 @@ Control {
onBackButtonClicked: root.backButtonClicked()
}
- LayoutItemProxy {
+ SectionPanelSlot {
id: centerPanelProxy
width: parent.width
anchors.top: statusToolBar.bottom
anchors.bottom: footerSlot.top
anchors.bottomMargin: footerSlot.visible ? root.footerSpacing : 0
target: root.centerPanel
+ frozen: root.panelsFrozen
}
LayoutItemProxy {
@@ -254,8 +302,10 @@ Control {
background: Rectangle {
color: root.Theme.palette.baseColor4
}
- contentItem: LayoutItemProxy {
+ contentItem: SectionPanelSlot {
+ id: rightPanelProxy
target: root.rightPanel
+ frozen: root.panelsFrozen
}
}
}
diff --git a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutPortrait.qml b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutPortrait.qml
index c93b5131b2..7f92ed59eb 100644
--- a/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutPortrait.qml
+++ b/ui/StatusQ/src/StatusQ/Layout/StatusSectionLayoutPortrait.qml
@@ -142,6 +142,43 @@ SwipeView {
when true, otherwise Header - Center - Footer
*/
property bool invertedLayout: false
+ /*!
+ \qmlproperty bool StatusSectionLayoutPortrait::panelsFrozen
+ While true the panel slots stop tracking their box: each panel keeps the
+ last geometry it was given. Raised by \l StatusSectionLayout for the
+ duration of a geometry transition it did not initiate (a device
+ rotation, an orientation swap), so a burst of host sizes costs one
+ relayout of the panel subtree instead of one per size.
+ */
+ property bool panelsFrozen: false
+
+ /*!
+ \qmlproperty real StatusSectionLayoutPortrait::leftPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutPortrait::leftPanelSlotHeight
+ The box the left panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real leftPanelSlotWidth: leftPanelProxy.width
+ readonly property real leftPanelSlotHeight: leftPanelProxy.height
+ /*!
+ \qmlproperty real StatusSectionLayoutPortrait::centerPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutPortrait::centerPanelSlotHeight
+ The box the centre panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real centerPanelSlotWidth: centerPanelProxy.width
+ readonly property real centerPanelSlotHeight: centerPanelProxy.height
+ /*!
+ \qmlproperty real StatusSectionLayoutPortrait::rightPanelSlotWidth
+ \qmlproperty real StatusSectionLayoutPortrait::rightPanelSlotHeight
+ The box the right panel slot will give its panel. Valid whether or not a
+ panel has arrived, so a section that builds its panel before the chrome
+ adopts it can pre-size it exactly and make the handoff resize-free.
+ */
+ readonly property real rightPanelSlotWidth: rightPanelProxy.width
+ readonly property real rightPanelSlotHeight: rightPanelProxy.height
/*!
\qmlsignal
@@ -188,6 +225,27 @@ SwipeView {
root.panelSwitchEnded()
}
+ // Where the page currently sits in the view, or -1 if it has been taken
+ // out of it.
+ function pageIndexOf(page) {
+ for (let i = 0; i < root.count; ++i)
+ if (root.itemAt(i) === page)
+ return i
+ return -1
+ }
+
+ // Pages keep their declared order, so a page belongs after every
+ // lower-numbered page that is currently in the view.
+ function insertionIndexFor(page) {
+ let at = 0
+ for (let i = 0; i < root.count; ++i) {
+ const other = root.itemAt(i)
+ if (!!other && other.implicitIndex < page.implicitIndex)
+ ++at
+ }
+ return at
+ }
+
function handleBackAction() {
if (!!root.backButtonName) {
root.backButtonClicked()
@@ -242,10 +300,16 @@ SwipeView {
onInViewChanged: {
// If the panel is not in view, we need to remove it from the swipe view
// and add it to the cache wrapper items so that we can restore it later if needed.
- if (!inView && !!parent) {
- d.items.push(root.takeItem(baseProxyPanel.implicitIndex));
- } else if (inView && !parent) {
- root.insertItem(implicitIndex, baseProxyPanel)
+ //
+ // Both positions are looked up rather than taken from implicitIndex:
+ // SwipeView indices close up as pages come and go, so with no left
+ // panel the right panel's page sits at index 1, and takeItem(2)
+ // would silently leave it in the view.
+ const at = d.pageIndexOf(baseProxyPanel)
+ if (!inView && at >= 0) {
+ d.items.push(root.takeItem(at));
+ } else if (inView && at < 0) {
+ root.insertItem(d.insertionIndexFor(baseProxyPanel), baseProxyPanel)
d.items.splice(d.items.indexOf(this), 1);
}
}
@@ -260,10 +324,14 @@ SwipeView {
}
BaseProxyPanel {
- id: leftPanelProxy
backgroundColor: Theme.palette.baseColor4
implicitIndex: 0
inView: !!root.leftPanel
+ target: SectionPanelSlot {
+ id: leftPanelProxy
+ anchors.fill: parent
+ frozen: root.panelsFrozen
+ }
}
BaseProxyPanel {
@@ -303,11 +371,12 @@ SwipeView {
}
// Central
- LayoutItemProxy {
+ SectionPanelSlot {
id: centerPanelProxy
Layout.fillWidth: true
Layout.fillHeight: true
Layout.row: 1
+ frozen: root.panelsFrozen
implicitHeight: centerPanel ? centerPanel.implicitHeight : 0
implicitWidth: centerPanel ? centerPanel.implicitWidth : 0
}
@@ -335,10 +404,11 @@ SwipeView {
BaseToolBar {
Layout.fillWidth: true
}
- LayoutItemProxy {
+ SectionPanelSlot {
id: rightPanelProxy
Layout.fillWidth: true
Layout.fillHeight: true
+ frozen: root.panelsFrozen
}
}
}
diff --git a/ui/StatusQ/src/StatusQ/Layout/qmldir b/ui/StatusQ/src/StatusQ/Layout/qmldir
index c33aa50f6d..4e46e7405c 100644
--- a/ui/StatusQ/src/StatusQ/Layout/qmldir
+++ b/ui/StatusQ/src/StatusQ/Layout/qmldir
@@ -5,3 +5,5 @@ StatusSectionLayoutLandscape 0.1 StatusSectionLayoutLandscape.qml
StatusSectionLayoutPortrait 0.1 StatusSectionLayoutPortrait.qml
LayoutChooser 0.1 LayoutChooser.qml
+internal SectionPanelSlot SectionPanelSlot.qml
+
diff --git a/ui/StatusQ/src/statusq.qrc b/ui/StatusQ/src/statusq.qrc
index 2c2f4b3dbc..be11c06255 100644
--- a/ui/StatusQ/src/statusq.qrc
+++ b/ui/StatusQ/src/statusq.qrc
@@ -231,6 +231,7 @@
StatusQ/Core/Utils/qmldir
StatusQ/Core/Utils/xss.js
StatusQ/Core/qmldir
+ StatusQ/Layout/SectionPanelSlot.qml
StatusQ/Layout/StatusSectionLayout.qml
StatusQ/Layout/LayoutChooser.qml
StatusQ/Layout/StatusSectionLayoutLandscape.qml
diff --git a/ui/app/AppLayouts/Chat/views/ChatView.qml b/ui/app/AppLayouts/Chat/views/ChatView.qml
index 4eaa5284d4..aaaaf715ee 100644
--- a/ui/app/AppLayouts/Chat/views/ChatView.qml
+++ b/ui/app/AppLayouts/Chat/views/ChatView.qml
@@ -260,10 +260,10 @@ Item {
// no visual parent. Bound it with the chrome geometry for that phase —
// otherwise the loader falls back to the chat list's implicit height
// (its full content height), which builds a delegate for every chat
- // only to discard them once the proxy applies the real size. The
- // anchors are inert until then and take over once the proxy parents it.
- width: Constants.chatSectionLeftColumnWidth
- height: root.sectionLayout?.height ?? 0
+ // only to discard them once the proxy applies the real size. Bound to
+ // the slot's box rather than guessed, the adoption writes nothing.
+ width: root.sectionLayout?.leftPanelSlotWidth ?? 0
+ height: root.sectionLayout?.leftPanelSlotHeight ?? 0
asynchronous: true
visible: contactColumnLoader.status === Loader.Ready
sourceComponent: root.rootStore.chatCommunitySectionModule.isCommunity()?
diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml
index 31dbfa9008..8a6344d300 100644
--- a/ui/app/AppLayouts/Wallet/WalletLayout.qml
+++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml
@@ -37,20 +37,27 @@ Item {
// injected here so the existing panel hook-ups keep their call sites.
property StatusSectionLayout sectionLayout
+ // Set by the loader: true once a skeleton is up and nothing is animating,
+ // i.e. when a synchronous panel build is safe.
+ property bool buildPanelsSync: false
+
// --- Back-navigation contract, forwarded to the loader-owned chrome.
// This Item wrapper would otherwise be a dead-end for AppMain's Link 2 on
// desktop. See AppMain.tryGoBack().
function tryGoBack() {
return root.sectionLayout?.tryGoBack() ?? false
}
+ readonly property bool leftPanelReady: leftPanelLoader.status === Loader.Ready
+ readonly property bool centerPanelReady: centerPanelLoader.status === Loader.Ready
+
readonly property bool canGoBack: root.sectionLayout?.canGoBack ?? false
// Consumed by the chrome in WalletLoader
readonly property string backButtonName: RootStore.backButtonName
function handleBackButtonClicked() {
- if (rightPanelStackView.currentItem && !!rightPanelStackView.currentItem.resetStack) {
- rightPanelStackView.currentItem.resetStack()
+ if (d.rightPanelStack?.currentItem && !!d.rightPanelStack.currentItem.resetStack) {
+ d.rightPanelStack.currentItem.resetStack()
}
}
@@ -182,7 +189,10 @@ Item {
}
d.resetRightPanelStackView()
- rightPanelStackView.currentItem.currentTabIndex = rightPanelSelection
+ if (d.rightPanelStack?.currentItem)
+ d.rightPanelStack.currentItem.currentTabIndex = rightPanelSelection
+ else
+ d.pendingTabIndex = rightPanelSelection
let savedAddress = data.savedAddress?? ""
if (!!savedAddress) {
@@ -198,14 +208,49 @@ Item {
QtObject {
id: d
+ // Only the panel the user is looking at is built when the section
+ // activates; the other follows once that one is up. Portrait shows one
+ // panel at a time so currentIndex names it; landscape shows both and
+ // the center panel is the one being read.
+ readonly property bool portrait: root.sectionLayout?.isPortrait ?? false
+ readonly property int primaryIndex: portrait ? (root.sectionLayout?.currentIndex ?? 1) : 1
+ property bool secondaryAllowed: false
+ readonly property bool leftWanted: primaryIndex === 0 || secondaryAllowed
+ readonly property bool centerWanted: primaryIndex === 1 || secondaryAllowed
+
+ // The center StackView is behind a Loader now, so a navigation request
+ // can arrive before it exists. Hold it and replay on load.
+ property Component pendingStackComponent: null
+ property int pendingTabIndex: -1
+
+ readonly property StackView rightPanelStack: centerPanelLoader.item as StackView
+
+ function replaceRightPanel(cmp) {
+ if (rightPanelStack)
+ rightPanelStack.replace(cmp)
+ else
+ pendingStackComponent = cmp
+ }
+
+ function flushPendingStackOps() {
+ if (pendingStackComponent) {
+ rightPanelStack.replace(pendingStackComponent)
+ pendingStackComponent = null
+ }
+ if (pendingTabIndex >= 0) {
+ rightPanelStack.currentItem.currentTabIndex = pendingTabIndex
+ pendingTabIndex = -1
+ }
+ }
+
readonly property bool showSavedAddresses: RootStore.showSavedAddresses
onShowSavedAddressesChanged: {
if(showSavedAddresses) {
- rightPanelStackView.replace(cmpSavedAddresses)
+ d.replaceRightPanel(cmpSavedAddresses)
RootStore.backButtonName = ""
} else if (!showFollowingAddresses) {
// Only replace with walletContainer if we're not showing following addresses
- rightPanelStackView.replace(walletContainer)
+ d.replaceRightPanel(walletContainer)
RootStore.backButtonName = ""
}
}
@@ -213,11 +258,11 @@ Item {
readonly property bool showFollowingAddresses: RootStore.showFollowingAddresses
onShowFollowingAddressesChanged: {
if(showFollowingAddresses) {
- rightPanelStackView.replace(cmpFollowingAddresses)
+ d.replaceRightPanel(cmpFollowingAddresses)
RootStore.backButtonName = ""
} else if (!showSavedAddresses) {
// Only replace with walletContainer if we're not showing saved addresses
- rightPanelStackView.replace(walletContainer)
+ d.replaceRightPanel(walletContainer)
RootStore.backButtonName = ""
}
}
@@ -254,8 +299,8 @@ Item {
}
function resetRightPanelStackView() {
- if (rightPanelStackView.currentItem && !!rightPanelStackView.currentItem.resetView) {
- rightPanelStackView.currentItem.resetView()
+ if (d.rightPanelStack?.currentItem && !!d.rightPanelStack.currentItem.resetView) {
+ d.rightPanelStack.currentItem.resetView()
}
}
@@ -362,52 +407,89 @@ Item {
// Panels are constructed and wired here, but presented by the loader-owned
// chrome (LayoutItemProxy targets); they have no visual parent until then.
- readonly property Item leftPanel: LeftTabView {
- id: leftTab
- anchors.fill: parent
- viewState: leftPanelState
+ readonly property Item leftPanel: Loader {
+ id: leftPanelLoader
+ // The panel incubates before the chrome adopts it, i.e. with no visual
+ // parent, and a Loader reports its item's implicit size as its own.
+ // Sized to the slot it is about to land in, nothing leaks upward and
+ // the adoption writes no geometry at all - the chrome takes the
+ // geometry over the moment it adopts it, so this cannot fight the
+ // layout in either orientation.
+ width: root.sectionLayout?.leftPanelSlotWidth ?? 0
+ height: root.sectionLayout?.leftPanelSlotHeight ?? 0
+ asynchronous: !(root.buildPanelsSync && d.primaryIndex === 0)
+ active: d.leftWanted
+ sourceComponent: leftPanelComponent
+ onLoaded: d.secondaryAllowed = true
+ }
- onAddAccountPopupRequested: root.walletRootStore.runAddAccountPopup()
- onAddWatchOnlyAccountPopupRequested: root.walletRootStore.runAddWatchOnlyAccountPopup()
- onEditAccountPopupRequested: address => root.walletRootStore.runEditAccountPopup(address)
- onWatchAccountHiddenFromTotalBalanceUpdated: (address, hideFromTotalBalance) =>
- root.walletRootStore.updateWatchAccountHiddenFromTotalBalance(address, hideFromTotalBalance)
- onAccountDeletionRequested: (address, password) =>
- root.walletRootStore.deleteAccount(address, password)
- onUserAuthenticationRequested: requestedBy =>
- root.walletRootStore.authenticateLoggedInUser(requestedBy)
+ Component {
+ id: leftPanelComponent
- onAccountSelected: address => {
- root.sectionLayout?.goToNextPanel()
- d.displayAddress(address)
+ LeftTabView {
+ id: leftTab
+ anchors.fill: parent
+ viewState: leftPanelState
+
+ onAddAccountPopupRequested: root.walletRootStore.runAddAccountPopup()
+ onAddWatchOnlyAccountPopupRequested: root.walletRootStore.runAddWatchOnlyAccountPopup()
+ onEditAccountPopupRequested: address => root.walletRootStore.runEditAccountPopup(address)
+ onWatchAccountHiddenFromTotalBalanceUpdated: (address, hideFromTotalBalance) =>
+ root.walletRootStore.updateWatchAccountHiddenFromTotalBalance(address, hideFromTotalBalance)
+ onAccountDeletionRequested: (address, password) =>
+ root.walletRootStore.deleteAccount(address, password)
+ onUserAuthenticationRequested: requestedBy =>
+ root.walletRootStore.authenticateLoggedInUser(requestedBy)
+
+ onAccountSelected: address => {
+ root.sectionLayout?.goToNextPanel()
+ d.displayAddress(address)
+ }
+ onAllAccountsSelected: {
+ root.sectionLayout?.goToNextPanel()
+ d.displayAllAddresses()
+ }
+ onSavedAddressesSelected: {
+ root.sectionLayout?.goToNextPanel()
+ d.displaySavedAddresses()
+ }
+ onFollowingAddressesSelected: {
+ root.sectionLayout?.goToNextPanel()
+ d.displayFollowingAddresses()
+ }
}
- onAllAccountsSelected: {
- root.sectionLayout?.goToNextPanel()
- d.displayAllAddresses()
- }
- onSavedAddressesSelected: {
- root.sectionLayout?.goToNextPanel()
- d.displaySavedAddresses()
- }
- onFollowingAddressesSelected: {
- root.sectionLayout?.goToNextPanel()
- d.displayFollowingAddresses()
- }
- }
+ }
- readonly property Item centerPanel: StackView {
- id: rightPanelStackView
- anchors.fill: parent
- anchors.leftMargin: Theme.xlPadding * 2
- anchors.rightMargin: Theme.xlPadding * 2
- initialItem: walletContainer
- replaceEnter: Transition {
- NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 400; easing.type: Easing.OutCubic }
+ readonly property Item centerPanel: Loader {
+ id: centerPanelLoader
+ // The panel incubates before the chrome adopts it, i.e. with no visual
+ // parent, and a Loader reports its item's implicit size as its own.
+ // Sized to the slot it is about to land in, nothing leaks upward and
+ // the adoption writes no geometry at all - the chrome takes the
+ // geometry over the moment it adopts it, so this cannot fight the
+ // layout in either orientation.
+ width: root.sectionLayout?.centerPanelSlotWidth ?? 0
+ height: root.sectionLayout?.centerPanelSlotHeight ?? 0
+ asynchronous: !(root.buildPanelsSync && d.primaryIndex === 1)
+ active: d.centerWanted
+ sourceComponent: centerPanelComponent
+ onLoaded: { d.secondaryAllowed = true; d.flushPendingStackOps() }
+ }
+
+ Component {
+ id: centerPanelComponent
+
+ StackView {
+ id: rightPanelStackView
+ initialItem: walletContainer
+ replaceEnter: Transition {
+ NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 400; easing.type: Easing.OutCubic }
+ }
+ replaceExit: Transition {
+ NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 400; easing.type: Easing.OutCubic }
+ }
}
- replaceExit: Transition {
- NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 400; easing.type: Easing.OutCubic }
- }
- }
+ }
readonly property Item headerBackground: AccountHeaderGradient {
width: parent ? parent.width : 0
overview: RootStore.overview
diff --git a/ui/app/AppLayouts/Wallet/controls/WalletAccountDelegateShell.qml b/ui/app/AppLayouts/Wallet/controls/WalletAccountDelegateShell.qml
new file mode 100644
index 0000000000..24bdc46446
--- /dev/null
+++ b/ui/app/AppLayouts/Wallet/controls/WalletAccountDelegateShell.qml
@@ -0,0 +1,89 @@
+import QtQuick
+
+import StatusQ.Core.Theme
+
+/*!
+ \qmltype WalletAccountDelegateShell
+ \inherits Item
+ \inqmlmodule AppLayouts.Wallet.controls
+ \brief Cheap stand-in for an account row, with the rich row behind an async Loader.
+
+ A \c ListView refill is a single uninterruptible call inside the window's
+ polish phase, and visible delegates are created with \c AsynchronousIfNested —
+ so a list laid out after its enclosing Loader is already Ready builds every
+ visible row synchronously. This shell is what the refill builds instead: row
+ geometry plus placeholder tiles, with \c sourceComponent incubated afterwards
+ in metered bites. Same shape as \c TokenDelegateShell.
+
+ \qml
+ delegate: WalletAccountDelegateShell {
+ width: ListView.view.width
+ sourceComponent: StatusListItem { width: parent.width }
+ }
+ \endqml
+*/
+Item {
+ id: root
+
+ //! The rich row. Always incubated; never built by the list's refill.
+ property alias sourceComponent: contentLoader.sourceComponent
+
+ readonly property alias contentItem: contentLoader.item
+ readonly property bool contentReady: contentLoader.status === Loader.Ready
+
+ /*!
+ Row height before the content exists. 64 is StatusListItem's floor for a
+ title + subtitle row, which is what an account row resolves to; the two
+ staying equal is what keeps contentHeight and the scroll position still
+ while rows fill in, and tst_WalletAccountDelegateShell guards it.
+ */
+ readonly property int placeholderHeight: 64
+
+ implicitHeight: !!contentLoader.item ? contentLoader.item.implicitHeight
+ : root.placeholderHeight
+ height: implicitHeight
+
+ Loader {
+ id: contentLoader
+
+ asynchronous: true
+ width: root.width
+ }
+
+ // Tiles in the shape of the row, mirroring WalletAccountsSkeleton: the list
+ // fills over the whole incubated phase, and a blank row for that long reads
+ // as breakage rather than as loading.
+ Item {
+ id: placeholder
+
+ anchors.fill: parent
+ visible: !root.contentReady
+
+ readonly property color tileColor: Theme.palette.statusLoadingHighlight
+
+ Rectangle {
+ x: Theme.padding
+ anchors.verticalCenter: parent.verticalCenter
+ width: 40
+ height: 40
+ radius: width / 2
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: Theme.padding + 40 + Theme.padding
+ y: parent.height / 2 - 15
+ width: 120
+ height: 14
+ radius: 4
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: Theme.padding + 40 + Theme.padding
+ y: parent.height / 2 + 3
+ width: 80
+ height: 12
+ radius: 4
+ color: placeholder.tileColor
+ }
+ }
+}
diff --git a/ui/app/AppLayouts/Wallet/controls/qmldir b/ui/app/AppLayouts/Wallet/controls/qmldir
index ef017936ba..ceea467a30 100644
--- a/ui/app/AppLayouts/Wallet/controls/qmldir
+++ b/ui/app/AppLayouts/Wallet/controls/qmldir
@@ -31,6 +31,7 @@ TokenIconWithNetworkBadge 1.0 TokenIconWithNetworkBadge.qml
TokenSelector 1.0 TokenSelector.qml
TokenSelectorButton 1.0 TokenSelectorButton.qml
TokenSelectorCompactButton 1.0 TokenSelectorCompactButton.qml
+WalletAccountDelegateShell 1.0 WalletAccountDelegateShell.qml
RecipientViewDelegate 1.0 RecipientViewDelegate.qml
SendRecipientInput 1.0 SendRecipientInput.qml
RouterErrorTag 1.0 RouterErrorTag.qml
diff --git a/ui/app/AppLayouts/Wallet/views/AssetsDetailView.qml b/ui/app/AppLayouts/Wallet/views/AssetsDetailView.qml
index 8564aca006..ef4a3a0e5c 100644
--- a/ui/app/AppLayouts/Wallet/views/AssetsDetailView.qml
+++ b/ui/app/AppLayouts/Wallet/views/AssetsDetailView.qml
@@ -151,10 +151,8 @@ Item {
formatBalance: function(balance){
return LocaleUtils.currencyAmountToLocaleString(root.currencyStore.getCurrencyAmount(balance, tokenGroup.key))
}
- communityTag.visible: d.isCommunityAsset
- communityTag.tagPrimaryLabel.text: d.isCommunityAsset ? tokenGroup.communityName: ""
- communityTag.asset.name: d.isCommunityAsset ? tokenGroup && !!tokenGroup.communityImage ? tokenGroup.communityImage : "" : ""
- communityTag.asset.isImage: true
+ communityName: d.isCommunityAsset && tokenGroup.communityName ? tokenGroup.communityName : ""
+ communityImage: d.isCommunityAsset && tokenGroup.communityImage ? tokenGroup.communityImage : ""
}
enum GraphType {
@@ -470,54 +468,61 @@ Item {
rowSpacing: 10
flow: detailsFlow.isOverflowing && detailsFlow.width > 400 ? GridLayout.LeftToRight: GridLayout.TopToBottom
- InformationTileAssetDetails {
- id: websiteBlock
-
+ // Latched, not hidden: a community asset has no website block
+ // and a plain asset has no minted-by block, so one of the two
+ // was always built and never shown.
+ Loader {
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: detailsFlow.isOverflowing ? -1 : detailsFlow.rightSideWidth
- visible: !d.isCommunityAsset && tokenGroup.websiteUrl
- primaryText: qsTr("Website")
- content: InformationTag {
- asset.name : "browser"
- tagPrimaryLabel.text: SQUtils.Utils.stripHttpsAndwwwFromUrl(tokenGroup.websiteUrl)
- visible: typeof tokenGroup != "undefined" && tokenGroup && tokenGroup.websiteUrl !== ""
- customBackground: Component {
- Rectangle {
- color: Theme.palette.baseColor2
- border.width: 1
- border.color: "transparent"
- radius: 36
+ active: !d.isCommunityAsset && !!tokenGroup.websiteUrl
+ visible: active
+ sourceComponent: InformationTileAssetDetails {
+ primaryText: qsTr("Website")
+ content: InformationTag {
+ asset.name : "browser"
+ tagPrimaryLabel.text: SQUtils.Utils.stripHttpsAndwwwFromUrl(tokenGroup.websiteUrl)
+ visible: typeof tokenGroup != "undefined" && tokenGroup && tokenGroup.websiteUrl !== ""
+ customBackground: Component {
+ Rectangle {
+ color: Theme.palette.baseColor2
+ border.width: 1
+ border.color: "transparent"
+ radius: 36
+ }
+ }
+ StatusMouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Global.requestOpenLink(tokenGroup.websiteUrl)
}
- }
- StatusMouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Global.requestOpenLink(tokenGroup.websiteUrl)
}
}
}
- InformationTileAssetDetails {
+ Loader {
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: detailsFlow.isOverflowing ? -1 : detailsFlow.rightSideWidth
- visible: d.isCommunityAsset
- primaryText: qsTr("Minted by")
- content: InformationTag {
- tagPrimaryLabel.text: tokenGroup && tokenGroup.communityName ? tokenGroup.communityName : ""
- asset.name: tokenGroup && tokenGroup.communityImage ? tokenGroup.communityImage : ""
- asset.isImage: true
- customBackground: Component {
- Rectangle {
- color: Theme.palette.baseColor2
- border.width: 1
- border.color: "transparent"
- radius: 36
+ active: d.isCommunityAsset
+ visible: active
+ sourceComponent: InformationTileAssetDetails {
+ primaryText: qsTr("Minted by")
+ content: InformationTag {
+ tagPrimaryLabel.text: tokenGroup && tokenGroup.communityName ? tokenGroup.communityName : ""
+ asset.name: tokenGroup && tokenGroup.communityImage ? tokenGroup.communityImage : ""
+ asset.isImage: true
+ customBackground: Component {
+ Rectangle {
+ color: Theme.palette.baseColor2
+ border.width: 1
+ border.color: "transparent"
+ radius: 36
+ }
+ }
+ StatusMouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: Global.switchToCommunity(tokenGroup.communityId)
}
- }
- StatusMouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: Global.switchToCommunity(tokenGroup.communityId)
}
}
}
diff --git a/ui/app/AppLayouts/Wallet/views/LeftTabView.qml b/ui/app/AppLayouts/Wallet/views/LeftTabView.qml
index 279f2d89b9..9c14e01ecc 100644
--- a/ui/app/AppLayouts/Wallet/views/LeftTabView.qml
+++ b/ui/app/AppLayouts/Wallet/views/LeftTabView.qml
@@ -267,6 +267,7 @@ Rectangle {
Loader {
id: walletAccountsListViewLoader
+ objectName: "walletAccountsListLoader"
asynchronous: true
visible: status === Loader.Ready
anchors {
@@ -291,55 +292,71 @@ Rectangle {
readonly property Item firstItem: count > 0 ? itemAtIndex(0) : null
readonly property bool footerOverlayed: d.loaded && contentHeight > availableHeight
- delegate: StatusListItem {
- objectName: "walletAccountListItem"
- readonly property bool itemLoaded: !model.assetsLoading // needed for e2e tests
+ delegate: WalletAccountDelegateShell {
+ id: rowShell
+
+ objectName: "walletAccountRowShell"
width: ListView.view.width - Theme.padding * 2
- highlighted: viewState.selectedAddress.toLowerCase() === model.address.toLowerCase()
- onHighlightedChanged: {
- if (highlighted)
+ anchors.horizontalCenter: !!parent ? parent.horizontalCenter : undefined
+
+ // Selection lives on the shell so the list still tracks the
+ // current row while the content is incubating.
+ readonly property bool selected:
+ viewState.selectedAddress.toLowerCase() === model.address.toLowerCase()
+ onSelectedChanged: {
+ if (selected)
ListView.view.currentIndex = index
}
- anchors.horizontalCenter: !!parent ? parent.horizontalCenter : undefined
- title: model.name
- subTitle: !model.hideFromTotalBalance ? LocaleUtils.currencyAmountToLocaleString(model.currencyBalance): ""
- asset.emoji: !!model.emoji ? model.emoji: ""
- asset.color: Utils.getColorForId(Theme.palette, model.colorId)
- asset.name: !model.emoji ? "filled-account": ""
- asset.width: 40
- asset.height: 40
- asset.letterSize: 14
- asset.isLetterIdenticon: !!model.emoji ? true : false
- asset.bgColor: Theme.palette.primaryColor3
- statusListItemTitle.font.weight: Font.Medium
- color: sensor.containsMouse || highlighted ? Theme.palette.baseColor3 : "transparent"
- statusListItemSubTitle.loading: !!model.assetsLoading
- errorMode: viewState.accountBalanceNotAvailable
- errorIcon.tooltip.maxWidth: 300
- errorIcon.tooltip.text: viewState.accountBalanceNotAvailableText
- onClicked: function(itemId, mouse) {
- if (mouse.button === Qt.RightButton) {
- walletAccountContextMenu.active = true
- walletAccountContextMenu.item.account = model
- walletAccountContextMenu.item.popup(this, mouse.x, mouse.y)
- return
- }
- root.accountSelected(model.address)
+ Component.onCompleted: {
+ if (selected)
+ ListView.view.currentIndex = index
}
- components: [
- StatusIcon {
- width: !!icon ? 15: 0
- height: !!icon ? 15: 0
- color: Theme.palette.directColor1
- icon: model.walletType === Constants.watchWalletType ? "show" : ""
- },
- StatusIcon {
- width: !!icon ? 15: 0
- height: !!icon ? 15: 0
- color: Theme.palette.directColor1
- icon: model.migratedToColdWallet ? "keycard" : ""
+
+ sourceComponent: StatusListItem {
+ objectName: "walletAccountListItem"
+ readonly property bool itemLoaded: !model.assetsLoading // needed for e2e tests
+ width: rowShell.width
+ highlighted: rowShell.selected
+ title: model.name
+ subTitle: !model.hideFromTotalBalance ? LocaleUtils.currencyAmountToLocaleString(model.currencyBalance): ""
+ asset.emoji: !!model.emoji ? model.emoji: ""
+ asset.color: Utils.getColorForId(Theme.palette, model.colorId)
+ asset.name: !model.emoji ? "filled-account": ""
+ asset.width: 40
+ asset.height: 40
+ asset.letterSize: 14
+ asset.isLetterIdenticon: !!model.emoji ? true : false
+ asset.bgColor: Theme.palette.primaryColor3
+ statusListItemTitle.font.weight: Font.Medium
+ color: sensor.containsMouse || highlighted ? Theme.palette.baseColor3 : "transparent"
+ statusListItemSubTitle.loading: !!model.assetsLoading
+ errorMode: viewState.accountBalanceNotAvailable
+ errorIcon.tooltip.maxWidth: 300
+ errorIcon.tooltip.text: viewState.accountBalanceNotAvailableText
+ onClicked: function(itemId, mouse) {
+ if (mouse.button === Qt.RightButton) {
+ walletAccountContextMenu.active = true
+ walletAccountContextMenu.item.account = model
+ walletAccountContextMenu.item.popup(this, mouse.x, mouse.y)
+ return
+ }
+ root.accountSelected(model.address)
}
- ]
+ components: [
+ StatusIcon {
+ width: !!icon ? 15: 0
+ height: !!icon ? 15: 0
+ color: Theme.palette.directColor1
+ icon: model.walletType === Constants.watchWalletType ? "show" : ""
+ },
+ StatusIcon {
+ width: !!icon ? 15: 0
+ height: !!icon ? 15: 0
+ color: Theme.palette.directColor1
+ icon: model.migratedToColdWallet ? "keycard" : ""
+ }
+ ]
+ }
}
header: StatusFlatButton {
@@ -478,7 +495,7 @@ Rectangle {
isRoundIcon: true
textColor: Theme.palette.directColor1
textFillWidth: true
- spacing: d.accountsListView?.firstItem?.statusListItemTitleArea.anchors.leftMargin ?? Theme.padding
+ spacing: d.accountsListView?.firstItem?.contentItem?.statusListItemTitleArea?.anchors.leftMargin ?? Theme.padding
onClicked: root.savedAddressesSelected()
}
}
@@ -509,7 +526,7 @@ Rectangle {
isRoundIcon: true
textColor: Theme.palette.directColor1
textFillWidth: true
- spacing: d.accountsListView?.firstItem?.statusListItemTitleArea.anchors.leftMargin ?? Theme.padding
+ spacing: d.accountsListView?.firstItem?.contentItem?.statusListItemTitleArea?.anchors.leftMargin ?? Theme.padding
onClicked: root.followingAddressesSelected()
}
}
diff --git a/ui/app/AppLayouts/Wallet/views/RightTabBaseView.qml b/ui/app/AppLayouts/Wallet/views/RightTabBaseView.qml
index 227c52ad8f..8a1be8c7c4 100644
--- a/ui/app/AppLayouts/Wallet/views/RightTabBaseView.qml
+++ b/ui/app/AppLayouts/Wallet/views/RightTabBaseView.qml
@@ -20,6 +20,8 @@ FocusScope {
visible: !!target
Layout.fillWidth: true
+ Layout.leftMargin: Theme.bigPadding
+ Layout.rightMargin: Theme.bigPadding
}
LayoutItemProxy {
@@ -29,6 +31,8 @@ FocusScope {
Layout.fillWidth: true
Layout.fillHeight: true
+ Layout.leftMargin: Theme.bigPadding
+ Layout.rightMargin: Theme.bigPadding
}
}
}
diff --git a/ui/app/AppLayouts/Wallet/views/RightTabView.qml b/ui/app/AppLayouts/Wallet/views/RightTabView.qml
index f7ad6d0332..8c38712373 100644
--- a/ui/app/AppLayouts/Wallet/views/RightTabView.qml
+++ b/ui/app/AppLayouts/Wallet/views/RightTabView.qml
@@ -195,6 +195,11 @@ RightTabBaseView {
]
readonly property var detailedCollectibleActivityController: RootStore.tmpActivityController0
+
+ // Held here rather than assigned into the view: the detail views are
+ // deferred, so the token to show is picked before anything to show it
+ // in exists.
+ property var assetDetailTokenGroup: null
}
Settings {
@@ -440,6 +445,7 @@ RightTabBaseView {
Loader {
id: mainViewLoader
+ objectName: "walletMainViewLoader"
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: Theme.padding
@@ -570,7 +576,7 @@ RightTabBaseView {
const tokenGroup = SQUtils.ModelUtils.getByKey(RootStore.walletAssetsStore.groupedAccountAssetsModel, "key", key)
const listAsset = SQUtils.ModelUtils.getByKey(RootStore.walletAssetsStore.assetsModel, "key", key)
- assetDetailView.tokenGroup = listAsset ? Object.assign({}, tokenGroup, {
+ d.assetDetailTokenGroup = listAsset ? Object.assign({}, tokenGroup, {
balance: listAsset.balance,
balanceLoading: listAsset.balanceLoading,
marketPrice: listAsset.marketPrice,
@@ -684,44 +690,62 @@ RightTabBaseView {
}
}
}
- CollectibleDetailView {
- id: collectibleDetailView
+ // Both detail views are built on navigation into their stack index and
+ // unloaded on the way out, so a section load never pays for a view the
+ // user has not opened. The reset that used to hang off `visible` now
+ // hangs off `active`: an unloaded view never sees a visibility change.
+ Loader {
+ id: collectibleDetailLoader
+ objectName: "collectibleDetailLoader"
- visible : (stack.currentIndex === 1)
+ asynchronous: true
+ active: stack.currentIndex === 1
- collectible: RootStore.collectiblesStore.detailedCollectible
- isCollectibleLoading: RootStore.collectiblesStore.isDetailedCollectibleLoading
- activityModel: d.detailedCollectibleActivityController.model
- addressFilters: RootStore.addressFilters
- rootStore: root.sharedRootStore
- walletRootStore: RootStore
- communitiesStore: root.communitiesStore
- networksStore: root.networksStore
-
- onVisibleChanged: {
- if (!visible) {
+ onActiveChanged: {
+ if (!active) {
RootStore.resetCurrentViewedHolding(Constants.TokenType.ERC721)
RootStore.collectiblesStore.resetDetailedCollectible()
}
}
+
+ sourceComponent: CollectibleDetailView {
+ objectName: "collectibleDetailView"
+
+ collectible: RootStore.collectiblesStore.detailedCollectible
+ isCollectibleLoading: RootStore.collectiblesStore.isDetailedCollectibleLoading
+ activityModel: d.detailedCollectibleActivityController.model
+ addressFilters: RootStore.addressFilters
+ rootStore: root.sharedRootStore
+ walletRootStore: RootStore
+ communitiesStore: root.communitiesStore
+ networksStore: root.networksStore
+ }
}
- AssetsDetailView {
- id: assetDetailView
+ Loader {
+ id: assetDetailLoader
+ objectName: "assetDetailLoader"
- visible: (stack.currentIndex === 2)
+ asynchronous: true
+ active: stack.currentIndex === 2
- tokensStore: RootStore.tokensStore
- allNetworksModel: root.networksStore.activeNetworks
- address: RootStore.overview.mixedcaseAddress
- currencyStore: RootStore.currencyStore
- networkFilters: root.networksStore.networkFilters
-
- networkConnectionStore: root.networkConnectionStore
-
- onVisibleChanged: {
- if (!visible)
+ onActiveChanged: {
+ if (!active)
RootStore.resetCurrentViewedHolding(Constants.TokenType.ERC20)
}
+
+ sourceComponent: AssetsDetailView {
+ objectName: "assetDetailView"
+
+ tokenGroup: d.assetDetailTokenGroup ?? ({})
+
+ tokensStore: RootStore.tokensStore
+ allNetworksModel: root.networksStore.activeNetworks
+ address: RootStore.overview.mixedcaseAddress
+ currencyStore: RootStore.currencyStore
+ networkFilters: root.networksStore.networkFilters
+
+ networkConnectionStore: root.networkConnectionStore
+ }
}
}
}
diff --git a/ui/app/mainui/sectionLoaders/WalletLoader.qml b/ui/app/mainui/sectionLoaders/WalletLoader.qml
index 4ef2afa1cc..b5eed39353 100644
--- a/ui/app/mainui/sectionLoaders/WalletLoader.qml
+++ b/ui/app/mainui/sectionLoaders/WalletLoader.qml
@@ -64,6 +64,15 @@ Loader {
asynchronous: true
+ // Once a skeleton is on screen and no panel switch is animating, building
+ // the panel the user is waiting for synchronously beats incubating it. The
+ // work is the same either way, but the incubation controller's gentle
+ // pacing - a 2ms bite every 4ms - spreads it across several hundred ms, and
+ // the only thing a block would stutter is the skeleton it replaces.
+ readonly property bool panelsMayBuildSync:
+ (accountsSkeleton.status === Loader.Ready || centerSkeleton.status === Loader.Ready)
+ && !d.panelSwitchOngoing
+
// The section chrome is owned by the loader: it shows instantly with
// skeleton panels and swaps in the real panels produced by WalletLayout
// (LayoutItemProxy retarget) once the section finishes incubating.
@@ -95,13 +104,13 @@ Loader {
// chrome's panel-switch animation, or the swap frame stutters it.
PanelSwapGate {
id: leftPanelGate
- ready: !!(root.item?.leftPanel ?? null)
+ ready: root.item?.leftPanelReady ?? false
switchOngoing: d.panelSwitchOngoing
}
PanelSwapGate {
id: centerPanelGate
- ready: !!(root.item?.centerPanel ?? null)
+ ready: root.item?.centerPanelReady ?? false
switchOngoing: d.panelSwitchOngoing
}
@@ -200,6 +209,7 @@ Loader {
? root.dappsServiceLoader.item.dappsModel
: null),
isKeycardEnabled: Qt.binding(() => root.featureFlagsStore.keycardEnabled),
+ buildPanelsSync: Qt.binding(() => root.panelsMayBuildSync),
})
}
diff --git a/ui/imports/shared/controls/AssetsDetailsHeader.qml b/ui/imports/shared/controls/AssetsDetailsHeader.qml
index 91c5357909..e65098c20e 100644
--- a/ui/imports/shared/controls/AssetsDetailsHeader.qml
+++ b/ui/imports/shared/controls/AssetsDetailsHeader.qml
@@ -21,7 +21,8 @@ Control {
property alias primaryText: tokenName.text
property alias secondaryText: cryptoBalance.text
property alias tertiaryText: fiatBalance.text
- property alias communityTag: communityTag
+ property string communityName
+ property string communityImage
property var balances
property int decimals
property var networksModel
@@ -36,6 +37,22 @@ Control {
topPadding: Theme.padding
+ // One Component shared by every chain tag: the button used to be built for
+ // each of them and hidden, and it carries a StatusToolTip.
+ Component {
+ id: chainErrorButton
+
+ StatusFlatRoundButton {
+ width: 14
+ height: 14
+ icon.width: 14
+ icon.height: 14
+ icon.name: "tiny/warning"
+ icon.color: Theme.palette.dangerColor1
+ tooltip.text: root.errorTooltipText
+ }
+ }
+
contentItem: ColumnLayout {
id: mainLayout
spacing: 4
@@ -106,14 +123,23 @@ Control {
id: communityAndBalances
Layout.fillWidth: true
spacing: Theme.halfPadding
- InformationTag {
- id: communityTag
+ Loader {
+ active: !!root.communityName || !!root.communityImage
+ visible: active
+ sourceComponent: InformationTag {
+ objectName: "assetsDetailsHeaderCommunityTag"
+ tagPrimaryLabel.text: root.communityName
+ asset.name: root.communityImage
+ asset.isImage: true
+ }
}
Repeater {
id: chainRepeater
Layout.alignment: Qt.AlignRight
model: root.networksModel
delegate: InformationTag {
+ objectName: "assetsDetailsHeaderChainTag_" + model.chainId
+
readonly property double aggregatedbalance: balancesAggregator.value/(10 ** root.decimals)
SortFilterProxyModel {
id: filteredBalances
@@ -141,16 +167,7 @@ Control {
asset.isImage: true
loading: root.isLoading
visible: balancesAggregator.value > 0
- rightComponent: StatusFlatRoundButton {
- width: visible ? 14 : 0
- height: visible ? 14 : 0
- icon.width: 14
- icon.height: 14
- icon.name: "tiny/warning"
- icon.color: Theme.palette.dangerColor1
- tooltip.text: root.errorTooltipText
- visible: !!root.errorTooltipText
- }
+ rightComponent: !!root.errorTooltipText ? chainErrorButton : null
}
}
}
diff --git a/ui/imports/shared/controls/TokenDelegate.qml b/ui/imports/shared/controls/TokenDelegate.qml
index f84ac47fc0..8bf00db00c 100644
--- a/ui/imports/shared/controls/TokenDelegate.qml
+++ b/ui/imports/shared/controls/TokenDelegate.qml
@@ -38,6 +38,10 @@ StatusListItem {
readonly property bool isCommunityToken: !!root.communityId
+ readonly property bool chainsErrorVisible: !!root.errorTooltipText_1
+ readonly property bool marketDataErrorVisible: root.marketDetailsAvailable
+ && !!root.errorTooltipText_2
+
readonly property string textColor: {
if (!root.marketDetailsAvailable)
return root.Theme.palette.successColor1
@@ -55,38 +59,43 @@ StatusListItem {
errorIcon.tooltip.maxWidth: 300
height: implicitHeight
+ // Both warning buttons are latched off rather than merely hidden: a button
+ // and its tooltip are ~40 QObjects each, and the rows that carry an error
+ // are the exception.
+ statusListItemTitleIcons.active: d.chainsErrorVisible
statusListItemTitleIcons.sourceComponent: StatusFlatRoundButton {
width: 14
- height: visible ? 14 : 0
+ height: 14
icon.width: 14
icon.height: 14
icon.name: "tiny/warning"
icon.color: Theme.palette.dangerColor1
tooltip.text: root.errorTooltipText_1
tooltip.maxWidth: 300
- visible: !!tooltip.text
}
components: [
Column {
anchors.verticalCenter: parent.verticalCenter
- StatusFlatRoundButton {
- id: errorIcon
- width: 14
- height: visible ? 14 : 0
- icon.width: 14
- icon.height: 14
- icon.name: "tiny/warning"
- icon.color: Theme.palette.dangerColor1
- tooltip.text: root.errorTooltipText_2
- tooltip.maxWidth: 200
- visible: root.marketDetailsAvailable && !!tooltip.text
+ Loader {
+ active: d.marketDataErrorVisible
+
+ sourceComponent: StatusFlatRoundButton {
+ width: 14
+ height: 14
+ icon.width: 14
+ icon.height: 14
+ icon.name: "tiny/warning"
+ icon.color: Theme.palette.dangerColor1
+ tooltip.text: root.errorTooltipText_2
+ tooltip.maxWidth: 200
+ }
}
StatusTextWithLoadingState {
id: currencyBalance
anchors.right: parent.right
- visible: !errorIcon.visible && root.marketDetailsAvailable
+ visible: !d.marketDataErrorVisible && root.marketDetailsAvailable
loading: root.marketDetailsLoading || root.balanceLoading
text: loading ? Constants.dummyText : root.marketBalance
@@ -94,7 +103,7 @@ StatusListItem {
Row {
anchors.right: parent.right
spacing: 6
- visible: !errorIcon.visible && root.marketDetailsAvailable
+ visible: !d.marketDataErrorVisible && root.marketDetailsAvailable
StatusTextWithLoadingState {
id: change24HourPercentageText
diff --git a/ui/imports/shared/controls/TokenDelegateShell.qml b/ui/imports/shared/controls/TokenDelegateShell.qml
new file mode 100644
index 0000000000..076627ab9a
--- /dev/null
+++ b/ui/imports/shared/controls/TokenDelegateShell.qml
@@ -0,0 +1,105 @@
+import QtQuick
+
+import StatusQ.Core.Theme
+
+/*!
+ \qmltype TokenDelegateShell
+ \inherits Item
+ \inqmlmodule shared.controls
+ \brief Cheap stand-in for a token row, with the rich row behind an async Loader.
+
+ A \c ListView refill is a single uninterruptible call inside the window's
+ polish phase, and visible delegates are created with \c AsynchronousIfNested —
+ so a list laid out after its enclosing Loader is already Ready builds every
+ visible row synchronously. This shell is what the refill builds instead: row
+ geometry plus placeholder tiles, with \c sourceComponent incubated afterwards
+ in metered bites.
+
+ \qml
+ delegate: TokenDelegateShell {
+ width: ListView.view.width
+ sourceComponent: TokenDelegate { width: parent.width }
+ }
+ \endqml
+*/
+Item {
+ id: root
+
+ //! The rich row. Always incubated; never built by the list's refill.
+ property alias sourceComponent: contentLoader.sourceComponent
+
+ readonly property alias contentItem: contentLoader.item
+ readonly property bool contentReady: contentLoader.status === Loader.Ready
+
+ /*!
+ Row height before the content exists. 64 is StatusListItem's floor for a
+ title + subtitle row, which is what TokenDelegate resolves to; the two
+ staying equal is what keeps contentHeight and the scroll position still
+ while rows fill in, and tst_TokenDelegateShell guards it.
+ */
+ readonly property int placeholderHeight: 64
+
+ implicitHeight: !!contentLoader.item ? contentLoader.item.implicitHeight
+ : root.placeholderHeight
+ height: implicitHeight
+
+ Loader {
+ id: contentLoader
+
+ asynchronous: true
+ width: root.width
+ }
+
+ // Tiles in the shape of the row, mirroring WalletAssetListSkeleton: the list
+ // fills over the whole incubated phase, and a blank row for that long reads
+ // as breakage rather than as loading.
+ Item {
+ id: placeholder
+
+ anchors.fill: parent
+ visible: !root.contentReady
+
+ readonly property color tileColor: Theme.palette.statusLoadingHighlight
+
+ Rectangle {
+ x: Theme.padding
+ anchors.verticalCenter: parent.verticalCenter
+ width: 32
+ height: 32
+ radius: width / 2
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: Theme.padding + 32 + Theme.padding
+ y: parent.height / 2 - 16
+ width: 90
+ height: 15
+ radius: 4
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: Theme.padding + 32 + Theme.padding
+ y: parent.height / 2 + 5
+ width: 130
+ height: 12
+ radius: 4
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: parent.width - Theme.padding - width
+ y: parent.height / 2 - 16
+ width: 100
+ height: 15
+ radius: 4
+ color: placeholder.tileColor
+ }
+ Rectangle {
+ x: parent.width - Theme.padding - width
+ y: parent.height / 2 + 5
+ width: 130
+ height: 12
+ radius: 4
+ color: placeholder.tileColor
+ }
+ }
+}
diff --git a/ui/imports/shared/controls/qmldir b/ui/imports/shared/controls/qmldir
index 25330e8433..bb423fc6ec 100644
--- a/ui/imports/shared/controls/qmldir
+++ b/ui/imports/shared/controls/qmldir
@@ -44,6 +44,7 @@ StyledTextEdit 1.0 StyledTextEdit.qml
StyledTextEditWithLoadingState 1.0 StyledTextEditWithLoadingState.qml
Timer 1.0 Timer.qml
TokenDelegate 1.0 TokenDelegate.qml
+TokenDelegateShell 1.0 TokenDelegateShell.qml
TransactionDelegate 1.0 TransactionDelegate.qml
TransactionDetailsHeader.qml 1.0 TransactionDetailsHeader.qml
WalletAccountListItem 1.0 WalletAccountListItem.qml
diff --git a/ui/imports/shared/views/AssetsView.qml b/ui/imports/shared/views/AssetsView.qml
index f677d9e734..c78eebb632 100644
--- a/ui/imports/shared/views/AssetsView.qml
+++ b/ui/imports/shared/views/AssetsView.qml
@@ -287,46 +287,57 @@ Control {
model: root.model ?? null
- delegate: TokenDelegate {
- objectName: `AssetView_TokenListItem_${model.symbol}` // TODO: use model.key
+ // Only the shell is built by the list's refill, which is one
+ // uninterruptible polish pass; the row itself incubates behind the
+ // shell's async Loader in metered bites.
+ delegate: TokenDelegateShell {
+ id: rowShell
+
+ objectName: `AssetView_TokenRowShell_${model.symbol}`
width: ListView.view.width
- // chainIds arrives as a comma-separated string from the terminal model
- readonly property var chainIdsList: {
- const ids = model.chainIds
- return (ids && ids.length) ? ids.split(",").map(Number) : []
+ sourceComponent: TokenDelegate {
+ objectName: `AssetView_TokenListItem_${model.symbol}` // TODO: use model.key
+
+ width: rowShell.width
+
+ // chainIds arrives as a comma-separated string from the terminal model
+ readonly property var chainIdsList: {
+ const ids = model.chainIds
+ return (ids && ids.length) ? ids.split(",").map(Number) : []
+ }
+
+ name: model.name
+ icon: model.logoUri || Constants.tokenIcon(model.symbol, false)
+ balance: root.formatBalance(model.balance, model.key)
+ balanceLoading: model.balanceLoading
+ marketBalance: root.formatFiat(model.marketBalance)
+
+ marketDetailsAvailable: model.marketDetailsAvailable
+ marketDetailsLoading: model.marketDetailsLoading
+ marketCurrencyPrice: root.formatFiat(model.change1DayFiat)
+ marketChangePct24hour: model.marketChangePct24hour
+
+ communityId: model.communityId
+ communityName: model.communityName ?? ""
+ communityIcon: model.communityImage ?? ""
+
+ errorTooltipText_1: root.chainsError(chainIdsList)
+ errorTooltipText_2: root.marketDataError
+
+ errorMode: !!root.balanceError
+ errorIcon.tooltip.text: root.balanceError
+
+ onClicked: function (itemId, mouse) {
+ if (mouse.button === Qt.LeftButton)
+ root.assetClicked(model.key)
+ else if (mouse.button === Qt.RightButton)
+ tokenContextMenu.createObject(this, { model }).popup(mouse.x, mouse.y)
+ }
+
+ onCommunityClicked: (communityId) => root.communityClicked(model.communityId)
}
-
- name: model.name
- icon: model.logoUri || Constants.tokenIcon(model.symbol, false)
- balance: root.formatBalance(model.balance, model.key)
- balanceLoading: model.balanceLoading
- marketBalance: root.formatFiat(model.marketBalance)
-
- marketDetailsAvailable: model.marketDetailsAvailable
- marketDetailsLoading: model.marketDetailsLoading
- marketCurrencyPrice: root.formatFiat(model.change1DayFiat)
- marketChangePct24hour: model.marketChangePct24hour
-
- communityId: model.communityId
- communityName: model.communityName ?? ""
- communityIcon: model.communityImage ?? ""
-
- errorTooltipText_1: root.chainsError(chainIdsList)
- errorTooltipText_2: root.marketDataError
-
- errorMode: !!root.balanceError
- errorIcon.tooltip.text: root.balanceError
-
- onClicked: function (itemId, mouse) {
- if (mouse.button === Qt.LeftButton)
- root.assetClicked(model.key)
- else if (mouse.button === Qt.RightButton)
- tokenContextMenu.createObject(this, { model }).popup(mouse.x, mouse.y)
- }
-
- onCommunityClicked: (communityId) => root.communityClicked(model.communityId)
}
}