Files
lez-programs/apps/amm/qml/components/swap/SwapCard.qml
r4bbit 4363f13912 feat(apps/amm): submit exact-output swaps and drop client-side swap math
Wire the Buy direction to submit: AmmUiBackend gains a swapExactOutput slot
(guarded like swapExactInput), SwapCard.executeSwap branches on direction —
sell -> swapExactInput(minReceivedRaw), buy -> swapExactOutput(maxInRaw) — and
canSubmit/submitButtonText/buildSnapshot handle both. The confirmation dialog
switches wording by mode ("You pay at most" / "You receive exactly" for exact
output). The Buy field is now digitsOnly since its value is submitted as a raw
base-units integer.

With both directions priced and oriented by the module, the client no longer
needs any pool math. Remove the reserve-orientation chain (sellIsPoolA,
buy/sellReserveNum, poolReserveA/B, poolDefAHex) — resolvePool now reports only
existence and fee — and the now-dead helpers (formatBaseUnits/formatAmountValue,
DummySwapState.amountInFor/minReceived/priceImpactPercent/maxSent). The
impossible-swap guard moves from a client reserve compare to the module's
output_exceeds_liquidity error, surfaced as "Insufficient liquidity".
2026-08-06 23:15:02 +02:00

683 lines
29 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import "../shared"
import "../../state"
// The real swap UI: two token inputs (sell/buy), a token picker (backed by
// AmmUiBackend::tokenList()/TOKENS_CONFIG via SwapPage). Editing either side
// server-quotes that direction (swapExactInQuote / swapExactOutQuote) and the
// matching submit slot (swapExactInput / swapExactOutput) runs on confirm — see
// apps/amm/src/AmmUiBackend.rep for the exact contract.
Rectangle {
id: root
property var theme
property var tokens: []
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
property var backend: null
property var sellToken: null
property var buyToken: null
property string sellInput: ""
property string buyInput: ""
property string editingSide: "sell"
property real slippageTolerancePercent: 0.5
// ── Pool resolution (backend.resolvePool) ───────────────────────────────
// Existence and fee drive the UI; the swap quotes read the pool and
// price/orient the swap server-side, so the client no longer prices against
// the reserves. The raw reserves are still surfaced (observability only, not
// used for pricing) so the e2e test can assert they changed on-chain after a
// swap.
property bool poolLoading: false
property bool poolResolved: false
property bool poolExists: false
property int poolFeeBps: 30
property string poolReserveA: "0"
property string poolReserveB: "0"
property string poolError: ""
// ── Exact-input quote (backend.swapExactInQuote) ────────────────────────
property bool quoteInLoading: false
property string quoteInError: ""
property string quoteExpectedOutRaw: "0"
property string quoteMinReceivedRaw: "0"
property int quotePriceImpactBps: 0
// ── Exact-output quote (backend.swapExactOutQuote) ──────────────────────
property bool quoteOutLoading: false
property string quoteOutError: ""
property string quoteRequiredInRaw: "0"
property string quoteMaxInRaw: "0"
property int quoteOutPriceImpactBps: 0
// ── Swap submission (backend.swapExactInput) ────────────────────────────
property bool swapInProgress: false
property string swapError: ""
signal requestTokenSelect(string side)
signal submitRequested(var snapshot)
// Emitted after a real swapExactInput call completes.
signal swapSucceeded(var result)
signal swapFailed(string message)
DummySwapState {
id: swapState
feeBps: root.poolFeeBps
}
function setToken(side, token) {
if (side === "sell") root.sellToken = token
else root.buyToken = token
}
function resetAmounts() {
root.sellInput = ""
root.buyInput = ""
root.editingSide = "sell"
}
// ── Pool resolution ──────────────────────────────────────────────────────
Timer {
id: resolveDebounce
interval: 500
repeat: false
onTriggered: root.doResolvePool()
}
function requestResolve() {
root.poolResolved = false
root.poolExists = false
root.poolError = ""
if (root.sellToken && root.buyToken)
resolveDebounce.restart()
else
resolveDebounce.stop()
}
onSellTokenChanged: { root.requestResolve(); root.requestQuoteIn(); root.requestQuoteOut() }
onBuyTokenChanged: { root.requestResolve(); root.requestQuoteIn(); root.requestQuoteOut() }
onSellInputChanged: root.requestQuoteIn()
onBuyInputChanged: root.requestQuoteOut()
onEditingSideChanged: { root.requestQuoteIn(); root.requestQuoteOut() }
onSlippageTolerancePercentChanged: { root.requestQuoteIn(); root.requestQuoteOut() }
function doResolvePool() {
if (!root.backend || !root.sellToken || !root.buyToken)
return
// Capture the pair this request is for. resolvePool callbacks can arrive
// out of order: if the user switches tokens while an earlier resolve is
// still in flight, its (stale) callback must NOT overwrite the current
// pair's pool state — that would corrupt the preview and the submitted
// min_out. A stale callback also leaves poolLoading alone, since the
// newer in-flight request owns it.
var reqSell = root.sellToken.definitionId
var reqBuy = root.buyToken.definitionId
function isStale() {
return !root.sellToken || !root.buyToken
|| root.sellToken.definitionId !== reqSell
|| root.buyToken.definitionId !== reqBuy
}
root.poolLoading = true
logos.watch(root.backend.resolvePool(reqSell, reqBuy),
function (pool) {
if (isStale())
return
root.poolLoading = false
root.poolResolved = true
root.poolExists = !!(pool && pool.exists)
root.poolReserveA = (pool && pool.reserveA) || "0"
root.poolReserveB = (pool && pool.reserveB) || "0"
// feeBps === 0 is a legitimate zero-fee pool; only fall back
// to the 30bps default when the backend didn't send a value.
root.poolFeeBps = (pool && pool.feeBps !== undefined) ? pool.feeBps : 30
root.poolError = (pool && pool.error && pool.error !== "no_pool") ? pool.error : ""
},
function (error) {
if (isStale())
return
console.warn("resolvePool error:", error)
root.poolLoading = false
root.poolResolved = true
root.poolExists = false
root.poolError = qsTr("Failed to resolve pool: %1").arg(error)
})
}
// ── Exact-input quote ──────────────────────────────────────────────────────
Timer {
id: quoteInDebounce
interval: 350
repeat: false
onTriggered: root.doQuoteIn()
}
function resetQuoteIn() {
root.quoteExpectedOutRaw = "0"
root.quoteMinReceivedRaw = "0"
root.quotePriceImpactBps = 0
}
function requestQuoteIn() {
root.quoteInError = ""
// Only the exact-input (Sell) direction is server-quoted; the Buy
// direction is still a local preview (see DummySwapState).
if (root.backend && root.editingSide === "sell" && root.sellToken && root.buyToken
&& root.parsedSellInput > 0) {
// Invalidate the previous quote up front: while a re-quote is pending
// (debounce + in-flight), the stale expected-out / min_out must not be
// shown or submittable. quoteInLoading gates canSubmit until the fresh
// quote lands. Gating on backend here avoids setting the loading flag
// when doQuoteIn would only bail — which would leave it stuck.
root.resetQuoteIn()
root.quoteInLoading = true
quoteInDebounce.restart()
} else {
quoteInDebounce.stop()
root.quoteInLoading = false
root.resetQuoteIn()
}
}
function doQuoteIn() {
if (!root.backend || root.editingSide !== "sell"
|| !root.sellToken || !root.buyToken || root.parsedSellInput <= 0) {
root.quoteInLoading = false
return
}
// Capture the request identity: quote callbacks can arrive out of order,
// so a stale one (tokens or the typed amount changed since) must not
// overwrite the current preview or the submitted min_out.
var reqSell = root.sellToken.definitionId
var reqBuy = root.buyToken.definitionId
var reqAmount = root.sellInput
function isStale() {
return root.editingSide !== "sell"
|| !root.sellToken || !root.buyToken
|| root.sellToken.definitionId !== reqSell
|| root.buyToken.definitionId !== reqBuy
|| root.sellInput !== reqAmount
}
var slippageBps = Math.round(root.slippageTolerancePercent * 100)
root.quoteInLoading = true
logos.watch(root.backend.swapExactInQuote(reqSell, reqBuy, reqAmount, slippageBps),
function (quote) {
if (isStale())
return
root.quoteInLoading = false
if (quote && quote.status === "ok") {
root.quoteExpectedOutRaw = quote.expectedOutRaw || "0"
root.quoteMinReceivedRaw = quote.minReceivedRaw || "0"
root.quotePriceImpactBps = quote.priceImpactBps || 0
root.quoteInError = ""
} else {
root.resetQuoteIn()
// no_pool is surfaced via the pool status text, not as an error.
var code = (quote && quote.error) || "backend_error"
root.quoteInError = code === "no_pool" ? "" : code
}
},
function (error) {
if (isStale())
return
console.warn("swapExactInQuote error:", error)
root.quoteInLoading = false
root.resetQuoteIn()
root.quoteInError = String(error)
})
}
// ── Exact-output quote ─────────────────────────────────────────────────────
Timer {
id: quoteOutDebounce
interval: 350
repeat: false
onTriggered: root.doQuoteOut()
}
function resetQuoteOut() {
root.quoteRequiredInRaw = "0"
root.quoteMaxInRaw = "0"
root.quoteOutPriceImpactBps = 0
}
// The Buy field is free-form (not digitsOnly like Sell), so its text is
// normalized to a base-units integer before quoting: trim whitespace and
// accept only a positive run of digits. Decimals / exponents / signs / empty
// yield "" (invalid), so the backend call is skipped rather than forwarding an
// amount that would come back as a confusing quote failure.
function normalizedAmountOut() {
var s = String(root.buyInput).trim()
return (/^\d+$/.test(s) && /[1-9]/.test(s)) ? s : ""
}
function requestQuoteOut() {
root.quoteOutError = ""
// Mirror of requestQuoteIn for the Buy direction: price the input needed
// for the typed output. Invalidate the previous quote up front so a stale
// required-in isn't shown while the re-quote is pending. Invalid input
// (see normalizedAmountOut) takes the else branch, clearing the loading
// flag so it can't get stuck.
if (root.editingSide === "buy" && root.sellToken && root.buyToken
&& root.normalizedAmountOut() !== "") {
root.resetQuoteOut()
root.quoteOutLoading = true
quoteOutDebounce.restart()
} else {
quoteOutDebounce.stop()
root.quoteOutLoading = false
root.resetQuoteOut()
}
}
function doQuoteOut() {
var amountOut = root.normalizedAmountOut()
if (!root.backend || root.editingSide !== "buy"
|| !root.sellToken || !root.buyToken || amountOut === "") {
root.quoteOutLoading = false
return
}
var reqSell = root.sellToken.definitionId
var reqBuy = root.buyToken.definitionId
// Staleness is keyed on the raw field text (a further edit re-quotes),
// while the backend gets the normalized base-units amount.
var reqInput = root.buyInput
function isStale() {
return root.editingSide !== "buy"
|| !root.sellToken || !root.buyToken
|| root.sellToken.definitionId !== reqSell
|| root.buyToken.definitionId !== reqBuy
|| root.buyInput !== reqInput
}
var slippageBps = Math.round(root.slippageTolerancePercent * 100)
root.quoteOutLoading = true
// tokenIn is the sold token (sell), tokenOut is the bought token (buy).
logos.watch(root.backend.swapExactOutQuote(reqSell, reqBuy, amountOut, slippageBps),
function (quote) {
if (isStale())
return
root.quoteOutLoading = false
if (quote && quote.status === "ok") {
root.quoteRequiredInRaw = quote.requiredInRaw || "0"
root.quoteMaxInRaw = quote.maxInRaw || "0"
root.quoteOutPriceImpactBps = quote.priceImpactBps || 0
root.quoteOutError = ""
} else {
root.resetQuoteOut()
// no_pool is surfaced via the pool status text, not as an error.
var code = (quote && quote.error) || "backend_error"
root.quoteOutError = code === "no_pool" ? "" : code
}
},
function (error) {
if (isStale())
return
console.warn("swapExactOutQuote error:", error)
root.quoteOutLoading = false
root.resetQuoteOut()
root.quoteOutError = String(error)
})
}
readonly property real parsedSellInput: {
var amt = parseFloat(sellInput)
return isNaN(amt) || amt < 0 ? 0 : amt
}
readonly property real parsedBuyInput: {
var amt = parseFloat(buyInput)
return isNaN(amt) || amt < 0 ? 0 : amt
}
// The computed side comes from the server quote: exact-input (Sell) yields the
// expected output, exact-output (Buy) yields the required input. Number() may
// lose precision on large base-unit values, so these drive gating only — the
// exact figures shown and submitted come from the raw quote strings directly.
readonly property real parsedSellAmount: editingSide === "sell"
? parsedSellInput
: (Number(root.quoteRequiredInRaw) || 0)
readonly property real parsedBuyAmount: editingSide === "buy"
? parsedBuyInput
: (Number(root.quoteExpectedOutRaw) || 0)
readonly property real feeAmount: swapState.feeAmount(parsedSellAmount)
// Slippage bound: exact input floors the received amount (Min received), exact
// output caps the spent amount (Maximum sent). Both come from the quote.
readonly property string boundLabel: editingSide === "sell" ? qsTr("Min received") : qsTr("Maximum sent")
// The quote's exact-integer bound, verbatim (no Number()/double round-trip,
// which would lose precision on large u128 values and diverge from execution):
// min received (exact input) or max sent (exact output).
readonly property string boundRaw: editingSide === "sell" ? root.quoteMinReceivedRaw : root.quoteMaxInRaw
readonly property string boundSymbol: editingSide === "sell"
? (buyToken ? buyToken.symbol : "")
: (sellToken ? sellToken.symbol : "")
readonly property real priceImpactPercent: editingSide === "sell"
? root.quotePriceImpactBps / 100
: root.quoteOutPriceImpactBps / 100
readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input")
readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0
readonly property bool tokensSelected: sellToken !== null && buyToken !== null
// Exact output only: the module reports output_exceeds_liquidity when the
// requested output is at least the pool's reserve. Exact input can never
// exceed the reserve, so it has no such case.
readonly property bool outputExceedsLiquidity: editingSide === "buy" && root.quoteOutError === "output_exceeds_liquidity"
// Loading flag for whichever direction the user is editing.
readonly property bool quoteLoading: editingSide === "sell" ? root.quoteInLoading : root.quoteOutLoading
// True only when THIS app's wallet is connected. The backend also enforces
// this before submitting, but gate the UI too so a disconnected app never
// even initiates a swap against the shared wallet.
readonly property bool walletOpen: root.backend !== null && root.backend.isWalletOpen
// Both directions are submittable: exact input via swapExactInput, exact
// output via swapExactOutput. The typed side and the quoted side must both be
// positive (a fresh quote has landed) and the active quote must not be
// in-flight.
readonly property bool canSubmit: tokensSelected && hasAmount
&& parsedSellAmount > 0 && parsedBuyAmount > 0
&& root.poolResolved && root.poolExists
&& !outputExceedsLiquidity && !root.swapInProgress
&& !root.quoteLoading && root.walletOpen
readonly property string submitButtonText: {
if (!tokensSelected) return qsTr("Select tokens")
if (root.swapInProgress) return qsTr("Submitting…")
if (!hasAmount) return qsTr("Enter an amount")
if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…")
if (!root.poolExists) return qsTr("No pool / no liquidity")
if (root.quoteLoading) return qsTr("Quoting…")
if (outputExceedsLiquidity) return qsTr("Insufficient liquidity")
if (parsedSellAmount <= 0 || parsedBuyAmount <= 0) return qsTr("Amount too small")
if (!root.walletOpen) return qsTr("Connect wallet to swap")
return qsTr("Swap")
}
readonly property string poolStatusText: {
if (!root.backend) return qsTr("Wallet backend not ready.")
if (!tokensSelected) return ""
if (root.poolLoading) return qsTr("Looking up pool…")
if (root.poolError.length > 0) return root.poolError
if (root.poolResolved && !root.poolExists) return qsTr("No pool / no liquidity for this pair.")
if (root.outputExceedsLiquidity) return qsTr("Not enough liquidity for that output amount.")
if (root.quoteInError.length > 0) return qsTr("Quote failed: %1").arg(root.quoteInError)
if (root.quoteOutError.length > 0) return qsTr("Quote failed: %1").arg(root.quoteOutError)
return ""
}
// The computed side is shown as the quote's exact-integer string verbatim (no
// double round-trip): the required input in the Buy direction, the expected
// output in the Sell direction.
readonly property string sellDisplay: editingSide === "sell"
? sellInput
: ((root.quoteRequiredInRaw && root.quoteRequiredInRaw !== "0") ? root.quoteRequiredInRaw : "")
readonly property string buyDisplay: editingSide === "buy"
? buyInput
: ((root.quoteExpectedOutRaw && root.quoteExpectedOutRaw !== "0") ? root.quoteExpectedOutRaw : "")
// Confirmation-dialog preview. The typed side is exact; the quoted side and
// the slippage bound come from the quote's exact-integer strings. boundValue
// is the min received (exact input) or the max sent (exact output).
function buildSnapshot() {
var isExactIn = editingSide === "sell"
return {
"sellToken": sellToken ? sellToken.symbol : "",
"buyToken": buyToken ? buyToken.symbol : "",
"sellAmount": isExactIn ? root.sellInput : root.quoteRequiredInRaw,
"buyAmount": isExactIn ? root.quoteExpectedOutRaw : root.buyInput,
"boundValue": isExactIn ? root.quoteMinReceivedRaw : root.quoteMaxInRaw,
"feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""),
"priceImpactPercent": swapState.formatPercent(priceImpactPercent),
"priceImpactPercentValue": priceImpactPercent,
"slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent),
"swapMode": isExactIn ? "swap-exact-input" : "swap-exact-output",
"swapModeText": swapModeText
}
}
// Called by SwapPage once the user confirms in SwapConfirmationDialog.
// Submits the real on-chain swap for the tokens/amounts in SwapCard's live
// state, in whichever direction the user is editing.
function executeSwap() {
if (!root.backend || !root.canSubmit)
return
root.swapInProgress = true
root.swapError = ""
// Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep.
var deadline = "18446744073709551615"
var inDef = root.sellToken.definitionId
var outDef = root.buyToken.definitionId
var inHolding = root.sellToken.holding
var outHolding = root.buyToken.holding
// The on-chain guard is the quote's exact-integer bound: the exact-input
// floor (minReceivedRaw) or the exact-output ceiling (maxInRaw). The typed
// side (sellInput / buyInput) is the exact amount for that direction.
var pending = root.editingSide === "sell"
? root.backend.swapExactInput(inDef, outDef, inHolding, outHolding,
root.sellInput, root.quoteMinReceivedRaw, deadline)
: root.backend.swapExactOutput(inDef, outDef, inHolding, outHolding,
root.buyInput, root.quoteMaxInRaw, deadline)
logos.watch(pending,
function (txHash) {
root.swapInProgress = false
if (txHash && txHash.length > 0) {
root.swapSucceeded({
"txHash": txHash,
"sellToken": root.sellToken.symbol,
"buyToken": root.buyToken.symbol
})
root.resetAmounts()
resolveDebounce.restart()
} else {
root.swapError = qsTr("Swap failed (empty response from sequencer).")
root.swapFailed(root.swapError)
}
},
function (error) {
console.warn("swap error:", error)
root.swapInProgress = false
root.swapError = qsTr("Swap error: %1").arg(error)
root.swapFailed(root.swapError)
})
}
radius: 24
color: theme.colors.cardBg
border.color: theme.colors.border
border.width: 1
implicitWidth: 480
implicitHeight: cardLayout.implicitHeight + 16
Behavior on color { ColorAnimation { duration: 300 } }
ColumnLayout {
id: cardLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 0
TokenInput {
Layout.fillWidth: true
theme: root.theme
label: "Sell"
inputObjectName: "swapSellInput"
buttonObjectName: "swapSellTokenButton"
amount: root.sellDisplay
token: root.sellToken
active: root.editingSide === "sell"
// Sell amount is sent to the backend as a raw base-units integer
// string; reject fractional entry rather than fail opaquely.
digitsOnly: true
onInputEdited: function(v) {
root.sellInput = v
if (root.editingSide !== "sell") root.editingSide = "sell"
}
onTokenClicked: root.requestTokenSelect("sell")
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 40
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: theme.colors.divider
}
Rectangle {
anchors.centerIn: parent
width: 36; height: 36; radius: 18
color: swapHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg
border.color: theme.colors.borderStrong
border.width: 1
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "↓"
color: theme.colors.textPrimary
font.pixelSize: 16
}
MouseArea {
id: swapHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var tmp = root.sellToken
root.sellToken = root.buyToken
root.buyToken = tmp
}
}
}
}
TokenInput {
Layout.fillWidth: true
theme: root.theme
label: "Buy"
inputObjectName: "swapBuyInput"
buttonObjectName: "swapBuyTokenButton"
amount: root.buyDisplay
token: root.buyToken
active: root.editingSide === "buy"
// Exact-output amount is sent to the backend as a raw base-units
// integer string; reject fractional entry rather than fail opaquely.
digitsOnly: true
onInputEdited: function(v) {
root.buyInput = v
if (root.editingSide !== "buy") root.editingSide = "buy"
}
onTokenClicked: root.requestTokenSelect("buy")
}
Text {
Layout.fillWidth: true
Layout.topMargin: 8
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: root.poolStatusText.length > 0
text: root.poolStatusText
color: (root.poolError.length > 0 || (root.poolResolved && !root.poolExists))
? "#F08A76" : theme.colors.textSecondary
font.pixelSize: 12
wrapMode: Text.WordWrap
}
Text {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: root.swapError.length > 0
text: root.swapError
color: "#F08A76"
font.pixelSize: 12
wrapMode: Text.WordWrap
}
SwapSummary {
Layout.fillWidth: true
Layout.topMargin: 12
Layout.leftMargin: 16
Layout.rightMargin: 16
theme: root.theme
visible: root.tokensSelected && root.hasAmount
swapModeText: root.swapModeText
feeText: swapState.formatTokenAmount(root.feeAmount, root.sellToken ? root.sellToken.symbol : "")
priceImpactText: swapState.formatPercent(root.priceImpactPercent)
priceImpactPercent: root.priceImpactPercent
boundLabel: root.boundLabel
boundText: root.boundSymbol ? (root.boundRaw + " " + root.boundSymbol) : root.boundRaw
}
SlippageToleranceControl {
Layout.fillWidth: true
Layout.topMargin: 12
Layout.leftMargin: 16
Layout.rightMargin: 16
tolerancePercent: root.slippageTolerancePercent
visible: root.tokensSelected && root.hasAmount
onToleranceChangeRequested: function(tolerancePercent) {
root.slippageTolerancePercent = swapState.clampSlippagePercent(tolerancePercent);
}
}
Rectangle {
id: ctaBox
objectName: "swapSubmitButton"
Layout.fillWidth: true
Layout.topMargin: 8
Layout.bottomMargin: 8
Layout.leftMargin: 8
Layout.rightMargin: 8
Layout.preferredHeight: 56
radius: 20
color: !root.canSubmit ? theme.colors.panelBg
: ctaHover.containsMouse ? theme.colors.ctaHoverBg
: theme.colors.ctaBg
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: root.submitButtonText
color: root.canSubmit ? "#ffffff" : theme.colors.textSecondary
font.pixelSize: 17
font.weight: Font.Medium
}
MouseArea {
id: ctaHover
anchors.fill: parent
hoverEnabled: true
enabled: root.canSubmit
cursorShape: root.canSubmit ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (root.canSubmit) root.submitRequested(root.buildSnapshot())
}
}
}
}
}