Repository navigation
Expand file tree
/
Copy pathPanel.qml
More file actions
3798 lines (3431 loc) · 152 KB
/
Copy pathPanel.qml
File metadata and controls
3798 lines (3431 loc) · 152 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
// The clock's calendar popup: a month grid with ISO week numbers, built to
// sit beside the weather panel — same hero-over-detail composition, same
// spacing scale, same small-caps labels.
//
// The grid is a read-out rather than a picker: today is the only marked
// day, and the only thing that moves is which month is on screen —
// chevrons, the scroll wheel, and the arrow keys all step it.
//
// BarWidget.qml owns the bar label and hands this panel the button to
// anchor against.
Panel {
id: root
moduleName: "omarchy.clock"
ipcTarget: "omarchy.clock"
manageIpc: false
property var anchorItem: null
// Shift watch for the Join button. The popup is its own layer-shell
// window, so key events never reach the bar window this Panel lives in;
// they bubble from the focused key catcher up through the popup's
// contentItem instead, which is where this watches.
property bool shiftHeld: false
onOpenedChanged: if (!opened) shiftHeld = false
// The bar tracks the widget mounted in its slot — BarWidget.qml — not this
// nested panel. Everything the bar identifies a panel by has to be that
// widget: the popout coordinator (and with it the open-panel dot under the
// pill) compares against `slot.activeItem`, and switchPanelFrom looks the
// slot up the same way.
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
// ---- Today. SystemClock keeps this honest across midnight so the
// highlight rolls over without the panel being reopened.
property date today: new Date()
readonly property string todayKey: Model.keyForDate(today)
// The month on screen. Stepping moves this and nothing else: the grid is
// a read-out, not a picker, so there is no per-day cursor to keep in sync.
property int viewYear: today.getFullYear()
property int viewMonth: today.getMonth()
readonly property date viewDate: new Date(viewYear, viewMonth, 1)
readonly property bool viewingCurrentMonth: viewYear === today.getFullYear() && viewMonth === today.getMonth()
// Pinned to today, not to the month being browsed — stepping through the
// calendar does not change how much of the year is gone.
readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate())
readonly property int yearDonePercent: Model.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate())
// Memento mori, for anyone who goes looking: double-tapping the year bar
// asks for a birth year and a life expectancy, and a second bar tracks one
// against the other. A birth year rather than an age, so it keeps counting
// on its own. Without one the bar stays hidden.
readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear())
readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear())
readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0))
readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy)
readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy)
property bool editingLife: false
// Unset falls through to the locale's own first day, so a fresh install
// starts out matching the rest of the desktop rather than a hardcoded
// convention. Clicking the grid's "W" heading writes the choice back to
// shell.json.
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
readonly property var weekdays: Model.weekdayOrder(weekStart)
property int eventRevision: 0
readonly property var weeks: {
eventRevision
return Model.monthGrid(viewYear, viewMonth, weekStart, todayKey, eventIndex, taskIndex)
}
property var eventDoc: null
property var eventIndex: ({})
property var visibleEventList: []
property string selectedDayKey: todayKey
readonly property var selectedEvents: Model.eventsForDateKey(eventIndex, selectedDayKey)
// ---- Tasks. A month grid has no room for the HEY calendar's "sometime
// this week" row per week, and it does not need one: a task with no
// due date, and a task whose due date has gone by, both belong to the
// work in front of you now. So there is one bucket, always the current
// week's — the week holding the selected day, when that is this week.
// Click into next week and the bucket is empty, because nothing has
// been carried into it yet. Tasks that do have a day ahead of them sit
// on that day instead, as a hollow ring in the cell.
//
// The rollover is a read, not a write (Model.taskPlacement): an
// unfinished task shows up in this week, and next week, and the one
// after, without the server ever being asked to move it.
property var taskIndex: ({})
property var visibleTaskList: []
readonly property var selectedTasks: {
eventRevision
return Model.tasksForDateKey(taskIndex, selectedDayKey)
}
readonly property string selectedWeekKey: Model.weekStartKeyOf(selectedDayKey, weekStart)
// The bucket's header shows on the current week even when the week is
// empty: the + on it is how the first todo gets added.
readonly property bool bucketWeekSelected: selectedWeekKey === Model.weekStartKeyOf(todayKey, weekStart)
readonly property string firstTaskCalendar: Model.firstTaskCalendarName(tbCalendars)
property bool quickTodoOpen: false
// Todos filed through the + but not yet read back from the calendar. They
// are merged into the bucket so a new one appears on Enter rather than
// three seconds later, and Model prunes them as the sync overtakes them.
property var pendingTodos: []
readonly property var bucketTasks: {
eventRevision
var list = root.pendingTodos.length
? root.visibleTaskList.concat(root.pendingTodos)
: root.visibleTaskList
return Model.weekTasksFor(list, root.selectedWeekKey, root.todayKey, root.weekStart)
}
readonly property date selectedDate: Model.dateFromKey(selectedDayKey, today)
readonly property string clockPluginDir: (Quickshell.env("HOME") || "") + "/.config/omarchy/plugins/mailbox.clock"
readonly property string suggestAddressScript: clockPluginDir + "/suggest-address"
// ---- Quick-add entry pane. Right-click a day to slide this in; Esc
// slides back. Create asks the mailbox daemon to write, and the push
// that answers it is what reads the calendar back.
property bool entryOpen: false
property string entryKind: "event"
property string nlText: ""
property string entryStatus: ""
property var tbCalendars: []
// ---- Editing an event already on the server, opened by clicking it in
// the day list rather than by the + or a right-click. Non-null id
// is what tells commitEntry to send `event edit` instead of `event
// add`, and the calendar chooser to go read-only -- an edit cannot
// move an event to another calendar.
property var editingEventId: null
// Same, for a todo opened by clicking its chip. Only one of the two is ever
// set: the pane edits one entry of one kind at a time.
property var editingTaskId: null
readonly property bool editingEntry: root.editingEventId !== null || root.editingTaskId !== null
property bool deleteConfirming: false
// The colour Omarchy's fastfetch logo is painted in — ANSI "green",
// which is `green` in a named colors.toml and `color2` in an indexed
// one. Today's cell is filled and ringed in it so the one date you look
// for first carries the theme's signature highlight instead of the grey
// the control-state tokens fall back to. Falls back to the shell accent.
property color todayAccent: Color.accent
property string formTitle: ""
property string formDate: ""
property string formStart: ""
property string formEnd: ""
property string formEndDate: ""
property bool formEndNextDay: false
// No toggle: an event with no start time is an all-day event, which is
// what a phrase without a time means anyway. Clearing the time chip is how
// an event goes back to all-day.
readonly property bool formAllDay: root.entryKind === "event" && root.formStart === ""
property string formLocation: ""
// ---- Address assist. What is typed into the location row is answered
// with whole postal addresses; picking one writes that line and
// nothing else. The query is kept beside the results so an answer
// that lands after the word moved on can be dropped.
property var locationSuggestions: []
property string locationSuggestQuery: ""
property int locationSuggestIndex: -1
readonly property bool locationSuggestOpen: root.locationSuggestions.length > 0
// Invitee suggestions: the location row's pattern pointed at the daemon
// instead of OSM. contact search first, then correspondent search, and
// the query kept beside the results so an answer to a word already
// typed over is dropped rather than shown.
property var inviteeSuggestions: []
property int inviteeSuggestIndex: -1
property string inviteeSuggestQuery: ""
readonly property bool inviteeSuggestOpen: root.inviteeSuggestions.length > 0
property string formDescription: ""
property string formCalendar: ""
property string formLink: ""
// ---- Teams and invitees. The toggle and the pills only exist on a
// Microsoft 365 calendar (the roster's teams flag), because the
// daemon refuses the args anywhere else. teamsLocked is the event
// already being a Teams meeting: there is no off for that — a
// Microsoft 365 rule Outlook shares — so the switch shows on and
// stops answering instead of offering a change that cannot happen.
property bool formTeams: false
property bool formTeamsLocked: false
property var formInvitees: []
// The addresses `event view` came back with, so an edit can send only
// the difference: new pills as `invite`, gone ones as `uninvite`.
property var formOriginalInvitees: []
// Which summary value is currently an inline editor ("" = display mode).
property string editingSegment: ""
// Roles the parser found in the phrase, as offsets into it.
property var nlSegments: []
// The calendar the phrase named, kept apart from the one in force: when
// the phrase names a list this kind cannot be written to, the pane says so
// rather than quietly filing the entry somewhere else.
property string nlCalendarName: ""
// Optional parts the user opened by hand. A part the phrase filled in is
// open because it has a value, so it never needs to be listed here.
property var openRows: []
// What the last parse put into each part. The phrase owns what it says;
// this is how the pane can tell a value the phrase put there — and has
// since dropped — from one typed into a row by hand, which another word in
// the phrase must not wipe.
property var nlApplied: ({})
// One value in the entry summary — the title, or a day or a time in the
// when-card. Display mode is the value over a hairline; a click swaps it
// for an inline field that takes the same words the phrase does.
component SlotEdit: Item {
id: slot
property string slotName: ""
property string display: ""
property string editValue: ""
property bool hero: false
property bool strong: false
property bool dim: false
property color tint: root.contentForeground
property int align: Text.AlignLeft
signal commitText(string text)
readonly property bool editing: root.editingSegment === slotName
implicitWidth: Math.max(slotText.implicitWidth + Style.space(8), Style.space(48))
implicitHeight: slotText.implicitHeight + Style.space(6)
width: implicitWidth
height: implicitHeight
Text {
id: slotText
visible: !slot.editing
anchors.fill: parent
anchors.bottomMargin: Style.space(4)
verticalAlignment: Text.AlignVCenter
horizontalAlignment: slot.align
elide: Text.ElideRight
text: slot.display
color: slot.dim ? Qt.darker(slot.tint, 1.7) : slot.tint
font.family: root.contentFontFamily
font.pixelSize: slot.hero ? Style.font.title : (slot.strong ? Style.font.heading : Style.font.body)
font.bold: slot.hero || slot.strong
}
// Hairline under the value: the form-field look of the reference, and
// the only hint that a value can be clicked.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.max(1, Style.space(1))
color: slot.editing
? Color.accent
: (slotHover.containsMouse ? slot.tint : Qt.darker(slot.tint, 2.6))
}
TextField {
id: slotEdit
visible: slot.editing
anchors.fill: parent
verticalPadding: 0
foreground: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: slotText.font.pixelSize
font.bold: slotText.font.bold
text: slot.editValue
// Write through on every keystroke. The Add button does not take
// focus, so accept/blur alone would lose a field left mid-edit.
onTextEdited: slot.commitText(text)
onAccepted: {
slot.commitText(text)
root.editingSegment = ""
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
root.editingSegment = ""
event.accepted = true
}
}
onActiveFocusChanged: if (!activeFocus && slot.editing) {
slot.commitText(text)
root.editingSegment = ""
}
}
MouseArea {
id: slotHover
anchors.fill: parent
enabled: !slot.editing
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.editingSegment = slot.slotName
slotEdit.text = slot.editValue
slotEdit.forceActiveFocus()
slotEdit.selectAll()
}
}
}
// The phrase field. A plain TextEdit does the editing but paints nothing;
// a styled Text with identical font, width and wrapping sits exactly on
// top of it and paints each parsed part in its own colour. Both run the
// same layout, so the two stay glyph-for-glyph aligned — which is why the
// editor's own text and cursor are transparent and drawn by us instead.
component PhraseField: Item {
id: field
property string phrase: ""
property string html: ""
property string placeholderText: ""
property real extraRightPadding: 0
signal edited(string text)
signal submitted()
signal cancelled()
readonly property real padX: Style.spacing.controlPaddingX
readonly property real padY: Style.spacing.inputPaddingY
readonly property bool hot: fieldHover.containsMouse
readonly property var spec: Border.controlSpec(
edit.activeFocus ? "focus" : (hot ? "hover-cursor" : "normal"),
root.contentForeground, Color.accent)
implicitHeight: edit.contentHeight + 2 * padY + Border.top(spec) + Border.bottom(spec)
height: implicitHeight
function focusInput() {
edit.forceActiveFocus()
edit.selectAll()
}
// Typing breaks the binding to `phrase`, so a reset from code has to
// write the editor directly rather than trust it to follow.
function setPhrase(text) {
edit.text = text
}
BorderSurface {
anchors.fill: parent
radius: Style.cornerRadius
color: Style.controlFill(edit.activeFocus, field.hot, root.contentForeground, Color.accent)
borderSpec: field.spec
}
TextEdit {
id: edit
anchors.fill: parent
anchors.leftMargin: field.padX + Border.left(field.spec)
anchors.rightMargin: field.padX + Border.right(field.spec) + field.extraRightPadding
anchors.topMargin: field.padY + Border.top(field.spec)
anchors.bottomMargin: field.padY + Border.bottom(field.spec)
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
// Invisible on purpose: the overlay above paints these same glyphs in
// the colours of the parts they were parsed as.
color: "transparent"
selectionColor: Style.selectionFillFor(root.contentForeground, Color.accent)
selectedTextColor: "transparent"
selectByMouse: true
font.family: root.contentFontFamily
font.pixelSize: Style.font.title
text: field.phrase
onTextChanged: if (text !== field.phrase) field.edited(text)
// The default cursor takes its colour from `color`, which is
// transparent here, so it has to be drawn.
cursorDelegate: Rectangle {
width: Math.max(1, Style.space(2))
color: root.contentForeground
visible: edit.activeFocus
SequentialAnimation on opacity {
running: edit.activeFocus
loops: Animation.Infinite
NumberAnimation { to: 0; duration: 500; easing.type: Easing.OutQuad }
NumberAnimation { to: 1; duration: 500; easing.type: Easing.InQuad }
}
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
field.cancelled()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
field.submitted()
event.accepted = true
}
}
}
Text {
anchors.fill: edit
visible: edit.text !== ""
textFormat: Text.StyledText
wrapMode: Text.Wrap
font: edit.font
color: root.contentForeground
text: field.html
}
Text {
anchors.fill: edit
visible: edit.text === ""
wrapMode: Text.Wrap
font: edit.font
color: Qt.darker(root.contentForeground, 1.8)
text: field.placeholderText
elide: Text.ElideRight
}
MouseArea {
id: fieldHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
}
}
// Notes are the one part that runs past a line, so they get a box rather
// than a field: five lines tall, scrolling once it is full. Enter puts in
// a newline here — Ctrl+Enter is what creates, since Enter everywhere else
// in the pane means "create".
component NotesField: Item {
id: notes
property string value: ""
property string placeholderText: ""
property int lines: 5
signal edited(string text)
signal cancelled()
signal submitted()
readonly property real padX: Style.spacing.controlPaddingX
readonly property real padY: Style.spacing.inputPaddingY
readonly property bool hot: notesHover.containsMouse
readonly property var spec: Border.controlSpec(
area.activeFocus ? "focus" : (hot ? "hover-cursor" : "normal"),
root.contentForeground, Color.accent)
implicitHeight: notes.lines * notesMetrics.height + 2 * padY
+ Border.top(spec) + Border.bottom(spec)
height: implicitHeight
function focusInput() {
area.forceActiveFocus()
area.cursorPosition = area.length
}
FontMetrics {
id: notesMetrics
font: area.font
}
BorderSurface {
anchors.fill: parent
radius: Style.cornerRadius
color: Style.controlFill(area.activeFocus, notes.hot, root.contentForeground, Color.accent)
borderSpec: notes.spec
}
Flickable {
id: notesScroll
anchors.fill: parent
anchors.leftMargin: notes.padX + Border.left(notes.spec)
anchors.rightMargin: notes.padX + Border.right(notes.spec)
anchors.topMargin: notes.padY + Border.top(notes.spec)
anchors.bottomMargin: notes.padY + Border.bottom(notes.spec)
contentWidth: width
contentHeight: area.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: contentHeight > height
TextEdit {
id: area
width: notesScroll.width
textFormat: TextEdit.PlainText
wrapMode: TextEdit.Wrap
selectByMouse: true
color: root.contentForeground
selectionColor: Style.selectionFillFor(root.contentForeground, Color.accent)
selectedTextColor: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
text: notes.value
onTextChanged: if (text !== notes.value) notes.edited(text)
// Keep the caret in view as the note grows past the box.
onCursorRectangleChanged: {
if (cursorRectangle.y < notesScroll.contentY)
notesScroll.contentY = cursorRectangle.y
else if (cursorRectangle.y + cursorRectangle.height > notesScroll.contentY + notesScroll.height)
notesScroll.contentY = cursorRectangle.y + cursorRectangle.height - notesScroll.height
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
notes.cancelled()
event.accepted = true
} else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
&& (event.modifiers & Qt.ControlModifier)) {
notes.submitted()
event.accepted = true
}
}
}
}
Text {
anchors.fill: notesScroll
visible: area.text === ""
wrapMode: Text.Wrap
color: Qt.darker(root.contentForeground, 1.8)
font.family: area.font.family
font.pixelSize: area.font.pixelSize
text: notes.placeholderText
}
MouseArea {
id: notesHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
}
}
// Event or task, as a pair of tabs rather than a shortcut nobody sees.
component KindTab: Item {
id: tab
property string label: ""
property bool active: false
signal activated()
implicitWidth: tabText.implicitWidth
implicitHeight: tabText.implicitHeight + Style.space(6)
Text {
id: tabText
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
text: tab.label
color: tab.active ? Color.accent
: (tabHover.containsMouse ? root.contentForeground : Qt.darker(root.contentForeground, 1.6))
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.bold: tab.active
}
Rectangle {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: tabText.width
height: Math.max(1, Style.space(2))
visible: tab.active
color: Color.accent
}
MouseArea {
id: tabHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tab.activated()
}
}
// ---- One task, as a chip rather than a row: box, title, and a note only
// when there is one to make. Chips flow, so a week's worth of small
// things takes one line instead of five — a to-do list is mostly two
// and three word titles, and a full-width row each throws away the
// width the panel already has. Only a title too long for what is left
// of the line takes a line of its own.
component TaskRow: Item {
id: taskRow
property var task: null
// In the bucket a task has no day of its own, so the note says how late
// it is instead of what time it is due.
property bool bucket: false
// The widest the chip may be — the Flow's width, so one long title elides
// on its own line instead of pushing the panel wider than the grid.
property real maxWidth: parent ? parent.width : 0
readonly property int lateDays: task ? Model.taskOverdueDays(task, root.todayKey) : 0
readonly property color tint: (task && Model.safeColor(task.color)) || Color.accent
// Measured off the texts' implicit widths, which do not depend on the
// widths handed back to them — so the chip sizes itself to its content
// without the binding chasing its own tail.
readonly property real noteReserve: taskNote.visible ? taskNote.implicitWidth + chip.spacing : 0
readonly property real naturalWidth: taskBox.width + chip.spacing + taskTitle.implicitWidth + noteReserve
// Waiting on the calendar to confirm it. Dimmed rather than hidden: it
// is on screen because you just typed it, and it should look like a thing
// still in flight until the sync says otherwise.
readonly property bool waiting: !!(task && task.pending)
implicitWidth: naturalWidth
width: maxWidth > 0 ? Math.min(naturalWidth, maxWidth) : naturalWidth
height: Math.max(taskBox.height, taskTitle.implicitHeight) + Style.space(5)
opacity: waiting ? 0.55 : 1
Row {
id: chip
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(7)
Rectangle {
id: taskBox
anchors.verticalCenter: parent.verticalCenter
width: Style.space(15)
height: Style.space(15)
radius: Style.space(4)
// Never drawn checked: ticking one takes it off the panel, so the
// only box that exists is an empty one. The box is where the calendar
// colour lives — the same colour the todo's ring takes in the grid
// above. It is why the chips do not name their list: "Aufgaben" on
// every one is a word you stop reading, and the colour says it
// without taking the room to say it.
color: "transparent"
border.width: Style.spacing.hairline * 1.5
border.color: taskBoxMouse.containsMouse
? taskRow.tint
: Util.alpha(taskRow.tint, 0.65)
MouseArea {
id: taskBoxMouse
anchors.fill: parent
// Enough to click at, but not past the gap before the title: the
// rest of the chip belongs to the other handler.
anchors.margins: -Style.space(2)
enabled: !taskRow.waiting
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleTask(taskRow.task)
}
PanelToolTip {
visible: taskBoxMouse.containsMouse
text: "Mark as done"
fontFamily: root.contentFontFamily
}
}
Text {
id: taskTitle
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, taskRow.width - taskBox.width - chip.spacing - taskRow.noteReserve)
text: (taskRow.task && taskRow.task.title) || ""
color: taskTitleMouse.containsMouse ? taskRow.tint : root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.bold: !!(taskRow.task && taskRow.task.priority === "high")
elide: Text.ElideRight
// Clicking the text opens the todo in the entry pane — the box next
// to it still just ticks it off.
MouseArea {
id: taskTitleMouse
anchors.fill: parent
enabled: !taskRow.waiting
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openTaskEdit(taskRow.task)
PanelToolTip {
visible: taskTitleMouse.containsMouse
text: "Edit todo"
fontFamily: root.contentFontFamily
}
}
}
Text {
id: taskNote
anchors.verticalCenter: parent.verticalCenter
// Only ever says something the title does not. A task with no due
// date is the ordinary case and needs no label for it; the note
// simply is not there.
text: {
if (taskRow.lateDays === 1) return "1 day late"
if (taskRow.lateDays > 1) return taskRow.lateDays + " days late"
if (!taskRow.bucket && taskRow.task && taskRow.task.time) return taskRow.task.time
return ""
}
visible: text !== ""
// Overdue is the one thing in this panel worth a warmer colour than
// the grey the rest of the meta text takes.
color: taskRow.lateDays > 0
? taskRow.tint
: Qt.darker(root.contentForeground, 1.6)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
}
}
// A labelled meter: small-caps name on the left, percentage on the right,
// the track between them. The year rail and the life rail are the same
// thing measured against different spans.
component ProgressRail: Item {
id: rail
property string label: ""
property real fraction: 0
property int percent: 0
width: parent ? parent.width : 0
height: Math.max(railLabel.implicitHeight, Style.space(10))
Text {
id: railLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: rail.label
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
font.letterSpacing: 1
}
Text {
id: railPercent
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: rail.percent + "%"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Rectangle {
anchors.left: railLabel.right
anchors.right: railPercent.left
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
height: Style.space(6)
radius: Style.cornerRadius > 0 ? height / 2 : 0
color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12)
Rectangle {
width: Math.round(parent.width * rail.fraction)
height: parent.height
radius: parent.radius
color: Style.selectedStateColor(root.contentForeground, Color.accent)
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
}
}
}
// The pill that opens one optional part. `focusTarget` is the control the
// row holds, so opening a part puts the caret straight in it.
component Pill: Button {
property string rowName: ""
property var focusTarget: null
bordered: true
horizontalPadding: Style.space(7)
selected: root.rowOpen(rowName)
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: {
root.toggleRow(rowName)
if (!focusTarget || !root.rowOpen(rowName)) return
// NotesField wraps its editor, so it focuses through a method of its own.
if (typeof focusTarget.focusInput === "function") focusTarget.focusInput()
else focusTarget.forceActiveFocus()
}
}
// One optional part of the entry — location, notes, alert, repeat,
// priority. The pill above opens it, the phrase opens it by mentioning it,
// and the × takes the part back off the entry.
component EntryRow: Item {
id: entryRow
property string rowName: ""
property string icon: ""
default property alias content: rowHolder.data
width: parent ? parent.width : 0
visible: root.rowOpen(rowName)
height: visible ? Math.max(Style.spacing.controlHeight, rowHolder.childrenRect.height) : 0
// Icon and × centre on the first line rather than on the row, so a tall
// box (notes) does not push them into its middle.
Text {
id: rowIcon
anchors.left: parent.left
anchors.top: parent.top
anchors.topMargin: Math.round((Style.spacing.controlHeight - height) / 2)
text: entryRow.icon
color: Qt.darker(root.contentForeground, 1.4)
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
}
Item {
id: rowHolder
anchors.left: rowIcon.right
anchors.leftMargin: Style.space(8)
anchors.right: rowClear.left
anchors.rightMargin: Style.space(6)
anchors.top: parent.top
height: childrenRect.height
}
PanelActionButton {
id: rowClear
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: Math.round((Style.spacing.controlHeight - height) / 2)
iconText: ""
tooltipText: "Remove"
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onClicked: root.clearRow(entryRow.rowName)
}
}
property int formDuration: 0
property int formAlertMinutes: 0
property var formRecurrence: null
property string formPriority: ""
readonly property var calendarChoices: Model.calendarOptions(tbCalendars, entryKind)
readonly property var calendarColorMap: Model.calendarColors(visibleEventList)
// The roster row behind the calendar the pane has picked. The Teams gate
// reads off it — a Microsoft 365 events calendar is the only place the
// daemon takes the teams and invite args — and so does the owner domain
// the invitee suggestions rank by.
readonly property var pickedCalendar: {
for (var i = 0; i < root.tbCalendars.length; i++)
if (root.tbCalendars[i].name === root.formCalendar) return root.tbCalendars[i]
return null
}
readonly property bool pickedCalendarTeams: !!(root.pickedCalendar && root.pickedCalendar.teams)
// Whether any calendar at all comes off Microsoft 365 — the gate for the
// standing meet-link button, so the icon is absent rather than an error
// waiting to be clicked.
readonly property bool hasTeamsCalendar: root.tbCalendars.some(function (c) { return !!c.teams })
property bool inviteOpen: false
readonly property string pickedCalendarOwner: root.pickedCalendar ? String(root.pickedCalendar.owner || "") : ""
// ---- Phrase colours. Omarchy themes are near-monochrome, so the parts of
// a phrase get hues of their own rather than shades of the accent —
// the whole point is telling a date from a time from a calendar at a
// glance. Lightness follows the popup surface so both dark and light
// themes stay readable, and the calendar part borrows the colour
// the server already paints that calendar in.
readonly property bool darkSurface: Color.popups.background.hslLightness < 0.5
function roleTint(hue, saturation) {
return Qt.hsla(hue, saturation === undefined ? 0.62 : saturation,
root.darkSurface ? 0.70 : 0.38, 1.0)
}
readonly property color roleDateColor: roleTint(0.78)
readonly property color roleTimeColor: roleTint(0.86)
readonly property color roleDurationColor: roleTint(0.92)
readonly property color roleAlertColor: roleTint(0.12)
readonly property color roleRepeatColor: roleTint(0.50)
readonly property color rolePriorityColor: roleTint(0.99)
readonly property color roleLocationColor: roleTint(0.42)
readonly property color roleLinkColor: roleTint(0.60, 0.85)
readonly property color roleCalendarColor: {
var own = root.formCalendar ? root.calendarColorMap[root.formCalendar] : ""
return own ? own : roleTint(0.97, 0.55)
}
// StyledText wants "#rrggbb"; a QML colour stringifies with its alpha.
function hexColor(value) {
function part(v) {
var text = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
return text.length < 2 ? "0" + text : text
}
return "#" + part(value.r) + part(value.g) + part(value.b)
}
readonly property var phraseColors: ({
title: hexColor(root.contentForeground),
date: hexColor(root.roleDateColor),
time: hexColor(root.roleTimeColor),
duration: hexColor(root.roleDurationColor),
alert: hexColor(root.roleAlertColor),
repeat: hexColor(root.roleRepeatColor),
priority: hexColor(root.rolePriorityColor),
location: hexColor(root.roleLocationColor),
link: hexColor(root.roleLinkColor),
calendar: hexColor(root.roleCalendarColor)
})
readonly property string phraseMarkup: Model.phraseHtml(root.nlText, root.nlSegments, root.phraseColors)
// ---- Optional parts. A part is on screen when the phrase gave it a value
// or its pill was clicked; the pill and the row's × are the same
// switch seen from two places.
function segmentValue(name) {
if (name === "link") return root.formLink
if (name === "location") return root.formLocation
if (name === "notes") return root.formDescription
if (name === "alert") return root.formAlertMinutes > 0 ? root.alertLabel() : ""
if (name === "repeat") return root.formRecurrenceValue
if (name === "priority") return root.formPriority
return ""
}
function rowOpen(name) {
return root.openRows.indexOf(name) !== -1 || root.segmentValue(name) !== ""
}
function openRow(name) {
if (root.openRows.indexOf(name) !== -1) return
var next = root.openRows.slice()
next.push(name)
root.openRows = next
}
function closeRow(name) {
var next = []
for (var i = 0; i < root.openRows.length; i++)
if (root.openRows[i] !== name) next.push(root.openRows[i])
root.openRows = next
}
function clearRow(name) {
if (name === "link") root.formLink = ""
else if (name === "location") root.formLocation = ""
else if (name === "notes") root.formDescription = ""
else if (name === "alert") root.formAlertMinutes = 0
else if (name === "repeat") root.formRecurrence = null
else if (name === "priority") root.formPriority = ""
if (name === "location") root.closeAddressSuggestions()
root.closeRow(name)
}
// ---- Address assist. Typing waits out a short pause before asking, so a
// word costs one request rather than one per keystroke, and only the
// location row ever asks — the phrase is never sent anywhere.
function requestAddressSuggestions(text) {
var query = String(text || "")
if (!Model.shouldSuggestAddress(query)) { root.closeAddressSuggestions(); return }
root.locationSuggestQuery = query.trim()
suggestTimer.restart()
}
function fetchAddressSuggestions() {
if (!root.locationSuggestQuery) return
suggestProc.running = false
suggestProc.command = [root.suggestAddressScript, root.locationSuggestQuery]
suggestProc.running = true
}
// Only the answer to the word still standing in the field is shown: a
// slower reply to something already typed over is thrown away.
function applyAddressSuggestions(raw) {
var reply = Model.parseAddressSuggestions(raw)
if (!root.entryOpen || !root.rowOpen("location")) return
if (reply.query !== root.locationSuggestQuery) return
if (reply.items.length === 1 && reply.items[0].value === root.formLocation) {
root.closeAddressSuggestions()
return
}
root.locationSuggestions = reply.items
root.locationSuggestIndex = reply.items.length ? 0 : -1
}
function closeAddressSuggestions() {
suggestTimer.stop()
root.locationSuggestions = []
root.locationSuggestIndex = -1
root.locationSuggestQuery = ""
}
// ---- Invitee suggestions. Both searches go out at once per keystroke,
// the way the mail composer's recipient field does — both are
// Mirror reads, microseconds — and the second's answer is the one
// shown, since it already contains the first's.
function requestInviteeSuggestions(text) {
var query = String(text || "").trim()
if (query.length < 2) { root.closeInviteeSuggestions(); return }
root.inviteeSuggestQuery = query
mailbox.call(["contact", "search"], { positional: query, limit: 6 }, function (error, data) {
if (!root.entryOpen || root.inviteeSuggestQuery !== query) return
var contacts = error ? [] : (data || [])
mailbox.call(["correspondent", "search"], { positional: query, limit: 6 }, function (error2, data2) {
if (!root.entryOpen || root.inviteeSuggestQuery !== query) return
root.inviteeSuggestions = Model.rankInviteeSuggestions(
contacts, error2 ? [] : (data2 || []), root.pickedCalendarOwner,
root.pickedInviteeAddresses(), 8)
root.inviteeSuggestIndex = root.inviteeSuggestions.length ? 0 : -1
})
})
}
function openInvite() {
root.inviteOpen = true
// The row is drawn by the binding above; focus once it exists.
Qt.callLater(function () { inviteeField.forceActiveFocus() })