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/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()