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/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()