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/media.py ADDED
@@ -0,0 +1,336 @@
1
+ """Native color/font pickers and an image viewport; Pillow is optional."""
2
+ import colorsys
3
+ import struct
4
+ import zlib
5
+ import math
6
+ from pathlib import Path
7
+ import tkinter as tk
8
+ from tkinter import ttk, font as tkfont, filedialog
9
+ from .core import PALETTE, Card
10
+ from .popups import ThemedCombobox
11
+ from .forms import ResultDialog
12
+
13
+ def _photo_rows(photo):
14
+ """Decode the known 8-bit RGB/RGBA PNG emitted by Tk, preserving alpha."""
15
+ png=bytes(photo.tk.call(str(photo),'data','-format','png'))
16
+ offset=8;compressed=[];header=None
17
+ while offset<len(png):
18
+ size=struct.unpack_from('>I',png,offset)[0];kind=png[offset+4:offset+8];data=png[offset+8:offset+8+size]
19
+ if kind==b'IHDR':header=struct.unpack('>IIBBBBB',data)
20
+ elif kind==b'IDAT':compressed.append(data)
21
+ offset+=size+12
22
+ width,height,depth,color_type,_,_,interlace=header
23
+ channels={2:3,6:4}.get(color_type)
24
+ if depth!=8 or channels is None or interlace:raise ValueError('当前 Tk 的图片导出格式不支持原生缩放')
25
+ raw=zlib.decompress(b''.join(compressed));stride=width*channels;previous=bytearray(stride);rows=[]
26
+ for y in range(height):
27
+ start=y*(stride+1);mode=raw[start];row=bytearray(raw[start+1:start+1+stride])
28
+ if mode:
29
+ for i in range(stride):
30
+ a=row[i-channels] if i>=channels else 0;b=previous[i];c=previous[i-channels] if i>=channels else 0
31
+ if mode==1:predictor=a
32
+ elif mode==2:predictor=b
33
+ elif mode==3:predictor=(a+b)//2
34
+ elif mode==4:
35
+ p=a+b-c;pa,pb,pc=abs(p-a),abs(p-b),abs(p-c)
36
+ predictor=a if pa<=pb and pa<=pc else b if pb<=pc else c
37
+ else:raise ValueError('无法解码 Tk 图片行')
38
+ row[i]=(row[i]+predictor)&255
39
+ rows.append(bytes(row));previous=row
40
+ return width,height,channels,color_type,rows
41
+
42
+ def _resize_photo(master,decoded,width,height):
43
+ source_width,source_height,channels,color_type,rows=decoded
44
+ offsets=[min(source_width-1,x*source_width//width)*channels for x in range(width)]
45
+ output=[];previous_y=None;scaled=None
46
+ for y in range(height):
47
+ source_y=min(source_height-1,y*source_height//height)
48
+ if source_y!=previous_y:
49
+ row=rows[source_y];scaled=b'\0'+b''.join(row[x:x+channels] for x in offsets);previous_y=source_y
50
+ output.append(scaled)
51
+ def chunk(kind,data):return struct.pack('>I',len(data))+kind+data+struct.pack('>I',zlib.crc32(kind+data)&0xffffffff)
52
+ png=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',width,height,8,color_type,0,0,0))
53
+ png+=chunk(b'IDAT',zlib.compress(b''.join(output),3))+chunk(b'IEND',b'')
54
+ return tk.PhotoImage(master=master,data=png,format='png')
55
+
56
+ class ColorPicker(ttk.Frame):
57
+ def __init__(self,master,*,color='#087DFF',command=None,**kwargs):
58
+ super().__init__(master,**kwargs)
59
+ self.command=command;self._busy=False;self._job=None;self._closed=False
60
+ self.dpi=self.winfo_fpixels('1i')/96;self.px=lambda n:max(1,round(n*self.dpi))
61
+ self._h,self._s,self._v=0,0,1;self._gradient=None
62
+ self.hex=tk.StringVar(self);self.rgb=[tk.StringVar(self) for _ in range(3)];self.hue=tk.DoubleVar(self)
63
+ self.canvas=tk.Canvas(self,height=self.px(145),width=self.px(280),highlightthickness=0,background=PALETTE['bg'],cursor='crosshair')
64
+ self.canvas.pack(fill='x');self.canvas.bind('<Configure>',lambda e:self._schedule_gradient())
65
+ self.canvas.bind('<Button-1>',self._pick);self.canvas.bind('<B1-Motion>',self._pick)
66
+ self.hue_canvas=tk.Canvas(self,height=self.px(20),background=PALETTE['bg'],highlightthickness=0,cursor='hand2')
67
+ self.hue_canvas.pack(fill='x',pady=8)
68
+ self.hue_canvas.bind('<Configure>',lambda e:self._draw_hue())
69
+ self.hue_canvas.bind('<Button-1>',self._pick_hue);self.hue_canvas.bind('<B1-Motion>',self._pick_hue)
70
+ self._hue_image=None
71
+ row=ttk.Frame(self);row.pack(fill='x')
72
+ self.swatch=tk.Label(row,width=5,background=color,relief='flat');self.swatch.pack(side='left',fill='y',padx=(0,8))
73
+ ttk.Label(row,text='HEX').pack(side='left',padx=(0,5))
74
+ self.hex_entry=ttk.Entry(row,textvariable=self.hex,width=10);self.hex_entry.pack(side='left',fill='x',expand=True)
75
+ self.hex_entry.bind('<Return>',lambda e:self.apply_hex());self.hex_entry.bind('<FocusOut>',lambda e:self.apply_hex())
76
+ ttk.Button(row,text='复制',command=self.copy_color).pack(side='left',padx=(6,0))
77
+ row=ttk.Frame(self);row.pack(fill='x',pady=8)
78
+ for name,var in zip('RGB',self.rgb):
79
+ ttk.Label(row,text=name).pack(side='left',padx=(0,4))
80
+ spin=ttk.Spinbox(row,from_=0,to=255,width=4,textvariable=var,command=self.apply_rgb)
81
+ spin.pack(side='left',padx=(0,9));spin.bind('<Return>',lambda e:self.apply_rgb());spin.bind('<FocusOut>',lambda e:self.apply_rgb())
82
+ palette=ttk.Frame(self);palette.pack(fill='x',pady=(3,0));self._swatches=[]
83
+ for color_value in ['#087DFF','#159576','#E8A138','#D54B58','#8B68D9','#197FA8','#16181D','#FFFFFF']:
84
+ image=tk.PhotoImage(master=self,width=self.px(24),height=self.px(18));image.put(color_value,to=(0,0,image.width(),image.height()))
85
+ for box in [(0,0,image.width(),1),(0,image.height()-1,image.width(),image.height()),(0,0,1,image.height()),(image.width()-1,0,image.width(),image.height())]:image.put('#CCD1DC',to=box)
86
+ self._swatches.append(image)
87
+ ttk.Button(palette,image=image,style='Quiet.TButton',command=lambda v=color_value:self.set_color(v)).pack(side='left',padx=1)
88
+ self.status=tk.StringVar(self);ttk.Label(self,textvariable=self.status,style='Danger.TLabel').pack(anchor='w',pady=(5,0))
89
+ self.bind('<Destroy>',self._cleanup,add='+');self.set_color(color,notify=False)
90
+ def _normalize(self,color):
91
+ text=str(color).strip()
92
+ if len(text)==4 and text.startswith('#'):text='#'+''.join(c*2 for c in text[1:])
93
+ try:rgb=self.winfo_rgb(text)
94
+ except tk.TclError as exc:raise ValueError('请输入有效颜色,例如 #087DFF') from exc
95
+ return '#'+''.join(f'{round(channel/257):02X}' for channel in rgb)
96
+ def get_color(self):return self._normalize(self.hex.get())
97
+ def set_color(self,color,*,notify=True):
98
+ color=self._normalize(color);rgb=[int(color[i:i+2],16) for i in (1,3,5)]
99
+ self._h,self._s,self._v=colorsys.rgb_to_hsv(*(c/255 for c in rgb))
100
+ self._busy=True
101
+ try:
102
+ self.hex.set(color)
103
+ for var,value in zip(self.rgb,rgb):var.set(str(value))
104
+ self.hue.set(self._h);self.swatch.configure(background=color);self.hex_entry.state(['!invalid']);self.status.set('')
105
+ finally:self._busy=False
106
+ self._schedule_gradient()
107
+ if notify:
108
+ self.event_generate('<<ColorChanged>>',when='tail')
109
+ if self.command:self.command(color)
110
+ def apply_hex(self):
111
+ try:self.set_color(self.hex.get());return True
112
+ except ValueError as exc:self.status.set(str(exc));self.hex_entry.state(['invalid']);return False
113
+ def apply_rgb(self):
114
+ if self._busy:return
115
+ try:
116
+ rgb=[int(var.get()) for var in self.rgb]
117
+ if any(not 0<=value<=255 for value in rgb):raise ValueError()
118
+ self.set_color('#'+''.join(f'{value:02X}' for value in rgb));return True
119
+ except ValueError:self.status.set('RGB 必须为 0–255 的整数');return False
120
+ def _hue_changed(self,value):
121
+ if self._busy:return
122
+ self._h=float(value);self.hue.set(self._h);self._apply_hsv(rebuild=True)
123
+ def _pick_hue(self,event):
124
+ self._hue_changed(max(0,min(1,event.x/max(1,self.hue_canvas.winfo_width()-1))))
125
+ def _draw_hue(self):
126
+ width=max(2,self.hue_canvas.winfo_width());source=tk.PhotoImage(master=self,width=width,height=1)
127
+ colors=['#'+''.join(f'{round(v*255):02x}' for v in colorsys.hsv_to_rgb(x/(width-1),1,1)) for x in range(width)]
128
+ source.put('{'+' '.join(colors)+'}');self._hue_image=source.zoom(1,self.px(12))
129
+ self.hue_canvas.delete('all');self.hue_canvas.create_image(0,self.px(4),image=self._hue_image,anchor='nw');self._hue_marker()
130
+ def _hue_marker(self):
131
+ self.hue_canvas.delete('marker');x=self._h*(self.hue_canvas.winfo_width()-1)
132
+ self.hue_canvas.create_rectangle(x-self.px(2),1,x+self.px(2),self.px(20)-1,outline='#FFFFFF',width=2,tags='marker')
133
+ def _pick(self,event):
134
+ self._s=max(0,min(1,event.x/max(1,self.canvas.winfo_width()-1)))
135
+ self._v=1-max(0,min(1,event.y/max(1,self.canvas.winfo_height()-1)))
136
+ self._apply_hsv(rebuild=False)
137
+ def _apply_hsv(self,rebuild):
138
+ rgb=[round(c*255) for c in colorsys.hsv_to_rgb(self._h,self._s,self._v)];color='#'+''.join(f'{c:02X}' for c in rgb)
139
+ self._busy=True
140
+ try:
141
+ self.hex.set(color)
142
+ for var,value in zip(self.rgb,rgb):var.set(str(value))
143
+ self.swatch.configure(background=color);self.status.set('');self.hex_entry.state(['!invalid'])
144
+ finally:self._busy=False
145
+ if rebuild:self._schedule_gradient()
146
+ else:self._marker()
147
+ if self.command:self.command(color)
148
+ self.event_generate('<<ColorChanged>>',when='tail')
149
+ def _schedule_gradient(self):
150
+ if not self._closed and self._job is None:self._job=self.after(35,self._draw_gradient)
151
+ def _draw_gradient(self):
152
+ self._job=None
153
+ if self._closed:return
154
+ w,h=self.canvas.winfo_width(),self.canvas.winfo_height()
155
+ if w<2 or h<2:return
156
+ cell=self.px(1);cols=math.ceil(w/cell);rows=math.ceil(h/cell)
157
+ source=tk.PhotoImage(master=self,width=cols,height=rows)
158
+ pixels=[]
159
+ pure=colorsys.hsv_to_rgb(self._h,1,1)
160
+ columns=[tuple(1+(channel-1)*x/max(1,cols-1) for channel in pure) for x in range(cols)]
161
+ for y in range(rows):
162
+ value=1-y/max(1,rows-1)
163
+ row=['#'+''.join(f'{round(channel*value*255):02x}' for channel in column) for column in columns]
164
+ pixels.append('{'+ ' '.join(row)+'}')
165
+ source.put(' '.join(pixels));self._gradient=source.zoom(cell)
166
+ self.canvas.delete('gradient');self.canvas.create_image(0,0,image=self._gradient,anchor='nw',tags='gradient');self.canvas.tag_lower('gradient');self._marker();self._hue_marker()
167
+ def _marker(self):
168
+ self.canvas.delete('marker');x=self._s*(self.canvas.winfo_width()-1);y=(1-self._v)*(self.canvas.winfo_height()-1);r=self.px(5)
169
+ self.canvas.create_oval(x-r,y-r,x+r,y+r,outline='#FFFFFF',width=2,tags='marker')
170
+ self.canvas.create_oval(x-r-1,y-r-1,x+r+1,y+r+1,outline='#333333',width=1,tags='marker')
171
+ def copy_color(self):
172
+ if self.apply_hex():self.clipboard_clear();self.clipboard_append(self.get_color())
173
+ def _cleanup(self,event):
174
+ if event.widget is self:
175
+ self._closed=True
176
+ if self._job is not None:self.after_cancel(self._job)
177
+
178
+ class ColorDialog(ResultDialog):
179
+ def __init__(self,master,*,color='#087DFF',title='选择颜色'):
180
+ super().__init__(master,title,width=480,height=470)
181
+ self.picker=ColorPicker(self.body,color=color);self.picker.pack(fill='both',expand=True)
182
+ self.primary.configure(text='使用颜色',command=self.submit);self.secondary.pack(side='right',padx=8)
183
+ def submit(self):
184
+ if self.picker.apply_hex():self.accept(self.picker.get_color())
185
+
186
+ class FontPicker(ttk.Frame):
187
+ def __init__(self,master,*,font='TkDefaultFont',**kwargs):
188
+ super().__init__(master,**kwargs)
189
+ self.preview_font=tkfont.Font(root=self._root(),font=font)
190
+ settings=self.preview_font.actual();self.families=sorted(set(tkfont.families(self)),key=str.casefold)
191
+ self.family=tk.StringVar(self,value=settings['family']);self.size=tk.StringVar(self,value=str(settings['size']))
192
+ self.bold=tk.BooleanVar(self,value=settings['weight']=='bold');self.italic=tk.BooleanVar(self,value=settings['slant']=='italic')
193
+ ttk.Label(self,text='字体(可输入名称搜索)').pack(anchor='w',pady=(0,6))
194
+ self.combo=ThemedCombobox(self,textvariable=self.family,values=self.families[:80]);self.combo.pack(fill='x')
195
+ row=ttk.Frame(self);row.pack(fill='x',pady=12)
196
+ ttk.Label(row,text='字号').pack(side='left',padx=(0,8))
197
+ ttk.Spinbox(row,from_=6,to=96,textvariable=self.size,width=5,command=self.refresh).pack(side='left')
198
+ ttk.Checkbutton(row,text='粗体',variable=self.bold,command=self.refresh).pack(side='left',padx=12)
199
+ ttk.Checkbutton(row,text='斜体',variable=self.italic,command=self.refresh).pack(side='left')
200
+ area=Card(self,padding=14);area.pack(fill='both',expand=True)
201
+ self.sample=ttk.Label(area,text='中文字体预览\nThe quick brown fox\n0123456789',font=self.preview_font,justify='left',wraplength=360)
202
+ self.sample.pack(fill='both',expand=True,pady=8)
203
+ self.status=tk.StringVar(self);ttk.Label(self,textvariable=self.status,style='Danger.TLabel').pack(anchor='w',pady=4)
204
+ self._family_trace=self.family.trace_add('write',self._filter);self._size_trace=self.size.trace_add('write',lambda *_:self.refresh())
205
+ self.bind('<Destroy>',self._cleanup,add='+')
206
+ def _filter(self,*_):
207
+ text=self.family.get().casefold();matches=[family for family in self.families if text in family.casefold()]
208
+ self.combo.configure(values=(matches or self.families)[:80]);self.refresh()
209
+ def refresh(self):
210
+ try:
211
+ size=int(self.size.get())
212
+ if not 6<=size<=96:raise ValueError()
213
+ except ValueError:self.status.set('字号应为 6–96 的整数');return False
214
+ self.preview_font.configure(family=self.family.get(),size=size,weight='bold' if self.bold.get() else 'normal',slant='italic' if self.italic.get() else 'roman')
215
+ self.status.set('');return True
216
+ def get_font(self):
217
+ if not self.refresh():raise ValueError(self.status.get())
218
+ if self.family.get() not in self.families:raise ValueError('请从字体列表中选择完整字体名称')
219
+ return self.preview_font.copy()
220
+ def _cleanup(self,event):
221
+ if event.widget is self:
222
+ self.family.trace_remove('write',self._family_trace);self.size.trace_remove('write',self._size_trace)
223
+
224
+ class FontDialog(ResultDialog):
225
+ def __init__(self,master,*,font='TkDefaultFont',title='选择字体'):
226
+ super().__init__(master,title,width=480,height=430)
227
+ self.picker=FontPicker(self.body,font=font);self.picker.pack(fill='both',expand=True)
228
+ self.primary.configure(text='使用字体',command=self.submit);self.secondary.pack(side='right',padx=8)
229
+ def submit(self):
230
+ try:font=self.picker.get_font()
231
+ except ValueError as exc:self.picker.status.set(str(exc));return
232
+ self.accept(font)
233
+
234
+ class ImageViewer(ttk.Frame):
235
+ def __init__(self,master,*,height=300,max_render_pixels=4_000_000,**kwargs):
236
+ super().__init__(master,**kwargs)
237
+ self._source=None;self._pil=None;self._display=None;self._native_rows=None;self.path=None;self.error=''
238
+ self._fit=True;self.zoom_ratio=1.;self.cx=self.cy=0.;self._job=None;self._closed=False;self.max_render_pixels=max_render_pixels
239
+ self.dpi=self.winfo_fpixels('1i')/96
240
+ tools=ttk.Frame(self);tools.pack(fill='x',pady=(0,6))
241
+ for text,command in [('打开',self.choose_file),('适应窗口',self.fit),('1:1',lambda:self.set_zoom(1)),('−',lambda:self.set_zoom(self.zoom_ratio/1.25)),('+',lambda:self.set_zoom(self.zoom_ratio*1.25)),('清空',self.clear)]:
242
+ ttk.Button(tools,text=text,command=command,width=3 if text in ('−','+') else None).pack(side='left',padx=(0,5))
243
+ self.canvas=tk.Canvas(self,height=round(height*self.dpi),background='#F5F6F9',highlightthickness=1,highlightbackground=PALETTE['border'])
244
+ self.canvas.pack(fill='both',expand=True)
245
+ self._item=self.canvas.create_image(0,0,anchor='center');self._empty=self.canvas.create_text(0,0,text='打开图片,或由程序传入 PhotoImage',fill=PALETTE['muted'],font='TkDefaultFont')
246
+ self.status=tk.StringVar(self,value='PNG / GIF / PPM / PGM 原生支持;其他格式可选 Pillow')
247
+ ttk.Label(self,textvariable=self.status,style='Muted.TLabel',wraplength=700).pack(fill='x',pady=(6,0))
248
+ self.canvas.bind('<Configure>',self._resized);self.canvas.bind('<MouseWheel>',self._wheel)
249
+ self.canvas.bind('<ButtonPress-1>',self._press);self.canvas.bind('<B1-Motion>',self._drag)
250
+ self.canvas.bind('<Double-Button-1>',lambda e:self.fit());self._drag_start=None
251
+ self.bind('<Destroy>',self._cleanup,add='+')
252
+ def choose_file(self):
253
+ path=filedialog.askopenfilename(parent=self,title='打开图片',filetypes=[('图片','*.png *.gif *.ppm *.pgm *.jpg *.jpeg *.bmp *.webp'),('所有文件','*.*')])
254
+ if path:self.load(path)
255
+ def load(self,path):
256
+ path=Path(path).expanduser().resolve()
257
+ Image=None
258
+ try:
259
+ try:
260
+ from PIL import Image, ImageOps
261
+ except ImportError:Image=None
262
+ if Image is not None:
263
+ with Image.open(path) as source:pil=ImageOps.exif_transpose(source).convert('RGBA').copy()
264
+ native=None
265
+ else:
266
+ pil=None;native=tk.PhotoImage(master=self,file=str(path))
267
+ except (OSError,ValueError,tk.TclError) as exc:
268
+ self.error='无法打开图片:'+str(exc)
269
+ if Image is None:self.error+=';JPEG / WebP 等格式可安装 Pillow 后使用'
270
+ self.status.set(self.error);return False
271
+ self._pil=pil;self._source=native;self._native_rows=None;self.path=path;self.error='';self.fit();return True
272
+ def set_image(self,image):
273
+ if not isinstance(image,tk.PhotoImage):raise TypeError('set_image 接收 tkinter.PhotoImage')
274
+ self._source=image.copy();self._pil=None;self._native_rows=None;self.path=None;self.error='';self.fit()
275
+ def _size(self):
276
+ if self._pil is not None:return self._pil.size
277
+ if self._source is not None:return self._source.width(),self._source.height()
278
+ return 0,0
279
+ def clear(self):
280
+ self._source=self._pil=self._display=self._native_rows=None;self.path=None;self.error='';self.canvas.itemconfigure(self._item,image='')
281
+ self.canvas.itemconfigure(self._empty,state='normal');self.status.set('未加载图片')
282
+ def fit(self):
283
+ self._fit=True;self._schedule()
284
+ def set_zoom(self,value,anchor=None):
285
+ w,h=self._size()
286
+ if not w or not h:return
287
+ old=self.zoom_ratio
288
+ upper=min(8,math.sqrt(self.max_render_pixels/(w*h)),4096/max(w,h))
289
+ value=max(1/max(w,h),min(float(value),upper))
290
+ if anchor and old:
291
+ self.cx=anchor[0]-(anchor[0]-self.cx)*value/old;self.cy=anchor[1]-(anchor[1]-self.cy)*value/old
292
+ self.zoom_ratio=value;self._fit=False;self._schedule()
293
+ def _resized(self,event):
294
+ self.canvas.coords(self._empty,event.width/2,event.height/2)
295
+ self.canvas.delete('checker');cell=round(18*self.dpi)
296
+ for y in range(0,event.height,cell):
297
+ for x in range(0,event.width,cell):
298
+ if (x//cell+y//cell)%2:self.canvas.create_rectangle(x,y,x+cell,y+cell,fill='#E9ECF1',outline='',tags='checker')
299
+ self.canvas.tag_lower('checker');self._schedule()
300
+ def _schedule(self):
301
+ if not self._closed and self._job is None:self._job=self.after(25,self._render)
302
+ def _render(self):
303
+ self._job=None
304
+ if self._closed:return
305
+ w,h=self._size()
306
+ if not w or not h:return
307
+ cw,ch=self.canvas.winfo_width(),self.canvas.winfo_height()
308
+ if cw<2 or ch<2:return
309
+ if self._fit:
310
+ self.zoom_ratio=min((cw-16)/w,(ch-16)/h,math.sqrt(self.max_render_pixels/(w*h)),4096/max(w,h))
311
+ self.zoom_ratio=max(self.zoom_ratio,1/max(w,h));self.cx=cw/2;self.cy=ch/2
312
+ if self._pil is not None:
313
+ from PIL import Image, ImageTk
314
+ size=(max(1,round(w*self.zoom_ratio)),max(1,round(h*self.zoom_ratio)))
315
+ self._display=ImageTk.PhotoImage(self._pil.resize(size,Image.Resampling.LANCZOS),master=self)
316
+ backend='Pillow'
317
+ else:
318
+ if self._native_rows is None:self._native_rows=_photo_rows(self._source)
319
+ self._display=_resize_photo(self,self._native_rows,max(1,round(w*self.zoom_ratio)),max(1,round(h*self.zoom_ratio)))
320
+ backend='Tk 原生'
321
+ self.canvas.itemconfigure(self._empty,state='hidden');self.canvas.itemconfigure(self._item,image=self._display)
322
+ self.canvas.coords(self._item,self.cx,self.cy);self.canvas.tag_raise(self._item)
323
+ actual=self._display.width()/w
324
+ self.status.set(f'{self.path.name if self.path else "内存图片"} · {w} × {h} · {actual*100:.0f}% · {backend}')
325
+ def _wheel(self,event):
326
+ if self._size()[0]:self.set_zoom(self.zoom_ratio*(1.2 if event.delta>0 else 1/1.2),(event.x,event.y))
327
+ return 'break'
328
+ def _press(self,event):self._drag_start=(event.x,event.y,self.cx,self.cy)
329
+ def _drag(self,event):
330
+ if self._drag_start is None:return
331
+ x,y,cx,cy=self._drag_start;self.cx=cx+event.x-x;self.cy=cy+event.y-y;self._fit=False
332
+ self.canvas.coords(self._item,self.cx,self.cy)
333
+ def _cleanup(self,event):
334
+ if event.widget is self:
335
+ self._closed=True
336
+ if self._job is not None:self.after_cancel(self._job)
tkwebui/placeholder.py ADDED
@@ -0,0 +1,174 @@
1
+ """Grey hint text for empty inputs (PlaceholderText), GitHub issue #3.
2
+
3
+ The hint is a separate label laid over the field, never text inside it, so
4
+ get(), textvariable, show='●', validation and readonly behave exactly as
5
+ without a hint. Like Windows 11 it stays visible while the field has focus and
6
+ disappears as soon as there is content.
7
+
8
+ set_placeholder(existing_entry, '请输入用户名') # any entry-like widget
9
+ PlaceholderEntry(parent, placeholder='搜索') # convenience class
10
+ """
11
+ import tkinter as tk
12
+ from tkinter import ttk
13
+ from .core import PALETTE
14
+
15
+ _ENTRIES=(ttk.Entry,tk.Entry,tk.Spinbox) # ttk.Combobox/Spinbox derive from ttk.Entry
16
+ _ARROWS=(ttk.Combobox,ttk.Spinbox,tk.Spinbox)
17
+
18
+
19
+ def _field_color(widget):
20
+ """Colour of the field face behind the hint (grey only when disabled)."""
21
+ if not isinstance(widget,ttk.Widget):
22
+ state=str(widget.cget('state'))
23
+ key='disabledbackground' if state=='disabled' else 'readonlybackground' if state=='readonly' else 'background'
24
+ try:return str(widget.cget(key))
25
+ except tk.TclError:return str(widget.cget('background'))
26
+ return PALETTE['sidebar'] if widget.instate(['disabled']) else PALETTE['bg']
27
+
28
+
29
+ def _disabled(widget):
30
+ if isinstance(widget,ttk.Widget):return widget.instate(['disabled'])
31
+ return str(widget.cget('state'))=='disabled'
32
+
33
+
34
+ class Placeholder:
35
+ """Controller behind set_placeholder(); one per widget."""
36
+ def __init__(self,widget,text=''):
37
+ self.widget=widget;self.text=str(text or '')
38
+ self.job=None;self.closed=False;self.variable=None;self._own=None;self._drawn=None;self._retries=0
39
+ self.label=tk.Label(widget.master,anchor='w',borderwidth=0,highlightthickness=0,padx=0,pady=0,
40
+ cursor='xterm',takefocus=0)
41
+ self.trace_command=widget.register(lambda *args:self.schedule())
42
+ # Our own bindtag: user bindings stay untouched and nothing to unbind.
43
+ self.tag=f'TkWebUIPlaceholder{id(self)}'
44
+ tags=widget.bindtags();widget.bindtags((tags[0],self.tag,*tags[1:]))
45
+ self.sequences=('<Map>','<Configure>','<FocusIn>','<FocusOut>','<KeyRelease>','<Enter>','<Leave>',
46
+ '<<Paste>>','<<Cut>>','<<Clear>>','<<ComboboxSelected>>')
47
+ for sequence in self.sequences:widget.bind_class(self.tag,sequence,self.schedule)
48
+ widget.bind_class(self.tag,'<Destroy>',self.destroyed)
49
+ # Clicks and hover belong to the field underneath.
50
+ self.label.bind('<ButtonPress-1>',lambda e:self.forward(e,'<ButtonPress-1>'))
51
+ self.label.bind('<ButtonRelease-1>',lambda e:self.forward(e,'<ButtonRelease-1>'))
52
+ self.label.bind('<Enter>',lambda e:widget.event_generate('<Enter>'))
53
+ self.label.bind('<Leave>',self.label_left)
54
+ # state()/configure() emit no event; follow them for the disabled look.
55
+ for name in ('state','configure','config'):
56
+ original=getattr(widget,name,None)
57
+ if original is None:continue # classic tk.Entry has no state()
58
+ def follow(*args,_original=original,**kwargs):
59
+ result=_original(*args,**kwargs)
60
+ if args or kwargs:self.schedule()
61
+ return result
62
+ setattr(widget,name,follow)
63
+ widget._tkwebui_placeholder=self
64
+ self.schedule()
65
+
66
+ def set_text(self,text):
67
+ self.text=str(text or '');self.schedule()
68
+
69
+ def forward(self,event,sequence):
70
+ w=self.widget
71
+ if not w.winfo_exists():return 'break'
72
+ w.event_generate(sequence,x=event.x_root-w.winfo_rootx(),y=event.y_root-w.winfo_rooty())
73
+ return 'break'
74
+
75
+ def label_left(self,event):
76
+ w=self.widget
77
+ if not w.winfo_exists():return
78
+ x,y=w.winfo_pointerxy()
79
+ if not (w.winfo_rootx()<=x<w.winfo_rootx()+w.winfo_width() and w.winfo_rooty()<=y<w.winfo_rooty()+w.winfo_height()):
80
+ w.event_generate('<Leave>')
81
+
82
+ def schedule(self,event=None):
83
+ # A frame later: after the widget's own handlers (hover/focus states are
84
+ # current) and after Tk's idle redraw, which is when ttk lays out the
85
+ # text area that bbox() reports.
86
+ if not self.closed and self.job is None:self.job=self.widget.after(16,self.refresh)
87
+
88
+ def hook_variable(self):
89
+ """Content changes arrive through the textvariable (typing, insert,
90
+ delete, set, current). Widgets without one get a private variable."""
91
+ w=self.widget
92
+ name=str(w.cget('textvariable'))
93
+ if not name:
94
+ self._own=tk.StringVar(w,value=w.get())
95
+ w.configure(textvariable=self._own);name=str(self._own)
96
+ if name==self.variable:return
97
+ self.untrace()
98
+ self.variable=name
99
+ w.tk.call('trace','add','variable',name,('write','unset'),self.trace_command)
100
+
101
+ def untrace(self):
102
+ if self.variable:
103
+ try:self.widget.tk.call('trace','remove','variable',self.variable,('write','unset'),self.trace_command)
104
+ except tk.TclError:pass
105
+ self.variable=None
106
+
107
+ def refresh(self):
108
+ self.job=None
109
+ w=self.widget
110
+ if self.closed or not w.winfo_exists():return
111
+ self.hook_variable()
112
+ if not self.text or w.get() or not w.winfo_ismapped():
113
+ if self._drawn is not None:self.label.place_forget();self._drawn=None
114
+ return
115
+ box=w.bbox(0) if isinstance(w,ttk.Widget) else None
116
+ if box and box[0]==0 and box[1]==0 and self._retries<10:
117
+ # Not laid out yet (first map): bbox is still all zeros.
118
+ self._retries+=1;self.job=w.after(30,self.refresh);return
119
+ self._retries=0
120
+ if box and box[3]>0:x,y,height=box[0],box[1],box[3]
121
+ else:
122
+ x=int(w.winfo_pixels(w.cget('borderwidth')))+int(w.winfo_pixels(w.cget('highlightthickness')))+1
123
+ y=height=None
124
+ right=x+(w.winfo_height() if isinstance(w,_ARROWS) else 0)
125
+ font=str(w.cget('font')) or 'TkTextFont'
126
+ options=dict(text=self.text,font=font,background=_field_color(w),
127
+ foreground=PALETTE['disabled'] if _disabled(w) else PALETTE['muted'])
128
+ place=dict(in_=w,x=x,relwidth=1,width=-(x+right))
129
+ place.update(dict(y=y,height=height) if y is not None else dict(rely=.5,anchor='w'))
130
+ signature=(tuple(sorted(options.items())),tuple(sorted((k,str(v)) for k,v in place.items())))
131
+ if signature!=self._drawn:
132
+ self._drawn=signature
133
+ self.label.configure(**options)
134
+ self.label.place(**place)
135
+ self.label.tk.call('raise',self.label._w,w._w)
136
+
137
+ def destroyed(self,event):
138
+ if event.widget is not self.widget or self.closed:return
139
+ self.closed=True
140
+ if self.job is not None:
141
+ try:self.widget.after_cancel(self.job)
142
+ except tk.TclError:pass
143
+ self.untrace()
144
+ try:self.widget.deletecommand(self.trace_command)
145
+ except tk.TclError:pass
146
+ for sequence in (*self.sequences,'<Destroy>'):self.widget.unbind_class(self.tag,sequence)
147
+ if self.label.winfo_exists():self.label.destroy()
148
+
149
+
150
+ def set_placeholder(widget,text):
151
+ """Show grey hint ``text`` while ``widget`` is empty; '' hides it.
152
+
153
+ Works on ttk.Entry, ttk.Combobox (editable or readonly, e.g. '请选择…'),
154
+ ttk.Spinbox, tk.Entry and tk.Spinbox. get() always returns the real
155
+ content. Calling it again changes the text. Returns the controller.
156
+ """
157
+ if not isinstance(widget,_ENTRIES):raise TypeError('set_placeholder 只支持 Entry / Combobox / Spinbox')
158
+ controller=getattr(widget,'_tkwebui_placeholder',None)
159
+ if controller is not None and not controller.closed:
160
+ controller.set_text(text);return controller
161
+ return Placeholder(widget,text)
162
+
163
+
164
+ class PlaceholderEntry(ttk.Entry):
165
+ """ttk.Entry with a grey hint; ``get()`` returns '' while it is empty."""
166
+ def __init__(self,master=None,placeholder='',**kwargs):
167
+ super().__init__(master,**kwargs)
168
+ self._placeholder=set_placeholder(self,placeholder)
169
+
170
+ @property
171
+ def placeholder(self):return self._placeholder.text
172
+
173
+ @placeholder.setter
174
+ def placeholder(self,text):self._placeholder.set_text(text)