mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 07:01:14 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,5 @@ StatusSectionLayoutLandscape 0.1 StatusSectionLayoutLandscape.qml
|
||||
StatusSectionLayoutPortrait 0.1 StatusSectionLayoutPortrait.qml
|
||||
LayoutChooser 0.1 LayoutChooser.qml
|
||||
|
||||
internal SectionPanelSlot SectionPanelSlot.qml
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user