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/table.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Sortable native Treeview and an editable, cell-selectable TSV spreadsheet."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import csv
|
|
4
|
+
from decimal import Decimal, InvalidOperation
|
|
5
|
+
import io
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
from tkinter import ttk, font as tkfont
|
|
10
|
+
from .core import PALETTE
|
|
11
|
+
from .scrolling import AutoScrollbar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sort_key(value):
|
|
15
|
+
text=str(value).strip()
|
|
16
|
+
try:
|
|
17
|
+
number=Decimal(text.replace(',','').rstrip('%'))
|
|
18
|
+
if number.is_finite():return 0,number
|
|
19
|
+
except InvalidOperation:pass
|
|
20
|
+
return 1,tuple((0,int(part)) if part.isdigit() else (1,part) for part in re.split(r'(\d+)',text.casefold()))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TableSorter:
|
|
24
|
+
def __init__(self,tree):
|
|
25
|
+
self.tree=tree;self.labels={};self.column=None;self.reverse=False
|
|
26
|
+
self.refresh()
|
|
27
|
+
def refresh(self):
|
|
28
|
+
for column in self.tree['columns']:
|
|
29
|
+
current=self.tree.heading(column)
|
|
30
|
+
if column not in self.labels and not current.get('command'):
|
|
31
|
+
self.labels[column]=current['text']
|
|
32
|
+
self.tree.heading(column,command=lambda c=column:self.sort_by(c))
|
|
33
|
+
def sort_by(self,column,reverse=None):
|
|
34
|
+
if column not in self.tree['columns']:raise ValueError('Unknown column: '+str(column))
|
|
35
|
+
reverse=(not self.reverse if column==self.column else False) if reverse is None else bool(reverse)
|
|
36
|
+
def sort_children(parent):
|
|
37
|
+
children=self.tree.get_children(parent)
|
|
38
|
+
values=[(self.tree.set(item,column),item) for item in children]
|
|
39
|
+
filled=[pair for pair in values if str(pair[0]).strip()]
|
|
40
|
+
empty=[pair for pair in values if not str(pair[0]).strip()]
|
|
41
|
+
ordered=sorted(filled,key=lambda pair:_sort_key(pair[0]),reverse=reverse)+empty
|
|
42
|
+
for position,(_,item) in enumerate(ordered):
|
|
43
|
+
self.tree.move(item,parent,position)
|
|
44
|
+
if self.tree.get_children(item):sort_children(item)
|
|
45
|
+
sort_children('')
|
|
46
|
+
for key in self.labels:
|
|
47
|
+
current=self.tree.heading(key,'text')
|
|
48
|
+
self.labels[key]=re.sub(r' [▲▼]$','',current)
|
|
49
|
+
self.tree.heading(key,text=self.labels[key]+((' ▼' if reverse else ' ▲') if key==column else ''))
|
|
50
|
+
self.column=column;self.reverse=reverse
|
|
51
|
+
self.tree.event_generate('<<TreeviewSorted>>',when='tail')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def enable_sorting(tree):
|
|
55
|
+
"""Install native heading commands. Existing custom heading commands are retained."""
|
|
56
|
+
if not hasattr(tree,'_web_sorter'):tree._web_sorter=TableSorter(tree)
|
|
57
|
+
else:tree._web_sorter.refresh()
|
|
58
|
+
return tree._web_sorter
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SortableTreeview(ttk.Treeview):
|
|
62
|
+
def __init__(self,master=None,**kwargs):
|
|
63
|
+
super().__init__(master,**kwargs)
|
|
64
|
+
enable_sorting(self)
|
|
65
|
+
def sort_by(self,column,reverse=None):
|
|
66
|
+
self._web_sorter.sort_by(column,reverse)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def column_name(index):
|
|
70
|
+
result='';index+=1
|
|
71
|
+
while index:
|
|
72
|
+
index,remainder=divmod(index-1,26);result=chr(65+remainder)+result
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Spreadsheet(ttk.Frame):
|
|
77
|
+
"""Editable cells; Shift range, Ctrl disjoint selection, TSV copy/paste and undo.
|
|
78
|
+
|
|
79
|
+
Coordinates in the API are zero-based (row, column). Paste starts at `active`,
|
|
80
|
+
not the selection's bounding box. Disjoint copying leaves blank TSV cells in
|
|
81
|
+
the gaps. Values are plain text; formulas are never evaluated.
|
|
82
|
+
"""
|
|
83
|
+
def __init__(self,master,*,data=None,rows=30,columns=8,headers=None,column_width=122,row_height=30,max_cells=1_000_000,**kwargs):
|
|
84
|
+
super().__init__(master,**kwargs)
|
|
85
|
+
self.scale=self.winfo_fpixels('1i')/96;self.px=lambda n:max(1,round(n*self.scale))
|
|
86
|
+
self.cw=self.px(column_width);self.rh=self.px(row_height);self.max_cells=max_cells
|
|
87
|
+
self.headers=list(headers or []);self.active=(0,0);self.anchor=(0,0)
|
|
88
|
+
self.selection={(0,0)};self._drag_base=set();self._drag_anchor=(0,0)
|
|
89
|
+
self._draw_job=None;self.editor=None;self.editing_cell=None;self._closing=False
|
|
90
|
+
self._undo=[];self._redo=[];self._previous_grab=None
|
|
91
|
+
self.font=tkfont.Font(self,family='Microsoft YaHei UI',size=10)
|
|
92
|
+
self.columnconfigure(1,weight=1);self.rowconfigure(1,weight=1)
|
|
93
|
+
canvas_options=dict(background=PALETTE['bg'],highlightthickness=0,borderwidth=0)
|
|
94
|
+
self.corner=tk.Canvas(self,width=self.px(45),height=self.rh,**canvas_options)
|
|
95
|
+
self.top=tk.Canvas(self,height=self.rh,**canvas_options)
|
|
96
|
+
self.left=tk.Canvas(self,width=self.px(45),**canvas_options)
|
|
97
|
+
self.canvas=tk.Canvas(self,takefocus=True,**canvas_options)
|
|
98
|
+
self.corner.grid(row=0,column=0,sticky='nsew');self.top.grid(row=0,column=1,sticky='ew')
|
|
99
|
+
self.left.grid(row=1,column=0,sticky='ns');self.canvas.grid(row=1,column=1,sticky='nsew')
|
|
100
|
+
self.vbar=AutoScrollbar(self,command=self.yview);self.vbar.grid(row=1,column=2,sticky='ns')
|
|
101
|
+
self.hbar=AutoScrollbar(self,orient='horizontal',command=self.xview);self.hbar.grid(row=2,column=1,sticky='ew')
|
|
102
|
+
self.canvas.configure(xscrollcommand=self._xscroll,yscrollcommand=self._yscroll)
|
|
103
|
+
self.status=tk.StringVar(self,value='A1')
|
|
104
|
+
ttk.Label(self,textvariable=self.status,style='Muted.TLabel',padding=(6,5)).grid(row=3,column=0,columnspan=3,sticky='ew')
|
|
105
|
+
self.canvas.bind('<Configure>',lambda e:self.schedule())
|
|
106
|
+
self.canvas.bind('<ButtonPress-1>',self._press)
|
|
107
|
+
self.canvas.bind('<B1-Motion>',self._drag)
|
|
108
|
+
self.canvas.bind('<ButtonRelease-1>',self._release)
|
|
109
|
+
self.canvas.bind('<Double-Button-1>',lambda e:self.start_edit())
|
|
110
|
+
self.canvas.bind('<MouseWheel>',self._wheel)
|
|
111
|
+
self.canvas.bind('<Shift-MouseWheel>',lambda e:self._wheel(e,horizontal=True))
|
|
112
|
+
self.canvas.bind('<KeyPress>',self._type)
|
|
113
|
+
for key,delta in [('Left',(0,-1)),('Right',(0,1)),('Up',(-1,0)),('Down',(1,0))]:
|
|
114
|
+
self.canvas.bind('<'+key+'>',lambda e,d=delta:self._navigate(d,bool(e.state&1)))
|
|
115
|
+
self.canvas.bind('<Home>',lambda e:self.select(self.active[0],0))
|
|
116
|
+
self.canvas.bind('<End>',lambda e:self.select(self.active[0],self.column_count-1))
|
|
117
|
+
self.canvas.bind('<Control-Home>',lambda e:self.select(0,0))
|
|
118
|
+
self.canvas.bind('<Control-End>',lambda e:self.select(self.row_count-1,self.column_count-1))
|
|
119
|
+
for key in ('Return','F2'):
|
|
120
|
+
self.canvas.bind('<'+key+'>',lambda e:self.start_edit())
|
|
121
|
+
self.canvas.bind('<Tab>',lambda e:self._navigate((0,-1 if e.state&1 else 1),False))
|
|
122
|
+
self.canvas.bind('<Delete>',lambda e:self.clear_selection())
|
|
123
|
+
for key,action in [('c',self.copy),('v',self.paste),('a',self.select_all),('z',self.undo),('y',self.redo)]:
|
|
124
|
+
self.canvas.bind('<Control-'+key+'>',lambda e,fn=action:self._shortcut(fn))
|
|
125
|
+
self.top.bind('<Button-1>',self._select_column)
|
|
126
|
+
self.left.bind('<Button-1>',self._select_row)
|
|
127
|
+
self.corner.bind('<Button-1>',lambda e:self.select_all())
|
|
128
|
+
self.bind('<Destroy>',self._destroyed,add='+')
|
|
129
|
+
self.set_data(data or [],rows=rows,columns=columns)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def row_count(self):return len(self.data)
|
|
133
|
+
@property
|
|
134
|
+
def column_count(self):return len(self.data[0]) if self.data else 0
|
|
135
|
+
def set_data(self,data,*,rows=None,columns=None):
|
|
136
|
+
self.finish_edit(False)
|
|
137
|
+
values=[[str(value) if value is not None else '' for value in row] for row in data]
|
|
138
|
+
nr=max(1,rows or 0,len(values));nc=max(1,columns or 0,max(map(len,values),default=0),len(self.headers))
|
|
139
|
+
self._check_size(nr,nc)
|
|
140
|
+
self.data=[(values[r] if r<len(values) else [])+['']*(nc-(len(values[r]) if r<len(values) else 0)) for r in range(nr)]
|
|
141
|
+
self.active=self.anchor=(0,0);self.selection={(0,0)};self._undo.clear();self._redo.clear()
|
|
142
|
+
self._regions();self.schedule();self._status()
|
|
143
|
+
def get_data(self):return [row[:] for row in self.data]
|
|
144
|
+
def get_cell(self,row,column):return self.data[row][column]
|
|
145
|
+
def set_cell(self,row,column,value):
|
|
146
|
+
if row<0 or column<0:raise IndexError('Cell coordinates must be nonnegative')
|
|
147
|
+
self._ensure_size(row+1,column+1)
|
|
148
|
+
self._apply([(row,column,str(value))])
|
|
149
|
+
def get_selection(self):return sorted(self.selection)
|
|
150
|
+
def _check_size(self,rows,columns):
|
|
151
|
+
if rows*columns>self.max_cells:raise ValueError(f'表格最多支持 {self.max_cells:,} 个单元格')
|
|
152
|
+
def _ensure_size(self,rows,columns):
|
|
153
|
+
rows=max(rows,self.row_count);columns=max(columns,self.column_count);self._check_size(rows,columns)
|
|
154
|
+
for row in self.data:row.extend(['']*(columns-len(row)))
|
|
155
|
+
self.data.extend([['']*columns for _ in range(rows-self.row_count)])
|
|
156
|
+
self._regions()
|
|
157
|
+
def _regions(self):
|
|
158
|
+
width=self.column_count*self.cw;height=self.row_count*self.rh
|
|
159
|
+
self.canvas.configure(scrollregion=(0,0,width,height))
|
|
160
|
+
self.top.configure(scrollregion=(0,0,width,self.rh))
|
|
161
|
+
self.left.configure(scrollregion=(0,0,self.px(45),height))
|
|
162
|
+
def schedule(self):
|
|
163
|
+
if not self._closing and self._draw_job is None:self._draw_job=self.after_idle(self._draw)
|
|
164
|
+
def _xscroll(self,first,last):
|
|
165
|
+
self.hbar.set(first,last);self.top.xview_moveto(first);self.schedule()
|
|
166
|
+
def _yscroll(self,first,last):
|
|
167
|
+
self.vbar.set(first,last);self.left.yview_moveto(first);self.schedule()
|
|
168
|
+
def xview(self,*args):
|
|
169
|
+
if args:self.finish_edit()
|
|
170
|
+
return self.canvas.xview(*args)
|
|
171
|
+
def yview(self,*args):
|
|
172
|
+
if args:self.finish_edit()
|
|
173
|
+
return self.canvas.yview(*args)
|
|
174
|
+
def _wheel(self,event,horizontal=False):
|
|
175
|
+
self.finish_edit()
|
|
176
|
+
if horizontal:self.canvas.xview_scroll(-int(event.delta/120)*2,'units')
|
|
177
|
+
else:self.canvas.yview_scroll(-int(event.delta/120)*2,'units')
|
|
178
|
+
return 'break'
|
|
179
|
+
def _fit_text(self,value):
|
|
180
|
+
value=value.replace('\r\n',' ↵ ').replace('\n',' ↵ ').replace('\t',' ')
|
|
181
|
+
available=self.cw-self.px(16)
|
|
182
|
+
if self.font.measure(value)<=available:return value
|
|
183
|
+
lo,hi=0,len(value)
|
|
184
|
+
while lo<hi:
|
|
185
|
+
middle=(lo+hi+1)//2
|
|
186
|
+
if self.font.measure(value[:middle]+'…')<=available:lo=middle
|
|
187
|
+
else:hi=middle-1
|
|
188
|
+
return value[:lo]+'…'
|
|
189
|
+
def _draw(self):
|
|
190
|
+
self._draw_job=None
|
|
191
|
+
if self._closing:return
|
|
192
|
+
c=PALETTE
|
|
193
|
+
for canvas in (self.canvas,self.top,self.left,self.corner):canvas.delete('all')
|
|
194
|
+
x0=self.canvas.canvasx(0);y0=self.canvas.canvasy(0)
|
|
195
|
+
first_col=max(0,int(x0//self.cw));first_row=max(0,int(y0//self.rh))
|
|
196
|
+
last_col=min(self.column_count,math.ceil((x0+self.canvas.winfo_width())/self.cw)+1)
|
|
197
|
+
last_row=min(self.row_count,math.ceil((y0+self.canvas.winfo_height())/self.rh)+1)
|
|
198
|
+
for r in range(first_row,last_row):
|
|
199
|
+
y=r*self.rh
|
|
200
|
+
self.left.create_rectangle(0,y,self.px(45),y+self.rh,fill=c['selected'] if r==self.active[0] else c['sidebar'],outline=c['border'])
|
|
201
|
+
self.left.create_text(self.px(22),y+self.rh/2,text=str(r+1),font=self.font,fill=c['blue'] if r==self.active[0] else c['muted'])
|
|
202
|
+
for col in range(first_col,last_col):
|
|
203
|
+
x=col*self.cw;selected=(r,col) in self.selection
|
|
204
|
+
self.canvas.create_rectangle(x,y,x+self.cw,y+self.rh,fill=c['selected'] if selected else c['bg'],outline=c['border'])
|
|
205
|
+
self.canvas.create_text(x+self.px(8),y+self.rh/2,text=self._fit_text(self.data[r][col]),anchor='w',font=self.font,fill=c['fg'])
|
|
206
|
+
for col in range(first_col,last_col):
|
|
207
|
+
x=col*self.cw
|
|
208
|
+
self.top.create_rectangle(x,0,x+self.cw,self.rh,fill=c['selected'] if col==self.active[1] else c['sidebar'],outline=c['border'])
|
|
209
|
+
title=self.headers[col] if col<len(self.headers) else column_name(col)
|
|
210
|
+
self.top.create_text(x+self.cw/2,self.rh/2,text=self._fit_text(title),font=self.font,fill=c['blue'] if col==self.active[1] else c['muted'])
|
|
211
|
+
self.corner.create_rectangle(0,0,self.px(45),self.rh,fill=c['sidebar'],outline=c['border'])
|
|
212
|
+
self.corner.create_text(self.px(23),self.rh/2,text='▦',font=self.font,fill=c['muted'])
|
|
213
|
+
r,col=self.active;x=col*self.cw;y=r*self.rh
|
|
214
|
+
self.canvas.create_rectangle(x+1,y+1,x+self.cw-1,y+self.rh-1,outline=c['blue'],width=self.px(2))
|
|
215
|
+
if self.editor is not None:
|
|
216
|
+
self.editor.place(x=round(x-x0),y=round(y-y0),width=self.cw,height=self.rh)
|
|
217
|
+
def _cell_at(self,x,y):
|
|
218
|
+
col=min(self.column_count-1,max(0,int(self.canvas.canvasx(x)//self.cw)))
|
|
219
|
+
row=min(self.row_count-1,max(0,int(self.canvas.canvasy(y)//self.rh)))
|
|
220
|
+
return row,col
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _rectangle(a,b):
|
|
223
|
+
return {(r,c) for r in range(min(a[0],b[0]),max(a[0],b[0])+1) for c in range(min(a[1],b[1]),max(a[1],b[1])+1)}
|
|
224
|
+
def select(self,row,column,*,extend=False,add=False):
|
|
225
|
+
self.finish_edit()
|
|
226
|
+
row=min(self.row_count-1,max(0,row));column=min(self.column_count-1,max(0,column));cell=(row,column)
|
|
227
|
+
if extend:
|
|
228
|
+
self.selection=(self.selection if add else set())|self._rectangle(self.anchor,cell)
|
|
229
|
+
elif add:
|
|
230
|
+
if cell in self.selection and len(self.selection)>1:self.selection.remove(cell)
|
|
231
|
+
else:self.selection.add(cell)
|
|
232
|
+
self.anchor=cell
|
|
233
|
+
else:self.selection={cell};self.anchor=cell
|
|
234
|
+
self.active=cell;self.see(row,column);self.canvas.focus_set();self._changed_selection()
|
|
235
|
+
return 'break'
|
|
236
|
+
def _changed_selection(self):
|
|
237
|
+
self._status();self.schedule();self.event_generate('<<SheetSelect>>',when='tail')
|
|
238
|
+
def _status(self,message=''):
|
|
239
|
+
row,col=self.active
|
|
240
|
+
self.status.set(f'{column_name(col)}{row+1} · 已选 {len(self.selection)} 格'+(' · '+message if message else ''))
|
|
241
|
+
def see(self,row,column):
|
|
242
|
+
x=column*self.cw;y=row*self.rh;x0=self.canvas.canvasx(0);y0=self.canvas.canvasy(0)
|
|
243
|
+
width=self.canvas.winfo_width();height=self.canvas.winfo_height()
|
|
244
|
+
if x<x0:self.canvas.xview_moveto(x/(self.column_count*self.cw))
|
|
245
|
+
elif x+self.cw>x0+width:self.canvas.xview_moveto((x+self.cw-width)/(self.column_count*self.cw))
|
|
246
|
+
if y<y0:self.canvas.yview_moveto(y/(self.row_count*self.rh))
|
|
247
|
+
elif y+self.rh>y0+height:self.canvas.yview_moveto((y+self.rh-height)/(self.row_count*self.rh))
|
|
248
|
+
def _press(self,event):
|
|
249
|
+
self.finish_edit();self.canvas.focus_set()
|
|
250
|
+
cell=self._cell_at(event.x,event.y)
|
|
251
|
+
shift=bool(event.state&1);control=bool(event.state&4)
|
|
252
|
+
before=self.selection.copy()
|
|
253
|
+
self.select(*cell,extend=shift,add=control)
|
|
254
|
+
self._drag_anchor=self.anchor if shift else cell
|
|
255
|
+
self._drag_base=before if control else set()
|
|
256
|
+
self._previous_grab=self.canvas.grab_current();self.canvas.grab_set()
|
|
257
|
+
return 'break'
|
|
258
|
+
def _drag(self,event):
|
|
259
|
+
if event.y<0:self.canvas.yview_scroll(-1,'units')
|
|
260
|
+
elif event.y>self.canvas.winfo_height():self.canvas.yview_scroll(1,'units')
|
|
261
|
+
if event.x<0:self.canvas.xview_scroll(-1,'units')
|
|
262
|
+
elif event.x>self.canvas.winfo_width():self.canvas.xview_scroll(1,'units')
|
|
263
|
+
cell=self._cell_at(event.x,event.y)
|
|
264
|
+
self.selection=self._drag_base|self._rectangle(self._drag_anchor,cell)
|
|
265
|
+
self.active=cell;self._changed_selection()
|
|
266
|
+
return 'break'
|
|
267
|
+
def _release(self,event=None):
|
|
268
|
+
if self.canvas.grab_current() is self.canvas:
|
|
269
|
+
self.canvas.grab_release()
|
|
270
|
+
if self._previous_grab is not None and self._previous_grab.winfo_exists():self._previous_grab.grab_set()
|
|
271
|
+
self._previous_grab=None
|
|
272
|
+
return 'break'
|
|
273
|
+
def _navigate(self,delta,extend=False):
|
|
274
|
+
return self.select(self.active[0]+delta[0],self.active[1]+delta[1],extend=extend)
|
|
275
|
+
def _select_column(self,event):
|
|
276
|
+
self.finish_edit();col=min(self.column_count-1,max(0,int(self.top.canvasx(event.x)//self.cw)))
|
|
277
|
+
self.selection={(r,col) for r in range(self.row_count)};self.active=self.anchor=(0,col)
|
|
278
|
+
self.canvas.focus_set();self._changed_selection();return 'break'
|
|
279
|
+
def _select_row(self,event):
|
|
280
|
+
self.finish_edit();row=min(self.row_count-1,max(0,int(self.left.canvasy(event.y)//self.rh)))
|
|
281
|
+
self.selection={(row,c) for c in range(self.column_count)};self.active=self.anchor=(row,0)
|
|
282
|
+
self.canvas.focus_set();self._changed_selection();return 'break'
|
|
283
|
+
def select_all(self):
|
|
284
|
+
self.finish_edit();self.selection=self._rectangle((0,0),(self.row_count-1,self.column_count-1))
|
|
285
|
+
self.canvas.focus_set();self._changed_selection();return 'break'
|
|
286
|
+
def _type(self,event):
|
|
287
|
+
if event.state&4 or not event.char or not event.char.isprintable():return
|
|
288
|
+
return self.start_edit(event.char)
|
|
289
|
+
def start_edit(self,initial=None):
|
|
290
|
+
self.finish_edit();row,col=self.active;self.see(row,col)
|
|
291
|
+
self.editing_cell=self.active
|
|
292
|
+
self.editor=ttk.Entry(self.canvas)
|
|
293
|
+
self.editor.insert(0,self.data[row][col] if initial is None else initial)
|
|
294
|
+
if initial is None:self.editor.selection_range(0,'end')
|
|
295
|
+
self.editor.icursor('end')
|
|
296
|
+
self.editor.bind('<Return>',lambda e:self._end_edit((1,0)))
|
|
297
|
+
self.editor.bind('<Tab>',lambda e:self._end_edit((0,-1 if e.state&1 else 1)))
|
|
298
|
+
self.editor.bind('<Escape>',lambda e:self._end_edit(None,False))
|
|
299
|
+
self.editor.bind('<FocusOut>',lambda e:self.finish_edit())
|
|
300
|
+
self.schedule();self.update_idletasks();self.editor.focus_set()
|
|
301
|
+
return 'break'
|
|
302
|
+
def _end_edit(self,delta=None,commit=True):
|
|
303
|
+
self.finish_edit(commit)
|
|
304
|
+
if delta:self._navigate(delta)
|
|
305
|
+
else:self.canvas.focus_set()
|
|
306
|
+
return 'break'
|
|
307
|
+
def finish_edit(self,commit=True):
|
|
308
|
+
if self.editor is None:return
|
|
309
|
+
editor=self.editor;cell=self.editing_cell;self.editor=None;self.editing_cell=None
|
|
310
|
+
value=editor.get();editor.destroy()
|
|
311
|
+
if commit and cell is not None:self._apply([(*cell,value)])
|
|
312
|
+
def _apply(self,values,record=True):
|
|
313
|
+
changes=[]
|
|
314
|
+
for row,column,value in values:
|
|
315
|
+
previous=self.data[row][column]
|
|
316
|
+
if previous!=value:
|
|
317
|
+
changes.append((row,column,previous,value));self.data[row][column]=value
|
|
318
|
+
if changes:
|
|
319
|
+
if record:self._undo.append(changes);self._undo=self._undo[-100:];self._redo.clear()
|
|
320
|
+
self.schedule();self.event_generate('<<SheetChanged>>',when='tail')
|
|
321
|
+
return changes
|
|
322
|
+
def copy(self):
|
|
323
|
+
self.finish_edit()
|
|
324
|
+
if not self.selection:return ''
|
|
325
|
+
rows=[r for r,_ in self.selection];columns=[c for _,c in self.selection]
|
|
326
|
+
# Tk's Windows clipboard renderer converts LF to CRLF. Passing CRLF
|
|
327
|
+
# here produces CR CR LF in CF_UNICODETEXT and blank rows in Excel.
|
|
328
|
+
output=io.StringIO(newline='');writer=csv.writer(output,delimiter='\t',lineterminator='\n')
|
|
329
|
+
for r in range(min(rows),max(rows)+1):
|
|
330
|
+
writer.writerow([self.data[r][c].replace('\r\n','\n').replace('\r','\n') if (r,c) in self.selection else '' for c in range(min(columns),max(columns)+1)])
|
|
331
|
+
text=output.getvalue();self.clipboard_clear();self.clipboard_append(text)
|
|
332
|
+
self._status('已复制;非连续选区的间隙保留为空格位')
|
|
333
|
+
return text
|
|
334
|
+
def paste(self,text=None):
|
|
335
|
+
self.finish_edit()
|
|
336
|
+
if text is None:text=self.clipboard_get()
|
|
337
|
+
values=list(csv.reader(io.StringIO(text,newline=''),delimiter='\t'))
|
|
338
|
+
if not values:return []
|
|
339
|
+
width=max(map(len,values),default=0)
|
|
340
|
+
if not width:return []
|
|
341
|
+
row,column=self.active
|
|
342
|
+
self._ensure_size(row+len(values),column+width)
|
|
343
|
+
changes=[(row+i,column+j,value) for i,line in enumerate(values) for j,value in enumerate(line)]
|
|
344
|
+
self._apply(changes)
|
|
345
|
+
self.selection={(r,c) for r,c,_ in changes};self.anchor=self.active
|
|
346
|
+
self._changed_selection();self._status(f'已粘贴 {len(values)} 行 × {width} 列')
|
|
347
|
+
return changes
|
|
348
|
+
def clear_selection(self):
|
|
349
|
+
self.finish_edit();self._apply([(r,c,'') for r,c in self.selection]);self._status('已清空选区');return 'break'
|
|
350
|
+
def undo(self):
|
|
351
|
+
self.finish_edit()
|
|
352
|
+
if self._undo:
|
|
353
|
+
changes=self._undo.pop();self._apply([(r,c,old) for r,c,old,new in changes],record=False);self._redo.append(changes)
|
|
354
|
+
self._status('已撤销')
|
|
355
|
+
return 'break'
|
|
356
|
+
def redo(self):
|
|
357
|
+
self.finish_edit()
|
|
358
|
+
if self._redo:
|
|
359
|
+
changes=self._redo.pop();self._apply([(r,c,new) for r,c,old,new in changes],record=False);self._undo.append(changes)
|
|
360
|
+
self._status('已重做')
|
|
361
|
+
return 'break'
|
|
362
|
+
def _shortcut(self,action):
|
|
363
|
+
try:action()
|
|
364
|
+
except (tk.TclError,ValueError,csv.Error) as error:self._status('操作未完成:'+str(error))
|
|
365
|
+
return 'break'
|
|
366
|
+
def _destroyed(self,event):
|
|
367
|
+
if event.widget is not self:return
|
|
368
|
+
self._closing=True
|
|
369
|
+
if self._draw_job is not None:self.after_cancel(self._draw_job)
|
tkwebui/tcl_bridge.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Tcl binding helpers shared by popups, menus and notifications."""
|
|
2
|
+
import tkinter as tk
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def unbind(widget, sequence, funcid):
|
|
6
|
+
"""Remove one ``bind(..., add='+')`` handler and keep the others.
|
|
7
|
+
|
|
8
|
+
Before Python 3.13, ``Misc.unbind(sequence, funcid)`` clears every script
|
|
9
|
+
bound to ``sequence`` on that widget, silently breaking other components.
|
|
10
|
+
"""
|
|
11
|
+
if not funcid:
|
|
12
|
+
return
|
|
13
|
+
try:
|
|
14
|
+
script = str(widget.tk.call('bind', widget._w, sequence))
|
|
15
|
+
prefix = 'if {"[' + funcid + ' '
|
|
16
|
+
kept = [line for line in script.split('\n') if line.strip() and not line.startswith(prefix)]
|
|
17
|
+
widget.tk.call('bind', widget._w, sequence, '\n'.join(kept))
|
|
18
|
+
except tk.TclError:
|
|
19
|
+
pass
|
|
20
|
+
try:
|
|
21
|
+
widget.deletecommand(funcid)
|
|
22
|
+
except (tk.TclError, ValueError):
|
|
23
|
+
pass
|