Repository navigation
Expand file tree
/
Copy pathwindow.py
More file actions
4289 lines (3931 loc) · 227 KB
/
Copy pathwindow.py
File metadata and controls
4289 lines (3931 loc) · 227 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
"""The app window: transcript history, Promptify (a dictation -> a prompt, and the engines that
write it) and settings. Hidden until opened from the tray.
One Toplevel, a sidebar and three views. The look is Fluent 2's layer model in achromatic
neutrals - a grained `base` under the sidebar, the user's pure white `layer` for the content,
`card` for grouped surfaces and `ctl` for anything raised - with the brand green as the only
chroma: the primary fill and the focus ring come from OKLCH at the accent's hue, and a danger
sibling sits on the same curve at hue 25. (Until 2026-08-28 the neutrals carried the bar's
accent hue at 1-2 % chroma; with a red bar colour the whole window read as pink, and the user
wants the window white and the brand green.) Depth is ONE language: a 1 px hairline, the surface
ladder, and on a raised control a 1 px elevation edge (a darkened bottom line; in dark a lit top
too) - zero drop shadows, no glow, no gradients. Tk cannot round a widget corner, so every corner
in here is PIL-rendered and handed to Tk as a PhotoImage: controls carry their shape as their own
image (`rr_png`), keycaps are the same raised recipe at 20 px (`kbd_img`), icons are stroked on a
16 grid (`icon_png`), the sidebar's grain is a tiled noise PNG, and surfaces that must stretch
wear four corner masks (`corner_pngs`) pinned at their corners. Settings autosave - there is no
Save button.
"""
import base64
import bisect
import datetime
import io
import json
import math
import os
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import font as tkfont
from tkinter import ttk
import brand
import connect
import promptify
import vault
# --- tokens ---------------------------------------------------------------------------------
def oklch_hex(L, C, h) -> str:
"""OKLCH (L 0..1, C, h degrees) -> '#rrggbb', gamut-clipped by reducing chroma."""
def to_srgb(L, C, h):
a, b = C * math.cos(math.radians(h)), C * math.sin(math.radians(h))
l_ = L + 0.3963377774 * a + 0.2158037573 * b
m_ = L - 0.1055613458 * a - 0.0638541728 * b
s_ = L - 0.0894841775 * a - 1.2914855480 * b
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
b2 = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
return r, g, b2
for k in range(20):
lin = to_srgb(L, C * (1 - k / 20), h)
if all(-0.0005 <= v <= 1.0005 for v in lin):
break
def enc(v):
v = min(1.0, max(0.0, v))
v = 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
return round(v * 255)
return "#%02x%02x%02x" % tuple(enc(v) for v in lin)
def hex_hue(hx, fallback=250.0) -> float:
"""OKLCH hue of an sRGB hex; fallback (a cool grey) when (near) achromatic."""
r, g, b = (int(hx.lstrip('#')[i:i + 2], 16) / 255 for i in (0, 2, 4))
lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in (r, g, b)]
l = 0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]
m = 0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]
s = 0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]
l_, m_, s_ = l ** (1 / 3), m ** (1 / 3), s ** (1 / 3)
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
if math.hypot(a, b2) < 0.02:
return fallback
return math.degrees(math.atan2(b2, a)) % 360
# The accent steps (L, C) at the accent's hue - the only chromatic part of the palette. The
# neutrals are fixed achromatic hexes in Fluent 2's roles (base darker than layer in BOTH themes,
# the "layer on Mica" model); tinting them from the hue was the method's rule and the user's
# "pinkish" verdict overruled it: at 1-2 % chroma a red hue is a visible blush across every
# surface. Every alpha is premixed to a hex with `mix()` against the surface it sits on - Tk has
# no alpha, a token is a hex.
ACCENT = {False: {"ring": (0.60, .17), "primary": (0.55, .17), "primary_hover": (0.50, .17)},
True: {"ring": (0.62, .15), "primary": (0.545, .15), "primary_hover": (0.59, .15)}}
DANGER_HUE = 25.0
DANGER = {False: {"danger": (0.55, .17), "danger_hover": (0.50, .17), "danger_text": (0.55, .17)},
True: {"danger": (0.62, .15), "danger_hover": (0.66, .15), "danger_text": (0.72, .14)}}
NEUTRAL = { # Fluent 2 roles, achromatic, the user's white as the layer
False: dict(base="#f3f3f3", layer="#ffffff", card="#ffffff", ctl="#fbfbfb", ctl_hover="#f5f5f5",
ctl_press="#efefef", ink="#1b1b1b", muted="#646464"),
True: dict(base="#121212", layer="#1c1c1c", card="#242424", ctl="#2e2e2e", ctl_hover="#353535",
ctl_press="#292929", ink="#ececec", muted="#a6a6a6")}
ALPHA = { # (light, dark): alpha of black (light) / white (dark) over a surface
"subtle": (.045, .065), "subtle_hover": (.03, .045), "stroke": (.08, .07), "divider": (.06, .06),
"stroke_edge": (.16, None), "stroke_top": (None, .10), "stroke_field": (.45, .55),
"stroke_strong": (.28, .30), "disabled": (.36, .36), "card_inset": (None, .04)}
def rgb(hx) -> tuple:
return tuple(int(hx.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
def lum(hx) -> float:
lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in
(c / 255 for c in rgb(hx))]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def contrast(a, b) -> float:
la, lb = sorted((lum(a), lum(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
def mix(a, b, t) -> str:
"""Blend hex a toward hex b by t (0..1). Disabled text is a 45 % blend toward the ground."""
return "#%02x%02x%02x" % tuple(round(x + (y - x) * t) for x, y in zip(rgb(a), rgb(b)))
def norm_hex(v, default=None):
"""'#RGB' / 'rrggbb' -> '#rrggbb' lower-case; `default` when it is not a hex colour."""
s = str(v or "").strip().lstrip("#")
if len(s) == 3 and all(c in "0123456789abcdefABCDEF" for c in s):
s = "".join(c * 2 for c in s)
if len(s) == 6 and all(c in "0123456789abcdefABCDEF" for c in s):
return "#" + s.lower()
return default
def palette(accent_hex: str, dark: bool) -> dict:
"""One hue -> the whole system. The neutrals are `NEUTRAL[dark]`; the four GROUND-RELATIVE
tokens are functions on the dict (`p["sub"](ground)`, `sub_hover`, `stroke`, `divider`) so a
caller always premixes against the surface it draws on; the edges, strokes and `disabled`
are premixed here over the surface they are defined on (`ctl`, `layer`, `card`). The accent
(ring, primary) and its danger sibling at hue 25 are OKLCH at the accent's hue, and `on_*`
is the text colour on a solid fill - white whenever white clears 4.5."""
h = hex_hue(norm_hex(accent_hex, "#3c8cff"))
p = dict(NEUTRAL[dark])
over = "#ffffff" if dark else "#000000" # what the alphas are alphas OF
a = lambda name: ALPHA[name][dark]
p["sub"] = lambda g: mix(g, over, a("subtle")) # selected row / nav fill
p["sub_hover"] = lambda g: mix(g, over, a("subtle_hover")) # hover on rows, nav, text buttons
p["stroke"] = lambda g: mix(g, over, a("stroke")) # a card's or control's outline
p["divider"] = lambda g: mix(g, over, a("divider")) # inset rules
# the elevation edge of a raised control: a darkened bottom line in both themes (black 25 %
# over `ctl` in dark), a lit top line in dark only
p["stroke_edge"] = mix(p["ctl"], "#000000", .25) if dark else mix(p["ctl"], over, a("stroke_edge"))
p["stroke_top"] = mix(p["ctl"], over, a("stroke_top")) if dark else None
p["stroke_field"] = mix(p["layer"], over, a("stroke_field")) # a field's underline at rest
p["stroke_strong"] = mix(p["layer"], over, a("stroke_strong")) # scroll thumb, slider track
p["thumb"] = p["stroke_strong"]
p["disabled"] = mix(p["layer"], over, a("disabled")) # < 4.5 on purpose (WCAG exempts)
p["card_inset"] = mix(p["card"], over, a("card_inset")) if dark else None
for n, (L, C) in ACCENT[dark].items():
p[n] = oklch_hex(L, C, h)
for n, (L, C) in DANGER[dark].items():
p[n] = oklch_hex(L, C, DANGER_HUE)
on = lambda fill: "#ffffff" if contrast("#ffffff", fill) >= 4.5 else \
max(("#ffffff", p["layer"], p["ink"]), key=lambda c: contrast(c, fill))
p["on_primary"], p["on_danger"] = on(p["primary"]), on(p["danger"])
# the chips' ground: a whisper of the ring over the layer, a little more under the pointer
# and behind the chosen chip - only a little in light, where the focus ring has to stay 3:1
# on the chosen chip (16 % holds 3.09)
p["tint"] = mix(p["layer"], p["ring"], 0.18 if dark else 0.12)
p["tint_hover"] = mix(p["layer"], p["ring"], 0.24 if dark else 0.16)
# the sidebar's material, not colours: the grain's sigma at 1x device px and the white
# alpha of the specular band at its top
p["grain"], p["specular"] = (2.5, .045) if dark else (3.0, .40)
return p
def system_dark() -> bool:
"""The Windows apps theme; anything unreadable means light."""
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
return not winreg.QueryValueEx(k, "AppsUseLightTheme")[0]
except Exception:
return False
SP = (4, 8, 12, 16, 24, 32, 48) # the only spacings allowed in this file
PAD_TOP = 16 # ... except the header's top pad, tuned by eye against the
LH = 3 # title bar, and the Text line lead that makes ~1.5 line height
R_CTL = 8 # radius family: controls (button, field, segment, chip,
R_CARD = 12 # nav pill, row highlight) and grouped cards. Inner radius
R_IN = 2 # = outer - inset, clamped at 0: the segment's selected cell
R_KBD = 4 # keycaps
H_CTL = 32 # control height
H_NAV = 36 # sidebar nav row (Fluent NavigationViewItem); rows 4 apart
H_ROW = 36 # history row (one line)
H_ROW2 = 52 # Promptify's dictation row (two lines)
H_GROUP = 24 # day-group header row in both lists
H_CHIP = 24 # question chips, the trigger-key cap
H_KBD = 20 # keycaps (inline and footer)
H_FOOT = 28 # the keyboard-hint footer strip
H_MARK = 24 # sidebar lockup: the mark's IMAGE box (its ink is 40/64 of
GAP_MARK = 16 # that, ~1.9x the wordmark's x-height) and the air after it.
# Both are up from the 20/10 first cut: at that size the mark
# read as a letter and the sidebar said "m murmur"
W_CHIP = 88 # trigger-key cap: fits "Previous Track" at 9 pt
W_SIDE = 192 # sidebar: 8 + pill 176 + 8 (icon 12 + 16 + 8 = label 36, then
# "Promptify" 56-58 + the Ctrl-digit chord 64 + 8 inset leaves
# >= 10 before the chord at every DPI; at 184 the chord overlaps)
W_FIELD = 96 # the hex and language entries (7 mono characters)
W_DAYS = 64 # the retention entry
W_COMBO = 200 # the microphone and model combos
W_TIME = 44 # the time column: a right-aligned mono9 "17:44" (35 px) at the
# row's inner right; the day lives in the group header above
W_FILTER = 260 # the History filter field
W_LIST = (176, 0.36, 240) # Promptify's list pane: clamp(min, share of the main width, max)
W_ACT = 80 # the engines sheet's action column
ICON = 16 # icon box (14 inside a chip, 10 for the draft mark in a row)
WIN = (780, 560)
WIN_MIN = (600, 400)
MOTION_FAST, MOTION_NORMAL = 83, 150 # ms (Fluent): a hover's two frames span FAST, the bar's
# three frames NORMAL - a frame is the span over the count
# the body measure: an 80-character line of body text is as wide as a prompt or a transcript
# gets. (`width=80` on a Text is 80 "0"s, which is ~100 average characters - too wide.)
SAMPLE80 = "The quick brown fox jumps over the lazy dog while the band plays on by the pier."
# --- domain ---------------------------------------------------------------------------------
MODELS = ["tiny.en", "base.en", "small.en", "tiny", "base", "small"] # nothing over ~500 MB: the app stays light (the user's rule)
MIC_DEFAULT = "System default" # the microphone combo's first entry (cfg["mic"] = None)
PRESETS = ["#ffffff", "#e63c3c", "#ff7a1a", "#ffc82a", "#4ade80", "#22d3ee", "#3c8cff", "#a855f7", "#ff4fa3"]
MEDIA_KEYS = {0xB3: "Play/Pause", 0xB2: "Media Stop", 0xB0: "Next Track", 0xB1: "Previous Track", 0xAD: "Mute",
0xAE: "Volume Down", 0xAF: "Volume Up", 0xB5: "Media Select", 0xB4: "Mail", 0xA6: "Browser Back"}
def vk_name(vk) -> str:
if vk is None:
return "none"
if vk in MEDIA_KEYS:
return MEDIA_KEYS[vk]
try:
import ctypes
sc = ctypes.windll.user32.MapVirtualKeyW(vk, 0)
buf = ctypes.create_unicode_buffer(64)
if sc and ctypes.windll.user32.GetKeyNameTextW(sc << 16, buf, 64):
return buf.value
except Exception:
pass
return f"key 0x{vk:02X}"
class History:
"""Append-only JSONL of {"t": epoch, "text": ...}; entries older than the retention
window are dropped on load and on every append."""
def __init__(self, path: Path, days: float):
self.path = path
self.days = days
self.items: list = []
self.path.parent.mkdir(parents=True, exist_ok=True)
self.load()
def load(self) -> None:
self.items = []
if self.path.exists():
for line in self.path.read_text(encoding="utf-8").splitlines():
try:
self.items.append(json.loads(line))
except ValueError:
continue
self.prune()
def prune(self) -> bool:
cutoff = time.time() - self.days * 86400
kept = [i for i in self.items if i["t"] >= cutoff]
changed = len(kept) != len(self.items)
self.items = kept
if changed:
self.save()
return changed
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text("".join(json.dumps(i, ensure_ascii=False) + "\n" for i in self.items),
encoding="utf-8")
def append(self, text: str) -> None:
self.items.append({"t": time.time(), "text": text})
if not self.prune():
with open(self.path, "a", encoding="utf-8") as f:
f.write(json.dumps(self.items[-1], ensure_ascii=False) + "\n")
def delete(self, idx: int) -> None:
del self.items[idx]
self.save()
def insert(self, idx: int, item: dict) -> None:
"""Undo of a delete: put the entry back where it was."""
self.items.insert(idx, item)
self.save()
def clear(self) -> None:
self.items = []
self.save()
# --- text -----------------------------------------------------------------------------------
def ellipsize(font, text: str, width: int) -> str:
"""The longest prefix of `text` that fits `width` px, closed with a real ellipsis."""
if width <= 0 or font.measure(text) <= width:
return text
lo, hi = 0, len(text)
while lo < hi: # binary search: measure() is the expensive call
mid = (lo + hi + 1) // 2
if font.measure(text[:mid] + "…") <= width:
lo = mid
else:
hi = mid - 1
return text[:lo] + "…"
def _days_ago(t: float, now: float = None) -> tuple:
"""(whole days between t's date and now's, t's struct_time, now's struct_time)."""
now = time.time() if now is None else now
lt, ln = time.localtime(t), time.localtime(now)
day = lambda s: datetime.date(s.tm_year, s.tm_mon, s.tm_mday)
return (day(ln) - day(lt)).days, lt, ln
def day_of(t: float, now: float = None) -> str:
"""The day-group header of a list row: 'Today' / 'Yesterday' / 'Monday' (2-6 days back) /
'Aug 12' / 'Aug 12 2025'. The row's own time is a plain clock beside it."""
d, lt, ln = _days_ago(t, now)
if d == 0:
return "Today"
if d == 1:
return "Yesterday"
if 2 <= d <= 6:
return time.strftime("%A", lt)
out = time.strftime("%b ", lt) + str(lt.tm_mday)
return out if lt.tm_year == ln.tm_year else out + " " + str(lt.tm_year)
# --- PIL-rendered round things --------------------------------------------------------------
SS = 3 # supersampling: Tk's canvas has no anti-aliasing, PIL does
def _png(img) -> bytes:
buf = io.BytesIO()
img.reduce(SS).save(buf, "PNG")
return buf.getvalue()
def compose_png(w: int, h: int, parts: list) -> bytes:
"""Several PNGs (already at device px) alpha-composited in order onto one transparent
w x h box - a nav row's grain, pill, icon and cap boxes as ONE image (`_nav_img`). Tk
blends every partial-alpha image it draws by reading the surface back first (~0.2 ms a
draw, measured 2026-09-08), so a row of four such images repainted on every view switch
cost 3 ms; one opaque image costs nothing of that."""
from PIL import Image
im = Image.new("RGBA", (w, h), (0, 0, 0, 0))
for x, y, png in parts:
im.alpha_composite(Image.open(io.BytesIO(png)).convert("RGBA"), (x, y))
buf = io.BytesIO()
im.save(buf, "PNG")
return buf.getvalue()
def dot_png(box: int, d: int, fill: str, ring: str = None, ring_w: int = 2, gap: int = 2,
edge: str = None, core: tuple = None) -> bytes:
"""A filled circle of diameter d centred in a transparent box; `edge` is its own hairline
(white would otherwise disappear on the light ground), `ring` the selection halo, `core` =
(diameter, colour) a second filled circle inside it - the slider thumb's accent core."""
from PIL import Image, ImageDraw
im = Image.new("RGBA", (box * SS, box * SS), (0, 0, 0, 0))
dr, c, r = ImageDraw.Draw(im), box * SS / 2, d * SS / 2
dr.ellipse([c - r, c - r, c + r, c + r], fill=fill, outline=edge, width=SS if edge else 0)
if core:
rc = core[0] * SS / 2
dr.ellipse([c - rc, c - rc, c + rc, c + rc], fill=core[1])
if ring:
# PIL draws an outline INWARD from its box, so the box's radius is the ring's outer
# edge: dot + gap + ring - a 14 px dot, gap 2, ring 2 fills a 22 box exactly
rr = r + (gap + ring_w) * SS
dr.ellipse([c - rr, c - rr, c + rr, c + rr], outline=ring, width=int(ring_w * SS))
return _png(im)
def pill_png(w: int, h: int, track: str, knob: str, on: bool, outline: str = None,
knob_d: int = None, t: float = None) -> bytes:
"""The toggle switch: a capsule with the knob at one end. `outline` is a 1 px hairline round
the track (the off state's), `knob_d` the knob's diameter (default h - 6), `t` the knob's
position 0.0 .. 1.0 (default: the end `on` says) - the slide's frames pass it."""
from PIL import Image, ImageDraw
im = Image.new("RGBA", (w * SS, h * SS), (0, 0, 0, 0))
dr = ImageDraw.Draw(im)
dr.rounded_rectangle([0, 0, w * SS - 1, h * SS - 1], radius=h * SS / 2, fill=track,
outline=outline, width=SS if outline else 0)
r, m = (h - 6 if knob_d is None else knob_d) * SS / 2, 3 * SS
t = (1.0 if on else 0.0) if t is None else t
cx = m + r + t * (w * SS - 2 * (m + r))
dr.ellipse([cx - r, h * SS / 2 - r, cx + r, h * SS / 2 + r], fill=knob)
return _png(im)
def _edges(dr, x0: int, y0: int, x1: int, y1: int, r: int, bottom: str = None,
top: str = None) -> None:
"""The Fluent elevation border on a rounded box already drawn at SS: a 1 logical px line
along the very bottom row (`bottom`) and/or the top row (`top`), BETWEEN the corner arcs -
it replaces the outline's straight run there and the arcs keep the outline's colour.
x0..x1 / y0..y1 are the box's inclusive pixel bounds, r its radius, all in SS px."""
if bottom:
dr.rectangle([x0 + r, y1 - SS + 1, x1 - r, y1], fill=bottom)
if top:
dr.rectangle([x0 + r, y0, x1 - r, y0 + SS - 1], fill=top)
def rr_png(w: int, h: int, r: int, fill: str, border: str = None, border_w: int = 1,
ground: str = None, bar: tuple = None, inner: tuple = None, edge: tuple = None,
under: tuple = None) -> bytes:
"""A rounded rectangle with an optional hairline - the shape a Tk widget cannot have, drawn
at SS and reduced so the arc is anti-aliased. `ground` is the colour BEHIND the corners: pass
it and the PNG is opaque (what a canvas item wants); leave it out and the corners are
transparent for Tk to composite against a Label's own bg. `edge` = (bottom, top), each a hex
or None: the elevation edge of a raised control (`_edges`). `under` = (colour, px): a text
field's underline, `px` tall along the bottom between the arcs. `bar` = (x, w, h, radius,
colour): an accent pill drawn inside, vertically centred. `inner` = (inset, width, colour):
a second outline INSIDE the edge, that far in, on the arc of radius r - inset - the focus
ring of a solid button, whose fill the accent ring would vanish against."""
from PIL import Image, ImageDraw
im = Image.new("RGBA", (w * SS, h * SS), ground if ground else (0, 0, 0, 0))
dr = ImageDraw.Draw(im)
dr.rounded_rectangle([0, 0, w * SS - 1, h * SS - 1], radius=r * SS, fill=fill,
outline=border, width=int(border_w * SS) if border else 0)
if edge:
_edges(dr, 0, 0, w * SS - 1, h * SS - 1, r * SS, *edge)
if under:
dr.rectangle([r * SS, (h - under[1]) * SS, (w - r) * SS - 1, h * SS - 1], fill=under[0])
if bar:
x, bw, bh, br, col = bar
y = (h - bh) * SS / 2
dr.rounded_rectangle([x * SS, y, (x + bw) * SS - 1, y + bh * SS - 1], radius=br * SS,
fill=col)
if inner:
n, iw, col = inner
dr.rounded_rectangle([n * SS, n * SS, (w - n) * SS - 1, (h - n) * SS - 1],
radius=max(0, r - n) * SS, outline=col, width=int(iw * SS))
return _png(im)
def corner_pngs(r: int, fill: str, ground: str, border: str = None, border_w: int = 1) -> list:
"""The four corner masks of a rounded rect, nw/ne/se/sw: one circle of diameter 2r on an
opaque `ground`, cut into quadrants. Pinned over the corners of a square frame they round
it - and because they are fixed-size images at RELATIVE positions, the frame underneath can
stretch with the window without re-rendering anything."""
from PIL import Image, ImageDraw
n = r * SS
im = Image.new("RGB", (2 * n, 2 * n), ground)
ImageDraw.Draw(im).ellipse([0, 0, 2 * n - 1, 2 * n - 1], fill=fill, outline=border,
width=int(border_w * SS) if border else 0)
return [_png(im.crop(b)) for b in
((0, 0, n, n), (n, 0, 2 * n, n), (n, n, 2 * n, 2 * n), (0, n, n, 2 * n))]
def segment_png(widths: tuple, h: int, r: int, i: int, fills: tuple, ground: str, fill: str,
border: str, border_w: int = 1, inset: int = R_IN) -> bytes:
"""Slice `i` of a segmented control. The container is rendered WHOLE - so both end caps come
off the same arc - the picked/hovered cells get an inner pill inset `inset` px, and only then
is it cut at the cell boundaries. Each slice goes on its own widget, which is how the cells
stay real widgets (one tab stop on the group, a <Button-1> per option) with round corners.
A cell in `fills` is None, a hex (the hovered cell: an inset pill) or a tuple
(fill, outline, bottom, top) - the picked cell as the raised key: a hairline and the
elevation edge between its inner arcs, the same recipe as `rr_png`'s `edge`."""
from PIL import Image, ImageDraw
w = sum(widths)
im = Image.new("RGBA", (w * SS, h * SS), ground)
dr = ImageDraw.Draw(im)
dr.rounded_rectangle([0, 0, w * SS - 1, h * SS - 1], radius=r * SS, fill=fill,
outline=border, width=int(border_w * SS))
for j, cell in enumerate(fills):
if cell:
x = sum(widths[:j])
box = [(x + inset) * SS, inset * SS, (x + widths[j] - inset) * SS - 1, (h - inset) * SS - 1]
ri = max(0, r - inset) * SS
if isinstance(cell, tuple):
dr.rounded_rectangle(box, radius=ri, fill=cell[0], outline=cell[1], width=SS)
_edges(dr, *box, ri, cell[2], cell[3])
else:
dr.rounded_rectangle(box, radius=ri, fill=cell)
x0 = sum(widths[:i])
return _png(im.crop((x0 * SS, 0, (x0 + widths[i]) * SS, h * SS)))
def grain_png(size: int, base: str, sigma: float) -> bytes:
"""The sidebar's material: a `size` px square of gaussian noise (sigma, at 1x device px - no
supersampling, the grain IS the pixels) around the achromatic `base`, clamped to base +- 5
so M6 (every sidebar pixel within 5 of base) holds by construction - at sigma 3 a gaussian
alone leaves ~9 % of the pixels past it, and 2 sigma is 6 anyway. Tiled on a canvas; a
monitor-high frost would be 190 ms, ~32 of these tiles are 0.4 ms once."""
from PIL import Image
b = rgb(base)[0] # achromatic: one channel is the colour
lo, hi = max(0, b - 5), min(255, b + 5)
im = Image.effect_noise((size, size), sigma).point(lambda v: max(lo, min(hi, v - 128 + b)))
buf = io.BytesIO()
im.convert("RGB").save(buf, "PNG")
return buf.getvalue()
def specular_png(w: int, h: int, alpha: float) -> bytes:
"""A white band whose alpha runs linearly from `alpha` at the top row to 0 at the bottom:
the light on the sidebar's top, composited by the canvas over the grain tiles (Tk 8.6
alpha-blends a partial-alpha PNG on a Canvas). Device px, no supersampling."""
from PIL import Image
im = Image.new("RGBA", (w, h), (255, 255, 255, 0))
im.putalpha(Image.frombytes("L", (w, h), bytes(round(255 * alpha * (1 - y / h))
for y in range(h) for _ in range(w))))
buf = io.BytesIO()
im.save(buf, "PNG")
return buf.getvalue()
# the icon glyphs on a 16 grid, logical px: ("line", points), ("closed", points), ("ellipse",
# bbox) - all stroked - and ("knob", (cx, cy, r)), a stroked circle whose inside is cut out to
# the ground. 1 px safe margin, 1.5 px stroke, round caps and joins, no fills.
GLYPHS = {
"history": (("ellipse", (2, 2, 14, 14)), ("line", ((8, 8), (8, 4.5))), ("line", ((8, 8), (10.5, 9.5)))),
"sparkle": (("closed", ((8, 2), (9.6, 6.4), (14, 8), (9.6, 9.6), (8, 14), (6.4, 9.6), (2, 8), (6.4, 6.4))),),
"options": (("line", ((2.5, 5.5), (13.5, 5.5))), ("line", ((2.5, 10.5), (13.5, 10.5))),
("knob", (10, 5.5, 1.75)), ("knob", (6, 10.5, 1.75))),
"search": (("ellipse", (2.5, 2.5, 11.5, 11.5)), ("line", ((10.3, 10.3), (13.5, 13.5)))),
"chev": (("line", ((4, 6.5), (8, 10.5), (12, 6.5))),),
"term": (("line", ((3, 4), (7.5, 8), (3, 12))), ("line", ((8.5, 12.5), (13.5, 12.5)))),
"globe": (("ellipse", (2, 2, 14, 14)), ("ellipse", (5, 2, 11, 14)), ("line", ((2, 8), (14, 8)))),
"note": (("closed", ((4, 2), (9.5, 2), (12, 4.5), (12, 14), (4, 14))),
("line", ((9.5, 2), (9.5, 4.5), (12, 4.5))),
("line", ((6, 8), (10, 8))), ("line", ((6, 10.5), (10, 10.5)))),
"check": (("line", ((3, 8.5), (6.5, 12), (13, 4.5))),),
# the three waveform styles in Settings > Appearance
"wave": (("line", ((2, 8), (4, 4.5), (6.5, 11.5), (9, 4), (11.5, 11), (14, 7.5))),),
"bars": (("line", ((3, 6.5), (3, 9.5))), ("line", ((6, 3), (6, 13))), ("line", ((9, 5), (9, 11))),
("line", ((12, 2.5), (12, 13.5)))),
"drop": (("closed", ((8, 2.5), (11.3, 7.8), (11.8, 10.4), (10.2, 12.9), (8, 13.6), (5.8, 12.9),
(4.2, 10.4), (4.7, 7.8))),),
}
def icon_png(name: str, colour: str, size: int = 16, ground: str = None) -> bytes:
"""One of `GLYPHS` stroked in `colour` on a transparent `size` px box (device px: the 16
grid scales with it), drawn at SS and reduced. Round joins are PIL's `joint="curve"`; round
caps are a filled circle of the stroke's diameter at each open end. `ground` fills the
`options` knobs; left out, their inside is cut to transparent, which reads the same on any
ground."""
from PIL import Image, ImageDraw
k = size * SS / 16 # grid units -> SS px
im = Image.new("RGBA", (size * SS, size * SS), (0, 0, 0, 0))
dr = ImageDraw.Draw(im)
sw = max(1, round(1.5 * k)) # 4 at SS 3, size 16
cap = lambda x, y: dr.ellipse([x - sw / 2, y - sw / 2, x + sw / 2, y + sw / 2], fill=colour)
for kind, geo in GLYPHS[name]:
if kind == "ellipse":
dr.ellipse([v * k for v in geo], outline=colour, width=sw)
elif kind == "knob":
cx, cy, r = (v * k for v in geo)
dr.ellipse([cx - r, cy - r, cx + r, cy + r], fill=ground or (0, 0, 0, 0),
outline=colour, width=sw)
else:
pts = [(x * k, y * k) for x, y in geo]
if kind == "closed": # the seam gets a joint like every other corner
pts += pts[:2]
dr.line(pts, fill=colour, width=sw, joint="curve")
for x, y in ((pts[0], pts[-1]) if kind == "line" else ()):
cap(x, y)
return _png(im)
TARGET_UI = {"code": ("term", "Code"), "chat": ("globe", "Web")} # Promptify's target cells: icon, label
# --- widgets --------------------------------------------------------------------------------
class _Btn:
"""A button is ONE Label carrying a rounded-rect image with its text drawn on top
(`compound="center"`): the shape is the image, so hover, press and focus are re-renders of
it - no motion, no weight or size shift. Kinds: primary (the one accent fill), danger,
secondary (the raised key: `ctl` with a hairline and the elevation edge, ink text; pressed
= `ctl_press` with the edge gone, the sink), text (bare until hovered; muted, or `ink`) and
chip (a full pill on the accent tint, see `_Chip`). The focus ring is drawn on the button's
own edge (2 px of `ring`) instead of around it, so focusing never moves anything; a solid
button, whose fill the ring would vanish against (1.2:1), wears it INSIDE its edge in its
own text colour."""
def __init__(self, ui, parent, text, cmd, kind="secondary", font=None, h=None, pad=None,
r=None, ink=False):
p, self.ui, self.cmd, self.kind = ui.pal, ui, cmd, kind
g = self.ground = parent["bg"]
chip = kind == "chip"
# rest fill, rest text, hover fill, hover text, pressed fill
self.fill, self.fg, self.hov, self.hov_fg, self.down = {
"primary": (p["primary"], p["on_primary"], p["primary_hover"], p["on_primary"], p["primary_hover"]),
"danger": (p["danger"], p["on_danger"], p["danger_hover"], p["on_danger"], p["danger_hover"]),
"secondary": (p["ctl"], p["ink"], p["ctl_hover"], p["ink"], p["ctl_press"]),
"text": (g, p["ink"] if ink else p["muted"], p["sub_hover"](g), p["ink"], p["sub"](g)),
"chip": (p["tint"], p["ink"], p["tint_hover"], p["ink"], p["tint_hover"]),
}[kind]
raised = kind == "secondary" # the one kind with an outline and an edge
self.outline = p["stroke"](p["ctl"]) if raised else None
self.edge = (p["stroke_edge"], p["stroke_top"]) if raised else None
self.h = ui.px(h or (H_CHIP if chip else H_CTL))
self.r = ui.px((H_CHIP // 2 if chip else R_CTL) if r is None else r)
self.pad = ui.px((SP[1] if kind in ("text", "chip") else SP[2]) if pad is None else pad)
self.state, self.ring, self.on = "idle", False, True
self.f = self._widget(parent, text, font or ui.F["body"])
self._size()
self.w0 = self.w # the width the button was born with
self._paint()
if kind == "primary":
ui.primaries.append(self) # so a check can count them on screen
self.f.bind("<Enter>", lambda e: self._set("hover"))
self.f.bind("<Leave>", lambda e: self._set("idle"))
self.f.bind("<Button-1>", lambda e: self._set("down"))
self.f.bind("<ButtonRelease-1>", self._release)
self.f.bind("<FocusIn>", lambda e: self._ring(True))
self.f.bind("<FocusOut>", lambda e: self._ring(False))
for k in ("<Return>", "<space>"):
self.f.bind(k, lambda e: self.on and self.cmd())
def _widget(self, parent, text, font) -> tk.Widget:
return tk.Label(parent, text=text, font=font, fg=self.fg, bd=0, bg=self.ground,
compound="center", highlightthickness=0, takefocus=1, cursor="hand2",
padx=0, pady=0) # Tk's default 1 px pad would show
def _size(self) -> None:
self.f.configure(image="") # measure the TEXT, not the image behind it
self.w = self.f.winfo_reqwidth() + 2 * self.pad
def _paint(self) -> None:
p = self.ui.pal
fill, fg = {"idle": (self.fill, self.fg), "hover": (self.hov, self.hov_fg),
"blend": (mix(self.fill, self.hov, .5), self.hov_fg), # the hover's first frame
"down": (self.down, self.hov_fg)}[self.state]
border, bw, inner = self.outline, 1, None
edge = None if self.state == "down" else self.edge # pressed: the edge goes, the sink
if not self.on:
fade = lambda c: mix(c, self.ground, .45) if c else c
fill, fg, border = fade(fill), fade(fg), fade(border)
edge = tuple(fade(c) for c in edge) if edge else None
if self.ring and self.kind in ("primary", "danger"):
inner = (self.ui.px(2), self.ui.px(2), fg)
elif self.ring:
border, bw = p["ring"], self.ui.px(2)
self._draw(self.ui.rr(self.w, self.h, self.r, fill, self.ground, border, bw, inner=inner,
edge=edge), fg)
def _draw(self, img, fg) -> None:
self.f.configure(image=img, fg=fg)
def _set(self, state) -> None:
"""Hover on a secondary, text or chip button comes in two frames over `MOTION_FAST`
(Fluent's 83 ms): the 50 % blend now, the full hover half of that later
(`ui.jobs[("blend", id)]`, so a rebuild cancels it); only from idle - a release after a
press paints hover at once - and only with motion on. Every other change, leaving
included, is instant and drops a pending frame, so a button never holds more than one.
The frame skips a button destroyed in the meantime (a rebuilt pane)."""
if not self.on:
return
ui, key = self.ui, ("blend", id(self.f))
ui._cancel(key)
if (state == "hover" and self.state == "idle" and ui.motion
and self.kind in ("secondary", "text", "chip")):
self.state = "blend"
self._paint()
ui.jobs[key] = ui.win.after(MOTION_FAST // 2, lambda: (
ui.jobs.pop(key, None), self.f.winfo_exists() and self._set("hover")))
return
self.state = state
self._paint()
def _ring(self, on) -> None:
self.ring = on and getattr(self.ui, "kbd", True) # focus-visible: keyboard focus only
self._paint()
def _release(self, e) -> None:
if not self.on:
return
self._set("hover")
self.ui.kbd = False
self.f.focus_set()
if 0 <= e.x < self.f.winfo_width() and 0 <= e.y < self.f.winfo_height():
self.cmd()
def text(self, s, hold=True) -> None:
"""Re-label. `hold` keeps at least the width the button was born with, so "Copy prompt"
turning into "Copied" for a moment moves nothing; a state change passes False and the
button is re-sized to its new label."""
self.f.configure(text=s)
self._size()
if hold:
self.w = max(self.w0, self.w)
else:
self.w0 = self.w
self._paint()
def enable(self, on) -> None:
"""Disabled: fill and text faded 45 % toward the ground, out of the tab ring, clicks
ignored. Nothing changes size."""
self.on = bool(on)
self.state = "idle"
self.ui._cancel(("blend", id(self.f)))
self.f.configure(takefocus=1 if self.on else 0, cursor="hand2" if self.on else "arrow")
self._paint()
def mute(self, on) -> None:
"""A text button's rest colour: muted, or ink (the engine name in the draft header)."""
self.fg = self.ui.pal["muted"] if on else self.ui.pal["ink"]
self._paint()
class _Chip(_Btn):
"""A question's option: a 24 px pill on the accent tint. The CHOSEN chip - the one whose
text is the answer - carries a 6 px primary dot before its text, so a chip is a Canvas: a
compound Label centres its text over the image and cannot start it after a dot. Chips are
not tab stops; their strip is (`_chips`), and the ring goes on the one under its cursor.
With `icon` (a glyph name, drawn at 14 px in the text's colour) the dot's slot shows the
icon instead, always - a Context note chip - and `chosen()` does nothing to it."""
def __init__(self, ui, parent, text, cmd, icon=None):
self.label, self.is_chosen, self.icon = text, False, icon
super().__init__(ui, parent, text, cmd, kind="chip")
def _widget(self, parent, text, font) -> tk.Widget:
c = tk.Canvas(parent, bd=0, highlightthickness=0, bg=self.ground, cursor="hand2", takefocus=0)
self.i_pill = c.create_image(0, 0, anchor="nw")
self.i_dot = c.create_image(self.ui.px(7), self.h // 2, anchor="w", state="hidden",
image=self.ui.dot(8, 6, self.ui.pal["ring"]))
self.i_text = c.create_text(self.pad, self.h // 2, anchor="w", text=text, font=font)
return c
def _lead(self) -> int:
"""What sits before the text: an icon 14 + gap 6, the chosen dot 6 + gap 6, or nothing."""
return self.ui.px(14) + self.ui.px(6) if self.icon else self.ui.px(SP[2]) if self.is_chosen else 0
def _size(self) -> None:
self.w = self.ui.mf["body"].measure(self.label) + 2 * self.pad + self._lead()
def _draw(self, img, fg) -> None:
c = self.f
c.configure(width=self.w, height=self.h)
c.itemconfigure(self.i_pill, image=img)
c.itemconfigure(self.i_text, fill=fg)
c.coords(self.i_text, self.pad + self._lead(), self.h // 2)
if self.icon:
c.itemconfigure(self.i_dot, state="normal", image=self.ui.icon(self.icon, fg, 14))
else:
c.itemconfigure(self.i_dot, state="normal" if self.is_chosen else "hidden")
def text(self, s, hold=True) -> None:
self.label = s
self.f.itemconfigure(self.i_text, text=s)
self._size()
self._paint()
def chosen(self, on) -> None:
if self.icon:
return
if on != self.is_chosen:
self.is_chosen = bool(on)
self.fill = self.ui.pal["tint_hover" if on else "tint"]
self._size()
self._paint()
class _Row:
"""A settings row inside a group card: label (+ description, + "restart to apply") left, the
control right-aligned in a column as wide as the control, 16 from the text. Rows are
separated by an INSET hairline - from the label's left edge to the right padding, in a
lighter step than the card's own outline, so the card reads as one object instead of a
stack of full-width rules. 12 px above and below the taller column: a label over its
description makes the row 62 (Fluent's SettingsCard is 68; 60-ish keeps the Indicator card
on a 560 px window), a 32 px control alone 56. The Labels carry none of Tk's default
border/padding (2 + 1 px a side), so their text lands on E and the row on the grid."""
def __init__(self, ui, group, label, desc=None):
p, bg = ui.pal, group["bg"]
self.rule = ui.hairline(group, color=p["divider"](bg)) if group.winfo_children() else None
if self.rule is not None:
self.rule.pack(fill="x", padx=ui.cpad)
self.ui, self.shown = ui, True
self.frame = tk.Frame(group, bg=bg)
self.frame.pack(fill="x")
main = tk.Frame(self.frame, bg=bg)
main.pack(fill="x", padx=ui.cpad)
self.right = tk.Frame(main, bg=bg)
self.right.pack(side="right", pady=ui.px(SP[2])) # the control's own width; the label
left = self.left = tk.Frame(main, bg=bg) # column takes what is left
left.pack(side="left", fill="x", expand=True, pady=ui.px(SP[2]), padx=(0, ui.px(SP[3])))
self.head = tk.Frame(left, bg=bg)
self.head.pack(anchor="w")
self.label = tk.Label(self.head, text=label, font=ui.F["body"], fg=p["ink"], bg=bg, bd=0,
padx=0, pady=0, anchor="w")
self.label.pack(side="left")
self.chip = None
self.desc = tk.Label(left, text=desc or "", font=ui.F["meta"], fg=p["muted"], bg=bg,
anchor="w", justify="left", bd=0, padx=0, pady=0)
# the description wraps at the text column's own width - never wider than the column
# that clips it (a floor of 120 ran it under the control on a 600 px window)
left.bind("<Configure>", lambda e: self.desc.configure(wraplength=max(e.width, ui.px(48))))
if desc:
self.desc.pack(anchor="w", pady=(ui.px(SP[0]), 0))
self.stacked = False
main.bind("<Configure>", lambda e: self._fit(e.width))
self.err = tk.Label(self.frame, text="", font=ui.F["meta"], fg=p["danger_text"], bg=bg,
anchor="e", bd=0, padx=0, pady=0)
def _fit(self, width) -> None:
"""The control column keeps its width. When what is left beside it would not hold the
label (the colour rows' dots + swatch + hex field, the trigger row's cap + two keys, on
a 600 px window - the labels rendered as "olou" / "Rec"), the control drops UNDER the
text on the row's right edge, so the one right edge survives and the label is whole;
back beside it when the room returns. Change-only: no re-pack on a resize that leaves
the answer alone."""
ui = self.ui
if width <= 1:
return
room = width - self.right.winfo_reqwidth() - ui.px(SP[3])
stack = room < max(self.head.winfo_reqwidth(), ui.px(120))
if stack == self.stacked:
return
self.stacked = stack
if stack:
self.left.pack_configure(side="top", fill="x", expand=False, padx=0)
self.right.pack_configure(side="top", anchor="e", after=self.left, pady=(0, ui.px(SP[2])))
else:
self.right.pack_configure(side="right", anchor="center", before=self.left, pady=ui.px(SP[2]))
self.left.pack_configure(side="left", fill="x", expand=True, padx=(0, ui.px(SP[3])))
def show(self, on, after=None) -> None:
"""Rows that apply to some settings only come and go; `after` (a _Row) keeps the order.
The hairline above the row goes with it, or hiding a row would leave a double rule."""
if on and not self.shown:
kw = {"after": after.frame} if after is not None else {}
if self.rule is not None:
self.rule.pack(fill="x", padx=self.ui.cpad, **kw)
kw = {"after": self.rule}
self.frame.pack(fill="x", **kw)
elif not on and self.shown:
if self.rule is not None:
self.rule.pack_forget()
self.frame.pack_forget()
self.shown = bool(on)
def restart(self) -> None:
"""Model / microphone / language changed: say so, and keep saying it - in plain meta
text after the label (a pill here was the one accent on the page that was not an
action)."""
if self.chip is None:
ui = self.ui
self.chip = tk.Label(self.head, text="restart to apply", font=ui.F["meta"],
bg=self.head["bg"], fg=ui.pal["muted"], bd=0, padx=0, pady=0)
self.chip.pack(side="left", padx=(ui.px(SP[1]), 0))
def error(self, msg) -> None:
self.err.configure(text=msg or "")
(self.err.pack(fill="x", padx=self.ui.cpad, pady=(0, self.ui.px(SP[1])))
if msg else self.err.pack_forget())
class RowList(tk.Canvas):
"""The dictations, newest first, as canvas items - 45 of them as Frames would be slow and
would each need their own hover bindings - grouped under a day header ("Today",
"Yesterday", "Monday", "Aug 12") wherever the day changes. Two modes: one line (History:
text, then the draft mark and a right-aligned mono time at the inner right; `H_ROW`) and
two lines (Promptify: text over time + mark; `H_ROW2`). `keep(item)` is the pane's filter
predicate (History's reads the filter field); `rows` holds `(item, y, h)` per canvas row,
`(None, y, h)` for a header, and `ys` the row tops for a bisect in `row_at`.
Hover and selection are not per-row rectangles but TWO rounded images moved to the row they
belong to - a rounded highlight would otherwise cost three rectangles and four corner masks
per row, and only two rows are ever lit. The selected one carries the 3 x 16 `ring` bar (the
nav's), hover is the fill alone. The keyboard's row wears the focus ring on the selected
highlight itself: a hard ring around the whole list would be the one rigid line in the
view. The selection is the window's (`ui.sel`, shared by both lists); `on_select(item)` is
told when a row is picked, `keys` are the pane's own bindings (Return, Delete...).
Ceiling: `draw()` redraws EVERY row on every <Configure>, ~7 font.measure calls each. Fine to
about 2 000 items (a year at 5 dictations a day); past that the upgrade path is to draw only
the slice between canvasy(0) and canvasy(0)+height, and redraw on scroll too."""
def __init__(self, ui, parent, two_line=False, on_select=None, keys=None, keep=None):
p = ui.pal
super().__init__(parent, bg=p["layer"], highlightthickness=0, takefocus=1, yscrollincrement=1,
width=1, height=1, bd=0)
self.ui, self.two, self.on_select = ui, two_line, on_select or (lambda it: None)
self.keep = keep or (lambda it: True)
self.on_scroll = lambda on: None # told (once per change) whether the list is scrolled
self.scrolled = False
self.H = ui.px(H_ROW2 if two_line else H_ROW)
self.rows, self.ys, self.hl, self.hl_id, self.hover = [], [], {}, {}, None
self.sb = ttk.Scrollbar(parent, orient="vertical", style="M.Vertical.TScrollbar",
command=self.yview, takefocus=0)
self.configure(yscrollcommand=self._scrolled)
self.pack(side="left", fill="both", expand=True)
self.bind("<Configure>", lambda e: self.draw())
# ... and the selection back into view once the canvas has its new height: the card
# grows for a taller transcript AFTER `move()` / `go()` scrolled to the row, through
# several idle passes (Text -> card -> grid -> this canvas), so an idle job asked too
# early and the row ended below the shorter list's fold; a window resize re-shows it too
self.bind("<Configure>", lambda e: self.show_sel(), add="+")
self.bind("<FocusIn>", lambda e: self.paint())
self.bind("<FocusOut>", lambda e: self.paint())
self.bind("<Motion>", lambda e: self.set_hover(self.row_at(e.y)))
self.bind("<Leave>", lambda e: self.set_hover(None))
self.bind("<Button-1>", self._click)
self.bind("<Enter>", lambda e: self.bind_all("<MouseWheel>", self._wheel))
self.bind("<Leave>", lambda e: self.unbind_all("<MouseWheel>"), add="+")
self.bind("<Up>", lambda e: self.move(-1))
self.bind("<Down>", lambda e: self.move(1))
for k, fn in (keys or {}).items():
self.bind(k, lambda e, fn=fn: fn())
def items(self) -> list:
"""The dictations this list shows (oldest first, as the file): the history through
the pane's `keep` predicate."""
return [it for it in self.ui.history.items if self.keep(it)]
def natural(self) -> int:
"""The height of every row drawn, headers included: the scrollregion, and what the
History view's height rule gives the list before the card takes the rest."""
return sum(h for _, _, h in self.rows)
def _scrolled(self, lo, hi) -> None:
self.sb.set(lo, hi)
if float(lo) <= 0.0 and float(hi) >= 1.0:
self.sb.pack_forget()
else:
self.sb.pack(side="right", fill="y")
if (float(lo) > 0.0) != self.scrolled: # the pane's scroll-only rule follows
self.scrolled = not self.scrolled
self.on_scroll(self.scrolled)
def _wheel(self, e) -> None:
if self.ui._visible():
self.yview_scroll(int(-e.delta / 120) * 3 * self.H, "units")
def draw(self) -> None:
ui, p, c = self.ui, self.ui.pal, self
px = ui.px
c.delete("all")
self.rows, self.ys = [], []
items = self.items()
w, h = c.winfo_width(), self.H
x0 = px(SP[3]) # E, the pane's one text edge
if not items:
self.hl_id = {} # the highlights went with delete("all"): nothing to move
self.empty(w, filtered=bool(ui.history.items))
c.configure(scrollregion=(0, 0, 0, 0))
return
# the highlight runs from E - 12 to the pane's right edge - 4, like every surface that
# carries text; the text keeps its edge, 12 px inside it
hx = px(SP[0])
hw = max(h, w - 2 * hx) # w is 1 until Tk has laid the canvas out
if self.hl.get("w") != hw:
png = lambda **kw: tk.PhotoImage(data=base64.b64encode(rr_png(
hw, h, px(R_CTL), ground=p["layer"], **kw)).decode())
# not in the img() cache: these are as wide as the window and would pile up a copy
# per pixel of a resize drag. The selected row carries the nav's 3 x 16 bar at x 4
g = p["layer"]
bar = (px(SP[0]), px(3), px(16), max(1, px(1.5)), p["ring"])
self.hl = {"w": hw, "hover": png(fill=p["sub_hover"](g)), "sel": png(fill=p["sub"](g), bar=bar),
"focus": png(fill=p["sub"](g), bar=bar, border=p["ring"], border_w=px(2))}
self.hl_id = {k: c.create_image(hx, 0, anchor="nw", image=self.hl[k], state="hidden")
for k in ("hover", "sel")} # created first: the row text draws over them
right = hx + hw - px(SP[2]) # the highlight's inner right edge
mark, hg = ui.mark(px(10), p["ring"]), px(H_GROUP) # the draft mark: the m at 10 px
mono, now, day, y = ui.mf["mono9"], time.time(), None, 0
for idx in range(len(items) - 1, -1, -1):
it = items[idx]
d = day_of(it["t"], now)
if d != day: # a day header: meta muted, its text 4 above the first row
day = d
c.create_text(x0, y + hg - px(SP[0]), anchor="sw", text=d, font=ui.mf["meta"],
fill=p["muted"])
self.rows.append((None, y, hg))
self.ys.append(y)
y += hg
body = " ".join(it["text"].split())
t = time.strftime("%H:%M", time.localtime(it["t"]))
draft = it.get("draft")
if self.two: # line 1 y+8..26: the words; line 2 y+28..44: the time, then the mark
c.create_text(x0, y + px(17), anchor="w", font=ui.mf["body"], fill=p["ink"],
text=ellipsize(ui.mf["body"], body, right - x0))
c.create_text(x0, y + px(36), anchor="w", text=t, fill=p["muted"], font=mono)
if draft:
c.create_image(x0 + mono.measure(t) + px(SP[1]), y + px(36), anchor="w", image=mark)
else: # the time right-aligned in its column, the mark 8 before it
c.create_text(right, y + h / 2, anchor="e", text=t, fill=p["muted"], font=mono)
room = right - px(W_TIME) - px(SP[2]) - x0 - (px(10) + px(SP[1]) if draft else 0)
c.create_text(x0, y + h / 2, anchor="w", font=ui.mf["body"], fill=p["ink"],
text=ellipsize(ui.mf["body"], body, room))
if draft: