feat(apps/amm): introduce positions view

This view allows the user to manage their positions.
This commit is contained in:
r4bbit
2026-08-20 19:52:35 +02:00
parent 06d7119a97
commit 99ea6805df
12 changed files with 853 additions and 26 deletions
+12 -1
View File
@@ -243,7 +243,18 @@ list shows its empty state. Entries missing `tokenA`, `tokenB`, or a numeric
`feeBps` are skipped individually. The AMM testnet setup script writes this file
for the pool(s) it seeds (see below).
Clicking a row opens the pool detail view, which reads the live pool through
The **Pool** tab in the nav bar is a dropdown with two entries. *Create pool*
opens the new-position / add-liquidity form. *View positions* lists the wallet's
liquidity positions: the program has no "list my positions" read and an LP
definition id cannot be reversed back to its pool, so the app resolves every
pool in this config and matches each pool's `lpDefinitionId` against the
wallet's token holdings. A pool that is not in the config therefore cannot
appear, however many LP tokens the wallet holds for it. Each row shows the pair,
fee tier, the wallet's claim on both reserves (`reserve × lpBalance / lpSupply`,
floored like the program's own payout), and its share of the pool. The list
needs an open wallet.
Clicking a row in the Pools list opens the pool detail view, which reads the live pool through
`resolvePoolAccount` and shows the reserve split, spot price, fee tier, LP
supply, an estimate of the fees accrued into the reserves, and the pool's
account ids. Its **Swap** and **Add liquidity** buttons switch tabs with the
+44 -14
View File
@@ -20,17 +20,24 @@ Item {
// StackView here — the Pools tab renders either the list or the detail view.
property var selectedPool: null
// Open a pool's detail view on the Explore tab. select() clears selectedPool
// via onTabChanged, so the pool is assigned after it, not before.
function openPoolDetail(pool) {
navbar.select(1, 0)
root.selectedPool = pool
}
// Hand a pool's pair to the Trade tab and leave the detail view.
function openSwapFor(pool) {
root.selectedPool = null
navbar.currentIndex = 0
navbar.select(0, 0)
swapPage.selectPair(pool)
}
// Hand a pool's pair to the Liquidity tab and leave the detail view.
// Hand a pool's pair to the Pool tab's create/add form and leave the detail view.
function openLiquidityFor(pool) {
root.selectedPool = null
navbar.currentIndex = 1
navbar.select(2, 1)
liquidityPage.selectPair(pool)
}
@@ -84,6 +91,7 @@ Item {
// control. Trade/Liquidity render immediately on launch.
NavBar {
id: navbar
objectName: "navBar"
anchors.top: connectionBanner.bottom
anchors.left: parent.left
anchors.right: parent.right
@@ -91,6 +99,16 @@ Item {
backend: root.ready ? root.backend : null
accountModel: root.accountModel
// Any nav selection leaves the pool detail view behind, so returning to
// Explore lands on the list rather than on the pool opened last time.
// Entering the create-pool form clears it for the same reason; a pair
// handed over by openLiquidityFor() is applied after this runs.
onTabChanged: {
root.selectedPool = null
if (navbar.currentIndex === 2 && navbar.currentSubIndex === 1)
liquidityPage.resetForm()
}
}
Item {
@@ -107,20 +125,12 @@ Item {
backend: root.ready ? root.backend : null
}
LiquidityPage {
id: liquidityPage
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 1
}
// Explore tab: the pool list, or the detail view for the pool opened from it.
PoolsPage {
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 2 && root.selectedPool === null
visible: navbar.currentIndex === 1 && root.selectedPool === null
onPoolActivated: function(pool) { root.selectedPool = pool }
}
@@ -129,12 +139,32 @@ Item {
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 2 && root.selectedPool !== null
visible: navbar.currentIndex === 1 && root.selectedPool !== null
pool: root.selectedPool
onBackRequested: root.selectedPool = null
onSwapRequested: function(pool) { root.openSwapFor(pool) }
onAddLiquidityRequested: function(pool) { root.openLiquidityFor(pool) }
}
// Pool tab, "View positions": the wallet's LP holdings matched to pools.
PositionsPage {
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 0
onPositionActivated: function(position) { root.openPoolDetail(position) }
}
// Pool tab, "Create pool": the new-position / add-liquidity form.
LiquidityPage {
id: liquidityPage
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 2 && navbar.currentSubIndex === 1
}
}
}
+155 -5
View File
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Logos.Theme
@@ -8,11 +9,19 @@ import Logos.Wallet
// Self-contained navigation bar — styling is independent of any view's theme.
// Use currentIndex to read the active tab; tabChanged(index) fires on selection.
// A tab carrying `items` opens a dropdown instead of switching directly, and the
// chosen entry lands in currentSubIndex (always 0 for tabs without a menu).
Item {
id: root
property int currentIndex: 0
readonly property var tabs: [qsTr("Trade"), qsTr("Liquidity"), qsTr("Pools")]
property int currentSubIndex: 0
readonly property var tabs: [
{ "label": qsTr("Trade"), "items": [] },
{ "label": qsTr("Explore"), "items": [] },
{ "label": qsTr("Pool"), "items": [qsTr("View positions"), qsTr("Create pool")] }
]
// Wallet wiring, passed down from Main.qml.
property var backend: null
@@ -23,6 +32,12 @@ Item {
signal tabChanged(int index)
function select(index, subIndex) {
root.currentIndex = index
root.currentSubIndex = subIndex
root.tabChanged(index)
}
implicitHeight: 56
Rectangle {
@@ -68,9 +83,16 @@ Item {
id: tabButton
required property int index
required property string modelData
required property var modelData
readonly property bool active: root.currentIndex === index
readonly property var items: modelData.items || []
readonly property bool hasMenu: tabButton.items.length > 0
// Tracks where the pointer is so the menu can stay open
// while it travels from the tab down into the menu.
property bool pointerOnTab: false
property bool pointerInMenu: false
height: 36
width: tabLabel.implicitWidth + 28
@@ -82,9 +104,125 @@ Item {
Accessible.role: Accessible.PageTab
Accessible.name: tabLabel.text
// A tab with a menu defers the switch to the chosen entry;
// one without goes straight to its page.
function activate() {
root.currentIndex = index
root.tabChanged(index)
if (tabButton.hasMenu) {
tabButton.openMenu()
return
}
root.select(tabButton.index, 0)
}
function openMenu() {
if (!tabButton.hasMenu)
return
menuCloseTimer.stop()
tabMenu.open()
}
// Closing is deferred: crossing the gap between the tab and
// the menu leaves the pointer over neither for a moment, and
// closing on that would make the menu unreachable by mouse.
function scheduleMenuClose() {
if (tabButton.hasMenu)
menuCloseTimer.restart()
}
Timer {
id: menuCloseTimer
interval: 180
onTriggered: {
if (!tabButton.pointerOnTab && !tabButton.pointerInMenu)
tabMenu.close()
}
}
Popup {
id: tabMenu
objectName: "navTabMenu%1".arg(tabButton.index)
y: tabButton.height + 6
width: 190
padding: 6
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
radius: 12
color: Theme.palette.background
border.color: Theme.palette.borderSecondary
border.width: 1
}
contentItem: Column {
spacing: 2
// A handler rather than a MouseArea: the entries
// below have their own hoverEnabled MouseAreas,
// which would steal a parent MouseArea's hover.
HoverHandler {
onHoveredChanged: {
tabButton.pointerInMenu = hovered
if (hovered)
menuCloseTimer.stop()
else
tabButton.scheduleMenuClose()
}
}
Repeater {
model: tabButton.items
delegate: Rectangle {
id: menuEntry
required property int index
required property string modelData
readonly property bool current: root.currentIndex === tabButton.index
&& root.currentSubIndex === menuEntry.index
objectName: "navMenuItem%1_%2".arg(tabButton.index).arg(menuEntry.index)
width: tabMenu.availableWidth
height: 36
radius: 8
color: entryMouse.containsMouse
? Theme.palette.backgroundSecondary : "transparent"
Accessible.role: Accessible.MenuItem
Accessible.name: menuEntry.modelData
function activate() {
tabMenu.close()
root.select(tabButton.index, menuEntry.index)
}
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: menuEntry.modelData
color: menuEntry.current || entryMouse.containsMouse
? Theme.palette.text : Theme.palette.textSecondary
font.pixelSize: 14
font.weight: menuEntry.current ? Font.Medium : Font.Normal
elide: Text.ElideRight
}
MouseArea {
id: entryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: menuEntry.activate()
}
}
}
}
}
Behavior on color { ColorAnimation { duration: 150 } }
@@ -107,7 +245,7 @@ Item {
Text {
id: tabLabel
anchors.centerIn: parent
text: tabButton.modelData
text: tabButton.modelData.label
color: tabButton.active ? Theme.palette.text : Theme.palette.textSecondary
font.pixelSize: 14
font.weight: tabButton.active ? Font.Medium : Font.Normal
@@ -117,7 +255,19 @@ Item {
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
tabButton.pointerOnTab = true
tabButton.openMenu()
}
onExited: {
tabButton.pointerOnTab = false
tabButton.scheduleMenuClose()
}
onClicked: {
tabButton.forceActiveFocus()
tabButton.activate()
@@ -71,6 +71,23 @@ function subtract(left, right) {
return normalize(result.join(""))
}
function add(left, right) {
var a = normalize(left)
var b = normalize(right)
var result = []
var carry = 0
var j = b.length - 1
for (var i = a.length - 1; i >= 0 || j >= 0 || carry > 0; --i) {
var digit = carry
+ (i >= 0 ? Number(a.charAt(i)) : 0)
+ (j >= 0 ? Number(b.charAt(j)) : 0)
carry = digit >= 10 ? 1 : 0
result.unshift(String(digit % 10))
--j
}
return normalize(result.join(""))
}
function multiply(left, right) {
var a = normalize(left)
var b = normalize(right)
@@ -801,6 +801,23 @@ AmmActionCard {
root.requestQuote(true)
}
// Back to a first-visit form. resetPairDraft() clears only the draft and keeps
// the selected pair, which is right when the pair itself just changed; this
// also drops the pair, the fee tier, slippage and any token-resolution error,
// for when the form is re-entered rather than edited.
function resetAll() {
root.selectedTokenAId = ""
root.selectedTokenBId = ""
root.selectedFeeBps = 30
root.slippageBps = 50
root.resolvingTokenId = ""
root.resolvingTokenSide = ""
root.tokenResolutionError = ""
root.tokenResolutionErrorSide = ""
root.tokenResolutionMessage = ""
root.resetPairDraft()
}
function resetPairDraft() {
root.activePoolQuote = ({})
root.amountA = ""
@@ -210,8 +210,12 @@ Popup {
delegate: Item {
id: tokenRow
objectName: "tokenListItem"
required property var modelData
// Exposed for UI tests to read the row's token (see swap.mjs).
readonly property string tokenSymbol: root.tokenSymbol(modelData)
|| root.tokenName(modelData)
readonly property bool selectable: root.isSelectable(modelData)
readonly property string disabledReason: root.disabledReasonForCode(
modelData.code
+8
View File
@@ -39,6 +39,14 @@ Item {
// has answered, since the handoff can arrive before the selector's rows do.
property var pendingPair: null
// Clears the form back to a first-visit state. Called by Main.qml whenever the
// page is navigated to, so a previous visit's pair and amounts don't linger.
// A pair handed over by selectPair() is applied after this, not before.
function resetForm() {
root.pendingPair = null
form.resetAll()
}
// Preselects a pool's pair on the new-position form. Called by Main.qml when
// the pool detail view's "Add liquidity" button is pressed.
function selectPair(pool) {
+582
View File
@@ -0,0 +1,582 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../components/liquidity"
import "../components/liquidity/AmountMath.js" as AmountMath
import "../components/shared/TokenVisuals.js" as TokenVisuals
// The wallet's liquidity positions. The program has no "list my positions" read
// and an LP definition id can't be reversed back to its pool, so positions are
// discovered the other way round: resolve every pool in AMM_POOLS_CONFIG, then
// match each pool's lpDefinitionId against the wallet's token holdings. A pool
// the config doesn't list therefore can't surface here, however many LP tokens
// the wallet holds for it.
Item {
id: root
objectName: "positionsPage"
// Real backend replica (logos.module("amm_ui")) and the watch runtime,
// wired from Main.qml. Null until the app is ready.
property var backend: null
property var runtime: null
readonly property int pageMargin: width < 640 ? 16 : 24
readonly property int contentMaxWidth: 760
// One entry per pool the wallet holds LP tokens for; see buildPosition().
property var positions: []
readonly property int positionCount: root.positions ? root.positions.length : 0
property bool loading: false
property string loadError: ""
// Emitted when a row is activated (click or keyboard); Main.qml opens the
// pool detail view, from where the pair can be topped up.
signal positionActivated(var position)
readonly property bool walletOpen: !!(root.backend && root.backend.isWalletOpen)
readonly property bool showEmptyState: !root.loading && root.positionCount === 0
// Monotonic tag for a whole reload: a wallet switch mid-flight would
// otherwise let the previous wallet's pool resolutions land in the list.
property int generation: 0
// Outstanding resolvePoolAccount calls for the current generation.
property int pendingResolves: 0
function reload() {
if (!root.backend || !root.runtime)
return
// Reserves move with every swap, so what was resolved last time is stale
// by the time the page is opened again. Resolving every pool for a page
// nobody is looking at would be wasted work, so an offscreen reload just
// marks the list dirty and onVisibleChanged refetches on the way in.
if (!root.visible)
return
const gen = ++root.generation
root.loading = true
root.loadError = ""
root.positions = []
root.pendingResolves = 0
// Read isWalletOpen off the backend rather than through the walletOpen
// binding: this runs from onBackendChanged, where that binding may not
// have re-evaluated against the new backend yet.
if (!root.backend.isWalletOpen) {
// tokenHoldings() needs an open wallet; without one there is nothing
// to match pools against.
root.loading = false
return
}
root.runtime.watch(root.backend.poolList(),
function(pools) {
if (gen !== root.generation)
return
root.loadHoldings(gen, pools || [])
},
function(err) { root.failLoad(gen, "poolList", err) })
}
function loadHoldings(gen, pools) {
root.runtime.watch(root.backend.tokenHoldings(),
function(holdings) {
if (gen !== root.generation)
return
root.resolvePools(gen, pools, holdings || [])
},
function(err) { root.failLoad(gen, "tokenHoldings", err) })
}
// Fans out one resolvePoolAccount per configured pool and collects whichever
// come back with an LP balance. Order follows completion, not config order.
function resolvePools(gen, pools, holdings) {
if (pools.length === 0) {
root.loading = false
return
}
var collected = []
root.pendingResolves = pools.length
for (var i = 0; i < pools.length; ++i)
root.resolveOne(gen, pools[i], holdings, collected)
}
function resolveOne(gen, pool, holdings, collected) {
const idA = String(pool.tokenADefinitionId || "")
const idB = String(pool.tokenBDefinitionId || "")
if (idA.length === 0 || idB.length === 0) {
// A config entry without definition ids can't be resolved; it simply
// contributes no position rather than failing the whole list.
root.finishOne(gen, collected)
return
}
root.runtime.watch(root.backend.resolvePoolAccount(idA, idB),
function(result) {
if (gen !== root.generation)
return
var position = root.buildPosition(pool, result, holdings)
if (position)
collected.push(position)
root.finishOne(gen, collected)
},
function(err) {
if (gen !== root.generation)
return
console.warn("resolvePoolAccount error:", err)
root.finishOne(gen, collected)
})
}
function finishOne(gen, collected) {
if (gen !== root.generation)
return
root.pendingResolves -= 1
if (root.pendingResolves > 0)
return
root.positions = collected
root.loading = false
}
function failLoad(gen, op, err) {
if (gen !== root.generation)
return
console.warn(op + " error:", err)
root.loading = false
root.loadError = qsTr("Failed to load positions: %1").arg(err)
}
// Total LP balance the wallet holds for one definition. Summed rather than
// first-match: nothing stops a wallet holding the same LP token in more than
// one account, and under-reporting a position would be worse than slow.
function lpBalanceFor(lpDefinitionId, holdings) {
var total = "0"
for (var i = 0; i < holdings.length; ++i) {
var holding = holdings[i]
if (String(holding.definitionId || "") === lpDefinitionId)
total = AmountMath.add(total, String(holding.balanceRaw || "0"))
}
return total
}
function buildPosition(pool, result, holdings) {
if (!result || result.status !== "ok")
return null
const lpDefinitionId = String(result.lpDefinitionId || "")
if (lpDefinitionId.length === 0)
return null
const supply = String(result.liquiditySupply || "0")
if (!AmountMath.isUnsigned(supply) || AmountMath.normalize(supply) === "0")
return null
const balance = root.lpBalanceFor(lpDefinitionId, holdings)
if (AmountMath.normalize(balance) === "0")
return null
const reserveA = String(result.reserveA || "0")
const reserveB = String(result.reserveB || "0")
return {
// Passed through so a row carries everything the pool detail view
// would need, without re-reading the config.
"tokenA": String(pool.tokenA || ""),
"tokenB": String(pool.tokenB || ""),
"tokenADefinitionId": String(pool.tokenADefinitionId || ""),
"tokenBDefinitionId": String(pool.tokenBDefinitionId || ""),
"poolId": String(result.poolId || pool.poolId || ""),
"feeBps": result.feeBps !== undefined ? result.feeBps : (Number(pool.feeBps) || 0),
"lpDefinitionId": lpDefinitionId,
"lpBalance": balance,
"liquiditySupply": supply,
// The wallet's claim on each reserve, floored the same way the
// program's remove_liquidity floors its payout.
"amountA": AmountMath.mulDivFloor(reserveA, balance, supply),
"amountB": AmountMath.mulDivFloor(reserveB, balance, supply),
"share": (Number(balance) || 0) / (Number(supply) || 1)
}
}
onBackendChanged: root.reload()
onRuntimeChanged: root.reload()
// Every entry refetches: a swap on another tab moves the reserves this page's
// amounts are derived from, and nothing else tells it that happened.
onVisibleChanged: {
if (root.visible)
root.reload()
}
Connections {
target: root.backend
function onIsWalletOpenChanged() { root.reload() }
}
AmmTheme {
id: theme
}
function feeLabel(feeBps) {
var percentage = Number(feeBps) / 100
return qsTr("%1%").arg(percentage.toLocaleString(Qt.locale(), "f", 2))
}
// Group an exact decimal string without routing it through Number(), which
// would round any balance past ~15 digits.
function amountText(rawValue) {
var digits = String(rawValue).replace(/[^0-9]/g, "").replace(/^0+(?=[0-9])/, "")
if (digits.length === 0)
return "0"
var separator = Qt.locale().groupSeparator
var grouped = ""
for (var i = 0; i < digits.length; ++i) {
if (i > 0 && (digits.length - i) % 3 === 0)
grouped += separator
grouped += digits[i]
}
return grouped
}
// A dust position is still a position: report it as "<0.01%" rather than
// rounding it to a flat 0% that reads as "you hold nothing".
function shareText(share) {
var percent = share * 100
if (percent > 0 && percent < 0.01)
return qsTr("<0.01%")
return qsTr("%1%").arg(percent.toLocaleString(Qt.locale(), "f", 2))
}
function emptyStateText() {
if (root.loadError.length > 0)
return root.loadError
if (!root.walletOpen)
return qsTr("Connect a wallet to see your liquidity positions.")
return qsTr("No liquidity positions yet. Create a pool or add liquidity to an existing one.")
}
Rectangle {
anchors.fill: parent
color: theme.colors.background
}
Flickable {
id: scroll
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: Math.max(height, pageContent.y + pageContent.implicitHeight
+ root.pageMargin)
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
Item {
id: pageContent
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
y: root.width < 640 ? 24 : 40
width: Math.max(0, Math.min(root.contentMaxWidth,
scroll.width - root.pageMargin * 2))
implicitHeight: contentColumn.implicitHeight
Column {
id: contentColumn
width: parent.width
spacing: 24
Column {
width: parent.width
spacing: 6
Text {
text: qsTr("Positions")
color: theme.colors.textPrimary
font.pixelSize: 30
font.weight: Font.Bold
}
Text {
width: parent.width
text: qsTr("Your share of each pool, from the LP tokens in your wallet.")
color: theme.colors.textSecondary
font.pixelSize: 13
wrapMode: Text.Wrap
}
}
Rectangle {
id: positionsList
objectName: "positionsList"
width: parent.width
implicitHeight: !root.showEmptyState && !root.loading
? listContent.implicitHeight : 144
color: theme.colors.cardBg
radius: 16
border.color: theme.colors.border
border.width: 1
Column {
id: listContent
width: parent.width
visible: !root.showEmptyState && !root.loading
Item {
width: parent.width
height: 48
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Position")
color: theme.colors.textSecondary
font.pixelSize: 12
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Pool share")
color: theme.colors.textSecondary
font.pixelSize: 12
font.weight: Font.DemiBold
}
}
Rectangle {
width: parent.width
height: 1
color: theme.colors.divider
}
Repeater {
model: root.positions || []
delegate: PositionRow {
width: listContent.width
showDivider: index < root.positionCount - 1
objectName: "positionRow%1".arg(index)
}
}
Item {
width: parent.width
height: 8
}
}
Text {
id: emptyState
objectName: "positionsListEmptyState"
anchors.centerIn: parent
width: parent.width - 40
visible: root.loading || root.showEmptyState
text: root.loading ? qsTr("Loading positions…") : root.emptyStateText()
color: root.loadError.length > 0 && !root.loading
? theme.colors.error : theme.colors.textSecondary
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
}
// Only pools the app knows about can be matched, so a missing
// position is a config gap rather than a missing balance.
Text {
width: parent.width
visible: root.walletOpen && !root.loading
text: qsTr("Positions are matched against the pools in the app's pool config.")
color: theme.colors.textPlaceholder
font.pixelSize: 11
wrapMode: Text.Wrap
}
}
}
}
component PositionRow: Item {
id: row
required property var modelData
required property int index
property bool showDivider: false
readonly property var position: modelData
readonly property string pairText: qsTr("%1 / %2")
.arg(String(position.tokenA || ""))
.arg(String(position.tokenB || ""))
readonly property string feeText: root.feeLabel(position.feeBps)
readonly property string shareText: root.shareText(position.share)
readonly property string underlyingText: qsTr("%1 %2 · %3 %4")
.arg(root.amountText(position.amountA))
.arg(String(position.tokenA || ""))
.arg(root.amountText(position.amountB))
.arg(String(position.tokenB || ""))
height: 76
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: qsTr("%1, %2 fee, %3 of the pool")
.arg(row.pairText).arg(row.feeText).arg(row.shareText)
Accessible.onPressAction: row.activate()
function activate() {
root.positionActivated(row.position)
}
Keys.onReturnPressed: row.activate()
Keys.onEnterPressed: row.activate()
Keys.onSpacePressed: row.activate()
// Hover/focus tint sits behind the content so the row reads as clickable.
Rectangle {
anchors.fill: parent
color: theme.colors.panelHoverBg
opacity: rowMouse.containsMouse || row.activeFocus ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: 120 }
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: row.activate()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
spacing: 12
Item {
Layout.preferredWidth: 46
Layout.preferredHeight: 30
Accessible.ignored: true
TokenAvatar {
x: 0
y: 1
symbol: String(row.position.tokenA || "")
z: 1
}
TokenAvatar {
x: 18
y: 1
symbol: String(row.position.tokenB || "")
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: row.pairText
color: theme.colors.textPrimary
font.pixelSize: 16
font.weight: Font.Medium
elide: Text.ElideRight
Layout.fillWidth: true
Layout.preferredWidth: implicitWidth
}
Rectangle {
Layout.preferredWidth: feePillText.implicitWidth + 16
Layout.preferredHeight: 22
radius: 6
color: theme.colors.inputBg
border.color: theme.colors.borderStrong
border.width: 1
Text {
id: feePillText
anchors.centerIn: parent
text: row.feeText
color: theme.colors.textSecondary
font.pixelSize: 11
font.weight: Font.Medium
}
}
}
Text {
Layout.fillWidth: true
text: row.underlyingText
color: theme.colors.textSecondary
font.pixelSize: 12
elide: Text.ElideRight
}
}
Text {
text: row.shareText
color: theme.colors.textPrimary
font.pixelSize: 14
font.weight: Font.Medium
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: row.showDivider
height: 1
color: theme.colors.divider
}
}
component TokenAvatar: Rectangle {
id: avatar
required property string symbol
width: 28
height: 28
radius: 14
color: TokenVisuals.colorFor(symbol)
border.color: theme.colors.cardBg
border.width: 2
Accessible.ignored: true
Text {
anchors.centerIn: parent
text: TokenVisuals.letterFor(avatar.symbol)
color: "#FFFFFF"
font.pixelSize: 10
font.weight: Font.Bold
Accessible.ignored: true
}
}
}
+1
View File
@@ -226,6 +226,7 @@ Item {
TokenSelectorModal {
id: tokenModal
objectName: "tokenSelectorModal"
z: 10
theme: pageTheme
tokens: root.tokens
+5 -2
View File
@@ -173,8 +173,11 @@ test("amm liquidity: add to the A/B pool", async (app) => {
const before = await readReserveA(app);
console.log(` A/B reserveA before: ${before.reserveA}`);
// 1. Switch to the Liquidity tab and wait for the form.
await ignore(() => app.click("Liquidity"));
// 1. Open the create-pool view (Pool > Create pool = tab 2, sub 1). Driving the
// navbar's select() fires tabChanged, which also resets the form. (The old
// "Liquidity" tab is now an entry under the "Pool" dropdown.)
const navBarId = await idByObjectName(app, "navBar");
await evaluate(app, navBarId, "select(2, 1)");
await app.waitFor(
async () => { await idByObjectName(app, "newPositionForm"); },
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
+4 -2
View File
@@ -145,10 +145,12 @@ test("amm liquidity: create the A/C pool", async (app) => {
// 1. Switch to the Liquidity tab and wait for the form to render.
await app.waitFor(
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
async () => { await app.expectTexts(["Trade", "Pool"]); },
{ timeout: 20000, interval: 500, description: "nav bar to load" },
);
await ignore(() => app.click("Liquidity"));
// "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly.
const navBarId = await idByObjectName(app, "navBar");
await evaluate(app, navBarId, "select(2, 1)");
// waitFor resolves when the condition stops throwing — it does NOT return the
// callback's value, so fetch the id with a direct call afterwards.
await app.waitFor(
+4 -2
View File
@@ -113,10 +113,12 @@ test("amm liquidity: add a custom (unlisted) token by id", async (app) => {
// 1. Switch to the Liquidity tab and wait for the form + page to render.
await app.waitFor(
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
async () => { await app.expectTexts(["Trade", "Pool"]); },
{ timeout: 20000, interval: 500, description: "nav bar to load" },
);
await ignore(() => app.click("Liquidity"));
// "Liquidity" is now "Pool > Create pool" (tab 2, sub 1); drive the navbar directly.
const navBarId = await idByObjectName(app, "navBar");
await evaluate(app, navBarId, "select(2, 1)");
await app.waitFor(
async () => { await idByObjectName(app, "newPositionForm"); },
{ timeout: 10000, interval: 300, description: "liquidity form to render" },