perf(wallet): build the wallet section around what the user is waiting for

A wallet section activation built everything it would ever need before it
showed anything: both panels inline, both detail views, every row of two
lists synchronously, and a set of subtrees that exist for cases that almost
never occur. This reorders that around a single rule - build what is on
screen first, build the rest in the incubation controller's metered bites,
and build the rest of the rest only when something asks for it.

Lists fill preemptibly. A ListView refill is one uninterruptible call inside
the window's polish phase, and QQuickItemView creates visible delegates with
AsynchronousIfNested - so a list first laid out after its enclosing Loader is
already Ready builds every visible row synchronously. On a warm load that was
one 34.7ms GUI-thread block against a 3.2ms budget for the assets list, and
8.7-13.5ms of post-content polish across both lists. The assets and accounts
delegates become TokenDelegateShell and WalletAccountDelegateShell: an Item
carrying the row's geometry and its placeholder tiles, with the real row
behind a nested asynchronous Loader. The refill builds shells; the rows
incubate afterwards.

Row height is the shell's, not the content's. StatusListItem.implicitHeight
is Math.max(64, titleArea.height + 16), and both a token row and an account
row resolve to that 64px floor, which is what placeholderHeight is.
tst_TokenDelegateShell and tst_WalletAccountDelegateShell gate the two
staying equal, so a change that makes a row taller fails a test rather than
moving contentHeight and the scroll position mid-fill. Each placeholder
mirrors the skeleton the per-tab loader showed a moment earlier, so the two
loading states read as one handoff.

Subtrees built for the exceptional case are latched off. The token row's two
warning buttons, the asset detail's four chain-tag warning buttons, its
header community tag and its "Minted by" tile were all built unconditionally
and merely hidden. Each now sits behind the condition that used to drive
`visible`. AssetsDetailsHeader loses its `communityTag` alias, which existed
only so callers could reach in and set the tag's contents; it takes
communityName/communityImage and builds the tag itself.

The two detail views move behind async Loaders keyed on the stack index. The
token to show becomes state held in `d` and bound into the view rather than
assigned into it, so a second click landing mid-incubation wins without a
queue-and-replay; the resets that hung off onVisibleChanged hang off
onActiveChanged, which fires on the same edges.

Panels build in the order the user sees them. WalletLayout declared both
panels as plain object bindings, so activation built LeftTabView and the
centre StackView inline and the chrome's two PanelSwapGates could not fire
independently - they opened 5ms apart because both panel properties resolve
when WalletLayout completes. Each panel now sits behind its own async Loader
with its own readiness, the primary one first, and once a skeleton is on
screen with no panel switch running the primary panel is built synchronously:
the pacing exists to protect section-switch animations, and here the only
thing a synchronous block could stutter is the skeleton it replaces.

Wrapping the panels in Loaders exposed three geometry defects. A Loader
reports its item's implicit size as its own, so the centre StackView's
content width propagated into the chrome and pushed the panel past the right
edge on a narrow screen; the loaders are bound to the chrome's geometry for
the unparented phase, the stack no longer anchors to its loader, and the side
inset moves onto the content where it belongs.

Panel geometry is coalesced across a rotation. A device rotation walks the
window through nine sizes over ~430ms and LayoutItemProxy forwarded every one
to the section's panels - a full relayout of a populated subtree each time -
and parked a panel at a degenerate box for ~148ms on the portrait/landscape
handoff. SectionPanelSlot replaces the panel proxies in both sub-layouts and
publishes the box each slot will give its panel, which is also what a section
building panels outside the tree needs: the centre slot cannot be guessed, as
it is short by the header and footer and narrow by the left column in
landscape. Wallet and chat both pre-size their incubating panels to it.

Two fixes fall out of that work. BaseProxyPanel looked its SwipeView page up
at a fixed implicitIndex, but SwipeView indices close up as pages come and
go, so hiding the right panel with no left panel present silently left its
page in the view, still swipeable; it now looks the page up by identity.
SectionPanelSlot also has to be listed in statusq.qrc - without it the type
is absent from the binary's resources, StatusSectionLayout becomes
unavailable and the UI process dies precompiling AppMain.

Measured on a whale profile, Release, arms alternated between rounds:

  warm t_first_asset_row       119-159ms -> 51-74ms
  warm max_stall_ms             31.7-42.6 -> 17.2-26.3ms
  section ready                     631ms -> 207ms
  visible panel promoted            609ms -> 338ms
  cold: skeleton shown -> visible  2272ms -> 1836ms
  objects_total (cold)               4602 -> 3344   (-27%)
  objects_settled                    9569 -> 8297   (-13%)
  token row QObjects                  326 -> 279
  panel geometry distinct sizes    24 -> 5 centre, 19 -> 2 left
  degenerate 0x0 box written    12 occurrences -> 0

Device numbers are only comparable once the app has settled; a run taken
while startup backend work is still in flight measured 925ms for a path that
measures ~520ms settled.

Measurements come from an offscreen storybook wallet bench that is not part
of this PR; see branch `feat/storybook-wallet-loader`.
This commit is contained in:
Alex Jbanca
2026-08-23 09:40:36 +03:00
parent a327e32ae0
commit 754276d853
34 changed files with 3113 additions and 254 deletions
@@ -45,7 +45,6 @@ SplitView {
formatBalance: function(balance){
return LocaleUtils.currencyAmountToLocaleString(currencyStore.getCurrencyAmount(balance, "ETH"))
}
communityTag.visible: false
}
ColumnLayout {
@@ -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
+13 -3
View File
@@ -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
+3 -2
View File
@@ -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
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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()
}
@@ -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
@@ -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
}
}
}
@@ -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
}
}
}
+2
View File
@@ -5,3 +5,5 @@ StatusSectionLayoutLandscape 0.1 StatusSectionLayoutLandscape.qml
StatusSectionLayoutPortrait 0.1 StatusSectionLayoutPortrait.qml
LayoutChooser 0.1 LayoutChooser.qml
internal SectionPanelSlot SectionPanelSlot.qml
+1
View File
@@ -231,6 +231,7 @@
<file>StatusQ/Core/Utils/qmldir</file>
<file>StatusQ/Core/Utils/xss.js</file>
<file>StatusQ/Core/qmldir</file>
<file>StatusQ/Layout/SectionPanelSlot.qml</file>
<file>StatusQ/Layout/StatusSectionLayout.qml</file>
<file>StatusQ/Layout/LayoutChooser.qml</file>
<file>StatusQ/Layout/StatusSectionLayoutLandscape.qml</file>
+4 -4
View File
@@ -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()?
+132 -50
View File
@@ -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
@@ -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
}
}
}
+1
View File
@@ -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
@@ -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)
}
}
}
+63 -46
View File
@@ -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()
}
}
@@ -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
}
}
}
+52 -28
View File
@@ -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
}
}
}
}
+12 -2
View File
@@ -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),
})
}
@@ -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
}
}
}
+24 -15
View File
@@ -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
@@ -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
}
}
}
+1
View File
@@ -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
+47 -36
View File
@@ -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)
}
}