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/extras.py ADDED
@@ -0,0 +1,525 @@
1
+ """Small-radius Web controls. Uses only tkinter and the Python standard library."""
2
+ from __future__ import annotations
3
+ import math
4
+ import sys
5
+ import tkinter as tk
6
+ from tkinter import ttk, font as tkfont
7
+ from .core import PALETTE, Card, ScrollArea, Modal, icon, _patch, _raster, _rgb
8
+ from .tcl_bridge import unbind
9
+
10
+
11
+ def configure_extra_styles(root, style):
12
+ c = PALETTE
13
+ scale = root.winfo_fpixels('1i') / 96
14
+ px = lambda n: max(1, round(n * scale))
15
+ cache = {}
16
+ def face(fill, border, radius=4):
17
+ key = fill, border, radius
18
+ if key not in cache:
19
+ image = _patch(root, fill, border, c['bg'], px(radius))
20
+ root._tkwebui_images.append(image)
21
+ cache[key] = image
22
+ return cache[key]
23
+ def surface(name, normal, states=(), radius=4, **options):
24
+ style.element_create(name, 'image', normal, *states, border=px(radius)+1,
25
+ width=px(radius)*2+2, height=px(radius)*2+2, sticky='nsew', **options)
26
+ for name, plain in [('Toggle.TButton', False), ('Toolbutton', True)]:
27
+ element = 'Web.'+name+'.surface'
28
+ surface(element, face(c['bg'], c['bg'] if plain else c['border']), [
29
+ ('disabled selected', face(c['sidebar'], c['border'])),
30
+ ('disabled', face(c['bg'], c['bg'] if plain else c['border'])),
31
+ ('pressed', face(c['selected'], c['blue'])),
32
+ ('selected', face(c['selected'], c['blue'] if not plain else c['selected'])),
33
+ ('focus', face(c['bg'], c['blue'])),
34
+ ('active', face(c['hover'], c['hover'] if plain else c['border'])),
35
+ # Same box as TButton: the style padding alone sets the content inset
36
+ # (ttk would default an image element's padding to its border).
37
+ ], padding=0)
38
+ style.layout(name, [(element, {'sticky':'nsew', 'children':[
39
+ ('Checkbutton.padding', {'sticky':'nsew', 'children':[
40
+ ('Checkbutton.label', {'sticky':'nsew'})]})]})])
41
+ style.configure(name, padding=(px(12),px(7)), anchor='center', foreground=c['fg'])
42
+ style.map(name, foreground=[('disabled',c['disabled']),('selected',c['blue'])])
43
+ # Compact rectangular switch; square thumb, with state handling kept in ttk.
44
+ switch_images={}
45
+ for on in (False,True):
46
+ for state in ('normal','active','focus','disabled'):
47
+ width,height=px(40),px(26)
48
+ track_color=(c['disabled'] if on else '#D9DEE6') if state=='disabled' else (
49
+ c['accent_hover'] if on and state=='active' else c['accent'] if on else
50
+ '#858E9C' if state=='active' else '#9B9DA7')
51
+ def sample(x,y,on=on,state=state,width=width,height=height,track_color=track_color):
52
+ x=x*40/width;y=y*26/height
53
+ if state=='focus' and 0.5<=x<=39.5 and 0.5<=y<=25.5:
54
+ if x<1.5 or x>38.5 or y<1.5 or y>24.5:return _rgb(c['blue'])
55
+ if not (3<=x<=37 and 3<=y<=23):return _rgb(c['bg'])
56
+ left=20 if on else 6
57
+ if left<=x<=left+14 and 6<=y<=20:
58
+ return _rgb('#F7F8FA' if state=='disabled' else '#FFFFFF')
59
+ return _rgb(track_color)
60
+ image=_raster(root,width,height,sample)
61
+ switch_images[on,state]=image;root._tkwebui_images.append(image)
62
+ style.element_create('Web.SquareSwitch.indicator','image',switch_images[False,'normal'],
63
+ ('disabled selected',switch_images[True,'disabled']),('disabled',switch_images[False,'disabled']),
64
+ ('focus selected',switch_images[True,'focus']),('focus',switch_images[False,'focus']),
65
+ ('active selected',switch_images[True,'active']),('active',switch_images[False,'active']),
66
+ ('selected',switch_images[True,'normal']),width=px(40),height=px(26),sticky='')
67
+ style.layout('Square.Switch.TCheckbutton',[('Checkbutton.padding',{'sticky':'nsew','children':[
68
+ ('Web.SquareSwitch.indicator',{'side':'left'}),('Checkbutton.label',{'side':'left','sticky':'w'})]})])
69
+ style.configure('Square.Switch.TCheckbutton',background=c['bg'],padding=(0,px(2)),foreground=c['fg'])
70
+ style.map('Square.Switch.TCheckbutton',background=[('active',c['bg'])],foreground=[('disabled',c['disabled'])])
71
+ for name in ('TMenubutton', 'TOptionMenu'):
72
+ style.layout(name, [('TButton.surface', {'sticky':'nsew','children':[
73
+ ('Menubutton.padding', {'sticky':'nsew','children':[
74
+ ('Switch.Dropdown.arrow', {'side':'right','sticky':''}),
75
+ ('Menubutton.label', {'side':'left','sticky':'nsew','expand':True})]})]})])
76
+ style.configure(name, padding=(px(10),px(7)), anchor='w')
77
+ style.map(name, foreground=[('disabled',c['disabled'])])
78
+ surface('Web.Labelframe.border', face(c['bg'],c['border']))
79
+ style.layout('TLabelframe', [('Web.Labelframe.border',{'sticky':'nsew'})])
80
+ style.configure('TLabelframe', background=c['bg'], borderwidth=px(1), relief='flat')
81
+ style.configure('TLabelframe.Label', background=c['bg'], foreground=c['muted'], font=('Microsoft YaHei UI',10))
82
+ surface('Web.Notebook.client', face(c['bg'],c['border']))
83
+ surface('Web.Notebook.tab', face(c['bg'],c['bg']), [
84
+ ('disabled',face(c['bg'],c['bg'])),
85
+ ('selected focus',face(c['selected'],c['blue'])),
86
+ ('selected',face(c['selected'],c['selected'])),
87
+ ('active',face(c['hover'],c['hover'])),
88
+ ])
89
+ style.layout('TNotebook', [('Web.Notebook.client',{'sticky':'nsew'})])
90
+ style.layout('TNotebook.Tab', [('Web.Notebook.tab',{'sticky':'nsew','children':[
91
+ ('Notebook.padding',{'sticky':'nsew','children':[('Notebook.label',{'sticky':'nsew'})]})]})])
92
+ style.configure('TNotebook', background=c['bg'], padding=px(1), tabmargins=(0,0,0,px(7)))
93
+ style.configure('TNotebook.Tab', padding=(px(16),px(8)), foreground=c['muted'])
94
+ style.map('TNotebook.Tab', foreground=[('disabled',c['disabled']),('selected',c['blue'])])
95
+ # Both sash orientations have a generous hit target and a small visible handle.
96
+ for orientation in ('Horizontal','Vertical'):
97
+ horizontal = orientation == 'Horizontal'
98
+ w,h = (px(36),px(12)) if horizontal else (px(12),px(36))
99
+ def sample(x,y,w=w,h=h,horizontal=horizontal):
100
+ long,short = (x,y) if horizontal else (y,x)
101
+ length,thickness = (w,h) if horizontal else (h,w)
102
+ center=min(max(long,px(5)),length-px(5))
103
+ return _rgb('#BFC4CD') if math.hypot(long-center,short-thickness/2)<=max(1,scale) else _rgb(c['bg'])
104
+ grip=_raster(root,w,h,sample); root._tkwebui_images.append(grip)
105
+ name='Web.'+orientation+'.Sash.grip'
106
+ style.element_create(name,'image',grip,width=w,height=h,sticky='')
107
+ style.layout(orientation+'.Sash',[(name,{'sticky':'ns' if not horizontal else 'ew'})])
108
+ style.configure('Sash',sashthickness=px(12),gripcount=0)
109
+ # Round and compact-bar slider variants share native ttk Scale behavior.
110
+ for orient in ('Horizontal','Vertical'):
111
+ horizontal=orient=='Horizontal'
112
+ w,h=(px(128),px(24)) if horizontal else (px(24),px(128))
113
+ def track(x,y,w=w,h=h,horizontal=horizontal):
114
+ long,short=(x,y) if horizontal else (y,x)
115
+ length,thickness=(w,h) if horizontal else (h,w)
116
+ center=min(max(long,px(12)),length-px(12))
117
+ return _rgb(c['hover']) if math.hypot(long-center,short-thickness/2)<=px(2) else _rgb(c['bg'])
118
+ image=_raster(root,w,h,track); root._tkwebui_images.append(image)
119
+ track_name='Web.'+orient+'.Scale.trough'
120
+ style.element_create(track_name,'image',image,border=(px(12),0) if horizontal else (0,px(12)),
121
+ width=px(24),height=px(24),sticky='nsew')
122
+ for variant in ('','Tick.'):
123
+ images=[]
124
+ size=px(24)
125
+ for edge,fill in [(c['border'],c['bg']),(c['blue'],c['bg']),(c['disabled'],c['sidebar'])]:
126
+ def thumb(x,y,edge=edge,fill=fill,variant=variant,size=size,horizontal=horizontal):
127
+ cx,cy=size/2,size/2
128
+ if variant:
129
+ a,b=(x,y) if horizontal else (y,x)
130
+ nearest=min(max(b,cy-px(5)),cy+px(5))
131
+ distance=math.hypot(a-cx,b-nearest)
132
+ return _rgb(edge if edge==c['disabled'] else c['blue']) if distance<=px(2) else _rgb(c['bg'])
133
+ d=math.hypot(x-cx,y-cy)
134
+ return _rgb(fill) if d<px(7)-1 else _rgb(edge) if d<=px(7) else _rgb(c['bg'])
135
+ im=_raster(root,size,size,thumb,transparent=_rgb(c['bg']) if variant else None,
136
+ outside=None if variant else lambda xx,yy,size=size:math.hypot(xx+.5-size/2,yy+.5-size/2)>px(7)+1)
137
+ root._tkwebui_images.append(im);images.append(im)
138
+ slider_name='Web.'+orient+'.'+variant+'Scale.slider'
139
+ style.element_create(slider_name,'image',images[0],('disabled',images[2]),('pressed',images[1]),('active',images[1]),('focus',images[1]),width=size,height=size,sticky='')
140
+ name=orient+'.'+variant+'TScale'
141
+ style.layout(name,[(track_name,{'sticky':'ew' if horizontal else 'ns','children':[
142
+ (slider_name,{'side':'left' if horizontal else 'top','sticky':''})]})])
143
+ style.configure(name,background=c['bg'],borderwidth=0)
144
+ # Small-radius progress surfaces, horizontal and vertical.
145
+ for part,color in [('trough',c['hover']),('pbar',c['accent'])]:
146
+ width,height=(px(128),px(7)) if horizontal else (px(7),px(128))
147
+ im=_patch(root,color,color,c['bg'],px(2),width=width,height=height)
148
+ root._tkwebui_images.append(im)
149
+ element='Web.'+orient+'.Progress.'+part
150
+ style.element_create(element,'image',im,border=px(2),width=px(7),height=px(7),sticky='nsew')
151
+ style.layout(orient+'.TProgressbar', [('Progressbar.padding',{'sticky':'nsew','children':[
152
+ ('Web.'+orient+'.Progress.trough',{'sticky':'ew' if horizontal else 'ns','children':[
153
+ ('Web.'+orient+'.Progress.pbar',{'side':'left' if horizontal else 'bottom','sticky':'ns' if horizontal else 'ew'})]})]})])
154
+ style.configure(orient+'.TProgressbar',background=c['bg'],thickness=px(7),borderwidth=0,padding=(px(14),0) if horizontal else (0,px(14)))
155
+ blank=tk.PhotoImage(master=root,width=px(16),height=px(16));root._tkwebui_images.append(blank)
156
+ style.element_create('Web.Tree.indicator','image',icon(root,'chevronright',px(13)),('user2',blank),('user1',icon(root,'chevrondown',px(13))),width=px(24),sticky='')
157
+ style.layout('Treeview.Item',[('Treeitem.padding',{'sticky':'nsew','children':[
158
+ ('Web.Tree.indicator',{'side':'left'}),('Treeitem.image',{'side':'left'}),('Treeitem.text',{'sticky':'nsew'})]})])
159
+ style.configure('Treeview.Item',padding=(px(3),0))
160
+ size=px(15)
161
+ grip=tk.PhotoImage(master=root,width=size,height=size)
162
+ for a,b in [(10,10),(6,10),(10,6),(2,10),(6,6),(10,2)]:
163
+ grip.put(c['disabled'],to=(px(a),px(b),px(a)+px(2),px(b)+px(2)))
164
+ root._tkwebui_images.append(grip)
165
+ style.element_create('Web.Sizegrip','image',grip,sticky='se',width=size,height=size)
166
+ style.layout('TSizegrip',[('Web.Sizegrip',{'side':'bottom','sticky':'se'})])
167
+ style.layout('Web.List.TFrame',style.layout('RoundedText.TFrame'))
168
+ for name,fill in [('Web.MenuRow.TFrame',c['bg']),('Web.ActiveMenuRow.TFrame',c['hover'])]:
169
+ element=name+'.surface';surface(element,face(fill,fill),radius=4)
170
+ style.layout(name,[(element,{'sticky':'nsew'})])
171
+ style.configure(name,background=fill)
172
+
173
+
174
+ class SquareSwitch(ttk.Checkbutton):
175
+ """Rectangular sliding switch with a square thumb; standard Checkbutton API."""
176
+ def __init__(self,master=None,**kwargs):
177
+ if 'variable' not in kwargs:
178
+ self.variable=tk.BooleanVar(master=master,value=False)
179
+ kwargs['variable']=self.variable
180
+ else:self.variable=kwargs['variable']
181
+ kwargs.setdefault('style','Square.Switch.TCheckbutton')
182
+ super().__init__(master,**kwargs)
183
+ self.bind('<Return>',self._activate)
184
+ def _activate(self,event=None):
185
+ self.invoke()
186
+ return 'break'
187
+
188
+
189
+ class ToggleButton(ttk.Checkbutton):
190
+ def __init__(self, master=None, **kwargs):
191
+ kwargs.setdefault('style','Toggle.TButton')
192
+ super().__init__(master,**kwargs)
193
+
194
+
195
+ class ToolButton(ttk.Checkbutton):
196
+ def __init__(self, master=None, **kwargs):
197
+ kwargs.setdefault('style','Toolbutton')
198
+ super().__init__(master,**kwargs)
199
+
200
+
201
+ class MenuButton(ttk.Menubutton):
202
+ """A real ttk Menubutton that opens the unified Web menu instead of Tk's popup."""
203
+ def __init__(self, master=None, **kwargs):
204
+ super().__init__(master,**kwargs)
205
+ self.bind('<ButtonPress-1>',self.post_menu)
206
+ self.bind('<ButtonRelease-1>',lambda e:'break')
207
+ for event in ('<space>','<Return>','<Down>','<Alt-Down>'):
208
+ self.bind(event,self.post_menu)
209
+ self.bind('<Destroy>',self._cleanup,add='+')
210
+ def post_menu(self,event=None):
211
+ if self.instate(['disabled']):return 'break'
212
+ self.focus_set()
213
+ name=str(self.cget('menu'))
214
+ if not name:return 'break'
215
+ menu=self.nametowidget(name)
216
+ popup=getattr(menu,'popup',None) or getattr(menu,'_web_popup',None)
217
+ if popup is not None and not popup._closed:
218
+ menu.unpost();return 'break'
219
+ if hasattr(menu,'popup'):
220
+ menu.tk_popup(self.winfo_rootx(),self.winfo_rooty()+self.winfo_height()+4,owner=self,min_width=self.winfo_width())
221
+ else:
222
+ # A supplied plain tk.Menu also gets the Web renderer.
223
+ menu._web_popup=MenuPopup(self,menu,self.winfo_rootx(),self.winfo_rooty()+self.winfo_height()+4,min_width=self.winfo_width())
224
+ return 'break'
225
+ def _cleanup(self,event):
226
+ if event.widget is not self:return
227
+ try:
228
+ name=str(self.cget('menu'))
229
+ if name:
230
+ menu=self.nametowidget(name)
231
+ popup=getattr(menu,'popup',None) or getattr(menu,'_web_popup',None)
232
+ if popup is not None and not popup._closed:popup.close()
233
+ except (tk.TclError,KeyError):pass
234
+
235
+
236
+ class OptionMenu(MenuButton):
237
+ """ttk.OptionMenu-like API backed by the same rounded menu as MenuButton."""
238
+ def __init__(self,master,variable,default=None,*values,command=None,**kwargs):
239
+ from .popups import ThemedMenu
240
+ self.variable=variable;self.on_change=command
241
+ kwargs.setdefault('style','TOptionMenu')
242
+ kwargs['textvariable']=variable
243
+ super().__init__(master,**kwargs)
244
+ self.menu=ThemedMenu(self,tearoff=False)
245
+ self.configure(menu=self.menu)
246
+ self.set_menu(default,*values)
247
+ def set_menu(self,default=None,*values):
248
+ self.menu.delete(0,'end')
249
+ for value in values:
250
+ self.menu.add_radiobutton(label=value,value=value,variable=self.variable,command=lambda v=value:self._selected(v))
251
+ if default is not None:self.variable.set(default)
252
+ def _selected(self,value):
253
+ self.event_generate('<<OptionMenuSelected>>',when='tail')
254
+ if self.on_change:self.on_change(value)
255
+
256
+
257
+ class RoundedListbox(tk.Listbox):
258
+ """Native Listbox API, small-radius focus shell, hover rows and native selection."""
259
+ def __init__(self,master,*,scrollbars=True,**kwargs):
260
+ from .widgets import _TextContainer
261
+ from .scrolling import _attach_scrollbars
262
+ self.container=_TextContainer(master,style='Web.List.TFrame',padding=6)
263
+ self.container.columnconfigure(0,weight=1);self.container.rowconfigure(0,weight=1)
264
+ kwargs.update(borderwidth=0,highlightthickness=0,relief='flat')
265
+ for key,value in dict(background=PALETTE['bg'],foreground=PALETTE['fg'],selectbackground=PALETTE['selected'],
266
+ selectforeground=PALETTE['blue'],activestyle='none',exportselection=False).items():
267
+ kwargs.setdefault(key,value)
268
+ super().__init__(self.container,**kwargs)
269
+ tk.Listbox.grid(self,row=0,column=0,sticky='nsew')
270
+ if scrollbars:_attach_scrollbars(self,self.container)
271
+ self._hover=None
272
+ self.bind('<FocusIn>',lambda e:self.container.state(['focus']),add='+')
273
+ self.bind('<FocusOut>',lambda e:self.container.state(['!focus']),add='+')
274
+ self.bind('<Motion>',self._motion,add='+');self.bind('<Leave>',lambda e:self._restore(),add='+')
275
+ self._sync()
276
+ def _sync(self):
277
+ self.container.state(['disabled' if str(self.cget('state'))=='disabled' else '!disabled'])
278
+ def _restore(self):
279
+ if self._hover is not None:
280
+ index,color=self._hover;self._hover=None
281
+ if index<self.size():self.itemconfigure(index,background=color)
282
+ def _motion(self,event):
283
+ if str(self.cget('state'))=='disabled' or not self.size():return
284
+ index=self.nearest(event.y)
285
+ bounds=self.bbox(index)
286
+ if bounds is None or not bounds[1]<=event.y<bounds[1]+bounds[3]:self._restore();return
287
+ if self._hover is not None and self._hover[0]==index:return
288
+ self._restore();self._hover=(index,self.itemcget(index,'background'))
289
+ self.itemconfigure(index,background=PALETTE['hover'])
290
+ def delete(self,first,last=None):
291
+ self._restore();return super().delete(first,last)
292
+ def configure(self,cnf=None,**kwargs):
293
+ if isinstance(cnf,str):return super().configure(cnf,**kwargs)
294
+ options=dict(cnf or {},**kwargs)
295
+ if not options:return super().configure()
296
+ options.update(borderwidth=0,highlightthickness=0,relief='flat')
297
+ result=super().configure(**options)
298
+ from .scrolling import _release_scrollbars
299
+ _release_scrollbars(self,options)
300
+ self._sync();return result
301
+ config=configure
302
+ def destroy(self):
303
+ if getattr(self.container,'_destroying',False):super().destroy()
304
+ else:self.container.destroy()
305
+ def pack(self,*args,**kw):return self.container.pack(*args,**kw)
306
+ pack_configure=pack
307
+ def grid(self,*args,**kw):return self.container.grid(*args,**kw)
308
+ grid_configure=grid
309
+ def place(self,*args,**kw):return self.container.place(*args,**kw)
310
+ place_configure=place
311
+ def pack_forget(self):return self.container.pack_forget()
312
+ def grid_forget(self):return self.container.grid_forget()
313
+ def grid_remove(self):return self.container.grid_remove()
314
+ def place_forget(self):return self.container.place_forget()
315
+ def pack_info(self):return self.container.pack_info()
316
+ def grid_info(self):return self.container.grid_info()
317
+ def place_info(self):return self.container.place_info()
318
+
319
+
320
+ def _monitor_api():
321
+ """Private user32 prototypes. Setting argtypes on the shared ctypes.windll
322
+ object would break other code (including the host program) that calls the
323
+ same functions with its own structures."""
324
+ global _MONITOR_API
325
+ if _MONITOR_API is None:
326
+ import ctypes
327
+ from types import SimpleNamespace
328
+ from ctypes import wintypes as w
329
+ class MonitorInfo(ctypes.Structure):
330
+ _fields_=[('cbSize',w.DWORD),('rcMonitor',w.RECT),('rcWork',w.RECT),('dwFlags',w.DWORD)]
331
+ user=ctypes.WinDLL('user32')
332
+ user.MonitorFromPoint.argtypes=[w.POINT,w.DWORD];user.MonitorFromPoint.restype=w.HANDLE
333
+ user.GetMonitorInfoW.argtypes=[w.HANDLE,ctypes.POINTER(MonitorInfo)];user.GetMonitorInfoW.restype=w.BOOL
334
+ _MONITOR_API=SimpleNamespace(ctypes=ctypes,w=w,user=user,MonitorInfo=MonitorInfo)
335
+ return _MONITOR_API
336
+
337
+
338
+ _MONITOR_API=None
339
+
340
+
341
+ class MenuPopup(tk.Toplevel):
342
+ """Shared menu renderer with native Menu commands/variables and nested popups."""
343
+ def __init__(self,owner,menu,x,y,*,min_width=190,parent_popup=None,parent_index=None,selected=None):
344
+ self.owner=owner;self.menu=menu;self.host=owner.winfo_toplevel()
345
+ self.parent_popup=parent_popup;self.parent_index=parent_index
346
+ self.root_popup=parent_popup.root_popup if parent_popup else self
347
+ self.child_popup=None;self.cursor=None;self.rows={};self._closed=False
348
+ self._hover_job=None;self._focus_job=None;self._region_size=None
349
+ self._host_binding=None;self._destroy_binding=None
350
+ self.scale=owner.winfo_fpixels('1i')/96;self.px=lambda n:max(1,round(n*self.scale));self.corner_radius=5
351
+ if parent_popup is None:
352
+ existing=getattr(self.host,'_choice_popup',None)
353
+ if existing is not None:existing.close()
354
+ self.previous_focus=owner.focus_get();self.previous_grab=owner.grab_current()
355
+ command=str(menu.cget('postcommand')).strip()
356
+ if command:menu.tk.call('uplevel','#0',command)
357
+ super().__init__(self.host)
358
+ self.withdraw();self.overrideredirect(True);self.transient(self.host);self.configure(background=PALETTE['bg'])
359
+ shell=Card(self,padding=5,border_color='#CCD1DC');shell.pack(fill='both',expand=True,padx=1,pady=1)
360
+ self.area=ScrollArea(shell);self.area.pack(fill='both',expand=True)
361
+ end=menu.index('end')
362
+ for index in range(end+1 if end is not None else 0):
363
+ kind=menu.type(index)
364
+ if kind=='tearoff':continue
365
+ if kind=='separator':
366
+ ttk.Separator(self.area.body).pack(fill='x',padx=self.px(7),pady=self.px(5));continue
367
+ disabled=str(menu.entrycget(index,'state'))=='disabled'
368
+ checked=False
369
+ if kind in ('checkbutton','radiobutton'):
370
+ variable=menu.entrycget(index,'variable')
371
+ expected=menu.entrycget(index,'onvalue' if kind=='checkbutton' else 'value')
372
+ try:checked=str(menu.getvar(variable))==str(expected)
373
+ except tk.TclError:pass
374
+ row=ttk.Frame(self.area.body,style='Web.MenuRow.TFrame',padding=(self.px(8),self.px(6)))
375
+ row.pack(fill='x',pady=1)
376
+ foreground=PALETTE['disabled'] if disabled else PALETTE['blue'] if checked else PALETTE['fg']
377
+ widgets=[]
378
+ mark=tk.Label(row,text='✓' if checked else '',width=2,anchor='center',borderwidth=0,background=PALETTE['bg'],foreground=foreground)
379
+ mark.pack(side='left');widgets.append(mark)
380
+ image=str(menu.entrycget(index,'image'))
381
+ if image:
382
+ picture=tk.Label(row,image=image,borderwidth=0,background=PALETTE['bg'])
383
+ picture.pack(side='left',padx=(0,self.px(7)));widgets.append(picture)
384
+ suffix='›' if kind=='cascade' else str(menu.entrycget(index,'accelerator'))
385
+ if suffix:
386
+ shortcut=tk.Label(row,text=suffix,borderwidth=0,background=PALETTE['bg'],foreground=PALETTE['disabled'] if disabled else PALETTE['muted'])
387
+ shortcut.pack(side='right',padx=(self.px(28),self.px(4)));widgets.append(shortcut)
388
+ label=tk.Label(row,text=menu.entrycget(index,'label'),anchor='w',borderwidth=0,background=PALETTE['bg'],foreground=foreground)
389
+ label.pack(side='left',fill='x',expand=True);widgets.append(label)
390
+ self.rows[index]=(row,widgets,disabled,kind)
391
+ for widget in [row,*widgets]:
392
+ widget.bind('<Enter>',lambda e,i=index:self.hover(i))
393
+ widget.bind('<ButtonRelease-1>',lambda e,i=index:self.choose(i))
394
+ self.update_idletasks()
395
+ left,top,right,bottom=self.work_area(x,y)
396
+ width=min(max(self.px(190),min_width,self.area.body.winfo_reqwidth()+self.px(18)),right-left-self.px(16))
397
+ height=min(self.area.body.winfo_reqheight()+self.px(18),self.px(430),bottom-top-self.px(24))
398
+ if parent_popup and x+width>right-self.px(8):x=parent_popup.winfo_rootx()-width+self.px(4)
399
+ elif parent_popup is None and y+height>bottom-self.px(8) and owner.winfo_viewable():
400
+ y=min(y,owner.winfo_rooty()-height-self.px(4))
401
+ x=max(left+self.px(8),min(x,right-width-self.px(8)))
402
+ y=max(top+self.px(8),min(y,bottom-height-self.px(8)))
403
+ self.geometry(f'{width}x{height}+{int(x)}+{int(y)}')
404
+ self.bind('<Up>',lambda e:self.move(-1));self.bind('<Down>',lambda e:self.move(1))
405
+ self.bind('<Home>',lambda e:self.highlight(next(iter(self.enabled()),None)))
406
+ self.bind('<End>',lambda e:self.highlight(self.enabled()[-1] if self.enabled() else None))
407
+ self.bind('<Return>',lambda e:self.choose(self.cursor));self.bind('<space>',lambda e:self.choose(self.cursor))
408
+ self.bind('<Right>',lambda e:self.open_child(self.cursor))
409
+ self.bind('<Left>',self.go_back);self.bind('<Escape>',self.go_back)
410
+ self.bind('<Tab>',lambda e:self.root_popup.close())
411
+ self.bind('<ButtonPress-1>',self.outside,add='+')
412
+ self.bind('<FocusOut>',lambda e:self.root_popup.defer_focus_check(),add='+')
413
+ if parent_popup is None:
414
+ self.host._choice_popup=self
415
+ self._host_binding=self.host.bind('<Configure>',self.host_configured,add='+')
416
+ self._destroy_binding=self.host.bind('<Destroy>',self.host_destroyed,add='+')
417
+ self.attributes('-topmost',True);self.deiconify();self.update_idletasks()
418
+ Modal.round_corners(self);self.lift();self.focus_force()
419
+ if parent_popup is None:self.grab_set()
420
+ if selected not in self.enabled():selected=next(iter(self.enabled()),None)
421
+ self.highlight(selected)
422
+ @staticmethod
423
+ def work_area(x,y):
424
+ if sys.platform=='win32':
425
+ api=_monitor_api()
426
+ info=api.MonitorInfo();info.cbSize=api.ctypes.sizeof(info)
427
+ if api.user.GetMonitorInfoW(api.user.MonitorFromPoint(api.w.POINT(int(x),int(y)),2),api.ctypes.byref(info)):
428
+ r=info.rcWork;return r.left,r.top,r.right,r.bottom
429
+ root=tk._default_root
430
+ return 0,0,root.winfo_screenwidth(),root.winfo_screenheight()
431
+ def enabled(self):return [i for i,(_,_,disabled,_) in self.rows.items() if not disabled]
432
+ def highlight(self,index):
433
+ if index not in self.enabled():return 'break'
434
+ if self.cursor is not None and self.cursor in self.rows:
435
+ row,widgets,_,_=self.rows[self.cursor];row.configure(style='Web.MenuRow.TFrame')
436
+ for widget in widgets:widget.configure(background=PALETTE['bg'])
437
+ self.cursor=index
438
+ row,widgets,_,_=self.rows[index];row.configure(style='Web.ActiveMenuRow.TFrame')
439
+ for widget in widgets:widget.configure(background=PALETTE['hover'])
440
+ self.update_idletasks()
441
+ total=max(1,self.area.body.winfo_reqheight());first,last=self.area.canvas.yview()
442
+ y=row.winfo_y();end=y+row.winfo_height()
443
+ if y<first*total:self.area.canvas.yview_moveto(y/total)
444
+ elif end>last*total:self.area.canvas.yview_moveto((end-self.area.canvas.winfo_height())/total)
445
+ return 'break'
446
+ def hover(self,index):
447
+ if self._hover_job is not None:self.after_cancel(self._hover_job);self._hover_job=None
448
+ if index not in self.enabled():return
449
+ if index!=self.cursor and self.child_popup is not None:self.child_popup.close()
450
+ self.highlight(index)
451
+ if self.rows[index][3]=='cascade':self._hover_job=self.after(180,lambda:self.open_child(index))
452
+ def move(self,delta):
453
+ if self.child_popup is not None:self.child_popup.close()
454
+ indices=self.enabled()
455
+ if indices:self.highlight(indices[(indices.index(self.cursor)+delta)%len(indices)] if self.cursor in indices else indices[0])
456
+ return 'break'
457
+ def choose(self,index):
458
+ if index not in self.enabled():return 'break'
459
+ if self.rows[index][3]=='cascade':return self.open_child(index)
460
+ menu=self.menu;self.root_popup.close()
461
+ if menu.winfo_exists():menu.invoke(index)
462
+ return 'break'
463
+ def open_child(self,index):
464
+ if self._hover_job is not None:self.after_cancel(self._hover_job)
465
+ self._hover_job=None
466
+ if self._closed or index not in self.enabled() or self.rows[index][3]!='cascade':return 'break'
467
+ self.highlight(index)
468
+ if self.child_popup is not None and not self.child_popup._closed and self.child_popup.parent_index==index:
469
+ self.child_popup.focus_force();return 'break'
470
+ if self.child_popup is not None:self.child_popup.close()
471
+ submenu=self.menu.nametowidget(self.menu.entrycget(index,'menu'))
472
+ ancestor=self
473
+ while ancestor is not None:
474
+ if ancestor.menu is submenu:return 'break'
475
+ ancestor=ancestor.parent_popup
476
+ row=self.rows[index][0]
477
+ self.child_popup=MenuPopup(self.owner,submenu,self.winfo_rootx()+self.winfo_width()-self.px(4),row.winfo_rooty()-self.px(6),
478
+ parent_popup=self,parent_index=index)
479
+ return 'break'
480
+ def go_back(self,event=None):
481
+ if self.parent_popup is not None:
482
+ parent=self.parent_popup;self.close();parent.focus_force()
483
+ else:self.close()
484
+ return 'break'
485
+ def chain(self):
486
+ current=self.root_popup
487
+ while current is not None and not current._closed:
488
+ yield current;current=current.child_popup
489
+ def outside(self,event):
490
+ if not any(p.winfo_rootx()<=event.x_root<p.winfo_rootx()+p.winfo_width() and p.winfo_rooty()<=event.y_root<p.winfo_rooty()+p.winfo_height() for p in self.chain()):
491
+ self.root_popup.close()
492
+ def defer_focus_check(self):
493
+ if not self._closed and self._focus_job is None:self._focus_job=self.after(100,self.check_focus)
494
+ def check_focus(self):
495
+ self._focus_job=None
496
+ if self._closed:return
497
+ focus=self.focus_get()
498
+ if focus is None or focus.winfo_toplevel() not in list(self.chain()):self.close(restore_focus=False)
499
+ def host_configured(self,event):
500
+ if event.widget is self.host:self.close()
501
+ def host_destroyed(self,event):
502
+ if event.widget is self.host:self.close(restore_focus=False)
503
+ def close(self,*,restore_focus=True):
504
+ if self._closed:return 'break'
505
+ self._closed=True
506
+ for job in (self._hover_job,self._focus_job):
507
+ if job is not None:self.after_cancel(job)
508
+ if self.child_popup is not None:self.child_popup.close(restore_focus=False)
509
+ if self.parent_popup is not None:
510
+ self.parent_popup.child_popup=None
511
+ super().destroy()
512
+ return 'break'
513
+ if self._host_binding:unbind(self.host,'<Configure>',self._host_binding)
514
+ if self._destroy_binding:unbind(self.host,'<Destroy>',self._destroy_binding)
515
+ if getattr(self.host,'_choice_popup',None) is self:self.host._choice_popup=None
516
+ self.grab_release();super().destroy()
517
+ try:
518
+ if self.previous_grab is not None and self.previous_grab.winfo_exists():self.previous_grab.grab_set()
519
+ if restore_focus:
520
+ target=self.previous_focus
521
+ if target is None or not target.winfo_exists() or not target.winfo_viewable():target=self.host
522
+ if target.winfo_exists():target.focus_force()
523
+ except tk.TclError:pass
524
+ return 'break'
525
+ def destroy(self):self.close()