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/window_chrome.py
ADDED
|
@@ -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,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.
|