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.
@@ -0,0 +1,494 @@
1
+ """Windows client-side title region, using ctypes only.
2
+
3
+ Keeps a real taskbar window, system menu, resize borders and Windows maximize.
4
+ No overrideredirect(), fake minimize window, WebView, or third-party package.
5
+ On non-Windows systems the ordinary OS title bar is retained.
6
+ """
7
+ from __future__ import annotations
8
+ import re
9
+ import sys
10
+ import tkinter as tk
11
+ from tkinter import ttk
12
+ from .core import icon, Tooltip, PALETTE
13
+
14
+
15
+ class WindowChrome:
16
+ def __init__(self,root,toolbar,drag_widgets=(),on_close=None,sidebar=None):
17
+ self.root=root
18
+ root._window_chrome=self
19
+ self.close_action=on_close or getattr(root,'close',root.destroy)
20
+ self.attached=False
21
+ self._old_proc=None
22
+ self._cache_job=None
23
+ self._drag_rects=[]
24
+ self._excluded=[]
25
+ self.extra_exclusions=[]
26
+ self._drag_anchor=None
27
+ self._drag_job=None
28
+ self._drag_target=None
29
+ self._drag_widget=None
30
+ self._batch_depth=0
31
+ self._native_moving=False
32
+ self._system_move_pending=False
33
+ self.system_move_count=0
34
+ self.resize_handles={}
35
+ self.toolbar=toolbar
36
+ self.sidebar=sidebar
37
+ self.drag_widgets=list(drag_widgets)
38
+ # resizable() per axis, and the client size a fixed axis must keep.
39
+ self._resize_axes=(True,True)
40
+ self._fixed_size=None
41
+ self._size_job=None
42
+ self._geometry_originals={}
43
+ if sys.platform!='win32':return
44
+ import ctypes
45
+ from ctypes import wintypes as w
46
+ self.c,self.w=ctypes,w
47
+ self.u=ctypes.WinDLL('user32',use_last_error=True)
48
+ self.dwm=ctypes.WinDLL('dwmapi',use_last_error=True)
49
+ self.PROC=ctypes.WINFUNCTYPE(ctypes.c_ssize_t,w.HWND,w.UINT,w.WPARAM,w.LPARAM)
50
+ self.get_long=self.u.GetWindowLongPtrW
51
+ self.get_long.argtypes=[w.HWND,ctypes.c_int];self.get_long.restype=ctypes.c_ssize_t
52
+ self.set_long=self.u.SetWindowLongPtrW
53
+ self.set_long.argtypes=[w.HWND,ctypes.c_int,ctypes.c_void_p];self.set_long.restype=ctypes.c_void_p
54
+ self.u.CallWindowProcW.argtypes=[ctypes.c_void_p,w.HWND,w.UINT,w.WPARAM,w.LPARAM]
55
+ self.u.CallWindowProcW.restype=ctypes.c_ssize_t
56
+ self.u.GetParent.argtypes=[w.HWND];self.u.GetParent.restype=w.HWND
57
+ self.u.SetWindowPos.argtypes=[w.HWND,w.HWND,ctypes.c_int,ctypes.c_int,ctypes.c_int,ctypes.c_int,w.UINT]
58
+ self.u.RedrawWindow.argtypes=[w.HWND,ctypes.c_void_p,w.HANDLE,w.UINT]
59
+ self.u.GetWindowRect.argtypes=[w.HWND,ctypes.POINTER(w.RECT)]
60
+ self.u.IsZoomed.argtypes=[w.HWND]
61
+ self.u.IsIconic.argtypes=[w.HWND];self.u.IsIconic.restype=w.BOOL
62
+ self.u.IsWindow.argtypes=[w.HWND];self.u.IsWindow.restype=w.BOOL
63
+ self.u.GetMenu.argtypes=[w.HWND];self.u.GetMenu.restype=w.HMENU
64
+ self.u.AdjustWindowRectEx.argtypes=[ctypes.POINTER(w.RECT),w.DWORD,w.BOOL,w.DWORD]
65
+ try:self.u.AdjustWindowRectExForDpi.argtypes=[ctypes.POINTER(w.RECT),w.DWORD,w.BOOL,w.DWORD,w.UINT]
66
+ except AttributeError:pass # Windows before 10 1607
67
+ self.u.SendMessageW.argtypes=[w.HWND,w.UINT,w.WPARAM,w.LPARAM];self.u.SendMessageW.restype=ctypes.c_ssize_t
68
+ self.u.PostMessageW.argtypes=[w.HWND,w.UINT,w.WPARAM,w.LPARAM];self.u.PostMessageW.restype=w.BOOL
69
+ self.u.DefWindowProcW.argtypes=[w.HWND,w.UINT,w.WPARAM,w.LPARAM];self.u.DefWindowProcW.restype=ctypes.c_ssize_t
70
+ self.u.MonitorFromWindow.argtypes=[w.HWND,w.DWORD];self.u.MonitorFromWindow.restype=w.HANDLE
71
+ self.u.GetMonitorInfoW.argtypes=[w.HANDLE,ctypes.c_void_p]
72
+ self.dwm.DwmSetWindowAttribute.argtypes=[w.HWND,w.DWORD,ctypes.c_void_p,w.DWORD]
73
+ self.dwm.DwmExtendFrameIntoClientArea.argtypes=[w.HWND,ctypes.c_void_p]
74
+ class MonitorInfo(ctypes.Structure):
75
+ _fields_=[('cbSize',w.DWORD),('rcMonitor',w.RECT),('rcWork',w.RECT),('dwFlags',w.DWORD)]
76
+ class MinMaxInfo(ctypes.Structure):
77
+ _fields_=[('ptReserved',w.POINT),('ptMaxSize',w.POINT),('ptMaxPosition',w.POINT),('ptMinTrackSize',w.POINT),('ptMaxTrackSize',w.POINT)]
78
+ self.MonitorInfo,self.MinMaxInfo=MonitorInfo,MinMaxInfo
79
+ self.controls=ttk.Frame(toolbar)
80
+ self.controls.pack(side='right',padx=(18,0))
81
+ self.pinned=False
82
+ self.buttons={}
83
+ self._drawn_controls={}
84
+ self.icon_size=round(root.winfo_fpixels('1i')/96*14)
85
+ for key,command,label in [('pin',self.toggle_pin,'窗口置顶'),('minimize',self.minimize,'最小化'),
86
+ ('maximize',self.toggle_maximize,'最大化 / 还原'),('close',self.close,'关闭')]:
87
+ button=ttk.Button(self.controls,image=icon(root,key,self.icon_size),
88
+ style='Close.TButton' if key=='close' else 'Quiet.TButton',
89
+ command=command,takefocus=False,padding=(12,9))
90
+ button.pack(side='left',padx=1)
91
+ self.buttons[key]=button
92
+ button.bind('<Enter>',lambda event,k=key:self.paint_control(k,True),add='+')
93
+ button.bind('<Leave>',lambda event,k=key:self.paint_control(k,False),add='+')
94
+ Tooltip(button,(lambda:'取消置顶' if self.pinned else '窗口置顶') if key=='pin' else label)
95
+ self.max_button=self.buttons['maximize']
96
+ for widget in self.drag_widgets:
97
+ widget.bind('<ButtonPress-1>',self.drag,add='+')
98
+ widget.bind('<B1-Motion>',self.drag_motion,add='+')
99
+ widget.bind('<ButtonRelease-1>',self.end_drag,add='+')
100
+ widget.bind('<Double-Button-1>',self.double_click,add='+')
101
+ root.bind('<Escape>',self.cancel_drag,add='+')
102
+ root.bind('<Configure>',self.schedule_cache,add='+')
103
+ root.bind('<Map>',self.schedule_cache,add='+')
104
+ self._attach_job=root.after_idle(self.attach)
105
+
106
+ def attach(self):
107
+ if self.attached:return
108
+ self._attach_job=None
109
+ self.root.update_idletasks()
110
+ # Tk creates its native wrapper after the first map. Subclassing the
111
+ # client HWND before then would leave the real OS caption untouched.
112
+ wrapper=self.u.GetParent(self.root.winfo_id())
113
+ if not wrapper:
114
+ self._attach_job=self.root.after(20,self.attach)
115
+ return
116
+ self.hwnd=wrapper
117
+ self.original_style=self.get_long(self.hwnd,-16)
118
+ self.original_exstyle=self.get_long(self.hwnd,-20)
119
+ self._resize_axes=tuple(bool(value) for value in self.root.resizable())
120
+ # Tk sized the wrapper as client + OS frame. Once WM_NCCALCSIZE hands
121
+ # the whole wrapper to the client, that frame becomes extra content
122
+ # (640x420 opened as 662x476). Remember what the program asked for.
123
+ # Also runs when Tk replaced its wrapper; that one is client + frame again.
124
+ self._fixed_size=self._requested_size()
125
+ self._install_geometry_bridge()
126
+ self._callback=self.PROC(self.window_proc) # Must stay alive for the window lifetime.
127
+ self._old_proc=self.set_long(self.hwnd,-4,self.c.cast(self._callback,self.c.c_void_p))
128
+ if not self._old_proc:return
129
+ self.attached=True
130
+ # Keep standard window capabilities for native Snap/animations. The
131
+ # caption is hidden by WM_NCCALCSIZE, not by stripping capability flags.
132
+ # Resize border / maximize box only where Tk's resizable() allows it.
133
+ style=self._capability_style(self.original_style|0x00C00000|0x00080000|0x00020000)
134
+ self.set_long(self.hwnd,-16,self.c.c_void_p(style))
135
+ self.set_long(self.hwnd,-20,self.c.c_void_p((self.original_exstyle|0x00040000)&~0x00000080))
136
+ margins=(self.c.c_int*4)(1,1,1,1)
137
+ self.dwm.DwmExtendFrameIntoClientArea(self.hwnd,self.c.byref(margins))
138
+ corner=self.c.c_int(2)
139
+ self.dwm.DwmSetWindowAttribute(self.hwnd,33,self.c.byref(corner),self.c.sizeof(corner))
140
+ self.u.SetWindowPos(self.hwnd,None,0,0,0,0,0x0001|0x0002|0x0004|0x0010|0x0020)
141
+ if not self.u.IsZoomed(self.hwnd) and not self.u.IsIconic(self.hwnd):
142
+ width,height=self._fixed_size
143
+ self.u.SetWindowPos(self.hwnd,None,0,0,width,height,0x0002|0x0004|0x0010)
144
+ if not self.resize_handles:self.install_resize_handles()
145
+ self.sync_capabilities()
146
+ self.schedule_cache()
147
+
148
+ def _requested_size(self):
149
+ """Client size the program requested, read before the frame is removed.
150
+
151
+ Derived from the native outer rectangle minus the frame Tk added. On a
152
+ monitor whose DPI differs from Tk's startup DPI, winfo_width() already
153
+ contains the frame-size mismatch, so it is only a fallback.
154
+ """
155
+ outer=self.w.RECT()
156
+ self.u.GetWindowRect(self.hwnd,self.c.byref(outer))
157
+ frame=self.w.RECT(0,0,0,0)
158
+ menu=bool(self.u.GetMenu(self.hwnd))
159
+ dpi=round(self.root.winfo_fpixels('1i'))
160
+ try:ok=self.u.AdjustWindowRectExForDpi(self.c.byref(frame),self.original_style,menu,self.original_exstyle,dpi)
161
+ except AttributeError:ok=self.u.AdjustWindowRectEx(self.c.byref(frame),self.original_style,menu,self.original_exstyle)
162
+ if ok and outer.right>outer.left:
163
+ return (max(1,outer.right-outer.left-(frame.right-frame.left)),max(1,outer.bottom-outer.top-(frame.bottom-frame.top)))
164
+ return (max(1,self.root.winfo_width()),max(1,self.root.winfo_height()))
165
+
166
+ def _capability_style(self,style):
167
+ style=style|0x00040000 if any(self._resize_axes) else style&~0x00040000 # WS_THICKFRAME
168
+ return style|0x00010000 if all(self._resize_axes) else style&~0x00010000 # WS_MAXIMIZEBOX
169
+
170
+ def sync_capabilities(self):
171
+ """Follow root.resizable(), including changes made after attach()."""
172
+ if not self.attached:return
173
+ previous=self._resize_axes
174
+ self._resize_axes=tuple(bool(value) for value in self.root.resizable())
175
+ if previous!=self._resize_axes and not self.u.IsIconic(self.hwnd):
176
+ # An axis that just became fixed keeps its current size.
177
+ size=list(self._fixed_size or (self.root.winfo_width(),self.root.winfo_height()))
178
+ for axis in (0,1):
179
+ if previous[axis] and not self._resize_axes[axis]:
180
+ size[axis]=self.root.winfo_width() if axis==0 else self.root.winfo_height()
181
+ self._fixed_size=tuple(size)
182
+ style=self.get_long(self.hwnd,-16);updated=self._capability_style(style)
183
+ if updated!=style:
184
+ self.set_long(self.hwnd,-16,self.c.c_void_p(updated))
185
+ self.u.SetWindowPos(self.hwnd,None,0,0,0,0,0x0001|0x0002|0x0004|0x0010|0x0020)
186
+ allowed=all(self._resize_axes) or bool(self.u.IsZoomed(self.hwnd))
187
+ # Custom title bars may destroy the built-in controls. Keep syncing
188
+ # native capabilities and resize handles even when no button remains.
189
+ if self.max_button.winfo_exists():
190
+ self.max_button.state(['!disabled'] if allowed else ['disabled'])
191
+ self.paint_control('maximize')
192
+ if previous!=self._resize_axes:self.layout_resize_handles()
193
+
194
+ def _install_geometry_bridge(self):
195
+ """Keep the stable size in step with root.geometry('WxH') at runtime."""
196
+ if self._geometry_originals:return
197
+ original=self.root.wm_geometry
198
+ for name in ('geometry','wm_geometry'):
199
+ self._geometry_originals[name]=(name in self.root.__dict__,getattr(self.root,name))
200
+ def geometry(value=None):
201
+ match=re.match(r'^\s*(\d+)x(\d+)',str(value)) if value is not None else None
202
+ if match and self.attached:
203
+ lower=self.root.minsize();upper=self.root.maxsize()
204
+ self._fixed_size=tuple(max(lower[i],min(upper[i],int(match.group(i+1)))) for i in (0,1))
205
+ result=original(value)
206
+ if match and self.attached:
207
+ # Tk re-adds its frame size; restore the exact requested size.
208
+ if self._size_job is not None:self.root.after_cancel(self._size_job)
209
+ self._size_job=self.root.after_idle(lambda:self._apply_fixed_size(all_axes=True))
210
+ return result
211
+ self._geometry_bridge=geometry
212
+ self.root.geometry=self.root.wm_geometry=geometry
213
+
214
+ def _apply_fixed_size(self,all_axes=False):
215
+ self._size_job=None
216
+ if not self.attached or self._fixed_size is None or self.u.IsIconic(self.hwnd) or self.u.IsZoomed(self.hwnd):return
217
+ rect=self.w.RECT();self.u.GetWindowRect(self.hwnd,self.c.byref(rect))
218
+ width=rect.right-rect.left if self._resize_axes[0] and not all_axes else self._fixed_size[0]
219
+ height=rect.bottom-rect.top if self._resize_axes[1] and not all_axes else self._fixed_size[1]
220
+ self.u.SetWindowPos(self.hwnd,None,0,0,width,height,0x0002|0x0004|0x0010)
221
+
222
+ def install_resize_handles(self):
223
+ # Tk child HWNDs cover the client edges after WM_NCCALCSIZE removes
224
+ # the frame. Wrapper-only WM_NCHITTEST therefore misses real mouse
225
+ # input there. Narrow client hit targets forward to Windows SC_SIZE.
226
+ for name,direction,cursor in [('left',1,'size_we'),('right',2,'size_we'),
227
+ ('top',3,'size_ns'),('topleft',4,'size_nw_se'),('topright',5,'size_ne_sw'),
228
+ ('bottom',6,'size_ns'),('bottomleft',7,'size_ne_sw'),('bottomright',8,'size_nw_se')]:
229
+ # Only sidebar layouts have a grey left edge. Plain windows must
230
+ # blend all resize targets, including both left corners, into bg.
231
+ color=PALETTE['sidebar'] if self.sidebar is not None and 'left' in name else PALETTE['bg']
232
+ handle=tk.Frame(self.root,bg=color,borderwidth=0,highlightthickness=0,cursor=cursor,takefocus=False)
233
+ handle.bind('<ButtonPress-1>',lambda event,d=direction:self.start_resize(d,event))
234
+ if name in ('top','bottom'):
235
+ handle.sidebar_fill=tk.Frame(handle,bg=PALETTE['sidebar'],borderwidth=0,highlightthickness=0,cursor=cursor)
236
+ handle.sidebar_fill.bind('<ButtonPress-1>',lambda event,d=direction:self.start_resize(d,event))
237
+ self.resize_handles[name]=handle
238
+ self.layout_resize_handles()
239
+
240
+ def layout_resize_handles(self):
241
+ if not self.resize_handles:return
242
+ if self.u.IsZoomed(self.hwnd):
243
+ for handle in self.resize_handles.values():handle.place_forget()
244
+ return
245
+ edge=getattr(self,'resize_margin',7);corner=edge*2
246
+ horizontal_ok,vertical_ok=self._resize_axes
247
+ positions={
248
+ 'left':dict(x=0,y=corner,width=edge,relheight=1,height=-2*corner),
249
+ 'right':dict(relx=1,x=-edge,y=corner,width=edge,relheight=1,height=-2*corner),
250
+ 'top':dict(x=corner,y=0,relwidth=1,width=-2*corner,height=edge),
251
+ 'bottom':dict(x=corner,rely=1,y=-edge,relwidth=1,width=-2*corner,height=edge),
252
+ 'topleft':dict(x=0,y=0,width=corner,height=corner),
253
+ 'topright':dict(relx=1,x=-corner,y=0,width=corner,height=corner),
254
+ 'bottomleft':dict(x=0,rely=1,y=-corner,width=corner,height=corner),
255
+ 'bottomright':dict(relx=1,x=-corner,rely=1,y=-corner,width=corner,height=corner)}
256
+ for name,handle in self.resize_handles.items():
257
+ # No resize targets on an axis the program fixed with resizable().
258
+ if ('left' in name or 'right' in name) and not horizontal_ok or ('top' in name or 'bottom' in name) and not vertical_ok:
259
+ handle.place_forget();continue
260
+ handle.place(**positions[name]);handle.lift()
261
+ if hasattr(handle,'sidebar_fill'):
262
+ sidebar=self.sidebar
263
+ width=max(0,sidebar.winfo_width()-corner) if sidebar is not None else 0
264
+ if width:handle.sidebar_fill.place(x=0,y=0,width=width,relheight=1)
265
+ else:handle.sidebar_fill.place_forget()
266
+
267
+ def start_resize(self,direction,event=None):
268
+ if not self.attached or self.u.IsZoomed(self.hwnd):return 'break'
269
+ if direction in (1,2,4,5,7,8) and not self._resize_axes[0]:return 'break'
270
+ if direction in (3,4,5,6,7,8) and not self._resize_axes[1]:return 'break'
271
+ if self.root.grab_current() is not None:return 'break'
272
+ self.u.ReleaseCapture()
273
+ self._system_move_pending=True
274
+ packed=0 if event is None else (event.x_root&0xffff)|((event.y_root&0xffff)<<16)
275
+ self.u.PostMessageW(self.hwnd,0x0112,0xf000|direction,packed)
276
+ return 'break'
277
+
278
+ def work_area(self):
279
+ info=self.MonitorInfo();info.cbSize=self.c.sizeof(info)
280
+ monitor=self.u.MonitorFromWindow(self.hwnd,2)
281
+ return info if self.u.GetMonitorInfoW(monitor,self.c.byref(info)) else None
282
+
283
+ def window_proc(self,hwnd,message,wparam,lparam):
284
+ try:
285
+ if message==0x0231: # WM_ENTERSIZEMOVE
286
+ self._native_moving=True;self._system_move_pending=False
287
+ self.system_move_count+=1
288
+ elif message==0x0232: # WM_EXITSIZEMOVE
289
+ self._native_moving=False;self._system_move_pending=False
290
+ if (message in (0x00A1,0x00A3) and (wparam==2 or 10<=wparam<=17)) or (message==0x0112 and wparam&0xfff0 in (0xf000,0xf010)):
291
+ # Use Windows' caption/move handler, not Tk's translated mouse
292
+ # bindings. This enables native Snap without synchronous Tcl
293
+ # callback -> SendMessage -> Tcl callback recursion.
294
+ result=self.u.DefWindowProcW(hwnd,message,wparam,lparam)
295
+ self._system_move_pending=False
296
+ return result
297
+ if message==0x0085: # WM_NCPAINT: no OS caption to paint, including inactive popups.
298
+ return 0
299
+ if message==0x0086: # WM_NCACTIVATE: retain client chrome when focus leaves the window.
300
+ return 1
301
+ if message==0x0083 and wparam: # WM_NCCALCSIZE: the toolbar owns the top client area.
302
+ if self.u.IsZoomed(hwnd):
303
+ info=self.work_area()
304
+ if info:
305
+ rect=self.c.cast(lparam,self.c.POINTER(self.w.RECT)).contents
306
+ rect.left,rect.top,rect.right,rect.bottom=info.rcWork.left,info.rcWork.top,info.rcWork.right,info.rcWork.bottom
307
+ return 0
308
+ if message==0x0112 and wparam&0xfff0==0xF030 and not all(self._resize_axes):
309
+ return 0 # SC_MAXIMIZE: a resizable(False) window never maximizes
310
+ if message==0x0024: # WM_GETMINMAXINFO, including multi-monitor taskbar bounds.
311
+ result=self.u.CallWindowProcW(self._old_proc,hwnd,message,wparam,lparam)
312
+ limits=self.c.cast(lparam,self.c.POINTER(self.MinMaxInfo)).contents
313
+ # Tk locks a fixed axis to client + 2 x frame, but the frame is
314
+ # now part of the client. Windows enforced that on every move
315
+ # (+22 px per drag) and from transient minimize/restore sizes.
316
+ # Lock fixed axes to the stable requested size instead.
317
+ if self._fixed_size is not None:
318
+ if not self._resize_axes[0]:
319
+ limits.ptMinTrackSize.x=limits.ptMaxTrackSize.x=limits.ptMaxSize.x=self._fixed_size[0]
320
+ if not self._resize_axes[1]:
321
+ limits.ptMinTrackSize.y=limits.ptMaxTrackSize.y=limits.ptMaxSize.y=self._fixed_size[1]
322
+ info=self.work_area()
323
+ if info and all(self._resize_axes):
324
+ limits.ptMaxPosition.x=info.rcWork.left-info.rcMonitor.left
325
+ limits.ptMaxPosition.y=info.rcWork.top-info.rcMonitor.top
326
+ limits.ptMaxSize.x=info.rcWork.right-info.rcWork.left
327
+ limits.ptMaxSize.y=info.rcWork.bottom-info.rcWork.top
328
+ return result
329
+ if message==0x0084: # WM_NCHITTEST, all pixel calculations; no Tk re-entry.
330
+ x=self.c.c_short(lparam&0xffff).value;y=self.c.c_short((lparam>>16)&0xffff).value
331
+ rect=self.w.RECT();self.u.GetWindowRect(hwnd,self.c.byref(rect))
332
+ edge=getattr(self,'resize_margin',7)
333
+ left=x<rect.left+edge and self._resize_axes[0];right=x>=rect.right-edge and self._resize_axes[0]
334
+ top=y<rect.top+edge and self._resize_axes[1];bottom=y>=rect.bottom-edge and self._resize_axes[1]
335
+ if not self.u.IsZoomed(hwnd):
336
+ if top and left:return 13
337
+ if top and right:return 14
338
+ if bottom and left:return 16
339
+ if bottom and right:return 17
340
+ if left:return 10
341
+ if right:return 11
342
+ if top:return 12
343
+ if bottom:return 15
344
+ contains=lambda r:r[0]<=x<r[2] and r[1]<=y<r[3]
345
+ if any(contains(r) for r in self._excluded):return 1
346
+ if any(contains(r) for r in self._drag_rects):return 2
347
+ except Exception:
348
+ # A platform-specific edge case must not crash the window procedure.
349
+ pass
350
+ return self.u.CallWindowProcW(self._old_proc,hwnd,message,wparam,lparam)
351
+
352
+ def schedule_cache(self,event=None):
353
+ if event is not None and event.widget is not self.root:return
354
+ if self._cache_job is None:self._cache_job=self.root.after_idle(self.refresh_cache)
355
+
356
+ def refresh_cache(self):
357
+ self._cache_job=None
358
+ if not self.attached:return
359
+ # Tk destroys and recreates its wrapper HWND after wm resizable /
360
+ # transient / attributes changes. Follow it, or the new wrapper shows
361
+ # the native caption again and every Win32 call targets a dead handle.
362
+ wrapper=self.u.GetParent(self.root.winfo_id())
363
+ if wrapper and wrapper!=self.hwnd:
364
+ if self.u.IsWindow(self.hwnd):self.set_long(self.hwnd,-4,self.c.c_void_p(self._old_proc))
365
+ self.attached=False
366
+ self.attach()
367
+ return
368
+ self.sync_capabilities()
369
+ self.resize_margin=max(5,round(self.root.winfo_fpixels('1i')/96*5))
370
+ self.layout_resize_handles()
371
+ def rect(widget):
372
+ x,y=widget.winfo_rootx(),widget.winfo_rooty()
373
+ return x,y,x+widget.winfo_width(),y+widget.winfo_height()
374
+ self._drag_rects=[rect(widget) for widget in self.drag_widgets if widget.winfo_exists()]
375
+ self._excluded=[rect(widget) for widget in self.toolbar.winfo_children()
376
+ if widget not in self.drag_widgets and widget.winfo_ismapped()]
377
+ self._excluded.extend(rect(widget) for widget in self.extra_exclusions if widget.winfo_exists() and widget.winfo_ismapped())
378
+ self.paint_control('maximize',False)
379
+
380
+ def paint_control(self,key,hover=False):
381
+ button=self.buttons.get(key)
382
+ if button is None or not button.winfo_exists():return
383
+ pinned=key=='pin' and self.pinned
384
+ disabled=self.buttons[key].instate(['disabled'])
385
+ hover=hover and not disabled
386
+ background=('#FFE8E8' if key=='close' else PALETTE['hover']) if hover else PALETTE['selected'] if pinned else PALETTE['bg']
387
+ color=PALETTE['disabled'] if disabled else '#DD3333' if key=='close' and hover else PALETTE['blue'] if pinned else PALETTE['muted']
388
+ symbol='restore' if key=='maximize' and self.attached and self.u.IsZoomed(self.hwnd) else key
389
+ signature=(symbol,color,background,pinned)
390
+ if self._drawn_controls.get(key)==signature:return
391
+ self._drawn_controls[key]=signature
392
+ self.buttons[key].configure(image=icon(self.root,symbol,self.icon_size,color=color,background=background),
393
+ style='Close.TButton' if key=='close' else 'CardIcon.TButton' if pinned else 'Quiet.TButton')
394
+
395
+ def toggle_pin(self):
396
+ self.pinned=not self.pinned
397
+ self.root.attributes('-topmost',self.pinned)
398
+ self.paint_control('pin')
399
+
400
+ def drag(self,event):
401
+ if not self.attached:return
402
+ if self._native_moving or self._system_move_pending:return 'break'
403
+ self._system_move_pending=True
404
+ self.u.ReleaseCapture()
405
+ point=((int(event.y_root)&0xffff)<<16)|(int(event.x_root)&0xffff)
406
+ # Child HWND fallback: post a system command and return to Tk first.
407
+ # Never synchronously send WM_NCLBUTTONDOWN from a Tk mouse callback.
408
+ if not self.u.PostMessageW(self.hwnd,0x0112,0xF012,point):self._system_move_pending=False
409
+ return 'break'
410
+
411
+ def drag_motion(self,event):
412
+ if self._drag_anchor is None:return
413
+ x,y,left,top=self._drag_anchor
414
+ self._drag_target=(left+event.x_root-x,top+event.y_root-y)
415
+ if self._drag_job is None:self._drag_job=self.root.after(12,self.flush_drag)
416
+ return 'break'
417
+
418
+ def flush_drag(self):
419
+ self._drag_job=None
420
+ if self._drag_target is not None and self.attached:
421
+ x,y=self._drag_target;self._drag_target=None
422
+ self.u.SetWindowPos(self.hwnd,None,round(x),round(y),0,0,0x0001|0x0004|0x0010)
423
+
424
+ def end_drag(self,event=None):
425
+ if self._drag_job is not None:
426
+ self.root.after_cancel(self._drag_job);self._drag_job=None
427
+ self.flush_drag()
428
+ self._drag_anchor=None
429
+ if self._drag_widget is not None:
430
+ try:self._drag_widget.grab_release()
431
+ except tk.TclError:pass
432
+ self._drag_widget=None
433
+ return 'break'
434
+
435
+ def cancel_drag(self,event=None):
436
+ if self._native_moving or self._system_move_pending:
437
+ self.u.PostMessageW(self.hwnd,0x001F,0,0)
438
+ self._system_move_pending=False
439
+ return 'break'
440
+ if self._drag_anchor is None and self._drag_job is None:return None
441
+ self._drag_target=None
442
+ return self.end_drag(event)
443
+
444
+ def double_click(self,event):
445
+ self.end_drag()
446
+ self.toggle_maximize();return 'break'
447
+
448
+ def toggle_maximize(self):
449
+ # Double-click and the maximize button do nothing for a fixed window.
450
+ if not all(self.root.resizable()) and self.root.state()!='zoomed':return
451
+ self.begin_update()
452
+ try:
453
+ self.root.state('normal' if self.root.state()=='zoomed' else 'zoomed')
454
+ self.root.update_idletasks()
455
+ finally:self.end_update()
456
+ self.schedule_cache()
457
+
458
+ def begin_update(self):
459
+ if self.attached:
460
+ if self._batch_depth==0:self.u.SendMessageW(self.hwnd,0x000B,0,0)
461
+ self._batch_depth+=1
462
+
463
+ def end_update(self):
464
+ if self.attached and self._batch_depth:
465
+ self._batch_depth-=1
466
+ if self._batch_depth==0:
467
+ self.u.SendMessageW(self.hwnd,0x000B,1,0)
468
+ self.u.RedrawWindow(self.hwnd,None,None,0x0001|0x0080|0x0100|0x0400)
469
+
470
+ def minimize(self):self.root.iconify()
471
+
472
+ def close(self):
473
+ self.detach()
474
+ self.close_action()
475
+
476
+ def detach(self):
477
+ if sys.platform!='win32':return
478
+ self.cancel_drag()
479
+ for handle in self.resize_handles.values():
480
+ if handle.winfo_exists():handle.destroy()
481
+ self.resize_handles.clear()
482
+ while self._batch_depth:self.end_update()
483
+ for job in (self._cache_job,getattr(self,'_attach_job',None),self._size_job):
484
+ if job is not None:
485
+ try:self.root.after_cancel(job)
486
+ except tk.TclError:pass
487
+ if self.attached and self._old_proc:
488
+ self.set_long(self.hwnd,-4,self.c.c_void_p(self._old_proc))
489
+ self.attached=False
490
+ for name,(owned,original) in self._geometry_originals.items():
491
+ if getattr(self.root,name,None) is self._geometry_bridge:
492
+ if owned:setattr(self.root,name,original)
493
+ else:self.root.__dict__.pop(name,None)
494
+ self._geometry_originals.clear()
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: tkwebui
3
+ Version: 0.3.2
4
+ Summary: Modern Web-style themes and widgets for tkinter and ttk
5
+ Project-URL: Homepage, https://github.com/bvb6417/tkwebui
6
+ Project-URL: Documentation, https://github.com/bvb6417/tkwebui#readme
7
+ Project-URL: Issues, https://github.com/bvb6417/tkwebui/issues
8
+ Project-URL: Changelog, https://github.com/bvb6417/tkwebui/blob/main/CHANGELOG.md
9
+ Author: bvb6417 and TkWebUI contributors
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: gui,theme,tkinter,ttk,widgets
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: User Interfaces
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
19
+ # TkWebUI
20
+
21
+ Modern Web-style themes and widgets for Python's `tkinter` / `ttk`.
22
+ 给 Python 自带的 Tkinter / ttk 换上现代 Web 应用风格的外观,并补齐常用控件。
23
+
24
+ ## Installation / 安装
25
+
26
+ ```bash
27
+ python -m pip install tkwebui
28
+ ```
29
+
30
+ Requires Python 3.8+ with Tkinter available. Developed and tested on Windows 11
31
+ with Python 3.11. Other platforms use pure-Python fallbacks and have not been
32
+ fully tested. On Linux, install your distribution's Tkinter package (often
33
+ `python3-tk`) if needed; Tkinter is not installed by pip.
34
+
35
+ Core widgets use only the Python standard library. No external theme images
36
+ or browser engine are required. `ImageViewer` can optionally use Pillow for
37
+ JPEG, WebP and additional image formats.
38
+
39
+ ## Quick start / 快速开始
40
+
41
+ ```python
42
+ import tkinter as tk
43
+ from tkinter import ttk
44
+ import tkwebui as ui
45
+
46
+ ui.enable_dpi_awareness()
47
+ root = tk.Tk()
48
+ root.title("TkWebUI example")
49
+ ui.apply_theme(root)
50
+ ttk.Entry(root).pack(padx=24, pady=12)
51
+ ttk.Button(root, text="Save", style="Primary.TButton").pack(padx=24, pady=12)
52
+ root.mainloop()
53
+ ```
54
+
55
+ Call `enable_dpi_awareness()` before creating the Tk root, and `apply_theme(root)`
56
+ before creating widgets.
57
+
58
+ The library provides menus, dropdowns, tooltips, dialogs, cards, navigation,
59
+ tables, an editable spreadsheet, charts, date pickers and form controls.
60
+ Version 0.3.2 fixes dropdown and tooltip positioning on secondary monitors,
61
+ including negative virtual-desktop coordinates.
62
+
63
+ ## Demo and documentation
64
+
65
+ Download the [GitHub source](https://github.com/bvb6417/tkwebui) or source
66
+ distribution, then run `python demo.py` to open the 12-page widget gallery.
67
+ The wheel installs the library; the demo stays in the source distribution.
68
+
69
+ - [Full documentation](https://github.com/bvb6417/tkwebui#readme)
70
+ - [Examples](https://github.com/bvb6417/tkwebui/tree/main/examples)
71
+ - [Changelog](https://github.com/bvb6417/tkwebui/blob/main/CHANGELOG.md)
72
+ - [Issue tracker](https://github.com/bvb6417/tkwebui/issues)
73
+ - [TkWin11](https://github.com/bvb6417/tkwin11), a related Windows 11 Fluent-style library
74
+
75
+ Do not mix TkWebUI and TkWin11 in the same Tk interpreter. MIT license.
@@ -0,0 +1,20 @@
1
+ tkwebui/__init__.py,sha256=q41WDkBmCXWttdpBHRMn51u0vMPs6QVFNArAkMn4UeE,2810
2
+ tkwebui/charts.py,sha256=3peHxjOLt1F-QjMWb4cu0llB_NocoEaNp-2IGYwyfa8,28611
3
+ tkwebui/core.py,sha256=8spQkEGc6_VFL2xxdCotYLofBxwKhTf0Nr7XoeU_mHc,66334
4
+ tkwebui/dataview.py,sha256=mrXazs4ywvFCZx_iyoV3PyR8rLwTQ-3K_jPZrxE35F4,13680
5
+ tkwebui/extras.py,sha256=mvJObOn1AMImJbvPERcAf3YAtDlOrH--iTVW5Gay7LE,31618
6
+ tkwebui/feedback.py,sha256=5PiBXhN6zd91oXUXLXHptcHMK53gpJxK1V0DOf7Fs3o,11563
7
+ tkwebui/forms.py,sha256=2UCBdWrycM92rF6rvvFPFHFIY-StIHfHiqfcMUDRMfs,20409
8
+ tkwebui/media.py,sha256=8v165LY95--d8-bhF4HB_P7RSqa8z6q48Af033r216Q,21916
9
+ tkwebui/placeholder.py,sha256=7SKimLOiqyHrFNsfrJIjv9FZiokUda9km9njeGDPHQk,8394
10
+ tkwebui/popups.py,sha256=dfsSWTjBM1r28__50mv6tzxQI5D6RfneJfWs8eJE9H4,9805
11
+ tkwebui/render.py,sha256=-VmgjbA6DWpaUM95v_fvm-nW-jY5rGwyzm3NIBZO1Ik,11228
12
+ tkwebui/scrolling.py,sha256=Ly7B5krLY_7GUGxFE7NQGc_4Blya-ALl1-nz5jCAJr8,2866
13
+ tkwebui/table.py,sha256=nntsbcTqeBdiNVV8j2lv5ddR92JscZ7JR2dDLtvpfqs,21199
14
+ tkwebui/tcl_bridge.py,sha256=SPJgrme9L4HhBIhwqYs6DDwnsdXW8PN0p7NNathm26o,842
15
+ tkwebui/widgets.py,sha256=cPehsbiW0fGR19RfMbsamkpcucGg5p_vHzPRiIA6qOo,15888
16
+ tkwebui/window_chrome.py,sha256=tGJ8irRplWaUwF9XuV1ECXG6SqN8IFj8ZCfKmIYmcAg,28259
17
+ tkwebui-0.3.2.dist-info/METADATA,sha256=n9Iu3Tf5eAtGfDALqB0BurDgtKTKZ3ISKbZsfkQak44,2886
18
+ tkwebui-0.3.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
19
+ tkwebui-0.3.2.dist-info/licenses/LICENSE,sha256=5_wVTaLqvTY8gsztsNJoCkzpLrSp2RveAbtsrvmQDcY,1098
20
+ tkwebui-0.3.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TkWebUI contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.