-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathCsvViewer.py
More file actions
executable file
·457 lines (386 loc) · 12.6 KB
/
CsvViewer.py
File metadata and controls
executable file
·457 lines (386 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from tkinter import font as tkfont
import pandas as pd
import os
# ---------------------------
# Root Window
# ---------------------------
root = tk.Tk()
root.title("Excel/CSV Hierarchical Viewer")
root.geometry("960x600") # initial; will be auto-adjusted later
# ---------------------------
# Styling
# ---------------------------
style = ttk.Style()
style.theme_use("default")
style.configure(
"Treeview",
font=("Segoe UI", 11), # Tk falls back if not available (Linux)
rowheight=28,
borderwidth=1,
relief="solid"
)
style.configure(
"Treeview.Heading",
font=("Segoe UI", 12, "bold"),
background="#f0f0f0",
foreground="black",
relief="solid"
)
style.map(
"Treeview",
background=[("selected", "#cce5ff")],
foreground=[("selected", "black")]
)
# Optional zebra striping tags
style.configure("Odd.Treeview", background="#ffffff")
style.configure("Even.Treeview", background="#f7f7f7")
# ---------------------------
# Layout and Widgets
# ---------------------------
frame = ttk.Frame(root)
frame.pack(fill=tk.BOTH, expand=True)
tree = ttk.Treeview(frame, show="tree headings", selectmode="extended")
vsb = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
hsb = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
# ---------------------------
# Cross-platform, robust fonts
# ---------------------------
def _safe_named_font(name, fallback=None):
try:
return tkfont.nametofont(name)
except tk.TclError:
return fallback
# Base UI font (cells)
tree_font = _safe_named_font(
"TkDefaultFont",
tkfont.Font(family="DejaVu Sans", size=11) # Linux-safe fallback
)
# Heading font (bold header)
heading_font = _safe_named_font(
"TkHeadingFont",
tkfont.Font(
family=tree_font.cget("family"),
size=int(tree_font.cget("size")) + 1,
weight="bold"
)
)
# ---------------------------
# Column Sizing Limits & Window Limits
# ---------------------------
# Column sizing parameters
TREE_MIN_W = 220 # min width for the #0 tree column
TREE_MAX_W = 600 # cap for the #0 tree column
DATA_MIN_W = 80 # min width for data columns
DATA_MAX_W = 420 # cap for data columns
COL_PADDING_PX = 24 # padding added after measuring
# Window sizing limits as a fraction of the screen
SCREEN_W = root.winfo_screenwidth()
SCREEN_H = root.winfo_screenheight()
WIN_MAX_W = int(SCREEN_W * 0.92)
WIN_MAX_H = int(SCREEN_H * 0.88)
WIN_MIN_W = 640
WIN_MIN_H = 360
MAX_VISIBLE_ROWS = 30 # cap how tall we grow window based on rows
# ---------------------------
# Helpers
# ---------------------------
def sort_key(key: str):
"""Natural sort for dot-separated keys like '1.2.10'."""
parts = str(key).split(".")
result = []
for p in parts:
if p.isdigit():
result.append(int(p))
else:
result.append(p)
return result
def iter_all_descendants(parent=""):
"""Yield all items depth-first, including nested descendants."""
for item in tree.get_children(parent):
yield item
yield from iter_all_descendants(item)
def ensure_parent_chain(nodes, parent_key):
"""
Ensure all segments in parent_key exist as nodes.
Returns the item_id for parent_key.
Creates placeholder nodes (text label only) if needed.
"""
if parent_key in nodes:
return nodes[parent_key]
parts = parent_key.split(".")
current_parent = ""
for i in range(1, len(parts) + 1):
k = ".".join(parts[:i])
if k not in nodes:
label = k if "." not in k else k.split(".")[-1]
item_id = tree.insert(current_parent, "end", text=label, values=())
nodes[k] = item_id
current_parent = nodes[k]
return nodes[parent_key]
def measure_text_px(text, use_heading=False):
"""Measure text width in pixels using the appropriate Tk font."""
f = heading_font if use_heading else tree_font
return f.measure(str(text))
# ---------------------------
# Expand to a specific depth
# ---------------------------
def expand_to_depth(depth=2):
"""
Expand nodes up to 'depth' levels:
depth=1 -> only roots expanded
depth=2 -> roots and their direct children expanded
"""
def _expand(item, level):
tree.item(item, open=(level < depth))
for child in tree.get_children(item):
_expand(child, level + 1)
for root_item in tree.get_children(""):
_expand(root_item, 1)
# ---------------------------
# Auto-fit columns (with caps)
# ---------------------------
def compute_column_widths():
"""
Compute desired widths for #0 and each data column based on
header + content, with min/max caps.
Returns (tree_w, {col_id: width}).
"""
# Measure #0 (tree) header
tree_header_w = measure_text_px(tree.heading("#0")["text"], use_heading=True)
tree_content_w = 0
for item in iter_all_descendants(""):
tree_content_w = max(tree_content_w, measure_text_px(tree.item(item, "text")))
tree_w = max(TREE_MIN_W, min(TREE_MAX_W, max(tree_header_w, tree_content_w) + COL_PADDING_PX))
# Measure data columns
data_widths = {}
for col_id in tree["columns"]:
header_w = measure_text_px(col_id, use_heading=True)
content_w = 0
for item in iter_all_descendants(""):
content_w = max(content_w, measure_text_px(tree.set(item, col_id)))
width_px = max(DATA_MIN_W, min(DATA_MAX_W, max(header_w, content_w) + COL_PADDING_PX))
data_widths[col_id] = width_px
return tree_w, data_widths
def apply_column_widths(tree_w, data_widths):
"""Apply computed widths and alignment/anchors."""
# First column: left aligned
tree.column("#0", width=tree_w, anchor=tk.W, stretch=True)
# Other columns: center aligned
for col_id in tree["columns"]:
tree.column(col_id, width=data_widths[col_id], anchor=tk.CENTER, stretch=True)
def auto_fit_columns_and_window():
"""
Auto-fit all columns (with caps) and then auto-size the window
to fit the content within screen limits.
"""
# 1) Fit columns
tree_w, data_widths = compute_column_widths()
apply_column_widths(tree_w, data_widths)
# 2) Expand to depth=2 (per requirements)
expand_to_depth(depth=2)
# 3) Adjust window to content (within limits)
root.update_idletasks() # ensure geometry info is up-to-date
# Total column widths + a little indent/padding + scrollbar if present
total_cols_w = tree.column("#0", option="width")
for col_id in tree["columns"]:
total_cols_w += tree.column(col_id, option="width")
# Add space for the vertical scrollbar if it will show
# A simple heuristic: if the number of items visible exceeds our cap.
# We'll add 18px as a typical scrollbar width.
scrollbar_w = 18
# Estimate visible rows after expand_to_depth(2)
def count_visible_rows():
count = 0
for root_item in tree.get_children(""):
count += 1 # root
if tree.item(root_item, "open"):
count += len(tree.get_children(root_item)) # first-level children
return count
visible_rows = count_visible_rows()
row_h = style.lookup("Treeview", "rowheight") or 28
try:
row_h = int(row_h)
except Exception:
row_h = 28
header_h = 32
top_bottom_pad = 24
estimated_tree_h = header_h + min(visible_rows, MAX_VISIBLE_ROWS) * row_h + top_bottom_pad
# Compute desired window width/height
desired_w = min(max(total_cols_w + scrollbar_w + 24, WIN_MIN_W), WIN_MAX_W)
desired_h = min(max(estimated_tree_h + 100, WIN_MIN_H), WIN_MAX_H) # +100 for buttons/frames
# Apply geometry
root.geometry(f"{desired_w}x{desired_h}")
root.minsize(WIN_MIN_W, WIN_MIN_H)
# ---------------------------
# Double-click to auto-fit a single column (also capped)
# ---------------------------
def autofit_single_column(event):
region = tree.identify_region(event.x, event.y)
if region != "separator":
return
col = tree.identify_column(event.x) # '#0', '#1', etc.
if col == "#0":
# Measure tree column
tree_header_w = measure_text_px(tree.heading("#0")["text"], use_heading=True)
tree_content_w = 0
for item in iter_all_descendants(""):
tree_content_w = max(tree_content_w, measure_text_px(tree.item(item, "text")))
tree_w = max(TREE_MIN_W, min(TREE_MAX_W, max(tree_header_w, tree_content_w) + COL_PADDING_PX))
tree.column("#0", width=tree_w, anchor=tk.W)
else:
try:
idx = int(col.replace("#", "")) - 1
except ValueError:
return
if idx < 0:
return
data_cols = tree["columns"]
if not data_cols or idx >= len(data_cols):
return
col_id = data_cols[idx]
header_w = measure_text_px(col_id, use_heading=True)
content_w = 0
for item in iter_all_descendants(""):
content_w = max(content_w, measure_text_px(tree.set(item, col_id)))
width_px = max(DATA_MIN_W, min(DATA_MAX_W, max(header_w, content_w) + COL_PADDING_PX))
tree.column(col_id, width=width_px, anchor=tk.CENTER)
# After resizing one column, consider window resize too
auto_fit_columns_and_window()
tree.bind("<Double-Button-1>", autofit_single_column)
# ---------------------------
# Build Hierarchy
# ---------------------------
def build_hierarchy(df: pd.DataFrame):
tree.delete(*tree.get_children())
if df is None or df.empty:
return
cols = list(df.columns)
if len(cols) == 0:
return
key_col = cols[0]
df = df.copy()
df[key_col] = df[key_col].astype(str).fillna("")
data_cols = cols[1:]
tree["columns"] = data_cols
# Configure headers
tree.heading("#0", text=key_col)
# anchor left; width will be set by auto-fit
tree.column("#0", anchor=tk.W, stretch=True)
for idx, col in enumerate(data_cols, start=1):
tree.heading(col, text=col)
# center alignment per requirement
tree.column(col, anchor=tk.CENTER, stretch=True)
# Pre-index rows by key (keep first occurrence)
keyed = {}
for _, row in df.iterrows():
k = str(row[key_col])
if k not in keyed:
keyed[k] = row
# Sort naturally and insert
sorted_keys = sorted(keyed.keys(), key=sort_key)
nodes = {}
for key in sorted_keys:
row = keyed[key]
parts = key.split(".")
if len(parts) == 1:
item_id = tree.insert(
"", "end",
text=parts[0],
values=list(row[data_cols]) if data_cols else ()
)
nodes[key] = item_id
else:
parent_key = ".".join(parts[:-1])
parent_id = ensure_parent_chain(nodes, parent_key)
label = parts[-1]
item_id = tree.insert(
parent_id, "end",
text=label,
values=list(row[data_cols]) if data_cols else ()
)
nodes[key] = item_id
# Zebra striping at top level
for idx, item in enumerate(tree.get_children("")):
tree.item(item, tags=("Odd" if idx % 2 == 0 else "Even",))
tree.tag_configure("Odd", background="#ffffff")
tree.tag_configure("Even", background="#f7f7f7")
# Auto-fit all columns and window (and expand to depth=2)
auto_fit_columns_and_window()
# ---------------------------
# Expand/Collapse (full)
# ---------------------------
def expand_all():
for item in tree.get_children():
tree.item(item, open=True)
_expand_children(item)
auto_fit_columns_and_window()
def _expand_children(item):
for child in tree.get_children(item):
tree.item(child, open=True)
_expand_children(child)
def collapse_all():
for item in tree.get_children():
tree.item(item, open=False)
_collapse_children(item)
auto_fit_columns_and_window()
def _collapse_children(item):
for child in tree.get_children(item):
tree.item(child, open=False)
_collapse_children(child)
# ---------------------------
# File Loader
# ---------------------------
_last_dir = os.path.expanduser("~")
def load_file():
global _last_dir
file_path = filedialog.askopenfilename(
initialdir=_last_dir,
title="Select Excel or CSV file",
filetypes=[
("Excel files", "*.xlsx *.xls"),
("CSV files", "*.csv"),
("All files", "*.*")
]
)
if not file_path:
return
_last_dir = os.path.dirname(file_path)
try:
if file_path.lower().endswith((".xlsx", ".xls")):
df = pd.read_excel(file_path) # specify engine if needed
else:
try:
df = pd.read_csv(file_path, encoding="utf-8-sig")
except UnicodeDecodeError:
df = pd.read_csv(file_path, encoding="utf-8")
build_hierarchy(df)
except Exception as e:
messagebox.showerror("Error", f"Failed to load file:\n{e}")
# ---------------------------
# Buttons & Shortcuts
# ---------------------------
btn_frame = ttk.Frame(root)
btn_frame.pack(pady=10)
btn_load = ttk.Button(btn_frame, text="Load Excel/CSV File", command=load_file)
btn_load.grid(row=0, column=0, padx=5)
btn_expand = ttk.Button(btn_frame, text="Expand All", command=expand_all)
btn_expand.grid(row=0, column=1, padx=5)
btn_collapse = ttk.Button(btn_frame, text="Collapse All", command=collapse_all)
btn_collapse.grid(row=0, column=2, padx=5)
# Keyboard shortcuts
root.bind_all("<Control-e>", lambda e: expand_all())
root.bind_all("<Control-w>", lambda e: collapse_all())
# ---------------------------
# Mainloop
# ---------------------------
root.mainloop()