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