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/dataview.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Searchable, filterable and paginated native Treeview with CSV export."""
|
|
2
|
+
import csv
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from tkinter import ttk, filedialog
|
|
6
|
+
from .popups import ThemedCombobox, ThemedMenu
|
|
7
|
+
from .extras import MenuButton
|
|
8
|
+
from .table import _sort_key
|
|
9
|
+
from .scrolling import AutoScrollbar
|
|
10
|
+
|
|
11
|
+
class DataTable(ttk.Frame):
|
|
12
|
+
def __init__(self,master,*,columns,rows=(),page_size=10,height=8,**kwargs):
|
|
13
|
+
super().__init__(master,**kwargs)
|
|
14
|
+
self.columns=[dict(key=str(item['key']),text=str(item.get('text',item['key'])),width=int(item.get('width',140))) if isinstance(item,dict)
|
|
15
|
+
else dict(key=str(i),text=str(item),width=140) for i,item in enumerate(columns)]
|
|
16
|
+
self.keys=[item['key'] for item in self.columns]
|
|
17
|
+
if not self.keys or len(set(self.keys))!=len(self.keys):raise ValueError('表格列不能为空,且 key 必须唯一')
|
|
18
|
+
self.page_size=max(1,int(page_size));self.page=0;self.filters={};self.sort_column=None;self.reverse=False
|
|
19
|
+
self.records=[];self.filtered=[];self.selected=set();self._rendering=False;self._search_job=None;self._next_id=0
|
|
20
|
+
self.dpi=self.winfo_fpixels('1i')/96
|
|
21
|
+
self.query=tk.StringVar(self);self.column_choice=tk.StringVar(self,value=self.columns[0]['text']);self.filter_text=tk.StringVar(self)
|
|
22
|
+
self.page_info=tk.StringVar(self);self.status=tk.StringVar(self)
|
|
23
|
+
tools=ttk.Frame(self);tools.pack(fill='x',pady=(0,8))
|
|
24
|
+
ttk.Label(tools,text='搜索').pack(side='left',padx=(0,6))
|
|
25
|
+
self.search_entry=ttk.Entry(tools,textvariable=self.query,width=22);self.search_entry.pack(side='left',fill='x',expand=True)
|
|
26
|
+
self.search_entry.bind('<Return>',lambda e:self.refresh(reset_page=True))
|
|
27
|
+
ttk.Button(tools,text='清除筛选',command=self.clear_filters).pack(side='left',padx=6)
|
|
28
|
+
columns_button=MenuButton(tools,text='列');columns_button.pack(side='right')
|
|
29
|
+
self.column_menu=ThemedMenu(columns_button,tearoff=False);columns_button.configure(menu=self.column_menu)
|
|
30
|
+
self.visible_vars={}
|
|
31
|
+
for column in self.columns:
|
|
32
|
+
var=tk.BooleanVar(self,value=True);self.visible_vars[column['key']]=var
|
|
33
|
+
self.column_menu.add_checkbutton(label=column['text'],variable=var,command=self._columns_changed)
|
|
34
|
+
self.column_menu.add_separator();self.column_menu.add_command(label='恢复列布局',command=self.reset_layout)
|
|
35
|
+
filters=ttk.Frame(self);filters.pack(fill='x',pady=(0,8))
|
|
36
|
+
ThemedCombobox(filters,textvariable=self.column_choice,values=[column['text'] for column in self.columns],state='readonly',width=12).pack(side='left')
|
|
37
|
+
ttk.Entry(filters,textvariable=self.filter_text,width=18).pack(side='left',padx=6,fill='x',expand=True)
|
|
38
|
+
ttk.Button(filters,text='按列精确筛选',command=self._apply_filter).pack(side='left')
|
|
39
|
+
area=ttk.Frame(self);area.pack(fill='both',expand=True);area.rowconfigure(0,weight=1);area.columnconfigure(0,weight=1)
|
|
40
|
+
self.tree=ttk.Treeview(area,columns=self.keys,show='headings',selectmode='extended',height=height)
|
|
41
|
+
self.tree.grid(row=0,column=0,sticky='nsew')
|
|
42
|
+
for column in self.columns:
|
|
43
|
+
key=column['key'];self.tree.heading(key,text=column['text'],command=lambda k=key:self.sort_by(k))
|
|
44
|
+
self.tree.column(key,width=round(column['width']*self.dpi),minwidth=60,stretch=key==self.keys[-1])
|
|
45
|
+
vbar=AutoScrollbar(area,command=self.tree.yview);vbar.grid(row=0,column=1,sticky='ns')
|
|
46
|
+
hbar=AutoScrollbar(area,orient='horizontal',command=self.tree.xview);hbar.grid(row=1,column=0,sticky='ew')
|
|
47
|
+
self.tree.configure(yscrollcommand=vbar.set,xscrollcommand=hbar.set)
|
|
48
|
+
self.tree.bind('<<TreeviewSelect>>',self._capture_selection)
|
|
49
|
+
self.tree.bind('<Button-3>',self._context)
|
|
50
|
+
foot=ttk.Frame(self);foot.pack(fill='x',pady=(8,0))
|
|
51
|
+
self.first=ttk.Button(foot,text='|‹',width=3,command=lambda:self.goto_page(0));self.first.pack(side='left')
|
|
52
|
+
self.prev=ttk.Button(foot,text='‹',width=3,command=lambda:self.goto_page(self.page-1));self.prev.pack(side='left',padx=4)
|
|
53
|
+
ttk.Label(foot,textvariable=self.page_info,style='Muted.TLabel').pack(side='left',padx=6)
|
|
54
|
+
self.next=ttk.Button(foot,text='›',width=3,command=lambda:self.goto_page(self.page+1));self.next.pack(side='left',padx=4)
|
|
55
|
+
self.last=ttk.Button(foot,text='›|',width=3,command=lambda:self.goto_page(self.page_count-1));self.last.pack(side='left')
|
|
56
|
+
self.page_size_var=tk.StringVar(self,value=str(self.page_size))
|
|
57
|
+
sizes=ThemedCombobox(foot,textvariable=self.page_size_var,values=['10','20','50','100'],state='readonly',width=5)
|
|
58
|
+
sizes.pack(side='right');sizes.bind('<<ComboboxSelected>>',lambda e:self.set_page_size(int(self.page_size_var.get())))
|
|
59
|
+
ttk.Label(foot,text='每页',style='Muted.TLabel').pack(side='right',padx=5)
|
|
60
|
+
ttk.Label(self,textvariable=self.status,style='Muted.TLabel',padding=(0,6)).pack(fill='x')
|
|
61
|
+
self._query_trace=self.query.trace_add('write',self._search_changed)
|
|
62
|
+
self.bind('<Destroy>',self._cleanup,add='+');self.set_rows(rows)
|
|
63
|
+
@property
|
|
64
|
+
def page_count(self):return max(1,(len(self.filtered)+self.page_size-1)//self.page_size)
|
|
65
|
+
def set_rows(self,rows):
|
|
66
|
+
converted=[]
|
|
67
|
+
for index,row in enumerate(rows):
|
|
68
|
+
values=list(row)
|
|
69
|
+
if len(values)!=len(self.keys):raise ValueError('每行的单元格数必须与列数一致')
|
|
70
|
+
converted.append({'id':str(index),'values':values})
|
|
71
|
+
self.records=converted;self._next_id=len(converted);self.selected.clear();self.page=0;self.refresh()
|
|
72
|
+
def insert_row(self,values):
|
|
73
|
+
if len(values)!=len(self.keys):raise ValueError('列数不匹配')
|
|
74
|
+
key=str(self._next_id);self._next_id+=1;self.records.append({'id':key,'values':list(values)});self.refresh();return key
|
|
75
|
+
def _search_changed(self,*_):
|
|
76
|
+
if self._search_job is not None:self.after_cancel(self._search_job)
|
|
77
|
+
self._search_job=self.after(150,lambda:self.refresh(reset_page=True))
|
|
78
|
+
def search(self,text):self.query.set(text);self.refresh(reset_page=True)
|
|
79
|
+
def filter_column(self,key,value):
|
|
80
|
+
if key not in self.keys:raise KeyError(key)
|
|
81
|
+
self.filters[key]=str(value).casefold();self.refresh(reset_page=True)
|
|
82
|
+
def _apply_filter(self):
|
|
83
|
+
column=next(c for c in self.columns if c['text']==self.column_choice.get())
|
|
84
|
+
self.filter_column(column['key'],self.filter_text.get())
|
|
85
|
+
def clear_filters(self):
|
|
86
|
+
self.filters.clear();self.query.set('');self.filter_text.set('');self.refresh(reset_page=True)
|
|
87
|
+
def sort_by(self,key,reverse=None):
|
|
88
|
+
if key not in self.keys:raise KeyError(key)
|
|
89
|
+
self.reverse=(not self.reverse if key==self.sort_column else False) if reverse is None else bool(reverse)
|
|
90
|
+
self.sort_column=key;self.refresh(reset_page=True)
|
|
91
|
+
self.event_generate('<<DataTableSorted>>',when='tail')
|
|
92
|
+
def refresh(self,reset_page=False):
|
|
93
|
+
if self._search_job is not None:self.after_cancel(self._search_job);self._search_job=None
|
|
94
|
+
self._capture_selection()
|
|
95
|
+
query=self.query.get().casefold().strip();filtered=[]
|
|
96
|
+
for row in self.records:
|
|
97
|
+
values=row['values']
|
|
98
|
+
if query and not any(query in str(value).casefold() for value in values):continue
|
|
99
|
+
if any(str(values[self.keys.index(key)]).casefold()!=value for key,value in self.filters.items()):continue
|
|
100
|
+
filtered.append(row)
|
|
101
|
+
if self.sort_column is not None:
|
|
102
|
+
index=self.keys.index(self.sort_column)
|
|
103
|
+
filled=[r for r in filtered if str(r['values'][index]).strip()];empty=[r for r in filtered if not str(r['values'][index]).strip()]
|
|
104
|
+
filtered=sorted(filled,key=lambda r:_sort_key(r['values'][index]),reverse=self.reverse)+empty
|
|
105
|
+
self.filtered=filtered
|
|
106
|
+
self.page=0 if reset_page else min(self.page,self.page_count-1)
|
|
107
|
+
self._render()
|
|
108
|
+
def _render(self):
|
|
109
|
+
self._rendering=True
|
|
110
|
+
try:
|
|
111
|
+
self.tree.delete(*self.tree.get_children())
|
|
112
|
+
rows=self.filtered[self.page*self.page_size:(self.page+1)*self.page_size]
|
|
113
|
+
for row in rows:self.tree.insert('','end',iid=row['id'],values=row['values'])
|
|
114
|
+
chosen=[row['id'] for row in rows if row['id'] in self.selected]
|
|
115
|
+
if chosen:self.tree.selection_set(chosen)
|
|
116
|
+
for column in self.columns:
|
|
117
|
+
text=column['text']+(' ▼' if self.reverse else ' ▲') if column['key']==self.sort_column else column['text']
|
|
118
|
+
self.tree.heading(column['key'],text=text)
|
|
119
|
+
self.page_info.set(f'{self.page+1} / {self.page_count} 页 · {len(self.filtered)} 条')
|
|
120
|
+
for button,disabled in [(self.first,self.page==0),(self.prev,self.page==0),(self.next,self.page==self.page_count-1),(self.last,self.page==self.page_count-1)]:
|
|
121
|
+
button.state(['disabled'] if disabled else ['!disabled'])
|
|
122
|
+
self.status.set(f'总计 {len(self.records)} 条 · 已选 {len(self.selected)} 条'+(' · 已应用筛选' if self.filters or self.query.get() else ''))
|
|
123
|
+
finally:self._rendering=False
|
|
124
|
+
def _capture_selection(self,event=None):
|
|
125
|
+
if self._rendering:return
|
|
126
|
+
visible=set(self.tree.get_children());self.selected.difference_update(visible);self.selected.update(self.tree.selection())
|
|
127
|
+
self.selected.intersection_update(row['id'] for row in self.records)
|
|
128
|
+
if event is not None:self.status.set(f'总计 {len(self.records)} 条 · 已选 {len(self.selected)} 条')
|
|
129
|
+
def goto_page(self,page):
|
|
130
|
+
self._capture_selection();self.page=max(0,min(self.page_count-1,int(page)));self._render()
|
|
131
|
+
def set_page_size(self,value):
|
|
132
|
+
self._capture_selection();self.page_size=max(1,int(value));self.page_size_var.set(str(self.page_size));self.page=0;self._render()
|
|
133
|
+
def get_rows(self,scope='all'):
|
|
134
|
+
self._capture_selection()
|
|
135
|
+
if scope=='all':rows=self.records
|
|
136
|
+
elif scope=='filtered':rows=self.filtered
|
|
137
|
+
elif scope=='page':rows=self.filtered[self.page*self.page_size:(self.page+1)*self.page_size]
|
|
138
|
+
elif scope=='selected':rows=[row for row in self.records if row['id'] in self.selected]
|
|
139
|
+
else:raise ValueError('scope 应为 all / filtered / page / selected')
|
|
140
|
+
return [row['values'][:] for row in rows]
|
|
141
|
+
def set_visible_columns(self,keys):
|
|
142
|
+
keys=list(keys)
|
|
143
|
+
if not keys or len(set(keys))!=len(keys) or any(key not in self.keys for key in keys):raise ValueError('至少保留一个有效列,且不能重复')
|
|
144
|
+
self.tree.configure(displaycolumns=keys)
|
|
145
|
+
for key,var in self.visible_vars.items():var.set(key in keys)
|
|
146
|
+
def _columns_changed(self):
|
|
147
|
+
keys=[key for key,var in self.visible_vars.items() if var.get()]
|
|
148
|
+
if not keys:
|
|
149
|
+
keys=list(self.tree['displaycolumns']);self.status.set('至少保留一列')
|
|
150
|
+
if keys==['#all']:keys=self.keys[:]
|
|
151
|
+
self.set_visible_columns(keys)
|
|
152
|
+
def reset_layout(self):
|
|
153
|
+
self.set_visible_columns(self.keys)
|
|
154
|
+
for column in self.columns:self.tree.column(column['key'],width=round(column['width']*self.dpi),stretch=column['key']==self.keys[-1])
|
|
155
|
+
def export_csv(self,path=None,scope='filtered'):
|
|
156
|
+
if path is None:path=filedialog.asksaveasfilename(parent=self,defaultextension='.csv',filetypes=[('CSV 文件','*.csv')])
|
|
157
|
+
if not path:return None
|
|
158
|
+
path=Path(path).expanduser().resolve()
|
|
159
|
+
with path.open('w',encoding='utf-8-sig',newline='') as file:
|
|
160
|
+
writer=csv.writer(file);writer.writerow([c['text'] for c in self.columns]);writer.writerows(self.get_rows(scope))
|
|
161
|
+
self.status.set('已导出:'+path.name);return path
|
|
162
|
+
def _move_column(self,key,delta):
|
|
163
|
+
keys=list(self.tree['displaycolumns'])
|
|
164
|
+
if keys==['#all']:keys=self.keys[:]
|
|
165
|
+
index=keys.index(key);new=max(0,min(len(keys)-1,index+delta));keys.insert(new,keys.pop(index));self.set_visible_columns(keys)
|
|
166
|
+
def _context(self,event):
|
|
167
|
+
menu=ThemedMenu(self,tearoff=False)
|
|
168
|
+
old=getattr(self,'_context_menu',None)
|
|
169
|
+
if old is not None:old.destroy()
|
|
170
|
+
self._context_menu=menu
|
|
171
|
+
display=list(self.tree['displaycolumns']);display=self.keys if display==['#all'] else display
|
|
172
|
+
identified=self.tree.identify_column(event.x)
|
|
173
|
+
key=display[int(identified[1:])-1] if identified and identified!='#0' else None
|
|
174
|
+
if key:
|
|
175
|
+
menu.add_command(label='升序排列',command=lambda:self.sort_by(key,False));menu.add_command(label='降序排列',command=lambda:self.sort_by(key,True))
|
|
176
|
+
menu.add_command(label='左移此列',command=lambda:self._move_column(key,-1));menu.add_command(label='右移此列',command=lambda:self._move_column(key,1))
|
|
177
|
+
if len(display)>1:menu.add_command(label='隐藏此列',command=lambda:self.set_visible_columns([k for k in display if k!=key]))
|
|
178
|
+
menu.add_separator();menu.add_command(label='恢复列布局',command=self.reset_layout)
|
|
179
|
+
for scope,label in [('filtered','导出筛选结果'),('page','导出当前页'),('selected','导出选中行')]:
|
|
180
|
+
menu.add_command(label=label,command=lambda s=scope:self.export_csv(scope=s))
|
|
181
|
+
menu.tk_popup(event.x_root,event.y_root)
|
|
182
|
+
def _cleanup(self,event):
|
|
183
|
+
if event.widget is self:
|
|
184
|
+
if self._search_job is not None:self.after_cancel(self._search_job)
|
|
185
|
+
self.query.trace_remove('write',self._query_trace)
|