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) } }