tkwebui 0.3.2__py3-none-any.whl
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.
- tkwebui/__init__.py +53 -0
- tkwebui/charts.py +496 -0
- tkwebui/core.py +1128 -0
- tkwebui/dataview.py +185 -0
- tkwebui/extras.py +525 -0
- tkwebui/feedback.py +179 -0
- tkwebui/forms.py +324 -0
- tkwebui/media.py +336 -0
- tkwebui/placeholder.py +174 -0
- tkwebui/popups.py +173 -0
- tkwebui/render.py +198 -0
- tkwebui/scrolling.py +73 -0
- tkwebui/table.py +369 -0
- tkwebui/tcl_bridge.py +23 -0
- tkwebui/widgets.py +301 -0
- tkwebui/window_chrome.py +494 -0
- tkwebui-0.3.2.dist-info/METADATA +75 -0
- tkwebui-0.3.2.dist-info/RECORD +20 -0
- tkwebui-0.3.2.dist-info/WHEEL +4 -0
- tkwebui-0.3.2.dist-info/licenses/LICENSE +21 -0
tkwebui/core.py
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
"""Lightweight sidebar/card UI for standard tkinter.ttk; no third-party imports.
|
|
2
|
+
|
|
3
|
+
Call enable_dpi_awareness() before Tk(), then apply_theme(root) before widgets.
|
|
4
|
+
Reusable pieces: Card, Sidebar, SegmentedControl, ScrollArea, icon.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import sys
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from tkinter import font as tkfont, ttk
|
|
13
|
+
from .tcl_bridge import unbind
|
|
14
|
+
|
|
15
|
+
PALETTE = dict(bg='#FFFFFF', sidebar='#F5F5F7', fg='#16181D', muted='#697180',
|
|
16
|
+
border='#E1E2E8', hover='#F1F1F4', active='#E5E5EB',
|
|
17
|
+
accent='#087DFF', accent_hover='#066BDB', blue='#087DFF',
|
|
18
|
+
selected='#EAF3FF', disabled='#B5BAC4')
|
|
19
|
+
THEME = 'switch-lite'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def enable_dpi_awareness():
|
|
23
|
+
if sys.platform == 'win32':
|
|
24
|
+
import ctypes
|
|
25
|
+
try:
|
|
26
|
+
ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
|
|
27
|
+
except (AttributeError, OSError):
|
|
28
|
+
try: ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
|
29
|
+
except (AttributeError, OSError): pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@lru_cache(maxsize=256)
|
|
33
|
+
def _rgb(color):
|
|
34
|
+
# Raster samplers call this once per subsample with a handful of colors.
|
|
35
|
+
return tuple(int(color[i:i+2], 16) for i in (1, 3, 5))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _blend(colors):
|
|
39
|
+
return '#' + ''.join(f'{round(sum(c[i] for c in colors)/len(colors)):02x}' for i in range(3))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _patch(root, fill, border, surrounding, radius, height=80, width=192):
|
|
43
|
+
# Broad centers avoid thousands of tiny image tiles during a resize.
|
|
44
|
+
width, height = max(width, radius*2+4), max(height, radius*2+4)
|
|
45
|
+
image = tk.PhotoImage(master=root, width=width, height=height)
|
|
46
|
+
image.put(fill, to=(0, 0, width, height))
|
|
47
|
+
for rect in ((radius,0,width-radius,1), (radius,height-1,width-radius,height),
|
|
48
|
+
(0,radius,1,height-radius), (width-1,radius,width,height-radius)):
|
|
49
|
+
image.put(border, to=rect)
|
|
50
|
+
rgb_fill, rgb_border, rgb_bg = map(_rgb, (fill,border,surrounding))
|
|
51
|
+
# Four bulk corner writes instead of one Tcl call per corner pixel.
|
|
52
|
+
corner = []
|
|
53
|
+
for y in range(radius):
|
|
54
|
+
row = []
|
|
55
|
+
for x in range(radius):
|
|
56
|
+
samples = []
|
|
57
|
+
for dx,dy in ((.25,.25),(.75,.25),(.25,.75),(.75,.75)):
|
|
58
|
+
distance = math.hypot(x+dx-radius,y+dy-radius)
|
|
59
|
+
samples.append(rgb_fill if distance <= radius-1 else rgb_border if distance <= radius else rgb_bg)
|
|
60
|
+
row.append(_blend(samples))
|
|
61
|
+
corner.append(row)
|
|
62
|
+
for right,bottom in ((False,False),(True,False),(False,True),(True,True)):
|
|
63
|
+
rows = list(reversed(corner)) if bottom else corner
|
|
64
|
+
data = ' '.join('{'+' '.join(reversed(row) if right else row)+'}' for row in rows)
|
|
65
|
+
if radius:image.put(data, to=(width-radius if right else 0, height-radius if bottom else 0))
|
|
66
|
+
return image
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _raster(root, width, height, sample, transparent=None, outside=None):
|
|
70
|
+
from .render import _png
|
|
71
|
+
# One RGBA PNG instead of a hex-string put plus a transparency_set call per
|
|
72
|
+
# background pixel; uniform 2x2 samples (most pixels) skip the averaging.
|
|
73
|
+
# ``outside(x, y)`` optionally marks further pixels as fully transparent.
|
|
74
|
+
transparent = tuple(transparent) if transparent is not None else None
|
|
75
|
+
rgba = bytearray()
|
|
76
|
+
for y in range(height):
|
|
77
|
+
for x in range(width):
|
|
78
|
+
a,b,c,d = [sample(x+dx,y+dy) for dx,dy in ((.25,.25),(.75,.25),(.25,.75),(.75,.75))]
|
|
79
|
+
pixel = tuple(a) if a==b==c==d else tuple(round((a[i]+b[i]+c[i]+d[i])/4) for i in range(3))
|
|
80
|
+
rgba.extend(pixel); rgba.append(0 if pixel==transparent or (outside and outside(x,y)) else 255)
|
|
81
|
+
return tk.PhotoImage(master=root, data=_png(width,height,bytes(rgba)), format='png')
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ICON_HITS={} # (name, size) -> per-pixel count of 2x2 samples on a stroke
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _icon_hits(shapes, size):
|
|
88
|
+
"""2x2 supersampled stroke hits of a 20-unit line icon at ``size`` pixels.
|
|
89
|
+
|
|
90
|
+
Same per-sample test as before, but each stroke only tests the samples
|
|
91
|
+
within its reach; samples elsewhere cannot hit it.
|
|
92
|
+
"""
|
|
93
|
+
segments,circles,dots=shapes
|
|
94
|
+
hit=bytearray(size*size*4)
|
|
95
|
+
offsets=((.25,.25),(.75,.25),(.25,.75),(.75,.75))
|
|
96
|
+
def visit(left,top,right,bottom,inside):
|
|
97
|
+
for y in range(max(0,int(top*size/20)-1),min(size,int(bottom*size/20)+2)):
|
|
98
|
+
for x in range(max(0,int(left*size/20)-1),min(size,int(right*size/20)+2)):
|
|
99
|
+
base=(y*size+x)*4
|
|
100
|
+
for i,(dx,dy) in enumerate(offsets):
|
|
101
|
+
if not hit[base+i] and inside((x+dx)*20/size,(y+dy)*20/size):hit[base+i]=1
|
|
102
|
+
for (ax,ay),(bx,by) in segments:
|
|
103
|
+
vx,vy=bx-ax,by-ay;length=vx*vx+vy*vy or 1
|
|
104
|
+
def segment(x,y,ax=ax,ay=ay,vx=vx,vy=vy,length=length):
|
|
105
|
+
t=max(0,min(1,((x-ax)*vx+(y-ay)*vy)/length))
|
|
106
|
+
return math.hypot(x-ax-t*vx,y-ay-t*vy)<.8
|
|
107
|
+
visit(min(ax,bx)-.8,min(ay,by)-.8,max(ax,bx)+.8,max(ay,by)+.8,segment)
|
|
108
|
+
for cx,cy,r in circles:
|
|
109
|
+
visit(cx-r-.8,cy-r-.8,cx+r+.8,cy+r+.8,lambda x,y,cx=cx,cy=cy,r=r:abs(math.hypot(x-cx,y-cy)-r)<.8)
|
|
110
|
+
for cx,cy in dots:
|
|
111
|
+
visit(cx-1.25,cy-1.25,cx+1.25,cy+1.25,lambda x,y,cx=cx,cy=cy:math.hypot(x-cx,y-cy)<1.25)
|
|
112
|
+
return bytes(sum(hit[i:i+4]) for i in range(0,len(hit),4))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def icon(root, name, size=20, color=None, background=None):
|
|
116
|
+
root = root._root()
|
|
117
|
+
color,background = color or PALETTE['muted'], background or PALETTE['bg']
|
|
118
|
+
key=(name,size,color,background)
|
|
119
|
+
cache=getattr(root,'_switch_icon_cache',None)
|
|
120
|
+
if cache is None: cache=root._switch_icon_cache={}
|
|
121
|
+
if key in cache:return cache[key]
|
|
122
|
+
paths={
|
|
123
|
+
'plus': [[(10,3),(10,17)],[(3,10),(17,10)]],
|
|
124
|
+
'search': [[(13,13),(18,18)]],
|
|
125
|
+
'edit': [[(4,12),(13,3),(17,7),(8,16),(3,17),(4,12)],[(11,5),(15,9)]],
|
|
126
|
+
'play': [[(6,3),(16,10),(6,17),(6,3)]],
|
|
127
|
+
'check': [[(4,10),(8,14),(16,5)]],
|
|
128
|
+
'library': [[(3,4),(8,4),(10,6),(12,4),(17,4),(17,16),(12,16),(10,18),(8,16),(3,16),(3,4)],[(10,6),(10,18)]],
|
|
129
|
+
'sources': [[(5,6),(5,14),(15,14)],[(5,6),(15,6)]],
|
|
130
|
+
'settings': [[(3,5),(17,5)],[(3,10),(17,10)],[(3,15),(17,15)]],
|
|
131
|
+
'controls': [[(3,4),(17,4),(17,16),(3,16),(3,4)],[(3,8),(17,8)],[(8,8),(8,16)]],
|
|
132
|
+
'folder': [[(2,5),(8,5),(10,7),(18,7),(16,16),(2,16),(2,5)]],
|
|
133
|
+
'download': [[(10,2),(10,12)],[(6,8),(10,12),(14,8)],[(3,13),(3,17),(17,17),(17,13)]],
|
|
134
|
+
'back': [[(12,4),(6,10),(12,16)]],
|
|
135
|
+
'speed': [[(3,15),(3,10)],[(7,15),(7,7)],[(11,15),(11,4)],[(15,15),(15,9)]],
|
|
136
|
+
'minimize': [[(4,10),(16,10)]],
|
|
137
|
+
'maximize': [[(4,4),(16,4),(16,16),(4,16),(4,4)]],
|
|
138
|
+
'restore': [[(7,6),(7,3),(17,3),(17,13),(14,13)],[(3,7),(13,7),(13,17),(3,17),(3,7)]],
|
|
139
|
+
'close': [[(5,5),(15,15)],[(15,5),(5,15)]],
|
|
140
|
+
'pin': [[(7,3),(13,3),(12,8),(15,12),(5,12),(8,8),(7,3)],[(10,12),(10,18)]],
|
|
141
|
+
'chevrondown': [[(5,7),(10,12),(15,7)]],
|
|
142
|
+
'chevronup': [[(5,12),(10,7),(15,12)]],
|
|
143
|
+
'chevronright': [[(7,4),(13,10),(7,16)]],
|
|
144
|
+
'collapse': [[(11,5),(6,10),(11,15)],[(16,5),(11,10),(16,15)]],
|
|
145
|
+
'expand': [[(4,5),(9,10),(4,15)],[(9,5),(14,10),(9,15)]],
|
|
146
|
+
}
|
|
147
|
+
circles={'search':[(8,8,5)],'sources':[(5,6,2),(15,6,2),(15,14,2)],
|
|
148
|
+
'settings':[(7,5,2),(13,10,2),(8,15,2)],'info':[(10,10,8)]}
|
|
149
|
+
dots={'more':[(4,10),(10,10),(16,10)],'info':[(10,6)]}
|
|
150
|
+
if name=='info':paths['info']=[[(10,9),(10,14)]]
|
|
151
|
+
from .render import _png
|
|
152
|
+
foreground,bg=map(_rgb,(color,background))
|
|
153
|
+
hits=_ICON_HITS.get((name,size))
|
|
154
|
+
if hits is None:
|
|
155
|
+
shapes=([(a,b) for path in paths.get(name,[]) for a,b in zip(path,path[1:])],circles.get(name,[]),dots.get(name,[]))
|
|
156
|
+
hits=_ICON_HITS[(name,size)]=_icon_hits(shapes,size)
|
|
157
|
+
# Same pixels as _raster(): k of 4 samples hit -> rounded fg/bg mix, and
|
|
158
|
+
# pixels equal to the background are transparent.
|
|
159
|
+
table=[]
|
|
160
|
+
for k in range(5):
|
|
161
|
+
pixel=foreground if k==4 else bg if k==0 else tuple(round((k*f+(4-k)*b)/4) for f,b in zip(foreground,bg))
|
|
162
|
+
table.append(bytes(pixel)+(b'\0' if pixel==bg else b'\xff'))
|
|
163
|
+
cache[key]=tk.PhotoImage(master=root,data=_png(size,size,b''.join(table[k] for k in hits)),format='png')
|
|
164
|
+
return cache[key]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def apply_theme(root, *, font_family='Microsoft YaHei UI', font_size=10, accent=None, accent_hover=None):
|
|
168
|
+
root=root._root()
|
|
169
|
+
if getattr(root,'_tkwebui_ready',False):
|
|
170
|
+
ttk.Style(root).theme_use(THEME)
|
|
171
|
+
return PALETTE.copy()
|
|
172
|
+
if accent is not None:PALETTE['accent']=accent
|
|
173
|
+
if accent_hover is not None:PALETTE['accent_hover']=accent_hover
|
|
174
|
+
scale=root.winfo_fpixels('1i')/96
|
|
175
|
+
px=lambda n:max(1,round(n*scale))
|
|
176
|
+
root.tk.call('tk','scaling',root.winfo_fpixels('1i')/72)
|
|
177
|
+
# Resolving one font is instant; tkfont.families() enumerates every
|
|
178
|
+
# installed font (up to ~1 s on a cold Windows start).
|
|
179
|
+
if tkfont.Font(root=root,family=font_family).actual('family')!=font_family:font_family=tkfont.nametofont('TkDefaultFont').actual('family')
|
|
180
|
+
for name in ('TkDefaultFont','TkTextFont','TkMenuFont','TkHeadingFont','TkCaptionFont','TkTooltipFont'):
|
|
181
|
+
tkfont.nametofont(name,root=root).configure(family=font_family,size=font_size)
|
|
182
|
+
normal=(font_family,font_size);bold=(font_family,font_size,'bold');small=(font_family,max(9,font_size-1))
|
|
183
|
+
c=PALETTE
|
|
184
|
+
style=ttk.Style(root)
|
|
185
|
+
if THEME not in style.theme_names():style.theme_create(THEME,parent='clam')
|
|
186
|
+
style.theme_use(THEME)
|
|
187
|
+
style.configure('.',background=c['bg'],foreground=c['fg'],font=normal,bordercolor=c['border'],
|
|
188
|
+
lightcolor=c['bg'],darkcolor=c['bg'],troughcolor=c['hover'])
|
|
189
|
+
style.map('.',foreground=[('disabled',c['disabled'])])
|
|
190
|
+
root.configure(background=c['bg'])
|
|
191
|
+
# Classic widget classes only. A bare '*Font' is also read by ttk.Label /
|
|
192
|
+
# ttk.Entry, whose explicit -font then overrides every style font
|
|
193
|
+
# (Strong.TLabel, Muted.TLabel, section titles ...).
|
|
194
|
+
for widget in ('Label','Button','Checkbutton','Radiobutton','Menubutton','Message','Labelframe',
|
|
195
|
+
'Scale','Text','Entry','Spinbox','Listbox'):
|
|
196
|
+
root.option_add(f'*{widget}.Font','TkDefaultFont')
|
|
197
|
+
for widget in ('Text','Listbox','Canvas','Menu'):
|
|
198
|
+
root.option_add(f'*{widget}.background',c['bg'])
|
|
199
|
+
for widget in ('Text','Listbox','Menu'):
|
|
200
|
+
root.option_add(f'*{widget}.foreground',c['fg'])
|
|
201
|
+
root.option_add(f'*{widget}.selectBackground',c['selected'])
|
|
202
|
+
root.option_add(f'*{widget}.selectForeground',c['fg'])
|
|
203
|
+
root._tkwebui_images=[]
|
|
204
|
+
patch_cache={}
|
|
205
|
+
def face(fill,border,background,radius=4,height=80):
|
|
206
|
+
key=(fill,border,background,radius,height)
|
|
207
|
+
if key not in patch_cache:
|
|
208
|
+
patch_cache[key]=_patch(root,fill,border,background,px(radius),height)
|
|
209
|
+
root._tkwebui_images.append(patch_cache[key])
|
|
210
|
+
return patch_cache[key]
|
|
211
|
+
def element(name,normal_image,states=(),radius=4,content_padding=0):
|
|
212
|
+
style.element_create(name,'image',normal_image,*states,border=px(radius)+1,
|
|
213
|
+
width=px(radius)*2+2,height=px(radius)*2+2,sticky='nsew',padding=content_padding)
|
|
214
|
+
for name,fill in [('TFrame',c['bg']),('Sidebar.TFrame',c['sidebar']),('Segment.TFrame',c['hover']),('Selected.TFrame',c['selected'])]:
|
|
215
|
+
style.configure(name,background=fill)
|
|
216
|
+
for prefix,bg in [('',c['bg']),('Sidebar.',c['sidebar']),('Selected.',c['selected'])]:
|
|
217
|
+
for suffix,fg,font in [('TLabel',c['fg'],normal),('Muted.TLabel',c['muted'],small),('Strong.TLabel',c['fg'],bold)]:
|
|
218
|
+
style.configure(prefix+suffix,background=bg,foreground=fg,font=font)
|
|
219
|
+
style.configure('Blue.TLabel',foreground=c['blue'],background=c['selected'])
|
|
220
|
+
for name,fill,border in [('Card.TFrame',c['bg'],c['border']),('Selected.Card.TFrame',c['selected'],c['blue']),('Segment.Card.TFrame',c['hover'],c['hover'])]:
|
|
221
|
+
elem=name+'.surface'
|
|
222
|
+
element(elem,face(fill,border,c['bg'],5,128),radius=5,content_padding=px(5)+1)
|
|
223
|
+
style.layout(name,[(elem,{'sticky':'nsew'})])
|
|
224
|
+
style.configure(name,background=fill)
|
|
225
|
+
element('Selected.Logo.surface',face(c['bg'],c['border'],c['selected'],5,128),radius=5)
|
|
226
|
+
style.layout('Selected.Logo.TFrame',[('Selected.Logo.surface',{'sticky':'nsew'})])
|
|
227
|
+
definitions=[
|
|
228
|
+
('TButton',c['bg'],c['border'],c['hover'],c['fg'],c['bg']),
|
|
229
|
+
('Primary.TButton',c['accent'],c['accent'],c['accent_hover'],'#FFFFFF',c['bg']),
|
|
230
|
+
('Quiet.TButton',c['bg'],c['bg'],c['hover'],c['muted'],c['bg']),
|
|
231
|
+
('Close.TButton',c['bg'],c['bg'],'#FFE8E8',c['muted'],c['bg']),
|
|
232
|
+
('CardIcon.TButton',c['selected'],c['selected'],'#DCEBFE',c['muted'],c['selected']),
|
|
233
|
+
('Nav.TButton',c['sidebar'],c['sidebar'],c['active'],c['fg'],c['sidebar']),
|
|
234
|
+
('Selected.Nav.TButton',c['active'],c['active'],c['active'],c['fg'],c['sidebar']),
|
|
235
|
+
('Segment.TButton',c['hover'],c['hover'],c['active'],c['muted'],c['hover']),
|
|
236
|
+
('Selected.Segment.TButton',c['bg'],c['bg'],c['bg'],c['blue'],c['hover']),
|
|
237
|
+
]
|
|
238
|
+
for name,fill,border,hover,fg,surround in definitions:
|
|
239
|
+
elem=name+'.surface'
|
|
240
|
+
element(elem,face(fill,border,surround),[
|
|
241
|
+
('disabled',face(surround if name=='Quiet.TButton' else c['hover'],surround if name=='Quiet.TButton' else c['border'],surround)),
|
|
242
|
+
('pressed',face(hover,hover,surround)),('active',face(hover,hover,surround))])
|
|
243
|
+
style.layout(name,[(elem,{'sticky':'nsew','children':[
|
|
244
|
+
('Button.padding',{'sticky':'nsew','children':[('Button.label',{'sticky':'nsew'})]})]})])
|
|
245
|
+
style.configure(name,foreground=fg,padding=(px(12),px(6)),font=normal,anchor='center')
|
|
246
|
+
style.map(name,foreground=[('disabled',c['disabled'])])
|
|
247
|
+
for name in ('Nav.TButton','Selected.Nav.TButton'):
|
|
248
|
+
style.configure(name,anchor='w',padding=(px(10),px(8)))
|
|
249
|
+
for name in ('Quiet.TButton','CardIcon.TButton','Close.TButton'):style.configure(name,padding=px(5))
|
|
250
|
+
style.configure('Dropdown.TButton',anchor='w')
|
|
251
|
+
# Fixed width leaves a gap before the label; sticky 'e' keeps the chevron
|
|
252
|
+
# from being tiled when a layout stretches the element vertically.
|
|
253
|
+
style.element_create('Switch.Dropdown.arrow','image',icon(root,'chevrondown',px(13)),
|
|
254
|
+
('disabled',icon(root,'chevrondown',px(13),color=c['disabled'])),width=px(13)+px(8),sticky='e')
|
|
255
|
+
style.layout('Dropdown.TButton',[('TButton.surface',{'sticky':'nsew','children':[
|
|
256
|
+
('Button.padding',{'sticky':'nsew','children':[
|
|
257
|
+
('Switch.Dropdown.arrow',{'side':'right','sticky':''}),
|
|
258
|
+
('Button.label',{'side':'left','sticky':'nsew','expand':True})]})]})])
|
|
259
|
+
style.configure('Popup.Quiet.TButton',anchor='w',padding=(px(12),px(8)))
|
|
260
|
+
for selected in (False,True):
|
|
261
|
+
source='Selected.Nav.TButton' if selected else 'Nav.TButton'
|
|
262
|
+
name='Selected.Icon.Nav.TButton' if selected else 'Icon.Nav.TButton'
|
|
263
|
+
style.layout(name,style.layout(source))
|
|
264
|
+
style.configure(name,anchor='center',padding=(px(4),px(8)))
|
|
265
|
+
# Native C-drawn controls; recolor only the small surfaces that need it.
|
|
266
|
+
entry_face='Switch.Entry.field'
|
|
267
|
+
element(entry_face,face(c['bg'],c['border'],c['bg'],3),[
|
|
268
|
+
('disabled',face(c['sidebar'],c['border'],c['bg'],3)),
|
|
269
|
+
('invalid',face(c['bg'],'#DD5555',c['bg'],3)),
|
|
270
|
+
('focus',face(c['bg'],c['blue'],c['bg'],3))],radius=3)
|
|
271
|
+
style.layout('TEntry',[(entry_face,{'sticky':'nsew','children':[
|
|
272
|
+
('Entry.padding',{'sticky':'nsew','children':[('Entry.textarea',{'sticky':'nsew'})]})]})])
|
|
273
|
+
style.configure('TEntry',padding=(px(9),px(6)),fieldbackground=c['bg'],insertcolor=c['fg'])
|
|
274
|
+
style.map('TEntry',fieldbackground=[('readonly',c['sidebar']),('disabled',c['sidebar'])])
|
|
275
|
+
for name in ('TCombobox','TSpinbox'):
|
|
276
|
+
style.configure(name,padding=px(6),fieldbackground=c['bg'],background=c['bg'],
|
|
277
|
+
arrowcolor=c['muted'],arrowsize=px(12),borderwidth=1)
|
|
278
|
+
style.map(name,fieldbackground=[('readonly',c['bg'])],bordercolor=[('focus',c['blue'])])
|
|
279
|
+
for direction,symbol in [('up','chevronup'),('down','chevrondown')]:
|
|
280
|
+
style.element_create('Switch.Spinbox.'+direction+'arrow','image',icon(root,symbol,px(9)),
|
|
281
|
+
('disabled',icon(root,symbol,px(9),color=c['disabled'])),
|
|
282
|
+
width=px(22),height=px(14),sticky='')
|
|
283
|
+
style.layout('TSpinbox',[(entry_face,{'sticky':'nsew','children':[
|
|
284
|
+
('null',{'side':'right','sticky':'ns','children':[
|
|
285
|
+
('Switch.Spinbox.uparrow',{'side':'top','sticky':'e'}),
|
|
286
|
+
('Switch.Spinbox.downarrow',{'side':'bottom','sticky':'e'})]}),
|
|
287
|
+
('Spinbox.padding',{'sticky':'nsew','children':[('Spinbox.textarea',{'sticky':'nsew'})]})]})])
|
|
288
|
+
for widget in ('Checkbutton','Radiobutton'):
|
|
289
|
+
if 'vista' in style.theme_names():
|
|
290
|
+
indicator='Switch.'+widget+'.indicator'
|
|
291
|
+
style.element_create(indicator,'from','vista',widget+'.indicator')
|
|
292
|
+
style.layout('T'+widget,[(widget+'.padding',{'sticky':'nsew','children':[
|
|
293
|
+
(indicator,{'side':'left'}),(widget+'.label',{'side':'left','sticky':'nsew'})]})])
|
|
294
|
+
style.configure('T'+widget,background=c['bg'],padding=(0,px(3)))
|
|
295
|
+
style.map('T'+widget,background=[('active',c['bg'])])
|
|
296
|
+
# A real ttk.Checkbutton: keyboard/focus/state handling stays native.
|
|
297
|
+
switch_images=[]
|
|
298
|
+
for on,disabled in [(False,False),(True,False),(False,True),(True,True)]:
|
|
299
|
+
w,h=px(34),px(20)
|
|
300
|
+
fill=_rgb(c['disabled'] if disabled else c['accent'] if on else '#9B9DA7')
|
|
301
|
+
bg=_rgb(c['bg']);knob=_rgb('#FFFFFF')
|
|
302
|
+
def sample(x,y,on=on,fill=fill,w=w,h=h):
|
|
303
|
+
x,y=x*34/w,y*20/h
|
|
304
|
+
cx=min(max(x,10),24)
|
|
305
|
+
distance=math.hypot(x-cx,y-10)
|
|
306
|
+
if distance>9:return bg
|
|
307
|
+
if math.hypot(x-(24 if on else 10),y-10)<7:return knob
|
|
308
|
+
return fill
|
|
309
|
+
img=_raster(root,w,h,sample);switch_images.append(img)
|
|
310
|
+
root._tkwebui_images.extend(switch_images)
|
|
311
|
+
style.element_create('Switch.toggle','image',switch_images[0],('disabled selected',switch_images[3]),
|
|
312
|
+
('disabled',switch_images[2]),('selected',switch_images[1]))
|
|
313
|
+
style.layout('Switch.TCheckbutton',[('Checkbutton.padding',{'sticky':'nsew','children':[
|
|
314
|
+
('Switch.toggle',{'side':'left'}),('Checkbutton.label',{'side':'left','sticky':'w'})]})])
|
|
315
|
+
style.configure('Switch.TCheckbutton',padding=(0,px(2)),background=c['bg'],focusthickness=0)
|
|
316
|
+
style.configure('Treeview',background=c['bg'],fieldbackground=c['bg'],borderwidth=0,
|
|
317
|
+
rowheight=tkfont.nametofont('TkDefaultFont').metrics('linespace')+px(14))
|
|
318
|
+
style.map('Treeview',background=[('selected',c['selected'])],foreground=[('selected',c['fg'])])
|
|
319
|
+
style.configure('Treeview.Heading',background=c['sidebar'],font=normal,relief='flat',padding=(px(10),px(10)))
|
|
320
|
+
style.map('Treeview.Heading',background=[('active',c['hover'])])
|
|
321
|
+
style.configure('TNotebook',borderwidth=0,background=c['bg'])
|
|
322
|
+
style.configure('TNotebook.Tab',padding=(px(14),px(8)),background=c['sidebar'])
|
|
323
|
+
style.map('TNotebook.Tab',background=[('selected',c['bg'])])
|
|
324
|
+
for orient in ('Horizontal','Vertical'):
|
|
325
|
+
direction='ew' if orient=='Horizontal' else 'ns'
|
|
326
|
+
thickness=px(6)
|
|
327
|
+
width,height=(96,thickness) if orient=='Horizontal' else (thickness,96)
|
|
328
|
+
thumb_images=[]
|
|
329
|
+
for color in ('#C6C8D0','#A8ADB8','#888F9F'):
|
|
330
|
+
rgb_color=_rgb(color);rgb_bg=_rgb(c['bg'])
|
|
331
|
+
def sample(x,y,w=width,h=height,fg=rgb_color,bg=rgb_bg):
|
|
332
|
+
radius=min(w,h)/2
|
|
333
|
+
cx=min(max(x,radius),w-radius);cy=min(max(y,radius),h-radius)
|
|
334
|
+
return fg if math.hypot(x-cx,y-cy)<=radius else bg
|
|
335
|
+
image=_raster(root,width,height,sample,transparent=rgb_bg)
|
|
336
|
+
thumb_images.append(image)
|
|
337
|
+
root._tkwebui_images.extend(thumb_images)
|
|
338
|
+
track='Switch.'+orient+'.Scrollbar.trough'
|
|
339
|
+
thumb='Switch.'+orient+'.Scrollbar.thumb'
|
|
340
|
+
# The default-theme trough is flat; no clam border, arrows or groove.
|
|
341
|
+
style.element_create(track,'from','default',orient+'.Scrollbar.trough')
|
|
342
|
+
style.element_create(thumb,'image',thumb_images[0],('pressed',thumb_images[2]),('active',thumb_images[1]),
|
|
343
|
+
border=max(1,thickness//2),width=thickness if orient=='Vertical' else thickness*2,
|
|
344
|
+
height=thickness if orient=='Horizontal' else thickness*2,sticky='nsew')
|
|
345
|
+
style.configure(orient+'.TScrollbar',background=c['bg'],troughcolor=c['bg'],
|
|
346
|
+
borderwidth=0,relief='flat',gripcount=0,width=thickness,arrowsize=thickness)
|
|
347
|
+
style.layout(orient+'.TScrollbar',[(track,{'sticky':'nsew','children':[
|
|
348
|
+
(thumb,{'expand':True,'sticky':direction})]})])
|
|
349
|
+
style.configure(orient+'.TProgressbar',background=c['accent'],lightcolor=c['accent'],darkcolor=c['accent'],
|
|
350
|
+
troughcolor=c['hover'],borderwidth=0,thickness=px(7))
|
|
351
|
+
style.configure(orient+'.TScale',background=c['blue'],troughcolor=c['hover'],borderwidth=0)
|
|
352
|
+
style.configure('TSeparator',background=c['border'])
|
|
353
|
+
for orientation,width,height in [('Horizontal',256,1),('Vertical',1,256)]:
|
|
354
|
+
image=tk.PhotoImage(master=root,width=width,height=height)
|
|
355
|
+
image.put(c['border'],to=(0,0,width,height));root._tkwebui_images.append(image)
|
|
356
|
+
element_name='Switch.'+orientation+'.hairline'
|
|
357
|
+
style.element_create(element_name,'image',image,width=1,height=1,sticky='nsew')
|
|
358
|
+
style.layout(orientation+'.TSeparator',[(element_name,{'sticky':'nsew'})])
|
|
359
|
+
style.configure('TPanedwindow',background=c['border'])
|
|
360
|
+
from .widgets import configure_widget_styles, install_input_focus
|
|
361
|
+
configure_widget_styles(root,style)
|
|
362
|
+
from .extras import configure_extra_styles
|
|
363
|
+
configure_extra_styles(root,style)
|
|
364
|
+
from .forms import configure_form_styles
|
|
365
|
+
configure_form_styles(root,style)
|
|
366
|
+
install_input_focus(root)
|
|
367
|
+
root._tkwebui_ready=True
|
|
368
|
+
return c.copy()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class Card(ttk.Frame):
|
|
372
|
+
def __init__(self,master,*,selected=False,padding=18,border_color=None,**kwargs):
|
|
373
|
+
self._selected=selected
|
|
374
|
+
self._border_color=border_color
|
|
375
|
+
self._last_size=None
|
|
376
|
+
minimum=round(master.winfo_fpixels('1i')/96*5)+1
|
|
377
|
+
padding=tuple(max(minimum,value) for value in padding) if isinstance(padding,(tuple,list)) else max(minimum,padding)
|
|
378
|
+
super().__init__(master,style='Selected.TFrame' if selected else 'TFrame',padding=padding,**kwargs)
|
|
379
|
+
self._surface=tk.Canvas(self,background=PALETTE['bg'],highlightthickness=0,borderwidth=0,takefocus=False)
|
|
380
|
+
self._surface.place(x=0,y=0,relwidth=1,relheight=1,bordermode='outside')
|
|
381
|
+
tk.Misc.lower(self._surface)
|
|
382
|
+
self._fills=[self._surface.create_rectangle(0,0,0,0,width=0) for _ in range(2)]
|
|
383
|
+
self._edges=[self._surface.create_line(0,0,0,0,width=1) for _ in range(4)]
|
|
384
|
+
self._corners=[self._surface.create_image(0,0,anchor='nw') for _ in range(4)]
|
|
385
|
+
self.bind('<Configure>',self._resize,add='+')
|
|
386
|
+
self._recolor()
|
|
387
|
+
def _recolor(self):
|
|
388
|
+
fill=PALETTE['selected'] if self._selected else PALETTE['bg']
|
|
389
|
+
border=self._border_color or (PALETTE['blue'] if self._selected else PALETTE['border'])
|
|
390
|
+
for item in self._fills:self._surface.itemconfigure(item,fill=fill)
|
|
391
|
+
for item in self._edges:self._surface.itemconfigure(item,fill=border)
|
|
392
|
+
root=self._root();radius=round(self.winfo_fpixels('1i')/96*5)
|
|
393
|
+
cache=getattr(root,'_card_corner_cache',None)
|
|
394
|
+
if cache is None:cache=root._card_corner_cache={}
|
|
395
|
+
key=(fill,border,radius)
|
|
396
|
+
if key not in cache:
|
|
397
|
+
source=_patch(root,fill,border,PALETTE['bg'],radius)
|
|
398
|
+
w,h=source.width(),source.height();size=radius+1
|
|
399
|
+
images=[]
|
|
400
|
+
for x,y in ((0,0),(w-size,0),(0,h-size),(w-size,h-size)):
|
|
401
|
+
image=tk.PhotoImage(master=root,width=size,height=size)
|
|
402
|
+
root.tk.call(image,'copy',source,'-from',x,y,x+size,y+size,'-to',0,0)
|
|
403
|
+
images.append(image)
|
|
404
|
+
cache[key]=images
|
|
405
|
+
for item,image in zip(self._corners,cache[key]):self._surface.itemconfigure(item,image=image)
|
|
406
|
+
def _resize(self,event):
|
|
407
|
+
size=(event.width,event.height)
|
|
408
|
+
if size==self._last_size:return
|
|
409
|
+
self._last_size=size
|
|
410
|
+
w,h=event.width,event.height
|
|
411
|
+
r=round(self.winfo_fpixels('1i')/96*5)+1
|
|
412
|
+
# Native filled rectangles/lines + four fixed, antialiased corners.
|
|
413
|
+
# Only coordinates change during resizing; no large image fills.
|
|
414
|
+
for item,coords in zip(self._fills,((r,0,w-r,h),(0,r,w,h-r))):self._surface.coords(item,*coords)
|
|
415
|
+
for item,coords in zip(self._edges,((r,0,w-r,0),(r,h-1,w-r,h-1),(0,r,0,h-r),(w-1,r,w-1,h-r))):self._surface.coords(item,*coords)
|
|
416
|
+
for item,point in zip(self._corners,((0,0),(w-r,0),(0,h-r),(w-r,h-r))):self._surface.coords(item,*point)
|
|
417
|
+
def configure(self,cnf=None,**kwargs):
|
|
418
|
+
if isinstance(cnf,dict):kwargs={**cnf,**kwargs};cnf=None
|
|
419
|
+
if kwargs.get('style') in ('Card.TFrame','Selected.Card.TFrame'):
|
|
420
|
+
self._selected=kwargs['style'].startswith('Selected.')
|
|
421
|
+
kwargs['style']='Selected.TFrame' if self._selected else 'TFrame'
|
|
422
|
+
if hasattr(self,'_surface'):self._recolor()
|
|
423
|
+
return super().configure(cnf,**kwargs)
|
|
424
|
+
config=configure
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class SegmentedControl(ttk.Frame):
|
|
428
|
+
def __init__(self,master,items,variable,command=None,**kwargs):
|
|
429
|
+
super().__init__(master,style='Segment.Card.TFrame',padding=4,**kwargs)
|
|
430
|
+
self.variable,self.command=variable,command
|
|
431
|
+
self.buttons={}
|
|
432
|
+
for value,label in items:
|
|
433
|
+
button=ttk.Button(self,text=label,style='Segment.TButton',command=lambda v=value:self.select(v))
|
|
434
|
+
button.pack(side='left',padx=2)
|
|
435
|
+
self.buttons[value]=button
|
|
436
|
+
self._trace=variable.trace_add('write',self.sync)
|
|
437
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
438
|
+
self.sync()
|
|
439
|
+
def sync(self,*_):
|
|
440
|
+
for value,button in self.buttons.items():
|
|
441
|
+
button.configure(style='Selected.Segment.TButton' if value==self.variable.get() else 'Segment.TButton')
|
|
442
|
+
def select(self,value):
|
|
443
|
+
self.variable.set(value)
|
|
444
|
+
if self.command:self.command(value)
|
|
445
|
+
def cleanup(self,event):
|
|
446
|
+
if event.widget is self:self.variable.trace_remove('write',self._trace)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class Sidebar(ttk.Frame):
|
|
450
|
+
def __init__(self,master,items,command,*,width=230):
|
|
451
|
+
super().__init__(master,style='Sidebar.TFrame',width=width)
|
|
452
|
+
self.pack_propagate(False)
|
|
453
|
+
self.buttons={}
|
|
454
|
+
self.labels={}
|
|
455
|
+
self.collapsed=False
|
|
456
|
+
self.selected_key=None
|
|
457
|
+
self.command=command
|
|
458
|
+
for key,label,symbol in items:
|
|
459
|
+
button=ttk.Button(self,text=' '+label,image=icon(self,symbol,background=PALETTE['sidebar']),
|
|
460
|
+
compound='left',style='Nav.TButton',command=lambda k=key:self.select(k))
|
|
461
|
+
button.pack(fill='x',padx=12,pady=3)
|
|
462
|
+
self.buttons[key]=(button,symbol)
|
|
463
|
+
self.labels[key]=label
|
|
464
|
+
Tooltip(button,label)
|
|
465
|
+
button.bind('<Enter>',lambda event,k=key:self.hover(k,True),add='+')
|
|
466
|
+
button.bind('<Leave>',lambda event,k=key:self.hover(k,False),add='+')
|
|
467
|
+
def select(self,key,notify=True):
|
|
468
|
+
self.selected_key=key
|
|
469
|
+
for name,(button,symbol) in self.buttons.items():
|
|
470
|
+
selected=name==key
|
|
471
|
+
if getattr(button,'_nav_visual',None)==(selected,self.collapsed):continue
|
|
472
|
+
button._nav_visual=(selected,self.collapsed)
|
|
473
|
+
style=('Selected.' if selected else '')+('Icon.Nav.TButton' if self.collapsed else 'Nav.TButton')
|
|
474
|
+
button.configure(style=style,text='' if self.collapsed else ' '+self.labels[name],
|
|
475
|
+
image=icon(self,symbol,background=PALETTE['active'] if selected else PALETTE['sidebar']))
|
|
476
|
+
if notify:self.command(key)
|
|
477
|
+
def set_collapsed(self,collapsed):
|
|
478
|
+
self.collapsed=bool(collapsed)
|
|
479
|
+
self.select(self.selected_key,notify=False)
|
|
480
|
+
def hover(self,key,active):
|
|
481
|
+
button,symbol=self.buttons[key]
|
|
482
|
+
background=PALETTE['active'] if active or key==self.selected_key else PALETTE['sidebar']
|
|
483
|
+
button.configure(image=icon(self,symbol,background=background))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class ScrollArea(ttk.Frame):
|
|
487
|
+
"""A scrollable native widget container. Coalesces geometry work at idle."""
|
|
488
|
+
def __init__(self,master,**kwargs):
|
|
489
|
+
super().__init__(master,**kwargs)
|
|
490
|
+
self.canvas=tk.Canvas(self,background=PALETTE['bg'],highlightthickness=0,borderwidth=0)
|
|
491
|
+
self.bar=ttk.Scrollbar(self,command=self.canvas.yview)
|
|
492
|
+
self.canvas.configure(yscrollcommand=self.bar.set)
|
|
493
|
+
self.canvas.pack(side='left',fill='both',expand=True)
|
|
494
|
+
self.body=ttk.Frame(self.canvas)
|
|
495
|
+
self.window=self.canvas.create_window((0,0),window=self.body,anchor='nw')
|
|
496
|
+
self._job=None;self._width=0
|
|
497
|
+
self.canvas.bind('<Configure>',self.resize)
|
|
498
|
+
self.body.bind('<Configure>',self.schedule)
|
|
499
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
500
|
+
# A per-container bindtag prevents wheel handlers leaking to other pages.
|
|
501
|
+
self._wheel_tag='ScrollArea'+str(id(self))
|
|
502
|
+
self.bind_class(self._wheel_tag,'<MouseWheel>',self.wheel)
|
|
503
|
+
self.bind_class(self._wheel_tag,'<Destroy>',self.schedule)
|
|
504
|
+
self.bind_class(self._wheel_tag,'<Unmap>',self.schedule)
|
|
505
|
+
def resize(self,event):
|
|
506
|
+
# The current width, not the event's: queued <Configure> events carry
|
|
507
|
+
# intermediate sizes (e.g. before the scrollbar appeared).
|
|
508
|
+
width=self.canvas.winfo_width()
|
|
509
|
+
if self._width!=width:
|
|
510
|
+
self._width=width
|
|
511
|
+
self.canvas.itemconfigure(self.window,width=width)
|
|
512
|
+
self.schedule()
|
|
513
|
+
def prepare(self,width,height):
|
|
514
|
+
"""Settle the layout at its final size before the area is first shown.
|
|
515
|
+
|
|
516
|
+
The body width, the scrollbar and any <Configure>-driven wrapping
|
|
517
|
+
depend on window events, which Tk delivers only once the area is on
|
|
518
|
+
screen: content would appear at its natural width, then widen. Placed
|
|
519
|
+
just outside its master, the area is laid out here and resized widgets
|
|
520
|
+
get their <Configure> now; no user input is processed. Afterwards
|
|
521
|
+
pack/grid/place it as usual.
|
|
522
|
+
"""
|
|
523
|
+
self.place(x=width+8,y=0,width=width,height=height)
|
|
524
|
+
def walk(widget):
|
|
525
|
+
yield widget
|
|
526
|
+
for child in widget.winfo_children():
|
|
527
|
+
if not isinstance(child,tk.Toplevel):yield from walk(child)
|
|
528
|
+
sizes={}
|
|
529
|
+
for _ in range(8):
|
|
530
|
+
self.update_idletasks()
|
|
531
|
+
changed=[w for w in walk(self) if sizes.get(w)!=(w.winfo_width(),w.winfo_height())]
|
|
532
|
+
if not changed:break
|
|
533
|
+
for w in changed:
|
|
534
|
+
sizes[w]=(w.winfo_width(),w.winfo_height())
|
|
535
|
+
w.event_generate('<Configure>',x=w.winfo_x(),y=w.winfo_y(),width=sizes[w][0],height=sizes[w][1])
|
|
536
|
+
def schedule(self,*_):
|
|
537
|
+
if self._job is None:self._job=self.after_idle(self.refresh)
|
|
538
|
+
def refresh(self):
|
|
539
|
+
self._job=None
|
|
540
|
+
# Geometry managers retain the old requested size after their last
|
|
541
|
+
# child disappears. Reset the body's request, retaining natural sizing
|
|
542
|
+
# so adding new children can grow it again.
|
|
543
|
+
empty=not any(child.winfo_manager() for child in self.body.winfo_children())
|
|
544
|
+
if empty and self.body.winfo_reqheight()>max(1,self.body.winfo_pixels(self.body.cget('height'))):
|
|
545
|
+
self.body.configure(height=max(1,self.body.winfo_pixels(self.body.cget('height'))))
|
|
546
|
+
self.schedule()
|
|
547
|
+
bounds=self.canvas.bbox('all') or (0,0,0,0)
|
|
548
|
+
self.canvas.configure(scrollregion=(0,0,max(bounds[2],self.canvas.winfo_width()),
|
|
549
|
+
max(bounds[3],self.canvas.winfo_height())))
|
|
550
|
+
if bounds[3]<=self.canvas.winfo_height() or self.canvas.canvasy(0)<0:self.canvas.yview_moveto(0)
|
|
551
|
+
self.install_wheel(self.canvas)
|
|
552
|
+
need=bounds[3]>self.canvas.winfo_height()
|
|
553
|
+
if need and not self.bar.winfo_manager():self.bar.pack(side='right',fill='y')
|
|
554
|
+
elif not need and self.bar.winfo_manager():self.bar.pack_forget()
|
|
555
|
+
def install_wheel(self,widget):
|
|
556
|
+
tags=widget.bindtags()
|
|
557
|
+
if self._wheel_tag not in tags:widget.bindtags((tags[0],self._wheel_tag,*tags[1:]))
|
|
558
|
+
for child in widget.winfo_children():self.install_wheel(child)
|
|
559
|
+
def wheel(self,event):
|
|
560
|
+
if isinstance(event.widget,(tk.Text,tk.Listbox,ttk.Treeview)):
|
|
561
|
+
first,last=event.widget.yview()
|
|
562
|
+
if last-first<1-1e-7:return None
|
|
563
|
+
if self.body.winfo_reqheight()>self.canvas.winfo_height():
|
|
564
|
+
self.canvas.yview_scroll(-int(event.delta/120),'units')
|
|
565
|
+
return 'break'
|
|
566
|
+
def cleanup(self,event):
|
|
567
|
+
if event.widget is self:
|
|
568
|
+
if self._job is not None:self.after_cancel(self._job)
|
|
569
|
+
self.unbind_class(self._wheel_tag,'<MouseWheel>')
|
|
570
|
+
self.unbind_class(self._wheel_tag,'<Destroy>')
|
|
571
|
+
self.unbind_class(self._wheel_tag,'<Unmap>')
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class Tooltip:
|
|
575
|
+
def __init__(self,widget,text,delay=450):
|
|
576
|
+
self.widget,self.text,self.delay=widget,text,delay
|
|
577
|
+
self.job=None;self.window=None
|
|
578
|
+
widget.bind('<Enter>',self.schedule,add='+')
|
|
579
|
+
widget.bind('<Leave>',self.hide,add='+')
|
|
580
|
+
widget.bind('<ButtonPress>',self.hide,add='+')
|
|
581
|
+
widget.bind('<Destroy>',self.hide,add='+')
|
|
582
|
+
def schedule(self,event=None):
|
|
583
|
+
self.hide()
|
|
584
|
+
self.job=self.widget.after(self.delay,self.show)
|
|
585
|
+
def show(self):
|
|
586
|
+
self.job=None
|
|
587
|
+
if not self.widget.winfo_exists():return
|
|
588
|
+
from .extras import MenuPopup
|
|
589
|
+
x=self.widget.winfo_rootx();anchor_y=self.widget.winfo_rooty()
|
|
590
|
+
left,top,right,bottom=MenuPopup.work_area(x,anchor_y)
|
|
591
|
+
self.window=tk.Toplevel(self.widget)
|
|
592
|
+
self.window.withdraw()
|
|
593
|
+
self.window.overrideredirect(True)
|
|
594
|
+
self.window.attributes('-topmost',True)
|
|
595
|
+
content=self.text() if callable(self.text) else self.text
|
|
596
|
+
ttk.Label(self.window,text=content,padding=(10,6),background='#252831',foreground='#FFFFFF',
|
|
597
|
+
wraplength=max(1,right-left-36)).pack()
|
|
598
|
+
self.window.update_idletasks()
|
|
599
|
+
width=self.window.winfo_reqwidth();height=self.window.winfo_reqheight()
|
|
600
|
+
y=anchor_y+self.widget.winfo_height()+6
|
|
601
|
+
if y+height>bottom-8:y=anchor_y-height-6
|
|
602
|
+
x=max(left+8,min(x,right-width-8));y=max(top+8,min(y,bottom-height-8))
|
|
603
|
+
self.window.geometry(f'+{int(x)}+{int(y)}')
|
|
604
|
+
self.window.deiconify()
|
|
605
|
+
def hide(self,event=None):
|
|
606
|
+
if self.job is not None:
|
|
607
|
+
self.widget.after_cancel(self.job);self.job=None
|
|
608
|
+
if self.window is not None:
|
|
609
|
+
self.window.destroy();self.window=None
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class Dropdown(ttk.Button):
|
|
613
|
+
"""Keyboard-operable, selected-mark popup for readonly choices."""
|
|
614
|
+
def __init__(self,master,values,variable=None,command=None,**kwargs):
|
|
615
|
+
self.values=tuple(values)
|
|
616
|
+
self.variable=variable or tk.StringVar(value=self.values[0] if self.values else '')
|
|
617
|
+
self.on_change=command
|
|
618
|
+
self.popup=None;self._return_focus=None
|
|
619
|
+
kwargs.setdefault('style','Dropdown.TButton')
|
|
620
|
+
super().__init__(master,text=self.variable.get(),command=self.open,**kwargs)
|
|
621
|
+
self._trace=self.variable.trace_add('write',self.sync)
|
|
622
|
+
self.bind('<Down>',lambda _:self.open())
|
|
623
|
+
self.bind('<Return>',lambda _:self.open())
|
|
624
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
625
|
+
def sync(self,*_):self.configure(text=self.variable.get())
|
|
626
|
+
def open(self):
|
|
627
|
+
if self.instate(['disabled']) or not self.values:return 'break'
|
|
628
|
+
if self.popup is not None and not self.popup._closed:return self.dismiss()
|
|
629
|
+
from .popups import ChoicePopup
|
|
630
|
+
self.cursor=self.values.index(self.variable.get()) if self.variable.get() in self.values else 0
|
|
631
|
+
choices=[dict(label=value,checked=value==self.variable.get(),command=lambda v=value:self.choose(v)) for value in self.values]
|
|
632
|
+
self.popup=ChoicePopup(self,choices,self.winfo_rootx(),self.winfo_rooty()+self.winfo_height()+4,
|
|
633
|
+
width=self.winfo_width(),selected=self.cursor,above=self.winfo_rooty())
|
|
634
|
+
self.rows=self.popup.rows
|
|
635
|
+
return 'break'
|
|
636
|
+
def move(self,delta):
|
|
637
|
+
if self.popup is not None and not self.popup._closed:
|
|
638
|
+
self.popup.move(delta);self.cursor=self.popup.cursor
|
|
639
|
+
return 'break'
|
|
640
|
+
def choose(self,value):
|
|
641
|
+
self.variable.set(value);self.dismiss()
|
|
642
|
+
if self.on_change:self.on_change(value)
|
|
643
|
+
self.event_generate('<<DropdownSelected>>')
|
|
644
|
+
def outside(self,event):
|
|
645
|
+
if self.popup is not None and not self.popup._closed:self.popup.outside(event)
|
|
646
|
+
def dismiss(self):
|
|
647
|
+
if self.popup is not None:
|
|
648
|
+
popup=self.popup;self.popup=None;popup.close()
|
|
649
|
+
return 'break'
|
|
650
|
+
def cleanup(self,event):
|
|
651
|
+
if event.widget is self:
|
|
652
|
+
self.dismiss();self.variable.trace_remove('write',self._trace)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class Accordion(Card):
|
|
656
|
+
def __init__(self,master,title,*,opened=False,**kwargs):
|
|
657
|
+
super().__init__(master,**kwargs)
|
|
658
|
+
self.opened=opened
|
|
659
|
+
self.header=ttk.Button(self,text=' '+title,compound='left',style='Quiet.TButton',command=self.toggle)
|
|
660
|
+
self.header.pack(fill='x')
|
|
661
|
+
self.body=ttk.Frame(self,padding=(12,10))
|
|
662
|
+
self.sync()
|
|
663
|
+
def toggle(self):self.opened=not self.opened;self.sync()
|
|
664
|
+
def sync(self):
|
|
665
|
+
self.header.configure(image=icon(self,'chevrondown' if self.opened else 'chevronright'))
|
|
666
|
+
if self.opened:self.body.pack(fill='x')
|
|
667
|
+
else:self.body.pack_forget()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class SettingRow(ttk.Frame):
|
|
671
|
+
def __init__(self,master,title,description='',*,help_text=None):
|
|
672
|
+
super().__init__(master,padding=(18,14))
|
|
673
|
+
self.columnconfigure(0,weight=1)
|
|
674
|
+
text=ttk.Frame(self);text.grid(row=0,column=0,sticky='w')
|
|
675
|
+
header=ttk.Frame(text);header.pack(anchor='w')
|
|
676
|
+
ttk.Label(header,text=title).pack(side='left')
|
|
677
|
+
if help_text:
|
|
678
|
+
helper=ttk.Label(header,image=icon(self,'info',16))
|
|
679
|
+
helper.pack(side='left',padx=8);Tooltip(helper,help_text)
|
|
680
|
+
if description:ttk.Label(text,text=description,style='Muted.TLabel').pack(anchor='w',pady=(5,0))
|
|
681
|
+
self.action=ttk.Frame(self);self.action.grid(row=0,column=1,sticky='e',padx=(20,0))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class BarChart(ttk.Frame):
|
|
685
|
+
"""Small native Canvas chart with persistent items and no plotting library."""
|
|
686
|
+
def __init__(self,master,values,labels,**kwargs):
|
|
687
|
+
super().__init__(master,**kwargs)
|
|
688
|
+
self.canvas=tk.Canvas(self,height=220,background=PALETTE['bg'],highlightthickness=0)
|
|
689
|
+
self.canvas.pack(fill='both',expand=True)
|
|
690
|
+
self.values=list(values);self.labels=list(labels)
|
|
691
|
+
self.bars=[];self.captions=[];self._job=None
|
|
692
|
+
self.lines=[self.canvas.create_line(0,0,0,0,fill=PALETTE['border']) for _ in range(3)]
|
|
693
|
+
self.ticks=[self.canvas.create_text(0,0,anchor='e',fill=PALETTE['muted'],font='TkDefaultFont') for _ in range(3)]
|
|
694
|
+
self.hint=tk.StringVar(value='将鼠标移到柱形上查看数值')
|
|
695
|
+
ttk.Label(self,textvariable=self.hint,style='Muted.TLabel').pack(anchor='w')
|
|
696
|
+
self.canvas.bind('<Configure>',self.schedule)
|
|
697
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
698
|
+
self.set_data(values,labels)
|
|
699
|
+
def set_data(self,values,labels):
|
|
700
|
+
self.values=list(values);self.labels=list(labels)
|
|
701
|
+
while len(self.bars)<len(values):
|
|
702
|
+
index=len(self.bars)
|
|
703
|
+
bar=self.canvas.create_rectangle(0,0,0,0,fill='#179BB4',outline='')
|
|
704
|
+
self.canvas.tag_bind(bar,'<Enter>',lambda event,i=index:self.hint.set(f'{self.labels[i]} · {self.values[i]} 篇'))
|
|
705
|
+
self.canvas.tag_bind(bar,'<Leave>',lambda event:self.hint.set('将鼠标移到柱形上查看数值'))
|
|
706
|
+
self.bars.append(bar)
|
|
707
|
+
self.captions.append(self.canvas.create_text(0,0,fill=PALETTE['muted'],font='TkDefaultFont'))
|
|
708
|
+
for i in range(len(self.bars)):
|
|
709
|
+
self.canvas.itemconfigure(self.bars[i],state='normal' if i<len(values) else 'hidden')
|
|
710
|
+
self.canvas.itemconfigure(self.captions[i],state='normal' if i<len(values) else 'hidden')
|
|
711
|
+
self.schedule()
|
|
712
|
+
def schedule(self,event=None):
|
|
713
|
+
if self._job is None:self._job=self.after_idle(self.draw)
|
|
714
|
+
def draw(self):
|
|
715
|
+
self._job=None
|
|
716
|
+
w,h=self.canvas.winfo_width(),self.canvas.winfo_height()
|
|
717
|
+
left,right,top,bottom=48,w-12,14,h-34
|
|
718
|
+
maximum=max(1,max(self.values,default=1))
|
|
719
|
+
for i in range(3):
|
|
720
|
+
y=bottom-(bottom-top)*i/2
|
|
721
|
+
self.canvas.coords(self.lines[i],left,y,right,y)
|
|
722
|
+
self.canvas.coords(self.ticks[i],left-10,y)
|
|
723
|
+
self.canvas.itemconfigure(self.ticks[i],text=f'{maximum*i/2:g}')
|
|
724
|
+
step=(right-left)/max(1,len(self.values))
|
|
725
|
+
for i,value in enumerate(self.values):
|
|
726
|
+
x=left+step*(i+.5);barwidth=max(3,step*.48)
|
|
727
|
+
self.canvas.coords(self.bars[i],x-barwidth/2,bottom-(bottom-top)*value/maximum,x+barwidth/2,bottom)
|
|
728
|
+
self.canvas.coords(self.captions[i],x,bottom+20)
|
|
729
|
+
self.canvas.itemconfigure(self.captions[i],text=self.labels[i])
|
|
730
|
+
def cleanup(self,event):
|
|
731
|
+
if event.widget is self and self._job is not None:self.after_cancel(self._job)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class CardList(ttk.Frame):
|
|
735
|
+
"""Retained Canvas card list: one native window instead of many per row.
|
|
736
|
+
|
|
737
|
+
Rows: id/name/detail/tag/mark/color. Callback: (id, action, screen_x, screen_y).
|
|
738
|
+
Up/Down changes focused row; Left/Right chooses action; Enter invokes it.
|
|
739
|
+
"""
|
|
740
|
+
def __init__(self,master,on_action,**kwargs):
|
|
741
|
+
super().__init__(master,**kwargs)
|
|
742
|
+
self.on_action=on_action;self.rows=[];self.items=[];self.active_id=None
|
|
743
|
+
self.row_index=0;self.action_index=0;self.job=None
|
|
744
|
+
self._hover_target=None;self._painted_hover=None
|
|
745
|
+
self.scale=self.winfo_fpixels('1i')/96
|
|
746
|
+
self._title_font=tkfont.Font(root=self,family='Microsoft YaHei UI',size=10,weight='bold')
|
|
747
|
+
self._body_font=tkfont.Font(root=self,family='Microsoft YaHei UI',size=10)
|
|
748
|
+
self.px=lambda n:max(1,round(n*self.scale))
|
|
749
|
+
self.row_height=self.px(68);self.gap=self.px(8)
|
|
750
|
+
self.canvas=tk.Canvas(self,background=PALETTE['bg'],highlightthickness=0,takefocus=True)
|
|
751
|
+
self.bar=ttk.Scrollbar(self,command=self.canvas.yview)
|
|
752
|
+
self.canvas.configure(yscrollcommand=self.bar.set)
|
|
753
|
+
self.canvas.pack(side='left',fill='both',expand=True)
|
|
754
|
+
self.canvas.bind('<Configure>',self.schedule)
|
|
755
|
+
self.canvas.bind('<MouseWheel>',self.wheel)
|
|
756
|
+
self.canvas.bind('<Button-1>',self.click)
|
|
757
|
+
self.canvas.bind('<Motion>',self.hover)
|
|
758
|
+
self.canvas.bind('<Leave>',self.clear_hover)
|
|
759
|
+
self.canvas.bind('<Up>',lambda _:self.navigate(-1,0))
|
|
760
|
+
self.canvas.bind('<Down>',lambda _:self.navigate(1,0))
|
|
761
|
+
self.canvas.bind('<Left>',lambda _:self.navigate(0,-1))
|
|
762
|
+
self.canvas.bind('<Right>',lambda _:self.navigate(0,1))
|
|
763
|
+
self.canvas.bind('<Return>',self.invoke)
|
|
764
|
+
self.canvas.bind('<space>',self.invoke)
|
|
765
|
+
self.canvas.bind('<Shift-F10>',lambda _:self.invoke(action='menu'))
|
|
766
|
+
self.canvas.bind('<FocusIn>',self.schedule)
|
|
767
|
+
self.canvas.bind('<FocusOut>',self.schedule)
|
|
768
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
769
|
+
self.faces={}
|
|
770
|
+
for selected in (False,True):
|
|
771
|
+
fill=PALETTE['selected'] if selected else PALETTE['bg']
|
|
772
|
+
edge=PALETTE['blue'] if selected else PALETTE['border']
|
|
773
|
+
source=_patch(self._root(),fill,edge,PALETTE['bg'],self.px(5))
|
|
774
|
+
size=self.px(5)+1;w,h=source.width(),source.height();corners=[]
|
|
775
|
+
for x,y in ((0,0),(w-size,0),(0,h-size),(w-size,h-size)):
|
|
776
|
+
image=tk.PhotoImage(master=self,width=size,height=size)
|
|
777
|
+
self.tk.call(image,'copy',source,'-from',x,y,x+size,y+size,'-to',0,0);corners.append(image)
|
|
778
|
+
logo=_patch(self._root(),PALETTE['bg'],PALETTE['border'],fill,self.px(4),height=self.px(34),width=self.px(34))
|
|
779
|
+
actions=[icon(self,symbol,self.px(16),background=fill) for symbol in ('play','edit','more')]
|
|
780
|
+
hover_face=_patch(self._root(),PALETTE['hover'],PALETTE['hover'],fill,self.px(3),height=self.px(30),width=self.px(30))
|
|
781
|
+
hover_actions=[icon(self,symbol,self.px(16),background=PALETTE['hover']) for symbol in ('play','edit','more')]
|
|
782
|
+
self.faces[selected]=(fill,edge,corners,logo,actions,hover_face,hover_actions)
|
|
783
|
+
self.hover_box=self.canvas.create_image(0,0,state='hidden')
|
|
784
|
+
def set_rows(self,rows,active_id,*,addresses=True,density='标准'):
|
|
785
|
+
self.rows=list(rows);self.active_id=active_id;self.addresses=addresses
|
|
786
|
+
self._hover_target=None;self._painted_hover=None;self.canvas.itemconfigure(self.hover_box,state='hidden')
|
|
787
|
+
self.row_height=self.px({'紧凑':60,'标准':68,'宽松':80}[density])
|
|
788
|
+
for group in self.items:
|
|
789
|
+
for value in group.values():
|
|
790
|
+
for item in value if isinstance(value,list) else [value]:self.canvas.delete(item)
|
|
791
|
+
self.items=[]
|
|
792
|
+
for index,row in enumerate(self.rows):
|
|
793
|
+
tags=(f'row:{index}',)
|
|
794
|
+
group={'fills':[self.canvas.create_rectangle(0,0,0,0,width=0,tags=tags) for _ in range(2)],
|
|
795
|
+
'edges':[self.canvas.create_line(0,0,0,0,tags=tags) for _ in range(4)],
|
|
796
|
+
'corners':[self.canvas.create_image(0,0,anchor='nw',tags=tags) for _ in range(4)],
|
|
797
|
+
'logo':self.canvas.create_image(0,0,anchor='nw',tags=tags)}
|
|
798
|
+
for key,font,foreground,anchor in [('mark',('Microsoft YaHei UI',12,'bold'),row['color'],'center'),
|
|
799
|
+
('name',('Microsoft YaHei UI',10,'bold'),PALETTE['fg'],'w'),
|
|
800
|
+
('tag',('Microsoft YaHei UI',9),PALETTE['muted'],'w'),
|
|
801
|
+
('detail',('Microsoft YaHei UI',10),PALETTE['muted'],'w'),
|
|
802
|
+
('status',('Microsoft YaHei UI',10),PALETTE['blue'],'e')]:
|
|
803
|
+
group[key]=self.canvas.create_text(0,0,text=row.get(key,''),font=font,fill=foreground,anchor=anchor,tags=tags)
|
|
804
|
+
group['hits']=[self.canvas.create_rectangle(0,0,0,0,outline='',tags=(*tags,'action:'+action)) for action in ('use','edit','menu')]
|
|
805
|
+
group['actions']=[self.canvas.create_image(0,0,tags=(*tags,'action:'+action)) for action in ('use','edit','menu')]
|
|
806
|
+
self.items.append(group)
|
|
807
|
+
self.row_index=min(self.row_index,max(0,len(self.rows)-1))
|
|
808
|
+
self.schedule()
|
|
809
|
+
def set_active(self,key):self.active_id=key;self.schedule()
|
|
810
|
+
@staticmethod
|
|
811
|
+
def elide(text,font,width):
|
|
812
|
+
if font.measure(text)<=width:return text
|
|
813
|
+
low,high=0,len(text)
|
|
814
|
+
while low<high:
|
|
815
|
+
middle=(low+high+1)//2
|
|
816
|
+
if font.measure(text[:middle]+'…')<=width:low=middle
|
|
817
|
+
else:high=middle-1
|
|
818
|
+
return text[:low]+'…'
|
|
819
|
+
def schedule(self,event=None):
|
|
820
|
+
if self.job is None:self.job=self.after_idle(self.draw)
|
|
821
|
+
def draw(self):
|
|
822
|
+
self.job=None
|
|
823
|
+
w=max(50,self.canvas.winfo_width()-2);h=self.row_height;r=self.px(5)+1
|
|
824
|
+
for i,(row,group) in enumerate(zip(self.rows,self.items)):
|
|
825
|
+
y=i*(h+self.gap)
|
|
826
|
+
selected=row['id']==self.active_id
|
|
827
|
+
fill,edge,corners,logo,actions,_,_=self.faces[selected]
|
|
828
|
+
for item,coords in zip(group['fills'],((r,y,w-r,y+h),(0,y+r,w,y+h-r))):
|
|
829
|
+
self.canvas.coords(item,*coords);self.canvas.itemconfigure(item,fill=fill)
|
|
830
|
+
for item,coords in zip(group['edges'],((r,y,w-r,y),(r,y+h-1,w-r,y+h-1),(0,y+r,0,y+h-r),(w-1,y+r,w-1,y+h-r))):
|
|
831
|
+
self.canvas.coords(item,*coords);self.canvas.itemconfigure(item,fill=edge)
|
|
832
|
+
for item,point,image in zip(group['corners'],((0,y),(w-r,y),(0,y+h-r),(w-r,y+h-r)),corners):
|
|
833
|
+
self.canvas.coords(item,*point);self.canvas.itemconfigure(item,image=image)
|
|
834
|
+
lx=self.px(16);ly=y+(h-self.px(34))/2
|
|
835
|
+
self.canvas.coords(group['logo'],lx,ly);self.canvas.itemconfigure(group['logo'],image=logo)
|
|
836
|
+
self.canvas.coords(group['mark'],lx+self.px(17),y+h/2)
|
|
837
|
+
tx=self.px(66)
|
|
838
|
+
self.canvas.coords(group['name'],tx,y+h/2-self.px(10))
|
|
839
|
+
self.canvas.itemconfigure(group['name'],text=self.elide(row['name'],self._title_font,max(80,w-tx-self.px(280))))
|
|
840
|
+
bbox=self.canvas.bbox(group['name']);self.canvas.coords(group['tag'],bbox[2]+self.px(10),y+h/2-self.px(10))
|
|
841
|
+
self.canvas.itemconfigure(group['detail'],text=self.elide(row['detail'],self._body_font,max(80,w-tx-self.px(24))) if self.addresses else '')
|
|
842
|
+
self.canvas.coords(group['detail'],tx,y+h/2+self.px(14))
|
|
843
|
+
self.canvas.coords(group['status'],w-self.px(152),y+h/2)
|
|
844
|
+
self.canvas.itemconfigure(group['status'],text='● 使用中' if selected else '')
|
|
845
|
+
for j,(item,image) in enumerate(zip(group['actions'],actions)):
|
|
846
|
+
action_x=w-self.px(110-j*40);action_y=y+h/2;pad=self.px(15)
|
|
847
|
+
self.canvas.coords(group['hits'][j],action_x-pad,action_y-pad,action_x+pad,action_y+pad)
|
|
848
|
+
self.canvas.itemconfigure(group['hits'][j],fill=fill)
|
|
849
|
+
self.canvas.coords(item,action_x,action_y);self.canvas.itemconfigure(item,image=image)
|
|
850
|
+
total=max(1,len(self.rows)*(h+self.gap)-self.gap)
|
|
851
|
+
self.canvas.configure(scrollregion=(0,0,w,total))
|
|
852
|
+
needs=total>self.canvas.winfo_height()
|
|
853
|
+
if needs and not self.bar.winfo_manager():self.bar.pack(side='right',fill='y')
|
|
854
|
+
elif not needs and self.bar.winfo_manager():self.bar.pack_forget()
|
|
855
|
+
self.paint_hover()
|
|
856
|
+
def target(self,event):
|
|
857
|
+
items=self.canvas.find_withtag('current')
|
|
858
|
+
tags=self.canvas.gettags(items[-1]) if items else ()
|
|
859
|
+
row=next((int(t.split(':')[1]) for t in tags if t.startswith('row:')),None)
|
|
860
|
+
action=next((t.split(':')[1] for t in tags if t.startswith('action:')),None)
|
|
861
|
+
return row,action
|
|
862
|
+
def click(self,event):
|
|
863
|
+
row,action=self.target(event)
|
|
864
|
+
if row is None:return
|
|
865
|
+
self.canvas.focus_set();self.row_index=row
|
|
866
|
+
self.action_index=('use','edit','menu').index(action or 'use')
|
|
867
|
+
self.schedule();self.on_action(self.rows[row]['id'],action or 'use',event.x_root,event.y_root)
|
|
868
|
+
def hover(self,event):
|
|
869
|
+
row,action=self.target(event);self.canvas.configure(cursor='hand2' if row is not None else '')
|
|
870
|
+
target=(row,('use','edit','menu').index(action)) if row is not None and action else None
|
|
871
|
+
if target!=self._hover_target:self._hover_target=target;self.paint_hover()
|
|
872
|
+
def clear_hover(self,event=None):
|
|
873
|
+
self.canvas.configure(cursor='');self._hover_target=None;self.paint_hover()
|
|
874
|
+
def paint_hover(self):
|
|
875
|
+
previous=self._painted_hover
|
|
876
|
+
if previous is not None and previous[0]<len(self.items):
|
|
877
|
+
i,j=previous;selected=self.rows[i]['id']==self.active_id
|
|
878
|
+
self.canvas.itemconfigure(self.items[i]['actions'][j],image=self.faces[selected][4][j])
|
|
879
|
+
self._painted_hover=self._hover_target
|
|
880
|
+
if self._hover_target is None:
|
|
881
|
+
self.canvas.itemconfigure(self.hover_box,state='hidden');return
|
|
882
|
+
i,j=self._hover_target
|
|
883
|
+
if i>=len(self.items):self.clear_hover();return
|
|
884
|
+
selected=self.rows[i]['id']==self.active_id
|
|
885
|
+
fill,edge,corners,logo,actions,hover_face,hover_actions=self.faces[selected]
|
|
886
|
+
w=max(50,self.canvas.winfo_width()-2)
|
|
887
|
+
x=w-self.px(110-j*40);y=i*(self.row_height+self.gap)+self.row_height/2
|
|
888
|
+
self.canvas.coords(self.hover_box,x,y)
|
|
889
|
+
self.canvas.itemconfigure(self.hover_box,image=hover_face,state='normal',tags=(f'row:{i}','action:'+('use','edit','menu')[j]))
|
|
890
|
+
self.canvas.tag_raise(self.hover_box)
|
|
891
|
+
item=self.items[i]['actions'][j]
|
|
892
|
+
self.canvas.itemconfigure(item,image=hover_actions[j]);self.canvas.tag_raise(item)
|
|
893
|
+
def navigate(self,rows,actions):
|
|
894
|
+
if not self.rows:return 'break'
|
|
895
|
+
self.row_index=max(0,min(len(self.rows)-1,self.row_index+rows));self.action_index=(self.action_index+actions)%3
|
|
896
|
+
self._hover_target=(self.row_index,self.action_index)
|
|
897
|
+
top=self.row_index*(self.row_height+self.gap);total=max(1,len(self.rows)*(self.row_height+self.gap))
|
|
898
|
+
visible_top=self.canvas.canvasy(0);visible_bottom=visible_top+self.canvas.winfo_height()
|
|
899
|
+
if top<visible_top:self.canvas.yview_moveto(top/total)
|
|
900
|
+
elif top+self.row_height>visible_bottom:self.canvas.yview_moveto((top+self.row_height-self.canvas.winfo_height())/total)
|
|
901
|
+
self.schedule();return 'break'
|
|
902
|
+
def invoke(self,event=None,action=None):
|
|
903
|
+
if self.rows:self.on_action(self.rows[self.row_index]['id'],action or ('use','edit','menu')[self.action_index],
|
|
904
|
+
self.canvas.winfo_rootx()+self.canvas.winfo_width()-100,self.canvas.winfo_rooty()+80)
|
|
905
|
+
return 'break'
|
|
906
|
+
def wheel(self,event):
|
|
907
|
+
if len(self.rows)*(self.row_height+self.gap)>self.canvas.winfo_height():self.canvas.yview_scroll(-int(event.delta/120),'units')
|
|
908
|
+
return 'break'
|
|
909
|
+
def cleanup(self,event):
|
|
910
|
+
if event.widget is self and self.job is not None:self.after_cancel(self.job)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _round_thumb(root,size):
|
|
914
|
+
"""Generate a tiny RGBA PNG with stdlib only; Tk handles the alpha channel."""
|
|
915
|
+
import struct,zlib
|
|
916
|
+
raw=bytearray();center=size/2;radius=size*.36
|
|
917
|
+
for y in range(size):
|
|
918
|
+
raw.append(0)
|
|
919
|
+
for x in range(size):
|
|
920
|
+
d=math.hypot(x+.5-center,y+.5-center)
|
|
921
|
+
shadow_d=math.hypot(x+.5-center,y-.5-center)
|
|
922
|
+
shadow=max(0,min(.13,(radius+2-shadow_d)*.065))
|
|
923
|
+
coverage=max(0,min(1,radius+.5-d))
|
|
924
|
+
color=(255,255,255) if d<radius-1 else (218,222,230)
|
|
925
|
+
alpha=coverage+shadow*(1-coverage)
|
|
926
|
+
raw.extend([round(channel*coverage/alpha) if alpha else 0 for channel in color])
|
|
927
|
+
raw.append(round(alpha*255))
|
|
928
|
+
def chunk(kind,data):
|
|
929
|
+
return struct.pack('!I',len(data))+kind+data+struct.pack('!I',zlib.crc32(kind+data)&0xffffffff)
|
|
930
|
+
png=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('!2I5B',size,size,8,6,0,0,0))+chunk(b'IDAT',zlib.compress(bytes(raw)))+chunk(b'IEND',b'')
|
|
931
|
+
return tk.PhotoImage(master=root,data=png,format='png')
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
class ProgressSlider(tk.Canvas):
|
|
935
|
+
"""Tick-free rounded progress selector. Mouse, arrows, Home and End work."""
|
|
936
|
+
def __init__(self,master,*,variable=None,from_=0,to=100,step=1,command=None,**kwargs):
|
|
937
|
+
self.scale=master.winfo_fpixels('1i')/96
|
|
938
|
+
self.px=lambda n:max(1,round(n*self.scale))
|
|
939
|
+
super().__init__(master,height=self.px(34),background=PALETTE['bg'],highlightthickness=0,
|
|
940
|
+
takefocus=True,cursor='hand2',**kwargs)
|
|
941
|
+
self.variable=variable or tk.DoubleVar(value=from_)
|
|
942
|
+
self.from_,self.to,self.step=from_,to,step
|
|
943
|
+
self.command=command
|
|
944
|
+
self._previous_grab=None
|
|
945
|
+
self.thumb_image=_round_thumb(self._root(),self.px(28))
|
|
946
|
+
self.track=self.create_line(0,0,0,0,width=self.px(7),capstyle='butt',fill=PALETTE['hover'])
|
|
947
|
+
self.fill=self.create_line(0,0,0,0,width=self.px(7),capstyle='butt',fill=PALETTE['accent'])
|
|
948
|
+
self.thumb=self.create_image(0,0,image=self.thumb_image)
|
|
949
|
+
self._trace=self.variable.trace_add('write',self.changed)
|
|
950
|
+
self.bind('<Configure>',self.changed)
|
|
951
|
+
self.bind('<Button-1>',self.press)
|
|
952
|
+
self.bind('<B1-Motion>',self.motion)
|
|
953
|
+
self.bind('<ButtonRelease-1>',self.release)
|
|
954
|
+
self.bind('<Left>',lambda _:self.nudge(-1));self.bind('<Right>',lambda _:self.nudge(1))
|
|
955
|
+
self.bind('<Down>',lambda _:self.nudge(-1));self.bind('<Up>',lambda _:self.nudge(1))
|
|
956
|
+
self.bind('<Home>',lambda _:self.set(self.from_));self.bind('<End>',lambda _:self.set(self.to))
|
|
957
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
958
|
+
self.changed()
|
|
959
|
+
def get(self):return float(self.variable.get())
|
|
960
|
+
def set(self,value):
|
|
961
|
+
value=max(min(self.from_,self.to),min(max(self.from_,self.to),float(value)))
|
|
962
|
+
if self.step:value=self.from_+round((value-self.from_)/self.step)*self.step
|
|
963
|
+
self.variable.set(max(min(self.from_,self.to),min(max(self.from_,self.to),value)))
|
|
964
|
+
return 'break'
|
|
965
|
+
def changed(self,*_):
|
|
966
|
+
left=self.px(14);right=max(left,self.winfo_width()-left);y=self.winfo_height()/2
|
|
967
|
+
fraction=max(0,min(1,(self.get()-self.from_)/(self.to-self.from_ or 1)))
|
|
968
|
+
x=left+(right-left)*fraction
|
|
969
|
+
self.coords(self.track,left,y,right,y);self.coords(self.fill,left,y,x,y)
|
|
970
|
+
self.itemconfigure(self.fill,state='hidden' if fraction==0 else 'normal')
|
|
971
|
+
self.coords(self.thumb,x,y)
|
|
972
|
+
if self.command:self.command(self.get())
|
|
973
|
+
def at(self,x):
|
|
974
|
+
margin=self.px(14);length=max(1,self.winfo_width()-2*margin)
|
|
975
|
+
self.set(self.from_+(x-margin)/length*(self.to-self.from_))
|
|
976
|
+
def press(self,event):
|
|
977
|
+
if str(self.cget('state'))=='disabled':return
|
|
978
|
+
if self.grab_current() is not self:self._previous_grab=self.grab_current()
|
|
979
|
+
self.focus_set();self.grab_set();self.at(event.x)
|
|
980
|
+
def motion(self,event):
|
|
981
|
+
if str(self.cget('state'))!='disabled':self.at(event.x)
|
|
982
|
+
def release(self,event):
|
|
983
|
+
if self.grab_current()==self:
|
|
984
|
+
self.grab_release()
|
|
985
|
+
previous=self._previous_grab
|
|
986
|
+
if previous is not None and previous.winfo_exists():previous.grab_set()
|
|
987
|
+
self._previous_grab=None
|
|
988
|
+
def nudge(self,direction):
|
|
989
|
+
if str(self.cget('state'))!='disabled':self.set(self.get()+direction*self.step*(1 if self.to>=self.from_ else -1))
|
|
990
|
+
return 'break'
|
|
991
|
+
def cleanup(self,event):
|
|
992
|
+
if event.widget is self:self.variable.trace_remove('write',self._trace)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
class Modal(tk.Toplevel):
|
|
996
|
+
"""Rounded, dimmed application modal with fixed header/footer and scroll body."""
|
|
997
|
+
def __init__(self,master,title,*,width=560,height=460):
|
|
998
|
+
self.host=master.winfo_toplevel()
|
|
999
|
+
existing=getattr(self.host,'_active_modal',None)
|
|
1000
|
+
if existing is not None and not existing._closed:existing.close()
|
|
1001
|
+
self.previous_focus=self.host.focus_get()
|
|
1002
|
+
self.previous_grab=self.host.grab_current()
|
|
1003
|
+
self.scale=self.host.winfo_fpixels('1i')/96
|
|
1004
|
+
self.target_width=round(width*self.scale);self.target_height=round(height*self.scale)
|
|
1005
|
+
self.mask=tk.Toplevel(self.host)
|
|
1006
|
+
self.mask.withdraw();self.mask.overrideredirect(True);self.mask.configure(background='#000000')
|
|
1007
|
+
self.mask.attributes('-alpha',.30);self.mask.transient(self.host)
|
|
1008
|
+
super().__init__(self.host)
|
|
1009
|
+
self.withdraw();self.overrideredirect(True);self.transient(self.host)
|
|
1010
|
+
self.title(title);self.configure(background=PALETTE['bg'])
|
|
1011
|
+
self._closed=False;self._position_job=None;self._round_job=None;self._region_size=None
|
|
1012
|
+
self._ready_job=None;self._ready_attempts=0;self._shown=False
|
|
1013
|
+
self._host_topmost=bool(self.host.attributes('-topmost'))
|
|
1014
|
+
self.shell=ttk.Frame(self,padding=round(22*self.scale));self.shell.pack(fill='both',expand=True)
|
|
1015
|
+
header=ttk.Frame(self.shell);header.pack(fill='x',pady=(0,round(18*self.scale)))
|
|
1016
|
+
self.title_var=tk.StringVar(value=title)
|
|
1017
|
+
ttk.Label(header,textvariable=self.title_var,font=('Microsoft YaHei UI',12,'bold')).pack(side='left')
|
|
1018
|
+
close=ttk.Button(header,image=icon(self,'close',round(15*self.scale)),style='Quiet.TButton',command=self.close)
|
|
1019
|
+
close.pack(side='right');Tooltip(close,'关闭')
|
|
1020
|
+
self.actions=ttk.Frame(self.shell);self.actions.pack(side='bottom',fill='x',pady=(round(18*self.scale),0))
|
|
1021
|
+
self.primary=ttk.Button(self.actions,text='知道了',style='Primary.TButton',command=self.close)
|
|
1022
|
+
self.primary.bind('<Return>',self.confirm)
|
|
1023
|
+
self.primary.pack(side='right')
|
|
1024
|
+
self.secondary=ttk.Button(self.actions,text='取消',command=self.close)
|
|
1025
|
+
self.secondary.bind('<Return>',lambda _:self.close())
|
|
1026
|
+
self.area=ScrollArea(self.shell);self.area.pack(fill='both',expand=True)
|
|
1027
|
+
self.body=self.area.body
|
|
1028
|
+
self.bind('<Escape>',lambda _:self.close());self.bind('<Alt-F4>',lambda _:self.close())
|
|
1029
|
+
self.bind('<Button-1>',self.outside,add='+')
|
|
1030
|
+
self.mask.bind('<Button-1>',lambda _:self.close())
|
|
1031
|
+
self._host_binding=self.host.bind('<Configure>',self.schedule_position,add='+')
|
|
1032
|
+
self._escape_binding=self.host.bind('<Escape>',lambda _:self.close(),add='+')
|
|
1033
|
+
self._show_job=self.after_idle(self.show)
|
|
1034
|
+
self.host._active_modal=self
|
|
1035
|
+
from .widgets import install_input_focus
|
|
1036
|
+
install_input_focus(self)
|
|
1037
|
+
def show(self):
|
|
1038
|
+
self._show_job=None
|
|
1039
|
+
if self._closed:return
|
|
1040
|
+
try:
|
|
1041
|
+
self.position()
|
|
1042
|
+
# Map and raise both windows before taking a grab. A hidden
|
|
1043
|
+
# overrideredirect window must never lock its owner.
|
|
1044
|
+
self.mask.attributes('-topmost',True);self.attributes('-topmost',True)
|
|
1045
|
+
self.mask.deiconify();self.deiconify();self.lift()
|
|
1046
|
+
self.update_idletasks()
|
|
1047
|
+
self.focus_force()
|
|
1048
|
+
getattr(self,'initial_focus',self.primary).focus_force()
|
|
1049
|
+
self._ready_job=self.after(20,self.finish_show)
|
|
1050
|
+
except tk.TclError:
|
|
1051
|
+
self.close()
|
|
1052
|
+
raise
|
|
1053
|
+
def finish_show(self):
|
|
1054
|
+
self._ready_job=None
|
|
1055
|
+
if self._closed:return
|
|
1056
|
+
if not self.winfo_viewable() or self.winfo_width()<20 or self.winfo_height()<20:
|
|
1057
|
+
self._ready_attempts+=1
|
|
1058
|
+
if self._ready_attempts<10:self._ready_job=self.after(20,self.finish_show)
|
|
1059
|
+
else:self.close()
|
|
1060
|
+
return
|
|
1061
|
+
try:
|
|
1062
|
+
self.round_corners()
|
|
1063
|
+
self.lift();getattr(self,'initial_focus',self.primary).focus_force()
|
|
1064
|
+
self.grab_set();self._shown=True
|
|
1065
|
+
if not self._host_topmost:
|
|
1066
|
+
self.mask.attributes('-topmost',False)
|
|
1067
|
+
self.attributes('-topmost',False)
|
|
1068
|
+
self.lift();getattr(self,'initial_focus',self.primary).focus_force()
|
|
1069
|
+
except tk.TclError:
|
|
1070
|
+
self.close()
|
|
1071
|
+
raise
|
|
1072
|
+
def title(self,string=None):
|
|
1073
|
+
if string is not None and hasattr(self,'title_var'):self.title_var.set(string)
|
|
1074
|
+
return super().title(string)
|
|
1075
|
+
def confirm(self,event=None):
|
|
1076
|
+
self.primary.invoke()
|
|
1077
|
+
return 'break'
|
|
1078
|
+
def outside(self,event):
|
|
1079
|
+
if self._closed:return
|
|
1080
|
+
if not (self.winfo_rootx()<=event.x_root<self.winfo_rootx()+self.winfo_width() and
|
|
1081
|
+
self.winfo_rooty()<=event.y_root<self.winfo_rooty()+self.winfo_height()):self.close()
|
|
1082
|
+
def schedule_position(self,event):
|
|
1083
|
+
if event.widget is self.host and self._position_job is None:
|
|
1084
|
+
self._position_job=self.after_idle(self.position)
|
|
1085
|
+
def position(self):
|
|
1086
|
+
self._position_job=None
|
|
1087
|
+
if self._closed:return
|
|
1088
|
+
x,y=self.host.winfo_rootx(),self.host.winfo_rooty()
|
|
1089
|
+
w,h=self.host.winfo_width(),self.host.winfo_height()
|
|
1090
|
+
self.mask.geometry(f'{w}x{h}+{x}+{y}')
|
|
1091
|
+
width=min(self.target_width,max(300,w-60));height=min(self.target_height,max(220,h-60))
|
|
1092
|
+
self.geometry(f'{width}x{height}+{x+(w-width)//2}+{y+(h-height)//2}')
|
|
1093
|
+
self.round_corners()
|
|
1094
|
+
def round_corners(self):
|
|
1095
|
+
self._round_job=None
|
|
1096
|
+
if self._closed or sys.platform!='win32':return
|
|
1097
|
+
if not self.winfo_ismapped():return
|
|
1098
|
+
import ctypes
|
|
1099
|
+
from ctypes import wintypes as w
|
|
1100
|
+
user=ctypes.WinDLL('user32',use_last_error=True);gdi=ctypes.WinDLL('gdi32',use_last_error=True)
|
|
1101
|
+
user.GetParent.argtypes=[w.HWND];user.GetParent.restype=w.HWND
|
|
1102
|
+
hwnd=user.GetParent(self.winfo_id())
|
|
1103
|
+
if not hwnd:return
|
|
1104
|
+
size=(self.winfo_width(),self.winfo_height())
|
|
1105
|
+
if size==self._region_size:return
|
|
1106
|
+
self._region_size=size
|
|
1107
|
+
gdi.CreateRoundRectRgn.argtypes=[ctypes.c_int]*6;gdi.CreateRoundRectRgn.restype=w.HANDLE
|
|
1108
|
+
user.SetWindowRgn.argtypes=[w.HWND,w.HANDLE,w.BOOL]
|
|
1109
|
+
gdi.DeleteObject.argtypes=[w.HANDLE]
|
|
1110
|
+
radius=round(2*getattr(self,'corner_radius',6)*self.scale)
|
|
1111
|
+
region=gdi.CreateRoundRectRgn(0,0,size[0]+1,size[1]+1,radius,radius)
|
|
1112
|
+
if not user.SetWindowRgn(hwnd,region,True):gdi.DeleteObject(region)
|
|
1113
|
+
def close(self):
|
|
1114
|
+
if self._closed:return 'break'
|
|
1115
|
+
self._closed=True
|
|
1116
|
+
if getattr(self.host,'_active_modal',None) is self:self.host._active_modal=None
|
|
1117
|
+
for job in (self._show_job,self._position_job,self._round_job,self._ready_job):
|
|
1118
|
+
if job is not None:self.after_cancel(job)
|
|
1119
|
+
unbind(self.host,'<Configure>',self._host_binding)
|
|
1120
|
+
unbind(self.host,'<Escape>',self._escape_binding)
|
|
1121
|
+
self.grab_release();self.mask.destroy()
|
|
1122
|
+
super().destroy()
|
|
1123
|
+
if self.previous_grab is not None and self.previous_grab.winfo_exists():self.previous_grab.grab_set()
|
|
1124
|
+
if self.previous_focus is not None and self.previous_focus.winfo_exists() and self.previous_focus.winfo_viewable():
|
|
1125
|
+
self.previous_focus.focus_force()
|
|
1126
|
+
elif self.host.winfo_exists():self.host.focus_force()
|
|
1127
|
+
return 'break'
|
|
1128
|
+
def destroy(self):self.close()
|