contents/ui/components/SpendView.qml
import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents
import "." as Components
import "../CostPresentation.js" as CostPresentation
ColumnLayout {
id: view
required property var applet
readonly property var dailyPoints: applet.spendDailyPoints()
readonly property var providerCosts: applet.spendProviderCosts()
readonly property var presentedProviderCosts: applet.presentedSpendProviderCosts(providerCosts)
readonly property var costTrustSummary: CostPresentation.costTrustSummary(providerCosts)
readonly property string spendCurrency: applet.spendCurrency(providerCosts)
readonly property bool hasMixedCostCurrencies: CostPresentation.spendHasMixedCostCurrencies(providerCosts)
readonly property real heatmapMaximum: chartMaximum(dailyPoints)
readonly property var heatmapDays: CostPresentation.spendHeatmapDays(dailyPoints, providerCosts)
readonly property int heatmapFirstWeekday: Qt.locale().firstDayOfWeek
readonly property var heatmapRowWeekdays: CostPresentation.spendHeatmapRowWeekdays(
heatmapDays, heatmapFirstWeekday)
readonly property int heatmapTrailingSlots: CostPresentation.spendHeatmapTrailingSlots(
heatmapDays, heatmapFirstWeekday)
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Kirigami.Units.largeSpacing
function chartMaximum(points) {
var maximum = 0
for (var i = 0; i < points.length; i++) {
maximum = Math.max(maximum, Number(points[i].value) || 0)
}
return maximum
}
// Day counts are numbers; the calendar periods are the CLI's names.
function rangeOptions(days) {
var values = [7, 30, 90]
var options = [
{ text: i18n("7 days"), value: 7 },
{ text: i18n("30 days"), value: 30 },
{ text: i18n("90 days"), value: 90 },
{ text: i18n("Month to date"), value: "month-to-date" },
{ text: i18n("All history"), value: "all" }
]
var currentDays = Number(days)
if (values.indexOf(currentDays) === -1) {
options.unshift({
text: i18np("%1 day", "%1 days", currentDays),
value: currentDays
})
}
return options
}
function rangeIndex(options, days, period) {
for (var i = 0; i < options.length; i++) {
if (period ? options[i].value === period : options[i].value === Number(days)) {
return i
}
}
return 0
}
function metricOptions() {
return [
{ text: i18n("Cost"), value: "cost" },
{ text: i18n("Tokens"), value: "tokens" }
]
}
function metricIndex(options, metric) {
for (var i = 0; i < options.length; i++) {
if (String(options[i].value) === String(metric)) {
return i
}
}
return 0
}
RowLayout {
id: spendHeaderRow
Layout.fillWidth: true
Layout.rightMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
PlainHeading {
text: i18n("Usage & Spend")
level: 2
type: Kirigami.Heading.Type.Primary
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
text: view.applet.spendTotalLine()
opacity: view.applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
}
PlasmaComponents.ToolButton {
id: shareButton
objectName: "shareUsageButton"
Layout.alignment: Qt.AlignTop
icon.name: "document-share"
Accessible.name: i18n("Share AI usage")
enabled: view.providerCosts.length > 0 && !view.applet.costLoading
onClicked: view.applet.openShareUsage()
PlainToolTip {
parent: shareButton
visible: shareButton.hovered
plainText: i18n("Share AI usage")
}
}
Components.RefreshButton {
Layout.alignment: Qt.AlignTop
busy: view.applet.costLoading
label: i18n("Refresh local history")
onRequested: view.applet.refreshCost(true)
}
}
RowLayout {
id: historyControlsRow
Layout.fillWidth: true
Layout.rightMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: i18n("History")
opacity: view.applet.secondaryTextOpacity
Layout.fillWidth: true
elide: Text.ElideRight
}
Controls.ComboBox {
id: metricCombo
textRole: "text"
valueRole: "value"
model: view.metricOptions()
currentIndex: view.metricIndex(model, view.applet.costHistoryMetric)
Accessible.name: i18n("History metric")
onActivated: function(index) {
view.applet.setCostHistoryMetric(metricCombo.valueAt(index))
// The interactive pick severs the currentIndex binding; restore
// it so the combo keeps tracking settings changes made outside
// this view.
metricCombo.currentIndex = Qt.binding(function() {
return view.metricIndex(metricCombo.model, view.applet.costHistoryMetric)
})
}
}
Controls.ComboBox {
id: rangeCombo
textRole: "text"
valueRole: "value"
model: view.rangeOptions(view.applet.costHistoryDays)
currentIndex: view.rangeIndex(model, view.applet.costHistoryDays, view.applet.costHistoryPeriod)
Accessible.name: i18n("History range")
onActivated: function(index) {
var value = rangeCombo.valueAt(index)
if (typeof value === "string") {
view.applet.setCostHistoryPeriod(value)
} else {
view.applet.setCostHistoryDays(value)
}
// Same restored binding as the metric combo: keep tracking
// external settings changes after a pick severs this one.
rangeCombo.currentIndex = Qt.binding(function() {
return view.rangeIndex(rangeCombo.model, view.applet.costHistoryDays,
view.applet.costHistoryPeriod)
})
}
}
}
Components.PlainInlineMessage {
visible: view.applet.costErrorText.length > 0
plainText: view.providerCosts.length > 0
? i18n("Some local history is unavailable: %1", view.applet.privateErrorText(view.applet.costErrorText))
: i18n("Local history is unavailable: %1", view.applet.privateErrorText(view.applet.costErrorText))
type: Kirigami.MessageType.Warning
Layout.fillWidth: true
}
Components.PlainInlineMessage {
visible: view.hasMixedCostCurrencies
plainText: i18n("The cost subtotal and charts use %1. Providers reporting another currency remain separate below. Token figures include every provider.", view.spendCurrency)
type: Kirigami.MessageType.Information
Layout.fillWidth: true
}
Components.PlainInlineMessage {
// Distinguishes "you spent little" from "the local scan has not reached
// that far back yet", which otherwise look identical on the chart.
visible: view.providerCosts.length > 0 && view.applet.spendHistoryStillBuilding()
plainText: i18n("Local history is still being collected, so early days may be incomplete.")
type: Kirigami.MessageType.Information
Layout.fillWidth: true
}
Components.CostTrustNotice {
noticeScope: "spend"
stateOwner: view.applet
presentationVisible: view.visible
summary: view.costTrustSummary
}
Components.PlainInlineMessage {
visible: view.providerCosts.length > 0 && view.dailyPoints.length === 0
plainText: view.applet.costHistoryShowsTokens
? i18n("No daily token history is available for this range.")
: i18n("No daily cost history is available for this range. Try Tokens to check for token-only history.")
type: Kirigami.MessageType.Information
Layout.fillWidth: true
}
Item {
visible: !view.applet.costLoading
&& view.providerCosts.length === 0
&& view.applet.costErrorText.length === 0
implicitHeight: emptySpendPlaceholder.implicitHeight
Layout.fillWidth: true
Layout.fillHeight: true
// Keep the native placeholder together while this item fills the view.
PlainPlaceholderMessage {
id: emptySpendPlaceholder
anchors.centerIn: parent
width: parent.width
plainText: i18n("No local token or cost history.")
plainExplanation: i18n("History appears for providers supported by the codexbar cost command.")
icon.name: "view-statistics-symbolic"
type: Kirigami.PlaceholderMessage.Type.Informational
}
}
Item {
visible: view.applet.costLoading && view.providerCosts.length === 0
Layout.fillWidth: true
Layout.fillHeight: true
Controls.BusyIndicator {
anchors.centerIn: parent
running: parent.visible
}
}
Item {
visible: !view.applet.costLoading
&& view.providerCosts.length === 0
&& view.applet.costErrorText.length > 0
Layout.fillWidth: true
Layout.fillHeight: true
}
PlasmaComponents.ScrollView {
objectName: "spendHistoryScroll"
visible: view.providerCosts.length > 0
Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
PlasmaComponents.ScrollBar.horizontal.policy: PlasmaComponents.ScrollBar.AlwaysOff
ColumnLayout {
width: Math.max(0, parent.width - Kirigami.Units.smallSpacing)
spacing: Kirigami.Units.largeSpacing
InteractiveChart {
id: historyChart
visible: view.dailyPoints.length > 0
applet: view.applet
points: view.dailyPoints
accent: view.applet.readableAccentColor(
Kirigami.Theme.highlightColor,
Kirigami.Theme.backgroundColor)
kind: "bar"
// This chart plots the whole selected range, up to 90 bars, and
// is the primary view here: it outranks the heatmap below it.
plotHeight: Kirigami.Units.gridUnit * 6
accessibleTitle: view.applet.costHistoryShowsTokens
? i18n("Daily token history")
: i18n("Daily cost history")
}
Components.ProjectCostSection {
applet: view.applet
providerCosts: view.presentedProviderCosts
}
ColumnLayout {
// One week column carries a single day per row, which repeats
// the chart above instead of showing a weekday pattern.
visible: view.heatmapDays.length > 7
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing / 2
PlainPlasmaLabel {
text: i18n("Activity heatmap")
font.weight: Font.DemiBold
Layout.fillWidth: true
elide: Text.ElideRight
}
// The grid follows the selected range instead of a fixed
// 42-day window, and derives the cell size from the available
// width so the cells stay large enough to hover.
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
// Without weekday names the rows are an unlabelled stripe
// pattern; the labels are what make it a weekday read.
Column {
id: heatmapWeekdayLabels
visible: view.heatmapRowWeekdays.length === 7
spacing: heatmapGrid.rowSpacing
Layout.alignment: Qt.AlignTop
Accessible.ignored: true
Repeater {
model: view.heatmapRowWeekdays
delegate: PlainPlasmaLabel {
required property var modelData
height: heatmapGrid.cellHeight
text: Qt.locale().dayName(modelData, Locale.ShortFormat)
font: Kirigami.Theme.smallFont
opacity: view.applet.secondaryTextOpacity
verticalAlignment: Text.AlignVCenter
}
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 7 * heatmapGrid.cellHeight
+ 6 * heatmapGrid.rowSpacing
clip: true
Grid {
id: heatmapGrid
readonly property real cellSpacing: Math.max(1, Math.round(Kirigami.Units.smallSpacing / 2))
readonly property real minimumCellSize: Kirigami.Units.gridUnit * 0.6
// Kept below the chart's weight: this is the secondary
// read, a weekday pattern, not the magnitude over time.
readonly property real maximumCellSize: Kirigami.Units.gridUnit * 1.15
readonly property int fittingColumns: Math.max(1, Math.floor(
(width + cellSpacing) / (minimumCellSize + cellSpacing)))
readonly property int columnCount: Math.max(1, Math.min(
fittingColumns, Math.ceil(
(view.heatmapDays.length + view.heatmapTrailingSlots) / 7)))
readonly property real availableCellWidth: Math.max(minimumCellSize,
(width - cellSpacing * (columnCount - 1)) / columnCount)
readonly property real cellHeight: Math.max(minimumCellSize, Math.min(
maximumCellSize, availableCellWidth))
// Cells widen into spare width up to a 2:1 tile, so a
// long range fills the row while a short one keeps
// contribution-graph proportions instead of bars.
readonly property real cellWidth: Math.min(
availableCellWidth, cellHeight * 2)
readonly property var cells: CostPresentation.spendHeatmapCells(
view.heatmapDays, columnCount * 7, view.heatmapTrailingSlots)
width: parent.width
rows: 7
flow: Grid.TopToBottom
columnSpacing: cellSpacing
rowSpacing: cellSpacing
Repeater {
model: heatmapGrid.cells
delegate: Rectangle {
id: heatmapCell
required property var modelData
// Padding slots carry no day: they fill the
// oldest corner of the block and stay out of
// hover, the readout, and the reading order.
readonly property bool measured: !!heatmapCell.modelData
readonly property real fraction: heatmapCell.measured && view.heatmapMaximum > 0
? Math.max(0, Math.min(1,
Number(heatmapCell.modelData.value) / view.heatmapMaximum))
: 0
Accessible.role: Accessible.Graphic
Accessible.ignored: !heatmapCell.measured
// Days read like the chart's, in the locale's date format.
readonly property string dayLabel: heatmapCell.measured
? historyChart.pointLabel(heatmapCell.modelData) : ""
Accessible.name: heatmapCell.dayLabel
Accessible.description: heatmapCell.measured ? heatmapCell.modelData.displayValue : ""
width: heatmapGrid.cellWidth
height: heatmapGrid.cellHeight
radius: Kirigami.Units.cornerRadius / 2
color: heatmapCell.measured
? view.applet.withAlpha(
Kirigami.Theme.highlightColor,
0.1 + heatmapCell.fraction * 0.8)
: view.applet.withAlpha(Kirigami.Theme.textColor, 0.05)
// The cell carries no border of its own and the
// hover outline is a separate overlay. Fading a
// border's own alpha does not work: Qt treats a
// zero-alpha pen as invalid and paints the
// rectangle with a zero-width border, so the fill
// snaps out to the cell edge the moment the
// highlight leaves. Grabbing the rendered pixels
// confirmed it: between alpha 0 and 0.01 an edge
// pixel jumped straight from the fill colour to
// the background. Fading this overlay's opacity
// blends that pixel instead, leaving the cell's
// painted geometry untouched.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.width: 1
border.color: view.applet.withAlpha(
Kirigami.Theme.textColor, 0.4)
visible: heatmapCell.measured
opacity: heatmapMouse.containsMouse ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Kirigami.Units.shortDuration
}
}
}
Components.PlainToolTip {
// A readout for the cell under the pointer,
// not a label for a control: scanning the
// grid must not wait out a hover delay.
delay: 0
visible: heatmapMouse.containsMouse && heatmapCell.measured
plainText: heatmapCell.measured
? i18n("%1: %2", heatmapCell.dayLabel,
heatmapCell.modelData.displayValue)
: ""
}
MouseArea {
id: heatmapMouse
anchors.fill: parent
hoverEnabled: true
// A binding avoids Qt rejecting a bare zero enum literal.
acceptedButtons: (0)
}
}
}
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlainPlasmaLabel {
text: i18n("Providers")
font.weight: Font.DemiBold
Layout.fillWidth: true
elide: Text.ElideRight
}
Repeater {
id: spendProviderRepeater
model: view.presentedProviderCosts
delegate: Rectangle {
id: spendProviderCard
required property var modelData
Layout.fillWidth: true
implicitHeight: spendProviderRow.implicitHeight + Kirigami.Units.smallSpacing * 1.5
radius: view.applet.nestedSurfaceRadius
// A passive display row: no hover state, because every
// other highlighted surface in the popup acts on click
// or reveals actions, and this one does neither.
color: view.applet.withAlpha(Kirigami.Theme.textColor, 0.035)
Accessible.role: Accessible.ListItem
Accessible.name: view.applet.providerDisplayTitle(spendProviderCard.modelData.provider)
Accessible.description: spendProviderCard.modelData.windowValueLine
RowLayout {
id: spendProviderRow
anchors.fill: parent
anchors.leftMargin: Kirigami.Units.smallSpacing
anchors.rightMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
Rectangle {
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
Layout.preferredHeight: Layout.preferredWidth
// Same identity-tile treatment as the overview
// and header tiles, scaled down for this row.
radius: view.applet.nestedSurfaceRadius
color: view.applet.withAlpha(view.applet.providerReadableColor(
spendProviderCard.modelData.provider,
Kirigami.Theme.backgroundColor), 0.12)
Kirigami.Icon {
anchors.centerIn: parent
source: view.applet.providerIconSource(spendProviderCard.modelData.provider)
fallback: "view-statistics"
isMask: view.applet.providerIconIsMask(spendProviderCard.modelData.provider)
color: view.applet.providerReadableColor(
spendProviderCard.modelData.provider,
Kirigami.Theme.backgroundColor)
width: Kirigami.Units.iconSizes.small
height: Kirigami.Units.iconSizes.small
}
}
PlainPlasmaLabel {
text: view.applet.providerDisplayTitle(spendProviderCard.modelData.provider)
font.weight: Font.DemiBold
Layout.fillWidth: true
elide: Text.ElideRight
}
PlainPlasmaLabel {
// The range selector above states the window once,
// so each row carries only its own figures.
text: spendProviderCard.modelData.windowValueLine
opacity: view.applet.valueTextOpacity
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
}
}
}
}
}
}