@cad0p/pi-usage 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1001 @@
1
+ import QtQuick
2
+ import QtQuick.Controls
3
+ import qs.Commons
4
+ import qs.Ui
5
+ import "UsageLogic.js" as Logic
6
+
7
+ // The scrolling body of the AI Usage panel: one collapsible section per
8
+ // provider, all in one list. Panel.qml mounts it inside the bar popup; the
9
+ // dev harness mounts it in a plain window.
10
+ //
11
+ // The rows (LimitRow, Meter, DayRow, ModelRow) and every type size, color
12
+ // and spacing are carried over from Omarchy's built-in Agents panel
13
+ // (plugins/agents/Panel.qml, MIT, by Omarchy), so an expanded section reads
14
+ // exactly like one of its tabs.
15
+ Item {
16
+ id: root
17
+
18
+ // Main.qml, or anything with the same providers / expandedOverrides /
19
+ // setExpanded / newestUpdate / refreshing surface.
20
+ property var usage: null
21
+ property color foreground: Color.foreground
22
+ property color urgent: Color.urgent
23
+ property string fontFamily: Style.font.family
24
+ property double nowMs: Date.now()
25
+ // Stand-in mark for a provider that ships no SVG: the bar glyph.
26
+ property string fallbackGlyph: "󱚣"
27
+
28
+ readonly property color dim: Qt.darker(foreground, 1.55)
29
+ readonly property color surface: Color.popups.background
30
+ readonly property color track: Style.selectedFillFor(foreground, Color.accent)
31
+ readonly property bool lightSurface: colorLuminance(surface) >= 0.5
32
+
33
+ readonly property var providers: usage ? usage.providers : []
34
+ readonly property var overrides: usage ? usage.expandedOverrides : ({})
35
+ readonly property string defaultExpanded: Logic.defaultExpandedId(providers)
36
+
37
+ // Keyboard cursor over the section headers. It follows the provider, not
38
+ // the slot, so a record that lands while the panel is open does not move
39
+ // it underneath you.
40
+ property bool cursorActive: false
41
+ property string cursorId: ""
42
+ readonly property int cursorIndex: {
43
+ for (var i = 0; i < providers.length; i++) if (providers[i].providerId === cursorId) return i
44
+ return 0
45
+ }
46
+
47
+ readonly property real contentImplicitHeight: column.implicitHeight
48
+ readonly property alias flick: panelFlick
49
+
50
+ implicitHeight: column.implicitHeight
51
+
52
+ function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
53
+
54
+ function colorChannelLuminance(value) {
55
+ var channel = Number(value)
56
+ if (!isFinite(channel)) return 0
57
+ return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
58
+ }
59
+
60
+ function colorLuminance(color) {
61
+ return 0.2126 * colorChannelLuminance(color.r)
62
+ + 0.7152 * colorChannelLuminance(color.g)
63
+ + 0.0722 * colorChannelLuminance(color.b)
64
+ }
65
+
66
+ function expanded(id) { return Logic.isExpanded(id, overrides, defaultExpanded) }
67
+
68
+ function setExpanded(id, value) {
69
+ if (usage) usage.setExpanded(id, value)
70
+ }
71
+
72
+ function toggle(id) { setExpanded(id, !expanded(id)) }
73
+
74
+ function sectionItem(index) { return sectionRepeater.itemAt(index) }
75
+
76
+ function resetView() {
77
+ cursorActive = false
78
+ cursorId = providers.length > 0 ? providers[0].providerId : ""
79
+ panelFlick.contentY = 0
80
+ }
81
+
82
+ // ------------------------------------------------------------- keyboard
83
+
84
+ function maxScroll() { return Math.max(0, panelFlick.contentHeight - panelFlick.height) }
85
+
86
+ function scrollTo(y) { panelFlick.contentY = Logic.clamp(y, 0, maxScroll()) }
87
+
88
+ function ensureVisible(item) {
89
+ if (!item) return
90
+ var margin = Style.space(6)
91
+ var top = item.mapToItem(column, 0, 0).y
92
+ var bottom = top + item.height
93
+ if (top < panelFlick.contentY + margin) scrollTo(top - margin)
94
+ else if (bottom > panelFlick.contentY + panelFlick.height - margin) scrollTo(bottom + margin - panelFlick.height)
95
+ }
96
+
97
+ // j/k walk the headers. An open section taller than the viewport is paged
98
+ // through first, so nothing in it is skipped on the way to the next one.
99
+ function moveCursor(dy) {
100
+ if (providers.length === 0) return
101
+ if (!cursorActive) {
102
+ cursorActive = true
103
+ if (cursorId === "") cursorId = providers[0].providerId
104
+ ensureVisible(sectionItem(cursorIndex) ? sectionItem(cursorIndex).header : null)
105
+ return
106
+ }
107
+ var step = Style.space(56)
108
+ var current = sectionItem(cursorIndex)
109
+ if (current && expanded(providers[cursorIndex].providerId)) {
110
+ var top = current.mapToItem(column, 0, 0).y
111
+ var bottom = top + current.height
112
+ if (dy > 0 && bottom > panelFlick.contentY + panelFlick.height + 1 && panelFlick.contentY < maxScroll()) {
113
+ scrollTo(panelFlick.contentY + step)
114
+ return
115
+ }
116
+ if (dy < 0 && top < panelFlick.contentY - 1) {
117
+ scrollTo(panelFlick.contentY - step)
118
+ return
119
+ }
120
+ }
121
+ var next = Logic.clamp(cursorIndex + dy, 0, providers.length - 1)
122
+ if (next === cursorIndex) {
123
+ scrollTo(panelFlick.contentY + dy * step)
124
+ return
125
+ }
126
+ cursorId = providers[next].providerId
127
+ var section = sectionItem(next)
128
+ if (section) ensureVisible(section.header)
129
+ }
130
+
131
+ function toggleCursor() {
132
+ if (providers.length === 0) return
133
+ cursorActive = true
134
+ var id = providers[cursorIndex].providerId
135
+ if (cursorId === "") cursorId = id
136
+ toggle(id)
137
+ Qt.callLater(function() {
138
+ var section = sectionItem(cursorIndex)
139
+ if (section) ensureVisible(section.header)
140
+ })
141
+ }
142
+
143
+ function setCursorExpanded(value) {
144
+ if (providers.length === 0) return
145
+ cursorActive = true
146
+ var id = providers[cursorIndex].providerId
147
+ if (cursorId === "") cursorId = id
148
+ setExpanded(id, value)
149
+ }
150
+
151
+ function footerText() {
152
+ if (usage && usage.refreshing) return "Refreshing…"
153
+ var at = usage ? String(usage.newestUpdate || "") : ""
154
+ if (at === "") return ""
155
+ var ms = new Date(at).getTime()
156
+ if (!isFinite(ms)) return ""
157
+ var age = root.nowMs - ms
158
+ return age < 60000 ? "Updated just now" : "Updated " + Logic.compactDuration(age) + " ago"
159
+ }
160
+
161
+ // ---------------------------------------------------------------- list
162
+
163
+ Flickable {
164
+ id: panelFlick
165
+ anchors.fill: parent
166
+ contentWidth: width
167
+ contentHeight: column.implicitHeight
168
+ clip: true
169
+ boundsBehavior: Flickable.StopAtBounds
170
+ flickableDirection: Flickable.VerticalFlick
171
+ interactive: contentHeight > height
172
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
173
+
174
+ Column {
175
+ id: column
176
+ width: panelFlick.width
177
+ spacing: Style.space(8)
178
+
179
+ Text {
180
+ visible: root.providers.length === 0
181
+ width: parent.width
182
+ topPadding: Style.space(24)
183
+ bottomPadding: Style.space(24)
184
+ text: "No AI usage records found.\nRun piquota omarchy, or use an agent once."
185
+ color: root.dim
186
+ font.family: root.fontFamily
187
+ font.pixelSize: Style.font.body
188
+ horizontalAlignment: Text.AlignHCenter
189
+ wrapMode: Text.WordWrap
190
+ }
191
+
192
+ Repeater {
193
+ id: sectionRepeater
194
+ model: root.providers
195
+
196
+ ProviderSection {
197
+ required property var modelData
198
+ required property int index
199
+ width: column.width
200
+ provider: modelData
201
+ sectionIndex: index
202
+ isLast: index === root.providers.length - 1
203
+ }
204
+ }
205
+
206
+ Text {
207
+ textFormat: Text.PlainText
208
+ visible: text !== ""
209
+ width: parent.width
210
+ topPadding: Style.space(2)
211
+ text: root.footerText()
212
+ color: root.dim
213
+ font.family: root.fontFamily
214
+ font.pixelSize: Style.font.caption
215
+ horizontalAlignment: Text.AlignHCenter
216
+ elide: Text.ElideRight
217
+ }
218
+ }
219
+ }
220
+
221
+ // ------------------------------------------------------------ section
222
+
223
+ component ProviderSection: Column {
224
+ id: section
225
+ property var provider: null
226
+ property int sectionIndex: 0
227
+ property bool isLast: false
228
+ property alias header: headerSurface
229
+
230
+ readonly property bool open: !!provider && root.expanded(provider.providerId)
231
+ readonly property var head: Logic.headline(provider)
232
+ readonly property var limitList: provider && (!provider.accounts || provider.accounts.length === 0)
233
+ ? Logic.limitWindows(provider.limits) : []
234
+ readonly property var accountList: Logic.accountRows(provider)
235
+ readonly property var balance: provider ? provider.balance : null
236
+ readonly property bool balanceAlarming: Logic.balanceAlarming(balance)
237
+ readonly property var models: Logic.modelRows(provider)
238
+ readonly property bool hasDays: Logic.hasDays(provider)
239
+
240
+ spacing: Style.space(12)
241
+
242
+ // ---------- Header: mark · name · plan · headline ----------
243
+ CursorSurface {
244
+ id: headerSurface
245
+ width: parent.width
246
+ hasCursor: root.cursorActive && root.cursorIndex === section.sectionIndex
247
+ foreground: root.foreground
248
+ implicitHeight: headerContent.implicitHeight + Style.spacing.lg * 2
249
+
250
+ MouseArea {
251
+ anchors.fill: parent
252
+ hoverEnabled: true
253
+ cursorShape: Qt.PointingHandCursor
254
+ onEntered: {
255
+ root.cursorActive = true
256
+ root.cursorId = section.provider.providerId
257
+ }
258
+ onClicked: {
259
+ root.cursorId = section.provider.providerId
260
+ root.toggle(section.provider.providerId)
261
+ }
262
+ }
263
+
264
+ Item {
265
+ id: headerContent
266
+ anchors.left: parent.left
267
+ anchors.right: parent.right
268
+ anchors.verticalCenter: parent.verticalCenter
269
+ anchors.leftMargin: Style.space(8)
270
+ anchors.rightMargin: Style.space(8)
271
+ implicitHeight: Math.max(mark.height, labels.implicitHeight, trailing.implicitHeight)
272
+
273
+ Mark {
274
+ id: mark
275
+ providerId: section.provider ? section.provider.providerId : ""
276
+ size: Style.font.display
277
+ anchors.left: parent.left
278
+ anchors.verticalCenter: parent.verticalCenter
279
+ }
280
+
281
+ Column {
282
+ id: labels
283
+ anchors.left: mark.right
284
+ anchors.leftMargin: Style.space(14)
285
+ anchors.right: trailing.left
286
+ anchors.rightMargin: Style.space(10)
287
+ anchors.verticalCenter: parent.verticalCenter
288
+ spacing: Style.space(2)
289
+
290
+ Row {
291
+ width: parent.width
292
+ spacing: Style.space(8)
293
+
294
+ Text {
295
+ id: nameText
296
+ textFormat: Text.PlainText
297
+ text: section.provider ? section.provider.providerName : ""
298
+ width: Math.min(implicitWidth, parent.width - (chip.visible ? chip.width + parent.spacing : 0))
299
+ color: root.foreground
300
+ font.family: root.fontFamily
301
+ font.pixelSize: Style.font.title
302
+ font.bold: true
303
+ elide: Text.ElideRight
304
+ anchors.verticalCenter: parent.verticalCenter
305
+ }
306
+
307
+ BorderSurface {
308
+ id: chip
309
+ readonly property string label: Logic.statusChip(section.provider)
310
+ visible: label !== ""
311
+ width: chipText.implicitWidth + Style.space(10)
312
+ height: chipText.implicitHeight + Style.space(2)
313
+ anchors.verticalCenter: parent.verticalCenter
314
+ color: root.alpha(root.urgent, 0.10)
315
+ borderSpec: Border.flat(root.alpha(root.urgent, 0.35), 1)
316
+ radius: Style.cornerRadius
317
+
318
+ Text {
319
+ id: chipText
320
+ textFormat: Text.PlainText
321
+ anchors.centerIn: parent
322
+ text: chip.label
323
+ color: root.urgent
324
+ font.family: root.fontFamily
325
+ font.pixelSize: Style.font.caption
326
+ font.bold: true
327
+ }
328
+ }
329
+ }
330
+
331
+ Text {
332
+ textFormat: Text.PlainText
333
+ width: parent.width
334
+ text: Logic.headerMeta(section.provider).toUpperCase()
335
+ visible: text !== ""
336
+ color: root.dim
337
+ font.family: root.fontFamily
338
+ font.pixelSize: Style.font.caption
339
+ font.bold: true
340
+ font.letterSpacing: 1.2
341
+ elide: Text.ElideRight
342
+ }
343
+ }
344
+
345
+ Row {
346
+ id: trailing
347
+ anchors.right: parent.right
348
+ anchors.verticalCenter: parent.verticalCenter
349
+ spacing: Style.space(10)
350
+
351
+ Column {
352
+ visible: !!section.head
353
+ anchors.verticalCenter: parent.verticalCenter
354
+ spacing: Style.space(2)
355
+
356
+ Text {
357
+ textFormat: Text.PlainText
358
+ anchors.right: parent.right
359
+ text: section.head ? Math.round(section.head.percent * 100) + "%" : ""
360
+ color: section.head && section.head.percent >= 0.9 ? root.urgent : root.foreground
361
+ font.family: root.fontFamily
362
+ font.pixelSize: Style.font.title
363
+ font.bold: true
364
+ }
365
+
366
+ Text {
367
+ textFormat: Text.PlainText
368
+ anchors.right: parent.right
369
+ text: {
370
+ if (!section.head) return ""
371
+ var ms = Logic.resetMs(section.head, root.nowMs)
372
+ return section.head.title + (ms > 0 ? " · " + Logic.compactDuration(ms) : "")
373
+ }
374
+ color: root.dim
375
+ font.family: root.fontFamily
376
+ font.pixelSize: Style.font.caption
377
+ }
378
+ }
379
+
380
+ Text {
381
+ textFormat: Text.PlainText
382
+ anchors.verticalCenter: parent.verticalCenter
383
+ text: section.open ? "󰅀" : "󰅂"
384
+ color: root.dim
385
+ font.family: root.fontFamily
386
+ font.pixelSize: Style.font.icon
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ // A folded section still shows how full its headline window is.
393
+ Meter {
394
+ visible: !section.open && !!section.head
395
+ width: parent.width
396
+ value: section.head ? section.head.percent : -1
397
+ alarming: !!section.head && section.head.percent >= 0.9
398
+ }
399
+
400
+ // ---------- Body: the built-in tab, section by section ----------
401
+ Column {
402
+ id: body
403
+ visible: section.open
404
+ width: parent.width
405
+ spacing: Style.space(14)
406
+
407
+ // Status
408
+ BorderSurface {
409
+ visible: !!section.provider && section.provider.usageStatusText !== "" && section.provider.authHelpText !== ""
410
+ width: parent.width
411
+ implicitHeight: statusText.implicitHeight + Style.spacing.xl * 2
412
+ color: root.alpha(root.urgent, 0.10)
413
+ borderSpec: Border.flat(root.alpha(root.urgent, 0.35), 1)
414
+ radius: Style.cornerRadius
415
+
416
+ Text {
417
+ id: statusText
418
+ textFormat: Text.PlainText
419
+ anchors.left: parent.left
420
+ anchors.right: parent.right
421
+ anchors.verticalCenter: parent.verticalCenter
422
+ anchors.leftMargin: Style.space(12)
423
+ anchors.rightMargin: Style.space(12)
424
+ text: section.provider
425
+ ? (section.provider.ready ? section.provider.usageStatusText + ": " : "") + section.provider.authHelpText
426
+ : ""
427
+ color: root.dim
428
+ font.family: root.fontFamily
429
+ font.pixelSize: Style.font.caption
430
+ wrapMode: Text.WordWrap
431
+ }
432
+ }
433
+
434
+ // Balance
435
+ Column {
436
+ visible: !!section.balance
437
+ width: parent.width
438
+ spacing: Style.space(10)
439
+
440
+ readonly property real ratio: section.balance && section.balance.funded > 0
441
+ ? Logic.clamp(section.balance.remaining / section.balance.funded, 0, 1)
442
+ : -1
443
+
444
+ PanelSectionHeader {
445
+ width: parent.width
446
+ text: "BALANCE"
447
+ foreground: root.foreground
448
+ fontFamily: root.fontFamily
449
+ }
450
+
451
+ Item {
452
+ width: parent.width
453
+ implicitHeight: Math.max(balanceLabel.implicitHeight, balanceValue.implicitHeight)
454
+
455
+ Text {
456
+ id: balanceLabel
457
+ text: "Prepaid credits"
458
+ color: root.foreground
459
+ font.family: root.fontFamily
460
+ font.pixelSize: Style.font.body
461
+ anchors.left: parent.left
462
+ anchors.verticalCenter: parent.verticalCenter
463
+ }
464
+
465
+ Text {
466
+ id: balanceValue
467
+ textFormat: Text.PlainText
468
+ text: section.balance ? Logic.formatMoney(section.balance.remaining, section.balance.currency) : ""
469
+ color: section.balanceAlarming ? root.urgent : root.foreground
470
+ font.family: root.fontFamily
471
+ font.pixelSize: Style.font.caption
472
+ anchors.right: parent.right
473
+ anchors.verticalCenter: parent.verticalCenter
474
+ }
475
+ }
476
+
477
+ Meter {
478
+ visible: parent.ratio >= 0
479
+ width: parent.width
480
+ value: parent.ratio
481
+ alarming: section.balanceAlarming
482
+ }
483
+
484
+ Text {
485
+ textFormat: Text.PlainText
486
+ visible: text !== ""
487
+ width: parent.width
488
+ text: Logic.balanceDetailText(section.balance)
489
+ color: root.dim
490
+ font.family: root.fontFamily
491
+ font.pixelSize: Style.font.caption
492
+ }
493
+ }
494
+
495
+ // Limits (single account)
496
+ Column {
497
+ id: limitsSection
498
+ visible: section.limitList.length > 0
499
+ width: parent.width
500
+ spacing: Style.space(10)
501
+
502
+ PanelSectionHeader {
503
+ text: "LIMITS"
504
+ foreground: root.foreground
505
+ fontFamily: root.fontFamily
506
+ }
507
+
508
+ Repeater {
509
+ model: section.limitList
510
+
511
+ LimitRow {
512
+ required property var modelData
513
+ width: limitsSection.width
514
+ window: modelData
515
+ }
516
+ }
517
+ }
518
+
519
+ // Accounts (multi-account): one row per account, every window
520
+ Column {
521
+ id: accountsSection
522
+ visible: section.accountList.length > 0
523
+ width: parent.width
524
+ spacing: Style.space(10)
525
+
526
+ PanelSectionHeader {
527
+ text: "ACCOUNTS"
528
+ foreground: root.foreground
529
+ fontFamily: root.fontFamily
530
+ }
531
+
532
+ Repeater {
533
+ model: section.accountList
534
+
535
+ AccountRow {
536
+ required property var modelData
537
+ width: accountsSection.width
538
+ account: modelData
539
+ }
540
+ }
541
+ }
542
+
543
+ // Tokens by day
544
+ Column {
545
+ id: usageSection
546
+ visible: section.hasDays
547
+ width: parent.width
548
+ spacing: Style.spacing.md
549
+
550
+ readonly property var days: section.provider ? Logic.recentDayRows(section.provider) : []
551
+ readonly property real peak: Math.max(1, Logic.weekPeak(section.provider))
552
+
553
+ PanelSectionHeader {
554
+ width: parent.width
555
+ text: "TOKENS BY DAY"
556
+ foreground: root.foreground
557
+ fontFamily: root.fontFamily
558
+ }
559
+
560
+ Repeater {
561
+ model: usageSection.days
562
+
563
+ DayRow {
564
+ required property var modelData
565
+ width: usageSection.width
566
+ day: modelData
567
+ provider: section.provider
568
+ ratio: Number(modelData.messageCount || 0) / usageSection.peak
569
+ today: String(modelData.date || "") === Logic.todayDate(root.nowMs)
570
+ }
571
+ }
572
+ }
573
+
574
+ // Tokens by model
575
+ Column {
576
+ id: modelSection
577
+ visible: section.models.length > 0
578
+ width: parent.width
579
+ spacing: Style.spacing.md
580
+
581
+ PanelSectionHeader {
582
+ width: parent.width
583
+ text: "TOKENS BY MODEL"
584
+ foreground: root.foreground
585
+ fontFamily: root.fontFamily
586
+ }
587
+
588
+ Repeater {
589
+ model: section.models
590
+
591
+ ModelRow {
592
+ required property var modelData
593
+ width: modelSection.width
594
+ row: modelData
595
+ share: modelData.total / Math.max(1, section.models[0].total)
596
+ }
597
+ }
598
+ }
599
+
600
+ Item { width: 1; height: Style.space(2) }
601
+ }
602
+
603
+ PanelSeparator {
604
+ visible: !section.isLast
605
+ foreground: root.foreground
606
+ }
607
+ }
608
+
609
+ // Provider mark: assets/<id>.svg, then the family without the pi- prefix,
610
+ // each with a -light twin first on light surfaces; the bar glyph otherwise.
611
+ component Mark: Item {
612
+ id: markItem
613
+ property string providerId: ""
614
+ property real size: Style.font.display
615
+ property var candidates: Logic.iconCandidates(providerId, root.lightSurface)
616
+ // Restart the fallback walk only when the URLs change: re-pointing source
617
+ // at a URL whose load already failed emits no statusChanged.
618
+ property string candidatesKey: candidates.join("\n")
619
+ property int candidateIndex: 0
620
+ onCandidatesKeyChanged: candidateIndex = 0
621
+
622
+ width: size
623
+ height: size
624
+
625
+ Image {
626
+ id: markImage
627
+ anchors.fill: parent
628
+ source: markItem.candidateIndex < markItem.candidates.length ? Qt.resolvedUrl(markItem.candidates[markItem.candidateIndex]) : ""
629
+ sourceSize.width: markItem.size * 2
630
+ sourceSize.height: markItem.size * 2
631
+ fillMode: Image.PreserveAspectFit
632
+ onStatusChanged: if (status === Image.Error && markItem.candidateIndex < markItem.candidates.length)
633
+ Qt.callLater(function() { markItem.candidateIndex++ })
634
+ }
635
+
636
+ Text {
637
+ textFormat: Text.PlainText
638
+ anchors.centerIn: parent
639
+ visible: markImage.status !== Image.Ready
640
+ text: root.fallbackGlyph
641
+ color: root.foreground
642
+ font.family: root.fontFamily
643
+ font.pixelSize: markItem.size
644
+ }
645
+ }
646
+
647
+ // A limit window: label and percentage, meter, and reset countdown.
648
+ component LimitRow: Column {
649
+ id: limitRow
650
+ property var window: null
651
+
652
+ readonly property bool alarming: window && window.percent >= 0.9
653
+
654
+ spacing: Style.space(6)
655
+
656
+ Item {
657
+ width: parent.width
658
+ implicitHeight: Math.max(limitLabel.implicitHeight, limitValue.implicitHeight)
659
+
660
+ Text {
661
+ id: limitLabel
662
+ textFormat: Text.PlainText
663
+ text: limitRow.window ? limitRow.window.title : ""
664
+ color: root.foreground
665
+ font.family: root.fontFamily
666
+ font.pixelSize: Style.font.body
667
+ elide: Text.ElideRight
668
+ anchors.left: parent.left
669
+ anchors.right: limitValue.left
670
+ anchors.rightMargin: Style.spacing.sm
671
+ anchors.verticalCenter: parent.verticalCenter
672
+ }
673
+
674
+ Text {
675
+ id: limitValue
676
+ textFormat: Text.PlainText
677
+ text: limitRow.window && limitRow.window.percent >= 0
678
+ ? Math.round(limitRow.window.percent * 100) + "%"
679
+ : "—"
680
+ color: limitRow.alarming ? root.urgent : root.foreground
681
+ font.family: root.fontFamily
682
+ font.pixelSize: Style.font.caption
683
+ anchors.right: parent.right
684
+ anchors.verticalCenter: parent.verticalCenter
685
+ }
686
+ }
687
+
688
+ Meter {
689
+ width: parent.width
690
+ value: limitRow.window ? limitRow.window.percent : -1
691
+ alarming: limitRow.alarming
692
+ }
693
+
694
+ Text {
695
+ textFormat: Text.PlainText
696
+ width: parent.width
697
+ text: {
698
+ var remainingMs = Logic.resetMs(limitRow.window, root.nowMs)
699
+ return remainingMs > 0 ? "Resets in " + Logic.formatDuration(remainingMs) : ""
700
+ }
701
+ color: root.dim
702
+ font.family: root.fontFamily
703
+ font.pixelSize: Style.font.caption
704
+ }
705
+ }
706
+
707
+ // One account of a multi-account provider: its slot, then every window as
708
+ // a column of tag, percentage, reset and a mini meter, aligned across rows
709
+ // like a table. A failed account is dimmed and says why.
710
+ component AccountRow: Item {
711
+ id: accountRow
712
+ property var account: null
713
+
714
+ readonly property var windows: account ? account.windows : []
715
+ readonly property bool ok: !!account && account.ok
716
+ readonly property real columnGap: Style.space(12)
717
+
718
+ implicitHeight: Math.max(slotText.implicitHeight, ok && windows.length > 0 ? windowColumns.implicitHeight : statusLine.implicitHeight)
719
+ opacity: ok ? 1 : 0.55
720
+
721
+ Text {
722
+ id: slotText
723
+ textFormat: Text.PlainText
724
+ text: accountRow.account ? accountRow.account.id : ""
725
+ color: root.foreground
726
+ font.family: root.fontFamily
727
+ font.pixelSize: Style.font.bodySmall
728
+ font.bold: true
729
+ width: Style.space(30)
730
+ anchors.left: parent.left
731
+ anchors.top: parent.top
732
+ }
733
+
734
+ Row {
735
+ id: windowColumns
736
+ visible: accountRow.ok && accountRow.windows.length > 0
737
+ anchors.left: slotText.right
738
+ anchors.leftMargin: Style.space(6)
739
+ anchors.right: parent.right
740
+ anchors.top: parent.top
741
+ spacing: accountRow.columnGap
742
+
743
+ readonly property real cellWidth: accountRow.windows.length > 0
744
+ ? (width - spacing * (accountRow.windows.length - 1)) / accountRow.windows.length
745
+ : 0
746
+
747
+ Repeater {
748
+ model: accountRow.windows
749
+
750
+ Column {
751
+ id: cell
752
+ required property var modelData
753
+ readonly property bool alarming: modelData.percent >= 0.9
754
+ width: windowColumns.cellWidth
755
+ spacing: Style.space(4)
756
+
757
+ Item {
758
+ width: parent.width
759
+ implicitHeight: cellPercent.implicitHeight
760
+
761
+ Text {
762
+ id: cellTag
763
+ textFormat: Text.PlainText
764
+ anchors.left: parent.left
765
+ anchors.verticalCenter: parent.verticalCenter
766
+ width: Math.min(implicitWidth, Math.max(0, cellReset.x - cellPercent.implicitWidth - Style.space(8)))
767
+ elide: Text.ElideRight
768
+ text: Logic.windowTag(cell.modelData.title)
769
+ color: root.dim
770
+ font.family: root.fontFamily
771
+ font.pixelSize: Style.font.caption
772
+ }
773
+
774
+ Text {
775
+ id: cellPercent
776
+ textFormat: Text.PlainText
777
+ anchors.left: cellTag.right
778
+ anchors.leftMargin: Style.space(4)
779
+ anchors.verticalCenter: parent.verticalCenter
780
+ text: Math.round(cell.modelData.percent * 100) + "%"
781
+ color: cell.alarming ? root.urgent : root.foreground
782
+ font.family: root.fontFamily
783
+ font.pixelSize: Style.font.caption
784
+ font.bold: true
785
+ }
786
+
787
+ Text {
788
+ id: cellReset
789
+ textFormat: Text.PlainText
790
+ anchors.right: parent.right
791
+ text: {
792
+ var ms = Logic.resetMs(cell.modelData, root.nowMs)
793
+ return ms > 0 ? Logic.compactDuration(ms) : ""
794
+ }
795
+ color: root.dim
796
+ font.family: root.fontFamily
797
+ font.pixelSize: Style.font.caption
798
+ }
799
+ }
800
+
801
+ Meter {
802
+ width: parent.width
803
+ thickness: Math.max(Style.space(3), Math.round(Style.spacing.controlHeight * 0.1))
804
+ value: cell.modelData.percent
805
+ alarming: cell.alarming
806
+ }
807
+ }
808
+ }
809
+ }
810
+
811
+ Text {
812
+ id: statusLine
813
+ visible: !accountRow.ok || accountRow.windows.length === 0
814
+ textFormat: Text.PlainText
815
+ anchors.left: slotText.right
816
+ anchors.leftMargin: Style.space(6)
817
+ anchors.right: parent.right
818
+ anchors.verticalCenter: slotText.verticalCenter
819
+ text: accountRow.account && accountRow.account.status !== "" ? accountRow.account.status : "No usage windows"
820
+ color: root.dim
821
+ font.family: root.fontFamily
822
+ font.pixelSize: Style.font.caption
823
+ elide: Text.ElideRight
824
+ }
825
+ }
826
+
827
+ // Rounded track showing the percentage of the allowance used.
828
+ component Meter: Item {
829
+ id: meter
830
+ property real value: -1
831
+ property bool alarming: false
832
+ property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
833
+
834
+ implicitHeight: thickness
835
+
836
+ Rectangle {
837
+ id: meterTrack
838
+ anchors.fill: parent
839
+ radius: height / 2
840
+ color: root.track
841
+ }
842
+
843
+ Rectangle {
844
+ anchors.left: meterTrack.left
845
+ anchors.verticalCenter: meterTrack.verticalCenter
846
+ height: meterTrack.height
847
+ radius: meterTrack.radius
848
+ width: meterTrack.width * Logic.clamp(meter.value, 0, 1)
849
+ color: meter.alarming ? root.urgent : root.foreground
850
+
851
+ Behavior on width {
852
+ NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
853
+ }
854
+ }
855
+ }
856
+
857
+ // One row per day: label, bar, tokens. Today is picked out in full
858
+ // foreground so the week reads as a run-up to right now.
859
+ component DayRow: Item {
860
+ id: dayRow
861
+ property var day: null
862
+ property var provider: null
863
+ property real ratio: 0
864
+ property bool today: false
865
+
866
+ implicitHeight: Math.max(dayLabel.implicitHeight, dayValue.implicitHeight) + Style.spacing.sm
867
+
868
+ Text {
869
+ id: dayLabel
870
+ textFormat: Text.PlainText
871
+ text: Logic.dayLabel(dayRow.day ? dayRow.day.date : "", dayRow.today)
872
+ color: dayRow.today ? root.foreground : root.dim
873
+ font.family: root.fontFamily
874
+ font.pixelSize: Style.font.caption
875
+ font.bold: dayRow.today
876
+ anchors.left: parent.left
877
+ anchors.verticalCenter: parent.verticalCenter
878
+ width: Style.space(52)
879
+ }
880
+
881
+ Rectangle {
882
+ anchors.left: dayLabel.right
883
+ anchors.right: dayValue.left
884
+ anchors.leftMargin: Style.space(8)
885
+ anchors.rightMargin: Style.space(10)
886
+ anchors.verticalCenter: parent.verticalCenter
887
+ height: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
888
+ radius: height / 2
889
+ color: root.track
890
+
891
+ Rectangle {
892
+ anchors.left: parent.left
893
+ anchors.verticalCenter: parent.verticalCenter
894
+ height: parent.height
895
+ radius: parent.radius
896
+ width: parent.width * Logic.clamp(dayRow.ratio, 0, 1)
897
+ color: dayRow.today ? root.foreground : root.alpha(root.foreground, 0.55)
898
+
899
+ Behavior on width {
900
+ NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
901
+ }
902
+ }
903
+ }
904
+
905
+ Text {
906
+ id: dayValue
907
+ textFormat: Text.PlainText
908
+ text: Logic.formatTokenCount(dayRow.day ? Number(dayRow.day.messageCount || 0) : 0)
909
+ color: dayRow.today ? root.foreground : root.dim
910
+ font.family: root.fontFamily
911
+ font.pixelSize: Style.font.caption
912
+ font.bold: true
913
+ horizontalAlignment: Text.AlignRight
914
+ anchors.right: parent.right
915
+ anchors.verticalCenter: parent.verticalCenter
916
+ width: Style.space(52)
917
+ }
918
+
919
+ MouseArea {
920
+ id: dayHover
921
+ anchors.fill: parent
922
+ hoverEnabled: true
923
+ acceptedButtons: Qt.NoButton
924
+ }
925
+
926
+ PanelToolTip {
927
+ visible: dayHover.containsMouse
928
+ text: Logic.dayTooltip(dayRow.day, dayRow.today, dayRow.provider)
929
+ fontFamily: root.fontFamily
930
+ }
931
+ }
932
+
933
+ // Model rows read as a table: the share bar fills the row behind the label.
934
+ component ModelRow: Item {
935
+ id: modelRow
936
+ property var row: null
937
+ property real share: 0
938
+
939
+ implicitHeight: modelName.implicitHeight + Style.spacing.lg
940
+
941
+ Rectangle {
942
+ anchors.fill: parent
943
+ radius: Style.cornerRadius
944
+ color: root.alpha(root.foreground, 0.05)
945
+ }
946
+
947
+ Rectangle {
948
+ anchors.left: parent.left
949
+ anchors.top: parent.top
950
+ anchors.bottom: parent.bottom
951
+ width: parent.width * Logic.clamp(modelRow.share, 0, 1)
952
+ radius: Style.cornerRadius
953
+ color: root.alpha(root.foreground, 0.14)
954
+
955
+ Behavior on width {
956
+ NumberAnimation { duration: 160; easing.type: Easing.OutCubic }
957
+ }
958
+ }
959
+
960
+ Text {
961
+ id: modelName
962
+ textFormat: Text.PlainText
963
+ text: modelRow.row ? modelRow.row.name : ""
964
+ color: root.foreground
965
+ font.family: root.fontFamily
966
+ font.pixelSize: Style.font.bodySmall
967
+ elide: Text.ElideRight
968
+ anchors.left: parent.left
969
+ anchors.leftMargin: Style.space(8)
970
+ anchors.right: modelTokens.left
971
+ anchors.rightMargin: Style.space(8)
972
+ anchors.verticalCenter: parent.verticalCenter
973
+ }
974
+
975
+ Text {
976
+ id: modelTokens
977
+ textFormat: Text.PlainText
978
+ text: modelRow.row ? Logic.formatTokenCount(modelRow.row.total) : ""
979
+ color: root.dim
980
+ font.family: root.fontFamily
981
+ font.pixelSize: Style.font.bodySmall
982
+ font.bold: true
983
+ anchors.right: parent.right
984
+ anchors.rightMargin: Style.space(8)
985
+ anchors.verticalCenter: parent.verticalCenter
986
+ }
987
+
988
+ MouseArea {
989
+ id: modelHover
990
+ anchors.fill: parent
991
+ hoverEnabled: true
992
+ acceptedButtons: Qt.NoButton
993
+ }
994
+
995
+ PanelToolTip {
996
+ visible: modelHover.containsMouse
997
+ text: Logic.modelTooltip(modelRow.row)
998
+ fontFamily: root.fontFamily
999
+ }
1000
+ }
1001
+ }