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/widgets.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Reusable standard-library widgets, layout helpers and input focus handling."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
from .core import Card, PALETTE, icon, _raster, _rgb
|
|
6
|
+
from .scrolling import _attach_scrollbars, _release_scrollbars
|
|
7
|
+
|
|
8
|
+
def focus_when_mapped(widget,*,when=None):
|
|
9
|
+
"""Hand off focus after a page is mapped; optional predicate cancels stale navigation."""
|
|
10
|
+
root=widget._root()
|
|
11
|
+
pending=getattr(root,'_switch_focus_job',None)
|
|
12
|
+
if pending is not None:root.after_cancel(pending)
|
|
13
|
+
def restore(attempt=0):
|
|
14
|
+
root._switch_focus_job=None
|
|
15
|
+
if not widget.winfo_exists() or (when is not None and not when()):return
|
|
16
|
+
grab=widget.grab_current()
|
|
17
|
+
if grab is not None and grab.winfo_toplevel() is not widget.winfo_toplevel():return
|
|
18
|
+
if not widget.winfo_viewable():
|
|
19
|
+
if attempt<5:root._switch_focus_job=root.after(20,lambda:restore(attempt+1))
|
|
20
|
+
return
|
|
21
|
+
if 'state' not in widget.keys() or str(widget.cget('state'))!='disabled':widget.focus_force()
|
|
22
|
+
root._switch_focus_job=root.after(20,restore)
|
|
23
|
+
|
|
24
|
+
def install_input_focus(scope):
|
|
25
|
+
"""Fix input activation locally, including inputs added to modal windows later."""
|
|
26
|
+
tag='TkWebUIInputFocus'
|
|
27
|
+
def clicked(event):
|
|
28
|
+
widget=event.widget
|
|
29
|
+
if not widget.winfo_viewable() or str(widget.cget('state'))=='disabled':return
|
|
30
|
+
grab=widget.grab_current()
|
|
31
|
+
if grab is None or grab.winfo_toplevel() is widget.winfo_toplevel():widget.focus_force()
|
|
32
|
+
def attach(widget):
|
|
33
|
+
if isinstance(widget,(tk.Text,tk.Entry,ttk.Entry,ttk.Combobox,ttk.Spinbox)):
|
|
34
|
+
tags=widget.bindtags()
|
|
35
|
+
if tag not in tags:widget.bindtags((tags[0],tag,*tags[1:]))
|
|
36
|
+
for child in widget.winfo_children():attach(child)
|
|
37
|
+
if not getattr(scope,'_switch_focus_installed',False):
|
|
38
|
+
scope.bind_class(tag,'<ButtonPress-1>',clicked)
|
|
39
|
+
scope.bind('<Map>',lambda event:attach(event.widget),add='+')
|
|
40
|
+
scope._switch_focus_installed=True
|
|
41
|
+
attach(scope)
|
|
42
|
+
|
|
43
|
+
class _TextContainer(ttk.Frame):
|
|
44
|
+
def destroy(self):
|
|
45
|
+
if getattr(self,'_destroying',False):return
|
|
46
|
+
self._destroying=True
|
|
47
|
+
super().destroy()
|
|
48
|
+
|
|
49
|
+
class RoundedText(tk.Text):
|
|
50
|
+
"""A real tk.Text inside a small-radius field; pack/grid/place operate on its shell.
|
|
51
|
+
|
|
52
|
+
Text methods, bindings, tags, undo, selection and scroll commands are native.
|
|
53
|
+
`container` exposes the surrounding ttk.Frame when layout inspection is needed.
|
|
54
|
+
"""
|
|
55
|
+
def __init__(self,master,*,padding=None,scrollbars=True,**kwargs):
|
|
56
|
+
scale=master.winfo_fpixels('1i')/96
|
|
57
|
+
self.container=_TextContainer(master,style='RoundedText.TFrame',
|
|
58
|
+
padding=padding if padding is not None else (round(6*scale),round(4*scale)))
|
|
59
|
+
self.container.columnconfigure(0,weight=1);self.container.rowconfigure(0,weight=1)
|
|
60
|
+
kwargs.update(borderwidth=0,highlightthickness=0,relief='flat')
|
|
61
|
+
kwargs.setdefault('padx',0);kwargs.setdefault('pady',0)
|
|
62
|
+
kwargs.setdefault('background',PALETTE['bg']);kwargs.setdefault('foreground',PALETTE['fg'])
|
|
63
|
+
kwargs.setdefault('insertbackground',PALETTE['fg'])
|
|
64
|
+
kwargs.setdefault('selectbackground',PALETTE['selected']);kwargs.setdefault('selectforeground',PALETTE['fg'])
|
|
65
|
+
super().__init__(self.container,**kwargs)
|
|
66
|
+
tk.Text.grid(self,row=0,column=0,sticky='nsew')
|
|
67
|
+
if scrollbars:_attach_scrollbars(self,self.container)
|
|
68
|
+
self.bind('<FocusIn>',lambda _:self.container.state(['focus']),add='+')
|
|
69
|
+
self.bind('<FocusOut>',lambda _:self.container.state(['!focus']),add='+')
|
|
70
|
+
self._sync_shell()
|
|
71
|
+
install_input_focus(self.winfo_toplevel())
|
|
72
|
+
|
|
73
|
+
def _sync_shell(self):
|
|
74
|
+
disabled=str(self.cget('state'))=='disabled'
|
|
75
|
+
self.container.state(['disabled' if disabled else '!disabled'])
|
|
76
|
+
# Read-only text remains a white reading surface, not a grey block.
|
|
77
|
+
if disabled:self.container.state(['!disabled'])
|
|
78
|
+
|
|
79
|
+
def configure(self,cnf=None,**kwargs):
|
|
80
|
+
if isinstance(cnf,str):return super().configure(cnf,**kwargs)
|
|
81
|
+
options=dict(cnf or {},**kwargs)
|
|
82
|
+
if options:
|
|
83
|
+
options.update(borderwidth=0,highlightthickness=0,relief='flat')
|
|
84
|
+
result=super().configure(**options)
|
|
85
|
+
_release_scrollbars(self,options)
|
|
86
|
+
self._sync_shell();return result
|
|
87
|
+
return super().configure()
|
|
88
|
+
config=configure
|
|
89
|
+
|
|
90
|
+
def destroy(self):
|
|
91
|
+
if getattr(self.container,'_destroying',False):super().destroy()
|
|
92
|
+
else:self.container.destroy()
|
|
93
|
+
|
|
94
|
+
def pack(self,*args,**kw):return self.container.pack(*args,**kw)
|
|
95
|
+
pack_configure=pack
|
|
96
|
+
def grid(self,*args,**kw):return self.container.grid(*args,**kw)
|
|
97
|
+
grid_configure=grid
|
|
98
|
+
def place(self,*args,**kw):return self.container.place(*args,**kw)
|
|
99
|
+
place_configure=place
|
|
100
|
+
def pack_forget(self):return self.container.pack_forget()
|
|
101
|
+
def grid_forget(self):return self.container.grid_forget()
|
|
102
|
+
def grid_remove(self):return self.container.grid_remove()
|
|
103
|
+
def place_forget(self):return self.container.place_forget()
|
|
104
|
+
def pack_info(self):return self.container.pack_info()
|
|
105
|
+
def grid_info(self):return self.container.grid_info()
|
|
106
|
+
def place_info(self):return self.container.place_info()
|
|
107
|
+
|
|
108
|
+
def initial_window_bounds(root,width,height):
|
|
109
|
+
"""Center the larger default window, leaving room for taskbar and borders."""
|
|
110
|
+
left,top,right,bottom=0,0,root.winfo_screenwidth(),root.winfo_screenheight()
|
|
111
|
+
import sys
|
|
112
|
+
if sys.platform=='win32':
|
|
113
|
+
import ctypes
|
|
114
|
+
from ctypes import wintypes as w
|
|
115
|
+
user=ctypes.WinDLL('user32',use_last_error=True)
|
|
116
|
+
class MonitorInfo(ctypes.Structure):
|
|
117
|
+
_fields_=[('cbSize',w.DWORD),('rcMonitor',w.RECT),('rcWork',w.RECT),('dwFlags',w.DWORD)]
|
|
118
|
+
user.MonitorFromWindow.argtypes=[w.HWND,w.DWORD];user.MonitorFromWindow.restype=w.HANDLE
|
|
119
|
+
user.GetMonitorInfoW.argtypes=[w.HANDLE,ctypes.POINTER(MonitorInfo)]
|
|
120
|
+
monitor=user.MonitorFromWindow(root.winfo_id(),2)
|
|
121
|
+
info=MonitorInfo();info.cbSize=ctypes.sizeof(info)
|
|
122
|
+
if user.GetMonitorInfoW(monitor,ctypes.byref(info)):
|
|
123
|
+
left,top,right,bottom=info.rcWork.left,info.rcWork.top,info.rcWork.right,info.rcWork.bottom
|
|
124
|
+
width=min(width,max(1,right-left-80));height=min(height,max(1,bottom-top-80))
|
|
125
|
+
return width,height,left+(right-left-width)//2,top+(bottom-top-height)//2
|
|
126
|
+
|
|
127
|
+
def align_tree_scrollbar(tree):
|
|
128
|
+
"""Fit the final visible column and show only genuinely needed scrollbars."""
|
|
129
|
+
if not hasattr(tree,'_responsive_table'):tree._responsive_table=ResponsiveTable(tree)
|
|
130
|
+
return tree._responsive_table
|
|
131
|
+
|
|
132
|
+
def configure_table(tree, *, sortable=True):
|
|
133
|
+
layout=align_tree_scrollbar(tree)
|
|
134
|
+
if sortable:
|
|
135
|
+
from .table import enable_sorting
|
|
136
|
+
enable_sorting(tree)
|
|
137
|
+
return layout
|
|
138
|
+
|
|
139
|
+
class ResponsiveTable:
|
|
140
|
+
def __init__(self,tree):
|
|
141
|
+
self.tree=tree;self.job=None;self.offset=None;self.visible=()
|
|
142
|
+
self.preferred={column:tree.column(column,'width') for column in tree['columns']}
|
|
143
|
+
self.bars={str(child.cget('orient')):child for child in tree.master.winfo_children()
|
|
144
|
+
if isinstance(child,ttk.Scrollbar)}
|
|
145
|
+
for orientation,option in [('vertical','yscrollcommand'),('horizontal','xscrollcommand')]:
|
|
146
|
+
if orientation in self.bars:
|
|
147
|
+
bar=self.bars[orientation]
|
|
148
|
+
def changed(first,last,bar=bar):
|
|
149
|
+
bar.set(first,last);self.schedule()
|
|
150
|
+
tree.configure(**{option:changed})
|
|
151
|
+
bar.grid_remove()
|
|
152
|
+
tree.bind('<Map>',self.schedule,add='+')
|
|
153
|
+
tree.bind('<Configure>',self.schedule,add='+')
|
|
154
|
+
tree.bind('<<ThemeChanged>>',self.theme_changed,add='+')
|
|
155
|
+
tree.bind('<Destroy>',self.cleanup,add='+')
|
|
156
|
+
# Mark the stretching column now. Treeview columns stretch by default,
|
|
157
|
+
# so a table laid out before it is shown (ScrollArea.prepare) would
|
|
158
|
+
# otherwise spread its final width over every column for good.
|
|
159
|
+
self.refresh()
|
|
160
|
+
|
|
161
|
+
def schedule(self,event=None):
|
|
162
|
+
if self.job is None:self.job=self.tree.after_idle(self.refresh)
|
|
163
|
+
|
|
164
|
+
def theme_changed(self,event):
|
|
165
|
+
self.offset=None;self.schedule()
|
|
166
|
+
|
|
167
|
+
def remember_columns(self):
|
|
168
|
+
# The stretched width is a layout result, not a new user preference.
|
|
169
|
+
last=self.visible[-1] if self.visible else None
|
|
170
|
+
for column in self.preferred:
|
|
171
|
+
if column!=last:self.preferred[column]=self.tree.column(column,'width')
|
|
172
|
+
return self.preferred.copy()
|
|
173
|
+
|
|
174
|
+
def refresh(self):
|
|
175
|
+
self.job=None
|
|
176
|
+
tree=self.tree
|
|
177
|
+
display=tree['displaycolumns']
|
|
178
|
+
count=len(tree['columns']) if '#all' in display else len(display)
|
|
179
|
+
visible=tuple(tree.column(f'#{i+1}','id') for i in range(count))
|
|
180
|
+
if visible!=self.visible:
|
|
181
|
+
for column in self.visible:
|
|
182
|
+
if column!=self.visible[-1]:self.preferred[column]=tree.column(column,'width')
|
|
183
|
+
if self.visible:
|
|
184
|
+
old=self.visible[-1]
|
|
185
|
+
tree.column(old,stretch=False,width=self.preferred[old])
|
|
186
|
+
self.visible=visible
|
|
187
|
+
for column in tree['columns']:tree.column(column,stretch=bool(visible and column==visible[-1]))
|
|
188
|
+
# Let Tk finish allocating the newly stretchable column before
|
|
189
|
+
# measuring overflow; no reset of the user's other column widths.
|
|
190
|
+
self.schedule();return
|
|
191
|
+
if not tree.winfo_ismapped():return
|
|
192
|
+
for orientation,view in [('horizontal',tree.xview),('vertical',tree.yview)]:
|
|
193
|
+
bar=self.bars.get(orientation)
|
|
194
|
+
if bar is None:continue
|
|
195
|
+
first,last=view()
|
|
196
|
+
needed=float(first)>1e-7 or float(last)<1-1e-7
|
|
197
|
+
showing=bar.winfo_manager()=='grid'
|
|
198
|
+
if needed!=showing:
|
|
199
|
+
if needed:bar.grid()
|
|
200
|
+
else:bar.grid_remove()
|
|
201
|
+
if orientation=='vertical' and needed:
|
|
202
|
+
if self.offset is None:
|
|
203
|
+
limit=min(tree.winfo_height(),round(tree.winfo_fpixels('1i')/96*120))
|
|
204
|
+
header=[y for y in range(limit) if tree.identify_region(8,y) in ('heading','separator')]
|
|
205
|
+
if header:self.offset=max(header)+1
|
|
206
|
+
if self.offset is not None and getattr(bar,'_header_offset',None)!=self.offset:
|
|
207
|
+
bar.grid_configure(pady=(self.offset,0));bar._header_offset=self.offset
|
|
208
|
+
|
|
209
|
+
def cleanup(self,event):
|
|
210
|
+
if event.widget is self.tree and self.job is not None:
|
|
211
|
+
self.tree.after_cancel(self.job);self.job=None
|
|
212
|
+
|
|
213
|
+
def auto_hide_text_scrollbar(widget):
|
|
214
|
+
"""Use the actual Text viewport, including wrapping, to hide its scrollbar."""
|
|
215
|
+
if getattr(widget,'_auto_bar_installed',False) or not widget.cget('yscrollcommand'):return
|
|
216
|
+
outer=getattr(widget,'container',widget)
|
|
217
|
+
bars=[child for child in outer.master.winfo_children()
|
|
218
|
+
if isinstance(child,ttk.Scrollbar) and str(child.cget('orient'))=='vertical']
|
|
219
|
+
if len(bars)!=1:return
|
|
220
|
+
bar=bars[0];manager=bar.winfo_manager()
|
|
221
|
+
if manager not in ('grid','pack'):return
|
|
222
|
+
options=bar.pack_info() if manager=='pack' else None
|
|
223
|
+
state={'job':None}
|
|
224
|
+
def refresh():
|
|
225
|
+
state['job']=None
|
|
226
|
+
if not widget.winfo_ismapped():return
|
|
227
|
+
first,last=widget.yview();needed=first>1e-7 or last<1-1e-7
|
|
228
|
+
showing=bool(bar.winfo_manager())
|
|
229
|
+
if needed==showing:return
|
|
230
|
+
if manager=='grid':bar.grid() if needed else bar.grid_remove()
|
|
231
|
+
elif needed:bar.pack(**options)
|
|
232
|
+
else:bar.pack_forget()
|
|
233
|
+
def changed(first,last):
|
|
234
|
+
bar.set(first,last)
|
|
235
|
+
if state['job'] is None:state['job']=widget.after_idle(refresh)
|
|
236
|
+
def cleanup(event):
|
|
237
|
+
if event.widget is widget and state['job'] is not None:widget.after_cancel(state['job'])
|
|
238
|
+
widget.configure(yscrollcommand=changed)
|
|
239
|
+
widget.bind('<Destroy>',cleanup,add='+')
|
|
240
|
+
widget.bind('<Map>',lambda _:changed(*widget.yview()),add='+')
|
|
241
|
+
widget._auto_bar_installed=True
|
|
242
|
+
if manager=='grid':bar.grid_remove()
|
|
243
|
+
else:bar.pack_forget()
|
|
244
|
+
|
|
245
|
+
class SectionCard(Card):
|
|
246
|
+
"""LabelFrame-compatible container with the reusable rounded card surface."""
|
|
247
|
+
def __init__(self, master, *, text='', padding=14, **kwargs):
|
|
248
|
+
super().__init__(master, padding=padding, **kwargs)
|
|
249
|
+
scale=self.winfo_fpixels('1i')/96
|
|
250
|
+
gap=round(14*scale)
|
|
251
|
+
self.configure(padding=(gap,round(38*scale),gap,gap))
|
|
252
|
+
self.caption=ttk.Label(self,text=text,style='Strong.TLabel')
|
|
253
|
+
# ttk.Frame's place cavity begins after its padding, unlike Canvas.
|
|
254
|
+
self.caption.place(x=0,y=-round(28*scale))
|
|
255
|
+
|
|
256
|
+
def configure_widget_styles(root,style):
|
|
257
|
+
style.layout('RoundedText.TFrame',[('Switch.Entry.field',{'sticky':'nsew'})])
|
|
258
|
+
style.configure('RoundedText.TFrame',background=PALETTE['bg'])
|
|
259
|
+
scale=root.winfo_fpixels('1i')/96
|
|
260
|
+
# Keep a generous invisible drag target with one short centered grip.
|
|
261
|
+
style.configure('TPanedwindow',background=PALETTE['bg'])
|
|
262
|
+
style.configure('Sash',sashthickness=round(12*scale),gripcount=0)
|
|
263
|
+
width,height=round(12*scale),round(36*scale)
|
|
264
|
+
def grip_sample(x,y):
|
|
265
|
+
import math
|
|
266
|
+
cx=width/2;cy=min(max(y,6*scale),height-6*scale)
|
|
267
|
+
return _rgb('#BFC4CD') if math.hypot(x-cx,y-cy)<=1.5*scale else _rgb(PALETTE['bg'])
|
|
268
|
+
grip=_raster(root,width,height,grip_sample)
|
|
269
|
+
root._tkwebui_images.append(grip)
|
|
270
|
+
style.element_create('TkWebUI.Sash.grip','image',grip,width=width,height=height,sticky='')
|
|
271
|
+
style.layout('Vertical.Sash',[('TkWebUI.Sash.grip',{'sticky':'ns'})])
|
|
272
|
+
style.configure('Popup.Quiet.TButton',foreground=PALETTE['fg'])
|
|
273
|
+
style.configure('Checked.Popup.Quiet.TButton',foreground=PALETTE['blue'])
|
|
274
|
+
# The containing Card owns the outer border, including both scrollbars.
|
|
275
|
+
style.layout('Treeview',[('Treeview.padding',{'sticky':'nsew','children':[
|
|
276
|
+
('Treeview.treearea',{'sticky':'nsew'})]})])
|
|
277
|
+
# Keep the real editable Combobox API, native popup and selection events.
|
|
278
|
+
style.element_create('TkWebUI.Combobox.downarrow','image',icon(root,'chevrondown',round(12*scale)),
|
|
279
|
+
width=round(30*scale),sticky='')
|
|
280
|
+
style.layout('TCombobox',[('Switch.Entry.field',{'sticky':'nsew','children':[
|
|
281
|
+
('TkWebUI.Combobox.downarrow',{'side':'right','sticky':'ns'}),
|
|
282
|
+
('Combobox.padding',{'sticky':'nsew','children':[('Combobox.textarea',{'sticky':'nsew'})]})]})])
|
|
283
|
+
style.configure('TCombobox',padding=(round(9*scale),round(6*scale)))
|
|
284
|
+
root.option_add('*TCombobox*Listbox.background',PALETTE['bg'])
|
|
285
|
+
root.option_add('*TCombobox*Listbox.selectBackground',PALETTE['selected'])
|
|
286
|
+
root.option_add('*TCombobox*Listbox.selectForeground',PALETTE['fg'])
|
|
287
|
+
# Small cached pill images for progress, preserving ttk .start/.stop semantics.
|
|
288
|
+
h=max(6,round(6*scale))
|
|
289
|
+
for part,color in [('trough',PALETTE['hover']),('pbar',PALETTE['accent'])]:
|
|
290
|
+
rgb=_rgb(color);bg=_rgb(PALETTE['bg'])
|
|
291
|
+
def sample(x,y,fg=rgb,h=h,bg=bg):
|
|
292
|
+
import math
|
|
293
|
+
cx=min(max(x,h/2),128-h/2)
|
|
294
|
+
return fg if math.hypot(x-cx,y-h/2)<=h/2 else bg
|
|
295
|
+
img=_raster(root,128,h,sample)
|
|
296
|
+
root._tkwebui_images.append(img)
|
|
297
|
+
style.element_create('TkWebUI.Progress.'+part,'image',img,border=h//2,width=h,height=h,sticky='nsew')
|
|
298
|
+
style.layout('Horizontal.TProgressbar',[('TkWebUI.Progress.trough',{'sticky':'ew','children':[
|
|
299
|
+
('TkWebUI.Progress.pbar',{'side':'left','sticky':'ns'})]})])
|
|
300
|
+
style.configure('Horizontal.TProgressbar',thickness=h,borderwidth=0)
|
|
301
|
+
|