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/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
+