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