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