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/feedback.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Nonblocking toasts, text progress and radial meters, all native Tk."""
|
|
2
|
+
import math
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk
|
|
5
|
+
from .core import PALETTE, Card, Modal
|
|
6
|
+
from .forms import SEMANTIC, Badge
|
|
7
|
+
from .render import SmoothImage
|
|
8
|
+
from .tcl_bridge import unbind
|
|
9
|
+
|
|
10
|
+
class ToastNotification(tk.Toplevel):
|
|
11
|
+
def __init__(self,master,message,*,title='',kind='info',duration=3000,position='bottom-right'):
|
|
12
|
+
self.host=master.winfo_toplevel();self.kind=kind;self.duration=duration;self.position=position
|
|
13
|
+
self._closed=False;self._timer=None;self._shown=False;self._region_size=None
|
|
14
|
+
self.scale=master.winfo_fpixels('1i')/96;self.corner_radius=5
|
|
15
|
+
super().__init__(self.host);self.withdraw();self.overrideredirect(True);self.transient(self.host)
|
|
16
|
+
self.configure(background=PALETTE['bg'])
|
|
17
|
+
body=Card(self,padding=14,border_color=SEMANTIC.get(kind,SEMANTIC['info']));body.pack(fill='both',expand=True,padx=1,pady=1)
|
|
18
|
+
head=ttk.Frame(body);head.pack(fill='x')
|
|
19
|
+
Badge(head,text=title or {'success':'已完成','danger':'操作失败','warning':'请注意','info':'提示'}.get(kind,'提示'),kind=kind).pack(side='left')
|
|
20
|
+
ttk.Button(head,text='×',width=2,style='Quiet.TButton',command=self.hide).pack(side='right')
|
|
21
|
+
ttk.Label(body,text=message,wraplength=round(280*self.scale),justify='left').pack(fill='x',pady=(10,0))
|
|
22
|
+
self.bind('<Enter>',self._pause,add='+');self.bind('<Leave>',self._resume,add='+')
|
|
23
|
+
self._configure_bind=self.host.bind('<Configure>',self._host_configure,add='+')
|
|
24
|
+
self._unmap_bind=self.host.bind('<Unmap>',lambda e:self.hide() if e.widget is self.host else None,add='+')
|
|
25
|
+
self._destroy_bind=self.host.bind('<Destroy>',lambda e:self.hide() if e.widget is self.host else None,add='+')
|
|
26
|
+
def show_toast(self):
|
|
27
|
+
if self._closed:return self
|
|
28
|
+
self.update_idletasks()
|
|
29
|
+
if not self._shown:
|
|
30
|
+
stack=getattr(self.host,'_web_toasts',None)
|
|
31
|
+
if stack is None:stack=self.host._web_toasts=[]
|
|
32
|
+
same=[toast for toast in stack if toast.position==self.position]
|
|
33
|
+
gap=round(10*self.scale)
|
|
34
|
+
while same and (len(same)>=3 or sum(toast.winfo_reqheight()+gap for toast in same)+self.winfo_reqheight()+2*gap>self.host.winfo_height()):
|
|
35
|
+
same.pop(0).hide()
|
|
36
|
+
stack.append(self);self._shown=True
|
|
37
|
+
self.update_idletasks();self.deiconify();self.reflow();self.update_idletasks();Modal.round_corners(self)
|
|
38
|
+
self._resume();return self
|
|
39
|
+
def _host_configure(self,event):
|
|
40
|
+
if event.widget is self.host:self.reflow()
|
|
41
|
+
def reflow(self):
|
|
42
|
+
if self._closed:return
|
|
43
|
+
gap=round(10*self.scale);offset=gap
|
|
44
|
+
for toast in list(getattr(self.host,'_web_toasts',[])):
|
|
45
|
+
if toast._closed or toast.position!=self.position:continue
|
|
46
|
+
w=toast.winfo_reqwidth();h=toast.winfo_reqheight()
|
|
47
|
+
x=self.host.winfo_rootx()+max(gap,self.host.winfo_width()-w-gap)
|
|
48
|
+
if self.position=='top-right':y=self.host.winfo_rooty()+offset
|
|
49
|
+
else:y=self.host.winfo_rooty()+max(gap,self.host.winfo_height()-offset-h)
|
|
50
|
+
toast.geometry(f'{w}x{h}{x:+d}{y:+d}');offset+=h+gap
|
|
51
|
+
def _pause(self,event=None):
|
|
52
|
+
if self._timer is not None:self.after_cancel(self._timer);self._timer=None
|
|
53
|
+
def _resume(self,event=None):
|
|
54
|
+
if self._closed:return
|
|
55
|
+
self._pause()
|
|
56
|
+
if self.duration>0:self._timer=self.after(self.duration,self.hide)
|
|
57
|
+
def hide(self):
|
|
58
|
+
if self._closed:return
|
|
59
|
+
self._closed=True;self._pause()
|
|
60
|
+
stack=getattr(self.host,'_web_toasts',[])
|
|
61
|
+
if self in stack:stack.remove(self)
|
|
62
|
+
for event,binding in [('<Configure>',self._configure_bind),('<Unmap>',self._unmap_bind),('<Destroy>',self._destroy_bind)]:
|
|
63
|
+
try:unbind(self.host,event,binding)
|
|
64
|
+
except tk.TclError:pass
|
|
65
|
+
super().destroy()
|
|
66
|
+
for toast in list(stack):toast.reflow()
|
|
67
|
+
def destroy(self):self.hide()
|
|
68
|
+
|
|
69
|
+
def notify(master,message,**kwargs):return ToastNotification(master,message,**kwargs).show_toast()
|
|
70
|
+
|
|
71
|
+
class TextProgress(ttk.Frame):
|
|
72
|
+
def __init__(self,master,*,variable=None,value=0,maximum=100,mask='{value:.0f}%',text='',orient='horizontal',mode='determinate',kind='primary',length=240,thickness=28,**kwargs):
|
|
73
|
+
super().__init__(master,**kwargs)
|
|
74
|
+
if maximum<=0 or orient not in ('horizontal','vertical'):raise ValueError('进度范围或方向无效')
|
|
75
|
+
self.variable=variable if variable is not None else tk.DoubleVar(self,value=value)
|
|
76
|
+
self.maximum,self.mask,self.text,self.orient,self.mode,self.kind=maximum,mask,text,orient,mode,kind
|
|
77
|
+
self._timer=None;self._phase=0;self._closed=False
|
|
78
|
+
self.dpi=self.winfo_fpixels('1i')/96;self.px=lambda n:max(1,round(n*self.dpi))
|
|
79
|
+
w,h=(self.px(length),self.px(thickness)) if orient=='horizontal' else (self.px(thickness),self.px(length))
|
|
80
|
+
self.canvas=tk.Canvas(self,width=w,height=h,background=PALETTE['bg'],highlightthickness=0)
|
|
81
|
+
self.canvas.pack(fill='both',expand=True)
|
|
82
|
+
self.canvas.bind('<Configure>',lambda e:self.draw())
|
|
83
|
+
self._trace=self.variable.trace_add('write',lambda *_:self.draw());self.bind('<Destroy>',self._cleanup,add='+')
|
|
84
|
+
self.draw()
|
|
85
|
+
def get(self):return float(self.variable.get())
|
|
86
|
+
def set(self,value):
|
|
87
|
+
value=float(value)
|
|
88
|
+
if not math.isfinite(value):raise ValueError('进度必须是有限数值')
|
|
89
|
+
self.variable.set(max(0,min(self.maximum,value)))
|
|
90
|
+
def step(self,amount=1):self.set(self.get()+amount)
|
|
91
|
+
def draw(self):
|
|
92
|
+
if self._closed:return
|
|
93
|
+
c=self.canvas;c.delete('all');w=c.winfo_width();h=c.winfo_height()
|
|
94
|
+
if w<2 or h<2:return
|
|
95
|
+
color=PALETTE['accent'] if self.kind=='primary' else SEMANTIC.get(self.kind,PALETTE['accent'])
|
|
96
|
+
ratio=max(0,min(1,self.get()/self.maximum))
|
|
97
|
+
c.create_rectangle(0,0,w,h,fill=PALETTE['hover'],outline='')
|
|
98
|
+
horizontal=self.orient=='horizontal'
|
|
99
|
+
if self.mode=='indeterminate':a=self._phase*.75;b=a+.25
|
|
100
|
+
else:a=0;b=ratio
|
|
101
|
+
if horizontal:c.create_rectangle(w*a,0,w*b,h,fill=color,outline='')
|
|
102
|
+
else:c.create_rectangle(0,h*(1-b),w,h*(1-a),fill=color,outline='')
|
|
103
|
+
caption=self.text or self.mask.format(value=self.get(),maximum=self.maximum,percent=ratio*100)
|
|
104
|
+
foreground='#FFFFFF' if a<=.5<=b else PALETTE['fg']
|
|
105
|
+
c.create_text(w/2,h/2,text=caption,fill=foreground,font='TkDefaultFont',angle=0 if horizontal else 90)
|
|
106
|
+
def start(self,interval=35):
|
|
107
|
+
if self._timer is not None:return
|
|
108
|
+
self._interval=max(15,interval);self._tick()
|
|
109
|
+
def _tick(self):
|
|
110
|
+
if self._closed:return
|
|
111
|
+
if self.mode=='indeterminate':self._phase=(self._phase+.035)%1;self.draw()
|
|
112
|
+
else:self.set((self.get()+1)%(self.maximum+1))
|
|
113
|
+
self._timer=self.after(self._interval,self._tick)
|
|
114
|
+
def stop(self):
|
|
115
|
+
if self._timer is not None:self.after_cancel(self._timer);self._timer=None
|
|
116
|
+
def _cleanup(self,event):
|
|
117
|
+
if event.widget is self:
|
|
118
|
+
self._closed=True;self.stop();self.variable.trace_remove('write',self._trace)
|
|
119
|
+
|
|
120
|
+
class Meter(ttk.Frame):
|
|
121
|
+
def __init__(self,master,*,variable=None,value=0,minimum=0,maximum=100,meter_type='full',unit='%',subtext='',interactive=False,thresholds=None,command=None,size=190,**kwargs):
|
|
122
|
+
super().__init__(master,**kwargs)
|
|
123
|
+
if maximum<=minimum or meter_type not in ('full','semi'):raise ValueError('仪表量程或类型无效')
|
|
124
|
+
self.minimum,self.maximum,self.meter_type=minimum,maximum,meter_type
|
|
125
|
+
self.unit,self.subtext,self.interactive,self.thresholds,self.command=unit,subtext,interactive,thresholds or [],command
|
|
126
|
+
self.variable=variable if variable is not None else tk.DoubleVar(self,value=value)
|
|
127
|
+
self.dpi=self.winfo_fpixels('1i')/96;self.px=lambda n:max(1,round(n*self.dpi))
|
|
128
|
+
self._closed=False;self._geometry=None;self._meter_image=None;self._draw_job=None
|
|
129
|
+
self.canvas=tk.Canvas(self,width=self.px(size),height=self.px(size if meter_type=='full' else size*.72),background=PALETTE['bg'],highlightthickness=0,takefocus=interactive)
|
|
130
|
+
self.canvas.pack(fill='both',expand=True)
|
|
131
|
+
self.canvas.bind('<Configure>',lambda e:self.schedule())
|
|
132
|
+
if interactive:
|
|
133
|
+
self.canvas.configure(cursor='hand2');self.canvas.bind('<Button-1>',self._pointer);self.canvas.bind('<B1-Motion>',self._pointer)
|
|
134
|
+
for event,delta in [('<Left>',-1),('<Down>',-1),('<Right>',1),('<Up>',1)]:self.canvas.bind(event,lambda e,d=delta:self.set(self.get()+d))
|
|
135
|
+
self.canvas.bind('<Home>',lambda e:self.set(self.minimum));self.canvas.bind('<End>',lambda e:self.set(self.maximum))
|
|
136
|
+
self._trace=self.variable.trace_add('write',lambda *_:self.schedule());self.bind('<Destroy>',self._cleanup,add='+')
|
|
137
|
+
self.draw()
|
|
138
|
+
def schedule(self):
|
|
139
|
+
if not self._closed and self._draw_job is None:self._draw_job=self.after(16,self.draw)
|
|
140
|
+
def get(self):return float(self.variable.get())
|
|
141
|
+
def set(self,value):
|
|
142
|
+
value=float(value)
|
|
143
|
+
if not math.isfinite(value):raise ValueError('仪表数值必须有限')
|
|
144
|
+
self.variable.set(max(self.minimum,min(self.maximum,value)))
|
|
145
|
+
self.event_generate('<<MeterChanged>>',when='tail')
|
|
146
|
+
if self.command:self.command(self.get())
|
|
147
|
+
return 'break'
|
|
148
|
+
def draw(self):
|
|
149
|
+
if self._draw_job is not None:self.after_cancel(self._draw_job);self._draw_job=None
|
|
150
|
+
if self._closed:return
|
|
151
|
+
c=self.canvas;c.delete('all');w=c.winfo_width();h=c.winfo_height()
|
|
152
|
+
if w<5 or h<5:return
|
|
153
|
+
full=self.meter_type=='full';width=self.px(12)
|
|
154
|
+
radius=max(1,min(w/2-self.px(14),h/2-self.px(12) if full else h-self.px(42)))
|
|
155
|
+
cx=w/2;cy=h/2 if full else radius+self.px(10)
|
|
156
|
+
self._geometry=(cx,cy,radius)
|
|
157
|
+
bounds=(cx-radius,cy-radius,cx+radius,cy+radius)
|
|
158
|
+
ratio=max(0,min(1,(self.get()-self.minimum)/(self.maximum-self.minimum)))
|
|
159
|
+
color=PALETTE['accent']
|
|
160
|
+
for limit,kind in sorted(self.thresholds):
|
|
161
|
+
if ratio*100<=limit:color=SEMANTIC.get(kind,kind);break
|
|
162
|
+
with SmoothImage(self,w,h) as surface:
|
|
163
|
+
surface.arc(bounds,270 if full else 180,360 if full else 180,PALETTE['hover'],width)
|
|
164
|
+
surface.arc(bounds,270 if full else 180,ratio*(360 if full else 180),color,width)
|
|
165
|
+
self._meter_image=surface.photo()
|
|
166
|
+
c.create_image(0,0,image=self._meter_image,anchor='nw')
|
|
167
|
+
c.create_text(cx,cy-self.px(9),text=f'{self.get():g}{self.unit}',fill=PALETTE['fg'],font=('Microsoft YaHei UI',17,'bold'))
|
|
168
|
+
c.create_text(cx,cy+self.px(19),text=self.subtext,fill=PALETTE['muted'],font='TkDefaultFont')
|
|
169
|
+
def _pointer(self,event):
|
|
170
|
+
if self._geometry is None:return
|
|
171
|
+
self.canvas.focus_set();cx,cy,r=self._geometry
|
|
172
|
+
angle=math.degrees(math.atan2(cy-event.y,event.x-cx))
|
|
173
|
+
if self.meter_type=='full':ratio=((90-angle)%360)/360
|
|
174
|
+
else:ratio=max(0,min(1,(180-angle)/180)) if angle>=0 else (1 if event.x>=cx else 0)
|
|
175
|
+
return self.set(round(self.minimum+ratio*(self.maximum-self.minimum),1))
|
|
176
|
+
def _cleanup(self,event):
|
|
177
|
+
if event.widget is self:
|
|
178
|
+
self._closed=True;self.variable.trace_remove('write',self._trace)
|
|
179
|
+
if self._draw_job is not None:self.after_cancel(self._draw_job);self._draw_job=None
|
tkwebui/forms.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Standard-library Web form controls and result-returning dialogs."""
|
|
2
|
+
import calendar
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
from tkinter import ttk
|
|
8
|
+
from .core import PALETTE, Modal, Card, ProgressSlider, _patch
|
|
9
|
+
from .render import calendar_icon
|
|
10
|
+
from .tcl_bridge import unbind
|
|
11
|
+
|
|
12
|
+
SEMANTIC={'primary':'#087DFF','success':'#159576','warning':'#B77717','danger':'#D54B58','info':'#197FA8','neutral':'#657080'}
|
|
13
|
+
|
|
14
|
+
def tint(color,white=.91):
|
|
15
|
+
return '#'+''.join(f'{round(int(color[i:i+2],16)*(1-white)+255*white):02x}' for i in (1,3,5))
|
|
16
|
+
|
|
17
|
+
def configure_form_styles(root,style):
|
|
18
|
+
px=lambda n:max(1,round(n*root.winfo_fpixels('1i')/96))
|
|
19
|
+
for key,base in SEMANTIC.items():
|
|
20
|
+
color=PALETTE['accent'] if key=='primary' else base;prefix=key.title()
|
|
21
|
+
style.configure(prefix+'.TLabel',background=PALETTE['bg'],foreground=color)
|
|
22
|
+
style.configure(prefix+'.Badge.TLabel',background=tint(color),foreground=color,padding=(px(8),px(4)))
|
|
23
|
+
for variant in ('','Outline.','Link.'):
|
|
24
|
+
name=prefix+'.'+variant+'TButton'
|
|
25
|
+
if name=='Primary.TButton':continue
|
|
26
|
+
fill=color if not variant else PALETTE['bg'];border=color if variant!='Link.' else PALETTE['bg']
|
|
27
|
+
normal=_patch(root,fill,border,PALETTE['bg'],px(4))
|
|
28
|
+
hover=_patch(root,tint(color,.84) if variant else color,color if variant!='Link.' else tint(color,.84),PALETTE['bg'],px(4))
|
|
29
|
+
disabled=_patch(root,PALETTE['sidebar'],PALETTE['border'] if variant!='Link.' else PALETTE['sidebar'],PALETTE['bg'],px(4))
|
|
30
|
+
focus=_patch(root,fill,PALETTE['fg'],PALETTE['bg'],px(4))
|
|
31
|
+
root._tkwebui_images.extend((normal,hover,disabled,focus))
|
|
32
|
+
element='Web.'+name+'.surface'
|
|
33
|
+
style.element_create(element,'image',normal,('disabled',disabled),('pressed',hover),('active',hover),('focus',focus),border=px(4)+1,padding=0,width=px(10),height=px(10),sticky='nsew')
|
|
34
|
+
style.layout(name,[(element,{'sticky':'nsew','children':[('Button.padding',{'sticky':'nsew','children':[('Button.label',{'sticky':'nsew'})]})]})])
|
|
35
|
+
style.configure(name,padding=(px(12),px(7)),anchor='center',foreground=color if variant else '#FFFFFF')
|
|
36
|
+
style.map(name,foreground=[('disabled',PALETTE['disabled'])])
|
|
37
|
+
|
|
38
|
+
class Badge(ttk.Label):
|
|
39
|
+
def __init__(self,master,*,kind='info',**kwargs):
|
|
40
|
+
kwargs.setdefault('style',kind.title()+'.Badge.TLabel');super().__init__(master,**kwargs)
|
|
41
|
+
|
|
42
|
+
class ValidatedEntry(ttk.Frame):
|
|
43
|
+
def __init__(self,master,*,variable=None,kind='text',required=False,minimum=None,maximum=None,pattern=None,validator=None,label='',**kwargs):
|
|
44
|
+
super().__init__(master)
|
|
45
|
+
self.variable=variable if variable is not None else tk.StringVar(self)
|
|
46
|
+
self.kind,self.required,self.minimum,self.maximum=kind,required,minimum,maximum
|
|
47
|
+
self.pattern,self.validator=pattern,validator
|
|
48
|
+
self.columnconfigure(0,weight=1)
|
|
49
|
+
row=0
|
|
50
|
+
if label:ttk.Label(self,text=label).grid(row=0,column=0,sticky='w',pady=(0,5));row=1
|
|
51
|
+
self.entry=ttk.Entry(self,textvariable=self.variable,**kwargs);self.entry.grid(row=row,column=0,sticky='ew')
|
|
52
|
+
self.error=tk.StringVar(self)
|
|
53
|
+
self.error_label=ttk.Label(self,textvariable=self.error,style='Danger.TLabel',wraplength=320)
|
|
54
|
+
self.error_label.grid(row=row+1,column=0,sticky='w',pady=(4,0));self.error_label.grid_remove()
|
|
55
|
+
self.entry.bind('<FocusOut>',lambda e:self.validate(),add='+')
|
|
56
|
+
self.entry.bind('<Return>',lambda e:self.validate(),add='+')
|
|
57
|
+
self._trace=self.variable.trace_add('write',self._edited)
|
|
58
|
+
self.bind('<Destroy>',self._cleanup,add='+')
|
|
59
|
+
def get(self):return str(self.variable.get())
|
|
60
|
+
def set(self,value):self.variable.set('' if value is None else str(value))
|
|
61
|
+
def get_value(self):
|
|
62
|
+
text=self.get().strip()
|
|
63
|
+
if not text:
|
|
64
|
+
if self.required:raise ValueError('此项不能为空')
|
|
65
|
+
return '' if self.kind=='text' else None
|
|
66
|
+
try:
|
|
67
|
+
if self.kind=='integer':value=int(text)
|
|
68
|
+
elif self.kind=='float':
|
|
69
|
+
value=float(text)
|
|
70
|
+
if not math.isfinite(value):raise ValueError()
|
|
71
|
+
elif self.kind=='date':value=date.fromisoformat(text)
|
|
72
|
+
else:value=text
|
|
73
|
+
except (ValueError,OverflowError) as exc:
|
|
74
|
+
raise ValueError({'integer':'请输入整数','float':'请输入有限数值','date':'请输入有效日期,格式 YYYY-MM-DD'}.get(self.kind,'输入无效')) from exc
|
|
75
|
+
if self.kind=='email' and not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+',text):raise ValueError('请输入有效的邮箱地址')
|
|
76
|
+
if self.minimum is not None and value<self.minimum:raise ValueError(f'不能小于 {self.minimum}')
|
|
77
|
+
if self.maximum is not None and value>self.maximum:raise ValueError(f'不能大于 {self.maximum}')
|
|
78
|
+
if self.pattern and not re.fullmatch(self.pattern,text):raise ValueError('输入格式不符合要求')
|
|
79
|
+
if self.validator:
|
|
80
|
+
result=self.validator(value)
|
|
81
|
+
if result is False:raise ValueError('输入未通过校验')
|
|
82
|
+
if isinstance(result,str) and result:raise ValueError(result)
|
|
83
|
+
return value
|
|
84
|
+
def validate(self):
|
|
85
|
+
try:self.get_value()
|
|
86
|
+
except (ValueError,TypeError) as exc:
|
|
87
|
+
self.error.set(str(exc));self.entry.state(['invalid']);self.error_label.grid();return False
|
|
88
|
+
self.error.set('');self.entry.state(['!invalid']);self.error_label.grid_remove();return True
|
|
89
|
+
def _edited(self,*_):
|
|
90
|
+
if self.error.get():self.validate()
|
|
91
|
+
def _cleanup(self,event):
|
|
92
|
+
if event.widget is self:self.variable.trace_remove('write',self._trace)
|
|
93
|
+
|
|
94
|
+
class ResultDialog(Modal):
|
|
95
|
+
def __init__(self,master,title,**kwargs):
|
|
96
|
+
self.result=None;super().__init__(master,title,**kwargs)
|
|
97
|
+
def accept(self,value):self.result=value;self.close()
|
|
98
|
+
def show_result(self):self.wait_window();return self.result
|
|
99
|
+
|
|
100
|
+
class MessageDialog(ResultDialog):
|
|
101
|
+
def __init__(self,master,message,*,title='提示',kind='info',question=False):
|
|
102
|
+
super().__init__(master,title,width=440,height=260)
|
|
103
|
+
Badge(self.body,text={'info':'提示','success':'完成','warning':'注意','danger':'错误'}.get(kind,'提示'),kind=kind).pack(anchor='w',pady=(0,12))
|
|
104
|
+
ttk.Label(self.body,text=message,wraplength=round(380*self.scale),justify='left').pack(fill='x')
|
|
105
|
+
self.primary.configure(text='确认' if question else '知道了',style=kind.title()+'.TButton',command=lambda:self.accept(True))
|
|
106
|
+
if question:
|
|
107
|
+
self.secondary.configure(command=lambda:self.accept(False));self.secondary.pack(side='right',padx=8)
|
|
108
|
+
|
|
109
|
+
class InputDialog(ResultDialog):
|
|
110
|
+
def __init__(self,master,prompt,*,title='输入',initial='',kind='text',minimum=None,maximum=None,required=False):
|
|
111
|
+
super().__init__(master,title,width=450,height=285)
|
|
112
|
+
self.field=ValidatedEntry(self.body,label=prompt,kind=kind,minimum=minimum,maximum=maximum,required=required)
|
|
113
|
+
self.field.pack(fill='x');self.field.set(initial)
|
|
114
|
+
self.initial_focus=self.field.entry
|
|
115
|
+
self.primary.configure(text='确定',command=self.submit)
|
|
116
|
+
self.secondary.pack(side='right',padx=8)
|
|
117
|
+
self.field.entry.bind('<Return>',lambda e:self.submit())
|
|
118
|
+
def submit(self):
|
|
119
|
+
if self.field.validate():self.accept(self.field.get_value())
|
|
120
|
+
|
|
121
|
+
class Dialogs:
|
|
122
|
+
@staticmethod
|
|
123
|
+
def info(master,message,title='提示'):return MessageDialog(master,message,title=title).show_result()
|
|
124
|
+
@staticmethod
|
|
125
|
+
def error(master,message,title='错误'):return MessageDialog(master,message,title=title,kind='danger').show_result()
|
|
126
|
+
@staticmethod
|
|
127
|
+
def warning(master,message,title='注意'):return MessageDialog(master,message,title=title,kind='warning').show_result()
|
|
128
|
+
@staticmethod
|
|
129
|
+
def confirm(master,message,title='确认操作',danger=False):
|
|
130
|
+
return MessageDialog(master,message,title=title,kind='danger' if danger else 'info',question=True).show_result() is True
|
|
131
|
+
@staticmethod
|
|
132
|
+
def ask_string(master,prompt,**kwargs):return InputDialog(master,prompt,kind='text',**kwargs).show_result()
|
|
133
|
+
@staticmethod
|
|
134
|
+
def ask_integer(master,prompt,**kwargs):return InputDialog(master,prompt,kind='integer',**kwargs).show_result()
|
|
135
|
+
@staticmethod
|
|
136
|
+
def ask_float(master,prompt,**kwargs):return InputDialog(master,prompt,kind='float',**kwargs).show_result()
|
|
137
|
+
|
|
138
|
+
def _as_date(value):
|
|
139
|
+
if value is None or value=='':return None
|
|
140
|
+
if isinstance(value,datetime):return value.date()
|
|
141
|
+
if isinstance(value,date):return value
|
|
142
|
+
return date.fromisoformat(str(value))
|
|
143
|
+
|
|
144
|
+
class DateEntry(ttk.Frame):
|
|
145
|
+
def __init__(self,master,*,value=None,minimum=None,maximum=None,command=None,**kwargs):
|
|
146
|
+
super().__init__(master,**kwargs)
|
|
147
|
+
self.minimum=_as_date(minimum);self.maximum=_as_date(maximum)
|
|
148
|
+
if self.minimum and self.maximum and self.minimum>self.maximum:raise ValueError('最小日期不能大于最大日期')
|
|
149
|
+
self.command=command;self.popup=None;self.variable=tk.StringVar(self)
|
|
150
|
+
self.columnconfigure(0,weight=1)
|
|
151
|
+
self.entry=ttk.Entry(self,textvariable=self.variable)
|
|
152
|
+
self.entry.grid(row=0,column=0,sticky='ew')
|
|
153
|
+
size=max(18,round(20*self.winfo_fpixels('1i')/96))
|
|
154
|
+
self._calendar_icon=calendar_icon(self,size,PALETTE['muted'])
|
|
155
|
+
self._calendar_disabled=calendar_icon(self,size,PALETTE['disabled'])
|
|
156
|
+
self.button=ttk.Button(self,image=self._calendar_icon,command=self.open_calendar,padding=(8,5))
|
|
157
|
+
self.button.grid(row=0,column=1,padx=(5,0))
|
|
158
|
+
self.entry.bind('<Alt-Down>',lambda e:self.open_calendar())
|
|
159
|
+
self.entry.bind('<FocusOut>',lambda e:self.validate(),add='+')
|
|
160
|
+
self.entry.bind('<Return>',lambda e:self.validate(),add='+')
|
|
161
|
+
self.bind('<Destroy>',self._cleanup,add='+')
|
|
162
|
+
self.set_date(value)
|
|
163
|
+
def get_date(self):
|
|
164
|
+
value=_as_date(self.variable.get().strip())
|
|
165
|
+
if value and self.minimum and value<self.minimum:raise ValueError('日期早于允许范围')
|
|
166
|
+
if value and self.maximum and value>self.maximum:raise ValueError('日期晚于允许范围')
|
|
167
|
+
return value
|
|
168
|
+
def set_date(self,value):
|
|
169
|
+
value=_as_date(value)
|
|
170
|
+
if value and ((self.minimum and value<self.minimum) or (self.maximum and value>self.maximum)):raise ValueError('日期超出允许范围')
|
|
171
|
+
self.variable.set(value.isoformat() if value else '');self.entry.state(['!invalid'])
|
|
172
|
+
def clear(self):self.set_date(None)
|
|
173
|
+
def validate(self):
|
|
174
|
+
try:self.get_date()
|
|
175
|
+
except ValueError:self.entry.state(['invalid']);return False
|
|
176
|
+
self.entry.state(['!invalid']);return True
|
|
177
|
+
def state(self,statespec=None):
|
|
178
|
+
result=self.entry.state(statespec)
|
|
179
|
+
if statespec is not None:
|
|
180
|
+
disabled=self.entry.instate(['disabled'])
|
|
181
|
+
self.button.state(['disabled'] if disabled else ['!disabled'])
|
|
182
|
+
self.button.configure(image=self._calendar_disabled if disabled else self._calendar_icon)
|
|
183
|
+
return result
|
|
184
|
+
def open_calendar(self):
|
|
185
|
+
if self.entry.instate(['disabled']):return 'break'
|
|
186
|
+
if self.popup is not None and not self.popup._closed:self.popup.close();return 'break'
|
|
187
|
+
self.popup=CalendarPopup(self)
|
|
188
|
+
return 'break'
|
|
189
|
+
def _chosen(self,value):
|
|
190
|
+
self.set_date(value);self.event_generate('<<DateEntrySelected>>',when='tail')
|
|
191
|
+
if self.command:self.command(value)
|
|
192
|
+
def _cleanup(self,event):
|
|
193
|
+
if event.widget is self and self.popup is not None and not self.popup._closed:self.popup.close(False)
|
|
194
|
+
|
|
195
|
+
class CalendarPopup(tk.Toplevel):
|
|
196
|
+
def __init__(self,owner):
|
|
197
|
+
self.owner=owner;self.host=owner.winfo_toplevel();self._closed=False
|
|
198
|
+
existing=getattr(self.host,'_choice_popup',None)
|
|
199
|
+
if existing is not None:existing.close()
|
|
200
|
+
self.previous_focus=owner.focus_get();self.previous_grab=owner.grab_current()
|
|
201
|
+
super().__init__(self.host);self.withdraw();self.overrideredirect(True);self.transient(self.host)
|
|
202
|
+
self.scale=owner.winfo_fpixels('1i')/96;self.corner_radius=5;self._region_size=None;self._focus_job=None
|
|
203
|
+
try:selected=owner.get_date()
|
|
204
|
+
except ValueError:selected=None
|
|
205
|
+
focused=selected or date.today()
|
|
206
|
+
if owner.minimum:focused=max(focused,owner.minimum)
|
|
207
|
+
if owner.maximum:focused=min(focused,owner.maximum)
|
|
208
|
+
self.selected=selected;self.year=tk.IntVar(self,value=focused.year);self.month=tk.IntVar(self,value=focused.month)
|
|
209
|
+
card=Card(self,padding=10);card.pack(fill='both',expand=True,padx=1,pady=1)
|
|
210
|
+
head=ttk.Frame(card);head.pack(fill='x',pady=(0,10))
|
|
211
|
+
ttk.Button(head,text='‹',width=2,command=lambda:self.move_month(-1)).pack(side='left')
|
|
212
|
+
ttk.Spinbox(head,from_=1,to=9999,textvariable=self.year,width=5,command=self.draw).pack(side='left',padx=4)
|
|
213
|
+
ttk.Label(head,text='年').pack(side='left')
|
|
214
|
+
ttk.Spinbox(head,from_=1,to=12,textvariable=self.month,width=3,command=self.draw).pack(side='left',padx=4)
|
|
215
|
+
ttk.Label(head,text='月').pack(side='left')
|
|
216
|
+
ttk.Button(head,text='›',width=2,command=lambda:self.move_month(1)).pack(side='right')
|
|
217
|
+
self.days=ttk.Frame(card);self.days.pack(fill='both',expand=True)
|
|
218
|
+
foot=ttk.Frame(card);foot.pack(fill='x',pady=(10,0))
|
|
219
|
+
ttk.Button(foot,text='今天',command=lambda:self.choose(date.today())).pack(side='left')
|
|
220
|
+
ttk.Button(foot,text='清空',command=lambda:self.choose(None)).pack(side='right')
|
|
221
|
+
self.bind('<Return>',lambda e:self.draw(),add='+');self.bind('<Escape>',lambda e:self.close())
|
|
222
|
+
self.bind('<ButtonPress-1>',self.outside,add='+');self.bind('<FocusOut>',self.focus_out,add='+')
|
|
223
|
+
self._host_bind=self.host.bind('<Configure>',lambda e:self.close() if e.widget is self.host else None,add='+')
|
|
224
|
+
self.host._choice_popup=self
|
|
225
|
+
self.draw();self.update_idletasks()
|
|
226
|
+
from .extras import MenuPopup
|
|
227
|
+
x=owner.winfo_rootx();y=owner.winfo_rooty()+owner.winfo_height()+4
|
|
228
|
+
left,top,right,bottom=MenuPopup.work_area(x,y);w=self.winfo_reqwidth();h=self.winfo_reqheight()
|
|
229
|
+
if y+h>bottom:y=owner.winfo_rooty()-h-4
|
|
230
|
+
x=max(left+4,min(x,right-w-4));y=max(top+4,min(y,bottom-h-4))
|
|
231
|
+
self.geometry(f'{w}x{h}{x:+d}{y:+d}')
|
|
232
|
+
self.attributes('-topmost',True);self.deiconify();self.update_idletasks();Modal.round_corners(self)
|
|
233
|
+
self.focus_force();self.grab_set()
|
|
234
|
+
def draw(self):
|
|
235
|
+
try:y=int(self.year.get());m=int(self.month.get());date(y,m,1)
|
|
236
|
+
except (ValueError,tk.TclError):return
|
|
237
|
+
for child in self.days.winfo_children():child.destroy()
|
|
238
|
+
for col,text in enumerate('一二三四五六日'):
|
|
239
|
+
ttk.Label(self.days,text=text,style='Muted.TLabel',anchor='center').grid(row=0,column=col,sticky='ew',pady=5)
|
|
240
|
+
self.days.columnconfigure(col,weight=1)
|
|
241
|
+
self.day_buttons={}
|
|
242
|
+
weeks=calendar.Calendar().monthdayscalendar(y,m)
|
|
243
|
+
for r in range(6):
|
|
244
|
+
week=weeks[r] if r<len(weeks) else [0]*7
|
|
245
|
+
for col,day in enumerate(week):
|
|
246
|
+
if not day:
|
|
247
|
+
ttk.Label(self.days,text='',width=3).grid(row=r+1,column=col,pady=2);continue
|
|
248
|
+
value=date(y,m,day);style='Primary.TButton' if value==self.selected else 'Quiet.TButton'
|
|
249
|
+
button=ttk.Button(self.days,text=str(day),width=3,style=style,command=lambda d=value:self.choose(d))
|
|
250
|
+
if (self.owner.minimum and value<self.owner.minimum) or (self.owner.maximum and value>self.owner.maximum):button.state(['disabled'])
|
|
251
|
+
button.grid(row=r+1,column=col,padx=1,pady=2);self.day_buttons[value]=button
|
|
252
|
+
def move_month(self,delta):
|
|
253
|
+
try:index=(int(self.year.get())-1)*12+int(self.month.get())-1+delta
|
|
254
|
+
except (ValueError,tk.TclError):return
|
|
255
|
+
index=max(0,min(9999*12-1,index));self.year.set(index//12+1);self.month.set(index%12+1);self.draw()
|
|
256
|
+
def choose(self,value):
|
|
257
|
+
if value and ((self.owner.minimum and value<self.owner.minimum) or (self.owner.maximum and value>self.owner.maximum)):return
|
|
258
|
+
self.close();self.owner._chosen(value)
|
|
259
|
+
def outside(self,event):
|
|
260
|
+
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()
|
|
261
|
+
def focus_out(self,event):
|
|
262
|
+
if self._focus_job is None and not self._closed:self._focus_job=self.after(100,self.check_focus)
|
|
263
|
+
def check_focus(self):
|
|
264
|
+
self._focus_job=None
|
|
265
|
+
if self._closed:return
|
|
266
|
+
focus=self.focus_get()
|
|
267
|
+
if focus is None or focus.winfo_toplevel() is not self:self.close(False)
|
|
268
|
+
def close(self,restore_focus=True):
|
|
269
|
+
if self._closed:return
|
|
270
|
+
self._closed=True
|
|
271
|
+
if self._focus_job is not None:self.after_cancel(self._focus_job)
|
|
272
|
+
unbind(self.host,'<Configure>',self._host_bind)
|
|
273
|
+
if getattr(self.host,'_choice_popup',None) is self:self.host._choice_popup=None
|
|
274
|
+
self.grab_release();super().destroy()
|
|
275
|
+
if self.previous_grab is not None and self.previous_grab.winfo_exists():self.previous_grab.grab_set()
|
|
276
|
+
if restore_focus:
|
|
277
|
+
focus=self.previous_focus
|
|
278
|
+
if focus is not None and focus.winfo_exists() and focus.winfo_viewable():focus.focus_force()
|
|
279
|
+
def destroy(self):self.close(False)
|
|
280
|
+
|
|
281
|
+
class ValueSlider(ttk.Frame):
|
|
282
|
+
def __init__(self,master,*,variable=None,from_=0,to=100,step=1,unit='',command=None,**kwargs):
|
|
283
|
+
super().__init__(master,**kwargs)
|
|
284
|
+
if to<=from_ or step<=0:raise ValueError('需要 to > from_ 且 step > 0')
|
|
285
|
+
self.minimum,self.maximum,self.step=from_,to,step;self.unit=unit;self.command=command;self._busy=False
|
|
286
|
+
self.variable=variable if variable is not None else tk.DoubleVar(self,value=from_)
|
|
287
|
+
self.number=tk.StringVar(self)
|
|
288
|
+
self.columnconfigure(0,weight=1)
|
|
289
|
+
self.slider=ProgressSlider(self,from_=from_,to=to,step=step,variable=self.variable);self.slider.grid(row=0,column=0,sticky='ew',padx=(0,8))
|
|
290
|
+
self.spin=ttk.Spinbox(self,from_=from_,to=to,increment=step,textvariable=self.number,width=7,command=self.commit)
|
|
291
|
+
self.spin.grid(row=0,column=1)
|
|
292
|
+
ttk.Label(self,text=unit,style='Muted.TLabel').grid(row=0,column=2,padx=(5,0))
|
|
293
|
+
self.spin.bind('<Return>',lambda e:self.commit());self.spin.bind('<FocusOut>',lambda e:self.commit())
|
|
294
|
+
self._trace=self.variable.trace_add('write',self._sync);self._sync()
|
|
295
|
+
self.bind('<Destroy>',self._cleanup,add='+')
|
|
296
|
+
def get(self):return float(self.variable.get())
|
|
297
|
+
def set(self,value):
|
|
298
|
+
value=float(value)
|
|
299
|
+
if not math.isfinite(value):raise ValueError('数值必须有限')
|
|
300
|
+
value=max(self.minimum,min(self.maximum,self.minimum+round((value-self.minimum)/self.step)*self.step))
|
|
301
|
+
self.variable.set(round(value,10))
|
|
302
|
+
def commit(self):
|
|
303
|
+
try:self.set(float(self.number.get()));self.spin.state(['!invalid'])
|
|
304
|
+
except ValueError:self.spin.state(['invalid'])
|
|
305
|
+
def _sync(self,*_):
|
|
306
|
+
if self._busy:return
|
|
307
|
+
self._busy=True
|
|
308
|
+
try:
|
|
309
|
+
try:value=self.get()
|
|
310
|
+
except (ValueError,tk.TclError):value=self.minimum
|
|
311
|
+
if not math.isfinite(value):value=self.minimum
|
|
312
|
+
value=max(self.minimum,min(self.maximum,self.minimum+round((value-self.minimum)/self.step)*self.step))
|
|
313
|
+
self.variable.set(round(value,10));self.number.set(f'{value:g}');self.spin.state(['!invalid'])
|
|
314
|
+
finally:self._busy=False
|
|
315
|
+
if self.command:self.command(self.get())
|
|
316
|
+
def state(self,statespec=None):
|
|
317
|
+
result=self.spin.state(statespec)
|
|
318
|
+
if statespec is not None:
|
|
319
|
+
disabled=self.spin.instate(['disabled'])
|
|
320
|
+
self.slider.configure(state='disabled' if disabled else 'normal')
|
|
321
|
+
self.slider.itemconfigure(self.slider.fill,fill=PALETTE['disabled'] if disabled else PALETTE['accent'])
|
|
322
|
+
return result
|
|
323
|
+
def _cleanup(self,event):
|
|
324
|
+
if event.widget is self:self.variable.trace_remove('write',self._trace)
|