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/popups.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Rounded context menus and ComboBox popups, retaining original widget APIs."""
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import ttk, font as tkfont
|
|
4
|
+
from .core import Card, ScrollArea, Modal, PALETTE
|
|
5
|
+
from .tcl_bridge import unbind
|
|
6
|
+
|
|
7
|
+
class ChoicePopup(tk.Toplevel):
|
|
8
|
+
def __init__(self,owner,items,x,y,*,width=180,selected=0,above=None):
|
|
9
|
+
self.owner=owner
|
|
10
|
+
self.host=owner.winfo_toplevel()
|
|
11
|
+
existing=getattr(self.host,'_choice_popup',None)
|
|
12
|
+
if existing is not None:existing.close()
|
|
13
|
+
self.previous_focus=owner.focus_get()
|
|
14
|
+
self.previous_grab=owner.grab_current()
|
|
15
|
+
super().__init__(self.host)
|
|
16
|
+
self.withdraw();self.overrideredirect(True);self.transient(self.host)
|
|
17
|
+
self.configure(background=PALETTE['bg'])
|
|
18
|
+
self.scale=owner.winfo_fpixels('1i')/96
|
|
19
|
+
self.corner_radius=5
|
|
20
|
+
self._closed=False;self._region_size=None;self._focus_job=None
|
|
21
|
+
self.host._choice_popup=self
|
|
22
|
+
self.items=items;self.rows=[];self.cursor=None
|
|
23
|
+
shell=Card(self,padding=6,border_color='#CCD1DC')
|
|
24
|
+
# Keep the one-pixel outline inside the native rounded window region.
|
|
25
|
+
shell.pack(fill='both',expand=True,padx=1,pady=1)
|
|
26
|
+
self.area=ScrollArea(shell);self.area.pack(fill='both',expand=True)
|
|
27
|
+
font=tkfont.nametofont('TkDefaultFont')
|
|
28
|
+
width=max(width,max((font.measure(str(item.get('label','')))+round(68*self.scale) for item in items),default=width))
|
|
29
|
+
height=round(22*self.scale)
|
|
30
|
+
for index,item in enumerate(items):
|
|
31
|
+
if item.get('separator'):
|
|
32
|
+
ttk.Separator(self.area.body).pack(fill='x',pady=round(5*self.scale))
|
|
33
|
+
self.rows.append(None);height+=round(11*self.scale);continue
|
|
34
|
+
text=('✓ ' if item.get('checked') else ' ')+str(item['label'])
|
|
35
|
+
button=ttk.Button(self.area.body,text=text,style='Popup.Quiet.TButton',command=lambda i=index:self.choose(i),takefocus=False)
|
|
36
|
+
if item.get('disabled'):button.state(['disabled'])
|
|
37
|
+
if item.get('checked'):button.configure(style='Checked.Popup.Quiet.TButton')
|
|
38
|
+
button.pack(fill='x',pady=1)
|
|
39
|
+
button.bind('<Enter>',lambda _,i=index:self.highlight(i))
|
|
40
|
+
self.rows.append(button);height+=button.winfo_reqheight()+2
|
|
41
|
+
from .extras import MenuPopup
|
|
42
|
+
# The owner may live on a different monitor from the root window.
|
|
43
|
+
left,top,right,bottom=MenuPopup.work_area(owner.winfo_rootx(),owner.winfo_rooty())
|
|
44
|
+
width=min(width,right-left-16)
|
|
45
|
+
height=min(height,round(430*self.scale),bottom-top-24)
|
|
46
|
+
if above is not None and y+height>bottom-8:y=above-height-4
|
|
47
|
+
x=max(left+8,min(x,right-width-8));y=max(top+8,min(y,bottom-height-8))
|
|
48
|
+
# Keep negative virtual-desktop coordinates relative to the origin,
|
|
49
|
+
# rather than asking Tk to offset from the right/bottom screen edge.
|
|
50
|
+
self.geometry(f'{int(width)}x{int(height)}+{int(x)}+{int(y)}')
|
|
51
|
+
self.bind('<Escape>',lambda _:self.close())
|
|
52
|
+
self.bind('<Up>',lambda _:self.move(-1));self.bind('<Down>',lambda _:self.move(1))
|
|
53
|
+
self.bind('<Home>',lambda _:self.highlight(self.enabled()[0]) if self.enabled() else 'break')
|
|
54
|
+
self.bind('<End>',lambda _:self.highlight(self.enabled()[-1]) if self.enabled() else 'break')
|
|
55
|
+
self.bind('<Return>',lambda _:self.choose(self.cursor))
|
|
56
|
+
self.bind('<Tab>',lambda _:self.close())
|
|
57
|
+
self.bind('<ButtonPress-1>',self.outside,add='+')
|
|
58
|
+
self.bind('<FocusOut>',self.defer_focus_check,add='+')
|
|
59
|
+
self._host_binding=self.host.bind('<Configure>',self.host_configured,add='+')
|
|
60
|
+
self.attributes('-topmost',True)
|
|
61
|
+
self.deiconify();self.update_idletasks()
|
|
62
|
+
Modal.round_corners(self)
|
|
63
|
+
self.lift();self.focus_force();self.grab_set()
|
|
64
|
+
self.highlight(selected if selected in self.enabled() else next(iter(self.enabled()),None))
|
|
65
|
+
|
|
66
|
+
def enabled(self):return [i for i,row in enumerate(self.rows) if row is not None and not row.instate(['disabled'])]
|
|
67
|
+
def highlight(self,index):
|
|
68
|
+
if index not in self.enabled():return 'break'
|
|
69
|
+
if self.cursor is not None:self.rows[self.cursor].state(['!active'])
|
|
70
|
+
self.cursor=index;row=self.rows[index];row.state(['active'])
|
|
71
|
+
self.update_idletasks()
|
|
72
|
+
canvas=self.area.canvas;total=max(1,self.area.body.winfo_reqheight())
|
|
73
|
+
first,last=canvas.yview()
|
|
74
|
+
y=row.winfo_y();end=y+row.winfo_height()
|
|
75
|
+
if y<first*total:canvas.yview_moveto(y/total)
|
|
76
|
+
elif end>last*total:canvas.yview_moveto((end-canvas.winfo_height())/total)
|
|
77
|
+
return 'break'
|
|
78
|
+
def move(self,delta):
|
|
79
|
+
enabled=self.enabled()
|
|
80
|
+
if enabled:self.highlight(enabled[(enabled.index(self.cursor)+delta)%len(enabled)] if self.cursor in enabled else enabled[0])
|
|
81
|
+
return 'break'
|
|
82
|
+
def choose(self,index):
|
|
83
|
+
if index not in self.enabled():return 'break'
|
|
84
|
+
action=self.items[index].get('command')
|
|
85
|
+
self.close()
|
|
86
|
+
if action:action()
|
|
87
|
+
return 'break'
|
|
88
|
+
def outside(self,event):
|
|
89
|
+
if not (self.winfo_rootx()<=event.x_root<self.winfo_rootx()+self.winfo_width() and self.winfo_rooty()<=event.y_root<self.winfo_rooty()+self.winfo_height()):self.close()
|
|
90
|
+
def host_configured(self,event):
|
|
91
|
+
if event.widget is self.host:self.close()
|
|
92
|
+
def defer_focus_check(self,event):
|
|
93
|
+
if not self._closed and self._focus_job is None:self._focus_job=self.after(100,self.check_focus)
|
|
94
|
+
def check_focus(self):
|
|
95
|
+
self._focus_job=None
|
|
96
|
+
if self._closed:return
|
|
97
|
+
focus=self.focus_get()
|
|
98
|
+
if focus is None or focus.winfo_toplevel() is not self:self.close(restore_focus=False)
|
|
99
|
+
def close(self,*,restore_focus=True):
|
|
100
|
+
if self._closed:return 'break'
|
|
101
|
+
self._closed=True
|
|
102
|
+
if self._focus_job is not None:self.after_cancel(self._focus_job)
|
|
103
|
+
unbind(self.host,'<Configure>',self._host_binding)
|
|
104
|
+
if getattr(self.host,'_choice_popup',None) is self:self.host._choice_popup=None
|
|
105
|
+
self.grab_release();super().destroy()
|
|
106
|
+
if self.previous_grab is not None and self.previous_grab.winfo_exists():self.previous_grab.grab_set()
|
|
107
|
+
if restore_focus:
|
|
108
|
+
target=self.previous_focus
|
|
109
|
+
if target is None or not target.winfo_exists() or not target.winfo_viewable():target=self.host
|
|
110
|
+
if target.winfo_exists():target.focus_force()
|
|
111
|
+
return 'break'
|
|
112
|
+
def destroy(self):self.close()
|
|
113
|
+
|
|
114
|
+
class ThemedCombobox(ttk.Combobox):
|
|
115
|
+
"""The native entry/current/values API with a replacement choice window."""
|
|
116
|
+
def __init__(self,master=None,**kw):
|
|
117
|
+
super().__init__(master,**kw)
|
|
118
|
+
self.popup=None
|
|
119
|
+
self.bind('<ButtonPress-1>',self.click,add='+')
|
|
120
|
+
self.bind('<Down>',self.post_from_event)
|
|
121
|
+
self.bind('<Alt-Down>',self.post_from_event)
|
|
122
|
+
self.bind('<F4>',self.post_from_event)
|
|
123
|
+
self.bind('<Destroy>',self.cleanup,add='+')
|
|
124
|
+
def click(self,event):
|
|
125
|
+
if self.instate(['disabled']):return 'break'
|
|
126
|
+
if self.instate(['readonly']) or 'downarrow' in self.identify(event.x,event.y):return self.post_from_event()
|
|
127
|
+
def post_from_event(self,event=None):
|
|
128
|
+
try:self.open_popup()
|
|
129
|
+
except Exception:
|
|
130
|
+
import sys
|
|
131
|
+
self._root().report_callback_exception(*sys.exc_info())
|
|
132
|
+
# Never run the native TCombobox post binding, even on a failed custom
|
|
133
|
+
# callback. Otherwise a second, unthemed popup opens underneath it.
|
|
134
|
+
return 'break'
|
|
135
|
+
def open_popup(self,event=None):
|
|
136
|
+
if self.instate(['disabled']):return 'break'
|
|
137
|
+
if self.popup is not None and not self.popup._closed:self.popup.close();return 'break'
|
|
138
|
+
# cget may return a truthy Tcl_Obj whose string value is empty after
|
|
139
|
+
# the first selection. Normalize before testing/evaluating the script.
|
|
140
|
+
command=str(self.cget('postcommand')).strip()
|
|
141
|
+
if command:self.tk.call('uplevel','#0',command)
|
|
142
|
+
values=self.cget('values')
|
|
143
|
+
if not values:return 'break'
|
|
144
|
+
current=self.current()
|
|
145
|
+
items=[dict(label=value,checked=index==current,command=lambda i=index:self.choose(i)) for index,value in enumerate(values)]
|
|
146
|
+
self.popup=ChoicePopup(self,items,self.winfo_rootx(),self.winfo_rooty()+self.winfo_height()+4,
|
|
147
|
+
width=self.winfo_width(),selected=max(0,current),above=self.winfo_rooty())
|
|
148
|
+
return 'break'
|
|
149
|
+
def choose(self,index):
|
|
150
|
+
self.current(index)
|
|
151
|
+
self.event_generate('<<ComboboxSelected>>',when='tail')
|
|
152
|
+
def cleanup(self,event):
|
|
153
|
+
if event.widget is self and self.popup is not None and not self.popup._closed:self.popup.close()
|
|
154
|
+
|
|
155
|
+
class ThemedMenu(tk.Menu):
|
|
156
|
+
"""Keep Tcl's menu model/invoke/checkbutton semantics; replace its popup."""
|
|
157
|
+
def __init__(self,*args,**kw):
|
|
158
|
+
super().__init__(*args,**kw);self.popup=None
|
|
159
|
+
def tk_popup(self,x,y,entry='',*,owner=None,min_width=190):
|
|
160
|
+
from .extras import MenuPopup
|
|
161
|
+
if self.index('end') is None and not str(self.cget('postcommand')).strip():return
|
|
162
|
+
if self.popup is not None and not self.popup._closed:self.popup.close()
|
|
163
|
+
selected=None if entry=='' else self.index(entry)
|
|
164
|
+
self.popup=MenuPopup(owner or self.master,self,x,y,min_width=min_width,selected=selected)
|
|
165
|
+
post=tk_popup
|
|
166
|
+
def unpost(self):
|
|
167
|
+
if self.popup is not None:self.popup.close()
|
|
168
|
+
def grab_release(self):
|
|
169
|
+
# Legacy callers use finally: menu.grab_release(). The custom popup
|
|
170
|
+
# owns its own grab until selection/dismissal, and restores it itself.
|
|
171
|
+
pass
|
|
172
|
+
def destroy(self):
|
|
173
|
+
self.unpost();super().destroy()
|
tkwebui/render.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Antialiased in-memory shapes for Tk PhotoImage. No third-party dependencies.
|
|
2
|
+
|
|
3
|
+
Windows uses the OS GDI+ renderer. Other platforms use a pixel-coverage fallback.
|
|
4
|
+
Text, events, axes and hit testing stay in Tk; no browser or external image files.
|
|
5
|
+
"""
|
|
6
|
+
import atexit
|
|
7
|
+
import ctypes
|
|
8
|
+
import math
|
|
9
|
+
import struct
|
|
10
|
+
import sys
|
|
11
|
+
import tkinter as tk
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
_gdip=None
|
|
15
|
+
_token=None
|
|
16
|
+
|
|
17
|
+
class _Point(ctypes.Structure):
|
|
18
|
+
_fields_=[('x',ctypes.c_float),('y',ctypes.c_float)]
|
|
19
|
+
|
|
20
|
+
def _windows_api():
|
|
21
|
+
global _gdip,_token
|
|
22
|
+
if _gdip is not None:return _gdip
|
|
23
|
+
dll=ctypes.WinDLL('gdiplus')
|
|
24
|
+
pointer=ctypes.c_void_p;integer=ctypes.c_int;real=ctypes.c_float
|
|
25
|
+
class Startup(ctypes.Structure):
|
|
26
|
+
_fields_=[('version',ctypes.c_uint32),('callback',pointer),('background',integer),('codecs',integer)]
|
|
27
|
+
signatures={
|
|
28
|
+
'GdiplusStartup':([ctypes.POINTER(ctypes.c_size_t),ctypes.POINTER(Startup),pointer],integer),
|
|
29
|
+
'GdiplusShutdown':([ctypes.c_size_t],None),
|
|
30
|
+
'GdipCreateBitmapFromScan0':([integer,integer,integer,integer,pointer,ctypes.POINTER(pointer)],integer),
|
|
31
|
+
'GdipGetImageGraphicsContext':([pointer,ctypes.POINTER(pointer)],integer),
|
|
32
|
+
'GdipSetSmoothingMode':([pointer,integer],integer),
|
|
33
|
+
'GdipSetPixelOffsetMode':([pointer,integer],integer),
|
|
34
|
+
'GdipCreateSolidFill':([ctypes.c_uint32,ctypes.POINTER(pointer)],integer),
|
|
35
|
+
'GdipCreatePen1':([ctypes.c_uint32,real,integer,ctypes.POINTER(pointer)],integer),
|
|
36
|
+
'GdipSetPenStartCap':([pointer,integer],integer),
|
|
37
|
+
'GdipSetPenEndCap':([pointer,integer],integer),
|
|
38
|
+
'GdipSetPenLineJoin':([pointer,integer],integer),
|
|
39
|
+
'GdipDrawLines':([pointer,pointer,ctypes.POINTER(_Point),integer],integer),
|
|
40
|
+
'GdipDrawEllipse':([pointer,pointer,real,real,real,real],integer),
|
|
41
|
+
'GdipFillEllipse':([pointer,pointer,real,real,real,real],integer),
|
|
42
|
+
'GdipDrawArc':([pointer,pointer,real,real,real,real,real,real],integer),
|
|
43
|
+
'GdipFillPie':([pointer,pointer,real,real,real,real,real,real],integer),
|
|
44
|
+
'GdipFillRectangle':([pointer,pointer,real,real,real,real],integer),
|
|
45
|
+
'GdipFlush':([pointer,integer],integer),
|
|
46
|
+
'GdipDeleteBrush':([pointer],integer),'GdipDeletePen':([pointer],integer),
|
|
47
|
+
'GdipDeleteGraphics':([pointer],integer),'GdipDisposeImage':([pointer],integer),
|
|
48
|
+
}
|
|
49
|
+
for name,(args,result) in signatures.items():
|
|
50
|
+
fn=getattr(dll,name);fn.argtypes=args;fn.restype=result
|
|
51
|
+
token=ctypes.c_size_t()
|
|
52
|
+
if dll.GdiplusStartup(ctypes.byref(token),ctypes.byref(Startup(1,None,0,0)),None):raise OSError('GDI+ startup failed')
|
|
53
|
+
_token=token;_gdip=dll;atexit.register(dll.GdiplusShutdown,token)
|
|
54
|
+
return dll
|
|
55
|
+
|
|
56
|
+
def _png(width,height,rgba):
|
|
57
|
+
def chunk(name,data):return struct.pack('>I',len(data))+name+data+struct.pack('>I',zlib.crc32(name+data)&0xffffffff)
|
|
58
|
+
stride=width*4
|
|
59
|
+
raw=b''.join(b'\0'+rgba[y*stride:(y+1)*stride] for y in range(height))
|
|
60
|
+
return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',width,height,8,6,0,0,0))+chunk(b'IDAT',zlib.compress(raw,2))+chunk(b'IEND',b'')
|
|
61
|
+
|
|
62
|
+
class SmoothImage:
|
|
63
|
+
"""A short-lived drawing surface. Keep the returned PhotoImage alive in Tk."""
|
|
64
|
+
def __init__(self,master,width,height,*,software=False):
|
|
65
|
+
self.master=master;self.width=max(1,int(math.ceil(width)));self.height=max(1,int(math.ceil(height)))
|
|
66
|
+
self.api=None;self.bitmap=ctypes.c_void_p();self.graphics=ctypes.c_void_p()
|
|
67
|
+
self.pens={};self.brushes={};self.colors={};self.closed=False
|
|
68
|
+
if sys.platform=='win32' and not software:
|
|
69
|
+
try:
|
|
70
|
+
self.api=_windows_api()
|
|
71
|
+
self.buffer=(ctypes.c_ubyte*(self.width*self.height*4))()
|
|
72
|
+
self._check(self.api.GdipCreateBitmapFromScan0(self.width,self.height,self.width*4,0x26200A,self.buffer,ctypes.byref(self.bitmap)))
|
|
73
|
+
self._check(self.api.GdipGetImageGraphicsContext(self.bitmap,ctypes.byref(self.graphics)))
|
|
74
|
+
self._check(self.api.GdipSetSmoothingMode(self.graphics,5)) # 8 x 8 edge coverage
|
|
75
|
+
self._check(self.api.GdipSetPixelOffsetMode(self.graphics,4))
|
|
76
|
+
except (OSError,AttributeError):
|
|
77
|
+
if self.graphics:self.api.GdipDeleteGraphics(self.graphics)
|
|
78
|
+
if self.bitmap:self.api.GdipDisposeImage(self.bitmap)
|
|
79
|
+
self.api=None
|
|
80
|
+
if self.api is None:self.buffer=bytearray(self.width*self.height*4)
|
|
81
|
+
self.backend='gdi+' if self.api else 'coverage'
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _check(status):
|
|
84
|
+
if status:raise OSError(f'GDI+ status {status}')
|
|
85
|
+
def _color(self,color):
|
|
86
|
+
if color not in self.colors:
|
|
87
|
+
self.colors[color]=tuple(round(c/257) for c in self.master.winfo_rgb(color))
|
|
88
|
+
return self.colors[color]
|
|
89
|
+
def _argb(self,color):
|
|
90
|
+
r,g,b=self._color(color);return 0xff000000|(r<<16)|(g<<8)|b
|
|
91
|
+
def _pen(self,color,width):
|
|
92
|
+
key=(color,width)
|
|
93
|
+
if key not in self.pens:
|
|
94
|
+
pen=ctypes.c_void_p();self._check(self.api.GdipCreatePen1(self._argb(color),width,2,ctypes.byref(pen)))
|
|
95
|
+
self.api.GdipSetPenStartCap(pen,2);self.api.GdipSetPenEndCap(pen,2);self.api.GdipSetPenLineJoin(pen,2)
|
|
96
|
+
self.pens[key]=pen
|
|
97
|
+
return self.pens[key]
|
|
98
|
+
def _brush(self,color):
|
|
99
|
+
if color not in self.brushes:
|
|
100
|
+
brush=ctypes.c_void_p();self._check(self.api.GdipCreateSolidFill(self._argb(color),ctypes.byref(brush)));self.brushes[color]=brush
|
|
101
|
+
return self.brushes[color]
|
|
102
|
+
def _blend(self,x,y,rgb,coverage):
|
|
103
|
+
if coverage<=0:return
|
|
104
|
+
coverage=min(1,coverage);i=(y*self.width+x)*4;buf=self.buffer
|
|
105
|
+
if coverage>=1:buf[i:i+4]=bytes((*rgb,255));return
|
|
106
|
+
old=buf[i+3]/255;alpha=coverage+old*(1-coverage)
|
|
107
|
+
if not alpha:return
|
|
108
|
+
for c in range(3):buf[i+c]=round((rgb[c]*coverage+buf[i+c]*old*(1-coverage))/alpha)
|
|
109
|
+
buf[i+3]=round(alpha*255)
|
|
110
|
+
def _paint(self,bounds,color,coverage):
|
|
111
|
+
x1,y1,x2,y2=bounds;rgb=self._color(color)
|
|
112
|
+
for y in range(max(0,math.floor(y1)),min(self.height,math.ceil(y2))):
|
|
113
|
+
for x in range(max(0,math.floor(x1)),min(self.width,math.ceil(x2))):
|
|
114
|
+
self._blend(x,y,rgb,coverage(x+.5,y+.5))
|
|
115
|
+
def line(self,points,color,width=1):
|
|
116
|
+
if len(points)<4:return
|
|
117
|
+
if self.api:
|
|
118
|
+
values=(_Point*(len(points)//2))(*[_Point(points[i],points[i+1]) for i in range(0,len(points),2)])
|
|
119
|
+
self._check(self.api.GdipDrawLines(self.graphics,self._pen(color,width),values,len(values)));return
|
|
120
|
+
radius=width/2
|
|
121
|
+
for i in range(0,len(points)-2,2):
|
|
122
|
+
x1,y1,x2,y2=points[i:i+4];dx=x2-x1;dy=y2-y1;square=dx*dx+dy*dy
|
|
123
|
+
def coverage(x,y):
|
|
124
|
+
t=max(0,min(1,((x-x1)*dx+(y-y1)*dy)/square)) if square else 0
|
|
125
|
+
return max(0,min(1,radius+.5-math.hypot(x-x1-t*dx,y-y1-t*dy)))
|
|
126
|
+
self._paint((min(x1,x2)-radius-1,min(y1,y2)-radius-1,max(x1,x2)+radius+1,max(y1,y2)+radius+1),color,coverage)
|
|
127
|
+
def ellipse(self,bounds,*,fill=None,outline=None,width=1):
|
|
128
|
+
x1,y1,x2,y2=bounds
|
|
129
|
+
if self.api:
|
|
130
|
+
if fill:self._check(self.api.GdipFillEllipse(self.graphics,self._brush(fill),x1,y1,x2-x1,y2-y1))
|
|
131
|
+
if outline:self._check(self.api.GdipDrawEllipse(self.graphics,self._pen(outline,width),x1,y1,x2-x1,y2-y1))
|
|
132
|
+
return
|
|
133
|
+
cx=(x1+x2)/2;cy=(y1+y2)/2;rx=max(.001,(x2-x1)/2);ry=max(.001,(y2-y1)/2);r=min(rx,ry)
|
|
134
|
+
def distance(x,y):return (math.hypot((x-cx)/rx,(y-cy)/ry)-1)*r
|
|
135
|
+
if fill:self._paint((x1-1,y1-1,x2+1,y2+1),fill,lambda x,y:max(0,min(1,.5-distance(x,y))))
|
|
136
|
+
if outline:self._paint((x1-width,y1-width,x2+width,y2+width),outline,lambda x,y:max(0,min(1,width/2+.5-abs(distance(x,y)))))
|
|
137
|
+
def arc(self,bounds,start,sweep,color,width=1):
|
|
138
|
+
if sweep<=0:return
|
|
139
|
+
if sweep>=359.999:self.ellipse(bounds,outline=color,width=width);return
|
|
140
|
+
x1,y1,x2,y2=bounds
|
|
141
|
+
if self.api:
|
|
142
|
+
self._check(self.api.GdipDrawArc(self.graphics,self._pen(color,width),x1,y1,x2-x1,y2-y1,start,sweep));return
|
|
143
|
+
cx=(x1+x2)/2;cy=(y1+y2)/2;radius=(x2-x1)/2
|
|
144
|
+
a=math.radians(start);b=math.radians(start+sweep)
|
|
145
|
+
ends=[(cx+radius*math.cos(angle),cy+radius*math.sin(angle)) for angle in (a,b)]
|
|
146
|
+
def coverage(x,y):
|
|
147
|
+
angle=(math.degrees(math.atan2(y-cy,x-cx))-start)%360
|
|
148
|
+
distance=abs(math.hypot(x-cx,y-cy)-radius) if angle<=sweep else min(math.hypot(x-ex,y-ey) for ex,ey in ends)
|
|
149
|
+
return max(0,min(1,width/2+.5-distance))
|
|
150
|
+
self._paint((x1-width,y1-width,x2+width,y2+width),color,coverage)
|
|
151
|
+
def pie(self,bounds,start,sweep,color):
|
|
152
|
+
if sweep<=0:return
|
|
153
|
+
if sweep>=359.999:self.ellipse(bounds,fill=color);return
|
|
154
|
+
x1,y1,x2,y2=bounds
|
|
155
|
+
if self.api:
|
|
156
|
+
self._check(self.api.GdipFillPie(self.graphics,self._brush(color),x1,y1,x2-x1,y2-y1,start,sweep));return
|
|
157
|
+
cx=(x1+x2)/2;cy=(y1+y2)/2;radius=(x2-x1)/2
|
|
158
|
+
def coverage(x,y):
|
|
159
|
+
hits=0
|
|
160
|
+
for dx,dy in ((-.25,-.25),(.25,-.25),(-.25,.25),(.25,.25)):
|
|
161
|
+
angle=(math.degrees(math.atan2(y+dy-cy,x+dx-cx))-start)%360
|
|
162
|
+
hits+=math.hypot(x+dx-cx,y+dy-cy)<=radius and angle<=sweep
|
|
163
|
+
return hits/4
|
|
164
|
+
self._paint((x1-1,y1-1,x2+1,y2+1),color,coverage)
|
|
165
|
+
def rectangle(self,bounds,color):
|
|
166
|
+
x1,y1,x2,y2=bounds
|
|
167
|
+
if self.api:self._check(self.api.GdipFillRectangle(self.graphics,self._brush(color),x1,y1,x2-x1,y2-y1));return
|
|
168
|
+
self._paint(bounds,color,lambda x,y:1)
|
|
169
|
+
def rgba(self):
|
|
170
|
+
if not self.api:return bytes(self.buffer)
|
|
171
|
+
self._check(self.api.GdipFlush(self.graphics,1))
|
|
172
|
+
bgra=bytes(self.buffer);rgba=bytearray(len(bgra))
|
|
173
|
+
rgba[0::4]=bgra[2::4];rgba[1::4]=bgra[1::4];rgba[2::4]=bgra[0::4];rgba[3::4]=bgra[3::4]
|
|
174
|
+
return bytes(rgba)
|
|
175
|
+
def photo(self):return tk.PhotoImage(master=self.master,data=_png(self.width,self.height,self.rgba()),format='png')
|
|
176
|
+
def close(self):
|
|
177
|
+
if self.closed:return
|
|
178
|
+
self.closed=True
|
|
179
|
+
if self.api:
|
|
180
|
+
for pen in self.pens.values():self.api.GdipDeletePen(pen)
|
|
181
|
+
for brush in self.brushes.values():self.api.GdipDeleteBrush(brush)
|
|
182
|
+
self.api.GdipDeleteGraphics(self.graphics);self.api.GdipDisposeImage(self.bitmap)
|
|
183
|
+
def __enter__(self):return self
|
|
184
|
+
def __exit__(self,*_):self.close()
|
|
185
|
+
|
|
186
|
+
def calendar_icon(master,size,color='#697180'):
|
|
187
|
+
root=master._root();cache=getattr(root,'_web_calendar_icons',None)
|
|
188
|
+
if cache is None:cache=root._web_calendar_icons={}
|
|
189
|
+
key=(size,color)
|
|
190
|
+
if key not in cache:
|
|
191
|
+
scale=size/24
|
|
192
|
+
with SmoothImage(root,size,size) as image:
|
|
193
|
+
def line(coords):image.line([v*scale for v in coords],color,max(1,1.5*scale))
|
|
194
|
+
line([4,6,20,6,20,21,4,21,4,6]);line([4,10,20,10]);line([8,3,8,8]);line([16,3,16,8])
|
|
195
|
+
for y in (13,17):
|
|
196
|
+
for x in (7,11,15):image.rectangle((x*scale,y*scale,(x+2)*scale,(y+2)*scale),color)
|
|
197
|
+
cache[key]=image.photo()
|
|
198
|
+
return cache[key]
|
tkwebui/scrolling.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Scrollbars that occupy layout space only while their viewport overflows."""
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
from tkinter import ttk
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AutoScrollbar(ttk.Scrollbar):
|
|
7
|
+
"""Use with grid() and a widget's x/yscrollcommand, just like ttk.Scrollbar.
|
|
8
|
+
|
|
9
|
+
Starts hidden. Tk's viewport fractions determine visibility after content
|
|
10
|
+
changes, scrolling and resizing. grid_remove preserves the layout options.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, master=None, **kwargs):
|
|
13
|
+
super().__init__(master, **kwargs)
|
|
14
|
+
self._needed = False
|
|
15
|
+
self._laid_out = False
|
|
16
|
+
self._visibility_job = None
|
|
17
|
+
self.bind('<Destroy>', self._cleanup, add='+')
|
|
18
|
+
|
|
19
|
+
def grid(self, *args, **kwargs):
|
|
20
|
+
super().grid(*args, **kwargs)
|
|
21
|
+
self._laid_out = True
|
|
22
|
+
if not self._needed:
|
|
23
|
+
super().grid_remove()
|
|
24
|
+
grid_configure = grid
|
|
25
|
+
|
|
26
|
+
def set(self, first, last):
|
|
27
|
+
super().set(first, last)
|
|
28
|
+
self._needed = float(first) > 1e-7 or float(last) < 1-1e-7
|
|
29
|
+
if self._visibility_job is None:
|
|
30
|
+
self._visibility_job = self.after_idle(self._sync)
|
|
31
|
+
|
|
32
|
+
def _sync(self):
|
|
33
|
+
self._visibility_job = None
|
|
34
|
+
if self._laid_out:
|
|
35
|
+
if self._needed and not self.winfo_manager():
|
|
36
|
+
super().grid()
|
|
37
|
+
elif not self._needed and self.winfo_manager():
|
|
38
|
+
super().grid_remove()
|
|
39
|
+
|
|
40
|
+
def pack(self, *args, **kwargs):
|
|
41
|
+
raise tk.TclError('AutoScrollbar uses grid(); place it in a Frame if the outer layout uses pack().')
|
|
42
|
+
pack_configure = pack
|
|
43
|
+
place = pack
|
|
44
|
+
place_configure = pack
|
|
45
|
+
|
|
46
|
+
def _cleanup(self, event):
|
|
47
|
+
if event.widget is self and self._visibility_job is not None:
|
|
48
|
+
self.after_cancel(self._visibility_job)
|
|
49
|
+
self._visibility_job = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _attach_scrollbars(widget, container):
|
|
53
|
+
"""Add internal bars without taking over caller-supplied scroll commands."""
|
|
54
|
+
widget._auto_scrollbars = {}
|
|
55
|
+
for axis, options in [('y', dict(row=0, column=1, sticky='ns')),
|
|
56
|
+
('x', dict(row=1, column=0, sticky='ew'))]:
|
|
57
|
+
option = axis+'scrollcommand'
|
|
58
|
+
if widget.cget(option):
|
|
59
|
+
continue
|
|
60
|
+
bar = AutoScrollbar(container, orient='vertical' if axis=='y' else 'horizontal',
|
|
61
|
+
command=getattr(widget, axis+'view'))
|
|
62
|
+
bar.grid(**options)
|
|
63
|
+
# Bypass subclass configure: this installs, rather than replaces, bars.
|
|
64
|
+
tk.Widget.configure(widget, **{option: bar.set})
|
|
65
|
+
widget._auto_scrollbars[option] = bar
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _release_scrollbars(widget, options):
|
|
69
|
+
# Existing applications can still connect their own external scrollbar.
|
|
70
|
+
for option in options:
|
|
71
|
+
bar = getattr(widget, '_auto_scrollbars', {}).pop(option, None)
|
|
72
|
+
if bar is not None:
|
|
73
|
+
bar.destroy()
|