tkwebui 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tkwebui/__init__.py +53 -0
- tkwebui/charts.py +496 -0
- tkwebui/core.py +1128 -0
- tkwebui/dataview.py +185 -0
- tkwebui/extras.py +525 -0
- tkwebui/feedback.py +179 -0
- tkwebui/forms.py +324 -0
- tkwebui/media.py +336 -0
- tkwebui/placeholder.py +174 -0
- tkwebui/popups.py +173 -0
- tkwebui/render.py +198 -0
- tkwebui/scrolling.py +73 -0
- tkwebui/table.py +369 -0
- tkwebui/tcl_bridge.py +23 -0
- tkwebui/widgets.py +301 -0
- tkwebui/window_chrome.py +494 -0
- tkwebui-0.3.2.dist-info/METADATA +75 -0
- tkwebui-0.3.2.dist-info/RECORD +20 -0
- tkwebui-0.3.2.dist-info/WHEEL +4 -0
- tkwebui-0.3.2.dist-info/licenses/LICENSE +21 -0
tkwebui/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()
|