ここまでの内容を統合した完成版です。
ポイント:
- 上部フィルタバーは廃止し、ヘッダー右クリックポップアップのみでフィルタ
- 数値列では「Values」タブを非表示
- 列表示パネル(右サイドバー)の表示切替(Ctrl+B)
- フォント設定、行番号固定、1列スクロール 等
完成版コード
ファイル名例:
csv_viewer_tk.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Excel-like CSV Viewer (Tkinter)
Step 11: Remove top filter bar / status; use only header popup filters
"""
import sys
import os
import csv
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import tkinter.font as tkfont
APP_TITLE = "CSV Viewer (Tkinter) — Step 11 (Header popup only)"
APP_GEOMETRY = "1200x720"
_BLANK_TOKEN = "(Blanks)" # ポップアップ上で空文字の見かけ値
class CSVViewerApp(tk.Tk):
def __init__(self, initial_path: str | None = None):
super().__init__()
self.title(APP_TITLE)
self.geometry(APP_GEOMETRY)
# --- State ---
self.csv_path: str | None = None
self.headers_all: list[str] = []
self.data_columns: list[str] = []
self.row_count: int = 0
# data_rows は (input_line_no:int, row_values:list[str]) のタプル配列
self.data_rows: list[tuple[int, list[str]]] = []
self.current_rows: list[tuple[int, list[str]]] = []
# Column visibility
self.col_vars: dict[str, tk.BooleanVar] = {}
self.display_columns: list[str] = []
# Sort state
self.sort_state: dict[str, str] = {}
self.sorted_by: str | None = None
# Header popup filter state
# col -> { "values": set[str]|None, "num_op": str|None, "num_val": float|None }
self.column_filters: dict[str, dict] = {}
# --- Font settings ---
self.font_size = 12
self._init_styles()
# UI
self._build_menubar()
self._build_toolbar()
self._build_main_panes() # (フィルタバー無し)
self._build_statusbar()
# Key bindings
self.bind_all("<Left>", self._on_left_col)
self.bind_all("<Right>", self._on_right_col)
self.bind_all("<Control-Left>", self._on_ctrl_left_key)
self.bind_all("<Control-Right>", self._on_ctrl_right_key)
self.bind_all("<Control-o>", lambda e: self.ask_open_csv())
self.bind_all("<Control-r>", lambda e: self.reload_csv())
self.bind_all("<Control-q>", lambda e: self.quit())
self.bind_all("<Control-minus>", lambda e: self._apply_font_size(self.font_size - 1))
self.bind_all("<Control-plus>", lambda e: self._apply_font_size(self.font_size + 1))
self.bind_all("<Control-equal>", lambda e: self._apply_font_size(self.font_size + 1))
self.bind_all("<Control-b>", lambda e: self.toggle_sidebar_visible())
if initial_path and os.path.exists(initial_path):
self.open_csv(initial_path)
# ---------- Styles / Fonts ----------
def _init_styles(self):
self.style = ttk.Style()
for candidate in ("vista", "xpnative", "clam", "default"):
try:
self.style.theme_use(candidate)
break
except Exception:
continue
self.table_font = tkfont.Font(family="TkDefaultFont", size=self.font_size)
self.heading_font = tkfont.Font(family="TkDefaultFont", size=max(self.font_size, 11), weight="bold")
self.style.configure("Treeview", font=self.table_font, rowheight=int(self.font_size * 1.6))
self.style.configure("Treeview.Heading", font=self.heading_font)
self.option_add("*Menu*font", self.table_font)
def _apply_font_size(self, size: int):
size = max(8, min(28, int(size)))
if size == self.font_size:
return
self.font_size = size
self.table_font.configure(size=self.font_size)
self.heading_font.configure(size=max(self.font_size, 11))
self.style.configure("Treeview", rowheight=int(self.font_size * 1.6))
# 可視列だけ簡易オートサイズ
try:
if self.display_columns:
right_children = self.tree_right.get_children()
sample = [self.tree_right.item(iid, "values") for iid in right_children[:100]]
self._autosize_right(self.display_columns, sample, use_display=True)
except Exception:
pass
# ---------- UI ----------
def _build_menubar(self):
menubar = tk.Menu(self)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="Open CSV…", accelerator="Ctrl+O", command=self.ask_open_csv)
file_menu.add_command(label="Reload", accelerator="Ctrl+R", command=self.reload_csv, state="disabled")
file_menu.add_separator()
file_menu.add_command(label="Exit", accelerator="Ctrl+Q", command=self.quit)
menubar.add_cascade(label="File", menu=file_menu)
self.file_menu = file_menu
view_menu = tk.Menu(menubar, tearoff=0)
self._sidebar_visible = tk.BooleanVar(value=True)
view_menu.add_checkbutton(
label="Show Right Slidebar",
variable=self._sidebar_visible,
command=self._on_view_menu_toggle_sidebar
)
font_menu = tk.Menu(view_menu, tearoff=0)
font_menu.add_command(label="Smaller (−1)", accelerator="Ctrl+-",
command=lambda: self._apply_font_size(self.font_size - 1))
font_menu.add_command(label="Larger (+1)", accelerator="Ctrl++",
command=lambda: self._apply_font_size(self.font_size + 1))
font_menu.add_command(label="Reset (12)", command=lambda: self._apply_font_size(12))
font_menu.add_separator()
font_menu.add_command(label="Set…", command=self._ask_font_size)
view_menu.add_cascade(label="Font Size", menu=font_menu)
menubar.add_cascade(label="View", menu=view_menu)
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="About", command=self._show_about)
menubar.add_cascade(label="Help", menu=help_menu)
self.config(menu=menubar)
def _on_view_menu_toggle_sidebar(self):
self.toggle_sidebar()
def _ask_font_size(self):
size = simpledialog.askinteger("Font Size", "Enter table font size (8–28):",
parent=self, minvalue=8, maxvalue=28, initialvalue=self.font_size)
if size:
self._apply_font_size(size)
def _build_toolbar(self):
self.toolbar = ttk.Frame(self, padding=(8, 4))
self.toolbar.pack(side=tk.TOP, fill=tk.X)
self.btn_open = ttk.Button(self.toolbar, text="Open CSV…", command=self.ask_open_csv)
self.btn_open.pack(side=tk.LEFT, padx=(0, 6))
self.btn_reload = ttk.Button(self.toolbar, text="Reload", command=self.reload_csv, state=tk.DISABLED)
self.btn_reload.pack(side=tk.LEFT)
ttk.Separator(self.toolbar, orient="vertical").pack(side=tk.LEFT, fill=tk.Y, padx=8)
self.btn_sidebar = ttk.Button(self.toolbar, text="Hide Columns ▶", command=self.toggle_sidebar_visible)
self.btn_sidebar.pack(side=tk.LEFT, padx=(0, 8))
ttk.Label(self.toolbar, text="Font:").pack(side=tk.LEFT)
self.font_label = ttk.Label(self.toolbar, text=str(self.font_size))
self.font_label.pack(side=tk.LEFT, padx=(2, 8))
ttk.Button(self.toolbar, text="−", width=3,
command=lambda: self._apply_font_size(self.font_size - 1)).pack(side=tk.LEFT)
ttk.Button(self.toolbar, text="+", width=3,
command=lambda: self._apply_font_size(self.font_size + 1)).pack(side=tk.LEFT)
self.after(0, self._update_font_label)
self.btn_sidebar.configure(
text="Hide Columns ▶" if hasattr(self, "_sidebar_visible") and self._sidebar_visible.get()
else "Show Columns ◀"
)
def _update_font_label(self):
try:
self.font_label.configure(text=str(self.font_size))
finally:
self.after(400, self._update_font_label)
def _build_main_panes(self):
# 左右メイン + 右サイドバー(フィルタバーは無し)
self.root_panes = ttk.Panedwindow(self, orient=tk.HORIZONTAL)
self.root_panes.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
# 中央エリア
self.center_frame = ttk.Frame(self.root_panes)
self.root_panes.add(self.center_frame, weight=1)
# 右サイドバー
self.sidebar = ttk.Frame(self.root_panes, width=260)
self.root_panes.add(self.sidebar, weight=0)
# 表本体のみ
table_wrap = ttk.Frame(self.center_frame)
table_wrap.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.vscroll = ttk.Scrollbar(table_wrap, orient="vertical")
self.hscroll = ttk.Scrollbar(table_wrap, orient="horizontal")
self.tree_left = ttk.Treeview(table_wrap, show="headings", style="Treeview",
columns=("#",), selectmode="none")
self.tree_left.heading("#", text="#")
self.tree_left.column("#", width=60, anchor="e", stretch=False)
self.tree_right = ttk.Treeview(table_wrap, show="headings", style="Treeview",
columns=(), selectmode="browse",
xscrollcommand=self.hscroll.set)
# レイアウト
self.tree_left.grid(row=0, column=0, sticky="ns")
self.tree_right.grid(row=0, column=1, sticky="nsew")
self.vscroll.grid(row=0, column=2, sticky="ns")
self.hscroll.grid(row=1, column=1, sticky="ew")
table_wrap.columnconfigure(1, weight=1)
table_wrap.rowconfigure(0, weight=1)
# スクロール同期
self.tree_left.configure(yscrollcommand=self._on_yset_left)
self.tree_right.configure(yscrollcommand=self._on_yset_right)
self.vscroll.configure(command=self._on_yview)
self.hscroll.configure(command=self.tree_right.xview)
# ヘッダー右クリックでフィルタポップアップ
self.tree_right.bind("<Button-3>", self._on_right_header_context)
# 右サイドバーの中身
self._build_right_sidebar_contents()
# ---------- CSV Loading ----------
def ask_open_csv(self):
path = filedialog.askopenfilename(
title="Open CSV",
filetypes=[("CSV files", "*.csv;*.tsv;*.txt"), ("All files", "*.*")],
)
if path:
self.open_csv(path)
def open_csv(self, path: str):
try:
with open(path, "r", newline="", encoding="utf-8") as f:
sample = f.read(4096)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample)
except csv.Error:
dialect = csv.get_dialect("excel")
reader = csv.reader(f, dialect)
rows = list(reader)
if not rows:
raise ValueError("The file appears to be empty.")
self.csv_path = path
headers = rows[0]
raw_rows = rows[1:]
self.row_count = len(raw_rows)
self.headers_all = ["#"] + headers
self.data_columns = headers[:]
self.display_columns = headers[:]
# 入力行番号を保持したタプル (line_no, row_values)
self.data_rows = [(i, r[:]) for i, r in enumerate(raw_rows, start=1)]
self.current_rows = list(self.data_rows)
# 状態リセット
self.sort_state.clear()
self.sorted_by = None
self.column_filters.clear()
# UI再構築
self._populate_trees(self.data_columns, self.current_rows)
self._populate_sidebar_checkboxes(self.data_columns)
self._set_status(f"Loaded: {os.path.basename(path)} — {self.row_count} rows, {len(headers)} columns")
self._set_open_enabled(True)
except Exception as e:
messagebox.showerror("Failed to open CSV", f"{e}")
self._set_status("Failed to open CSV.")
def reload_csv(self):
if not self.csv_path:
return
self.open_csv(self.csv_path)
# ---------- Populate & Autosize ----------
def _populate_trees(self, headers: list[str], rows: list[tuple[int, list[str]]]):
# クリア
self.tree_left.delete(*self.tree_left.get_children())
for col in self.tree_right["columns"]:
self.tree_right.heading(col, text="")
self.tree_right.delete(*self.tree_right.get_children())
# 列セットと表示列
self.tree_right["columns"] = headers
self.tree_right["displaycolumns"] = tuple(self.display_columns) if self.display_columns else tuple(headers)
for h in headers:
self.tree_right.heading(h, text=self._heading_label(h), command=lambda col=h: self._on_sort_by(col))
self.tree_right.column(h, width=120, anchor="w", stretch=False) # 列幅は画面サイズと非連動
# データ挿入
for line_no, r in rows:
self.tree_left.insert("", "end", values=(line_no,))
vals = (r + [""] * (len(headers) - len(r)))[:len(headers)]
self.tree_right.insert("", "end", values=vals)
# 簡易オートサイズ(可視列のみ)
sample = [self.tree_right.item(iid, "values") for iid in self.tree_right.get_children()[:100]]
self._autosize_right(self.display_columns, sample, use_display=True)
self.tree_left.column("#", width=60, anchor="e", stretch=False)
self._sync_vscroll_from_right()
self._set_status_rows_count()
def _autosize_right(self, headers_or_display: list[str], sample_rows: list[list[str]], use_display: bool = False):
cols = headers_or_display if use_display else self.data_columns
for h in cols:
full_idx = list(self.tree_right["columns"]).index(h)
max_chars = len(h)
for r in sample_rows:
if full_idx < len(r):
max_chars = max(max_chars, len(str(r[full_idx])))
width_px = min(600, max(80, int(max_chars * (self.font_size * 0.6) + 24)))
self.tree_right.column(h, width=width_px)
# ---------- Column type helper ----------
def _is_numeric_column(self, col: str) -> bool:
"""非空セルが 1 つ以上あり、それらがすべて float に変換可能なら数値列とみなす"""
if col not in self.data_columns:
return False
idx = self.data_columns.index(col)
saw = False
for _, row in self.data_rows:
v = row[idx] if idx < len(row) else ""
if v == "":
continue
try:
float(v)
saw = True
except Exception:
return False
return saw
# ---------- Header Popup Filter ----------
def _on_right_header_context(self, event):
region = self.tree_right.identify_region(event.x, event.y)
if region != "heading":
return
col_id = self.tree_right.identify_column(event.x) # "#1"
try:
idx = int(col_id.replace("#", "")) - 1
except Exception:
return
cols = list(self.tree_right["columns"])
if 0 <= idx < len(cols):
col_name = cols[idx]
self._open_header_filter_popup(col_name, event)
def _open_header_filter_popup(self, col: str, event=None):
if col not in self.data_columns:
return
try:
col_idx = self.data_columns.index(col)
except ValueError:
return
cfg = self.column_filters.get(col, {})
num_op_init = cfg.get("num_op")
num_val_init = cfg.get("num_val")
is_numeric = self._is_numeric_column(col)
top = tk.Toplevel(self)
top.title(f"Filter: {col}")
top.transient(self); top.grab_set(); top.resizable(True, True)
try:
x = self.winfo_rootx() + self.tree_right.winfo_rootx() + event.x_root - self.tree_right.winfo_rootx() - 20
y = self.winfo_rooty() + self.tree_right.winfo_rooty() + event.y_root - self.tree_right.winfo_rooty() + 10
top.geometry(f"+{x}+{y}")
except Exception:
pass
nb = ttk.Notebook(top)
nb.pack(fill=tk.BOTH, expand=True)
# --- Values tab(非数値列のみ) ---
var_items = None
if not is_numeric:
tab_values = ttk.Frame(nb); nb.add(tab_values, text="Values")
values = set()
for _, row in self.data_rows:
v = row[col_idx] if col_idx < len(row) else ""
values.add(v if v != "" else _BLANK_TOKEN)
values = sorted(values, key=lambda x: (x == _BLANK_TOKEN, str(x).lower()))
allowed_values = set("" if v == _BLANK_TOKEN else v for v in cfg.get("values", set(values)))
head = ttk.Frame(tab_values, padding=(8, 6)); head.pack(side=tk.TOP, fill=tk.X)
ttk.Label(head, text=f"{col} values").pack(side=tk.LEFT)
ttk.Label(head, text=" Search:").pack(side=tk.LEFT, padx=(12, 4))
search_var = tk.StringVar()
ent_search = ttk.Entry(head, textvariable=search_var, width=20); ent_search.pack(side=tk.LEFT)
btns = ttk.Frame(tab_values, padding=(8, 0)); btns.pack(side=tk.TOP, fill=tk.X)
list_wrap = ttk.Frame(tab_values); list_wrap.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=6)
canvas = tk.Canvas(list_wrap, borderwidth=0, highlightthickness=0)
vbar = ttk.Scrollbar(list_wrap, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vbar.set)
inner_frame = ttk.Frame(canvas)
win = canvas.create_window((0, 0), window=inner_frame, anchor="nw")
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True); vbar.pack(side=tk.RIGHT, fill=tk.Y)
inner_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.bind("<Configure>", lambda e: canvas.itemconfigure(win, width=e.width))
var_items = []
def rebuild_value_list():
for child in list(inner_frame.winfo_children()):
child.destroy()
var_items.clear()
needle = search_var.get().strip().lower()
shown = 0
for val in values:
text = str(val)
if needle and needle not in text.lower():
continue
real = "" if val == _BLANK_TOKEN else val
var = tk.BooleanVar(value=(real in allowed_values) or (not cfg.get("values")))
cb = ttk.Checkbutton(inner_frame, text=text, variable=var)
cb.pack(side=tk.TOP, anchor="w")
var_items.append((real, var, cb))
shown += 1
if shown == 0:
ttk.Label(inner_frame, text="(no values)", foreground="gray").pack(side=tk.TOP, anchor="w")
ttk.Button(btns, text="Select All",
command=lambda: [v.set(True) for _, v, _ in var_items]).pack(side=tk.LEFT)
ttk.Button(btns, text="Clear",
command=lambda: [v.set(False) for _, v, _ in var_items]).pack(side=tk.LEFT, padx=(6, 0))
rebuild_value_list()
ent_search.bind("<KeyRelease>", lambda e: rebuild_value_list())
# --- Number Filter tab(常に表示) ---
tab_num = ttk.Frame(nb, padding=8); nb.add(tab_num, text="Number Filter")
ttk.Label(tab_num, text=f"{col} 数値条件(空欄なら未使用)").pack(anchor="w")
row1 = ttk.Frame(tab_num); row1.pack(fill=tk.X, pady=(6, 4))
ttk.Label(row1, text="比較").pack(side=tk.LEFT, padx=(0, 6))
op_var = tk.StringVar(value=num_op_init if num_op_init in ["<=", "<", "==", ">=", ">"] else "<=")
cb_op = ttk.Combobox(row1, textvariable=op_var, values=["<=", "<", "==", ">=", ">"], width=5, state="readonly")
cb_op.pack(side=tk.LEFT)
ttk.Label(row1, text="値").pack(side=tk.LEFT, padx=(12, 4))
val_var = tk.StringVar(value=("" if num_val_init is None else str(num_val_init)))
ent_val = ttk.Entry(row1, textvariable=val_var, width=12); ent_val.pack(side=tk.LEFT)
row2 = ttk.Frame(tab_num); row2.pack(fill=tk.X, pady=(6, 0))
ttk.Label(row2, text="ソート").pack(side=tk.LEFT, padx=(0, 6))
sort_var = tk.StringVar(value="")
if self.sorted_by == col and self.sort_state.get(col) in ("asc", "desc"):
sort_var.set(self.sort_state[col])
ttk.Radiobutton(row2, text="なし", variable=sort_var, value="").pack(side=tk.LEFT)
ttk.Radiobutton(row2, text="昇順", variable=sort_var, value="asc").pack(side=tk.LEFT, padx=(6, 0))
ttk.Radiobutton(row2, text="降順", variable=sort_var, value="desc").pack(side=tk.LEFT, padx=(6, 0))
footer = ttk.Frame(top, padding=(8, 6)); footer.pack(side=tk.TOP, fill=tk.X)
def on_ok():
values_set = None
if var_items is not None:
selected_values = [real for real, var, _ in var_items if var.get()]
if len(selected_values) != len(var_items):
values_set = set(selected_values) # 全選択のときは None 扱い(=フィルタ無し)
num_op = None; num_val = None
s = val_var.get().strip()
if s != "":
try:
num_val = float(s)
num_op = op_var.get()
except Exception:
messagebox.showerror("Number Filter", "数値が無効です。例: 123 または 123.45")
return
new_cfg = {}
if (var_items is not None) and (values_set is not None):
new_cfg["values"] = values_set
if num_op is not None:
new_cfg["num_op"] = num_op
new_cfg["num_val"] = num_val
# 数値列では values は保存しない
if self._is_numeric_column(col) and "values" in new_cfg:
new_cfg.pop("values", None)
if new_cfg:
self.column_filters[col] = new_cfg
else:
self.column_filters.pop(col, None)
if sort_var.get() in ("asc", "desc"):
self._apply_header_sort(col, sort_var.get())
top.destroy()
self.apply_filter()
ttk.Button(footer, text="OK", command=on_ok).pack(side=tk.RIGHT)
ttk.Button(footer, text="Cancel", command=top.destroy).pack(side=tk.RIGHT, padx=(0, 8))
def _apply_header_sort(self, col: str, direction: str):
if col not in self.data_columns:
return
idx = self.data_columns.index(col)
def keyfunc(item: tuple[int, list[str]]):
row = item[1]
v = row[idx] if idx < len(row) else ""
try:
if v is None or v == "":
raise ValueError
return (0, float(v)) # 数値優先
except Exception:
return (1, str(v).lower()) # 文字列
reverse = (direction == "desc")
self.data_rows.sort(key=keyfunc, reverse=reverse)
self.sort_state = {col: direction}
self.sorted_by = col
self._refresh_heading_labels()
# ---------- Filtering (header popup only) ----------
def apply_filter(self):
base_rows = self.data_rows
if self.column_filters:
rows1 = []
for ln, r in base_rows:
ok = True
for col, cfg in self.column_filters.items():
try:
idx = self.data_columns.index(col)
except ValueError:
continue
val = r[idx] if idx < len(r) else ""
if "values" in cfg and cfg["values"] is not None:
if val not in cfg["values"]:
ok = False; break
if ok and ("num_op" in cfg and cfg["num_op"] is not None):
try:
x = float(val)
except Exception:
ok = False; break
op = cfg["num_op"]; th = cfg.get("num_val")
if th is None:
ok = False; break
if op == "<=" and not (x <= th): ok = False; break
if op == "<" and not (x < th): ok = False; break
if op == "==" and not (x == th): ok = False; break
if op == ">=" and not (x >= th): ok = False; break
if op == ">" and not (x > th): ok = False; break
if ok:
rows1.append((ln, r))
else:
rows1 = list(base_rows)
self.current_rows = rows1
self._populate_trees(self.data_columns, self.current_rows)
# ---------- Sorting (header left click) ----------
def _on_sort_by(self, col: str):
if not self.data_rows or col not in self.data_columns:
return
prev = self.sort_state.get(col)
direction = "desc" if prev == "asc" else "asc"
self._apply_header_sort(col, direction)
self.apply_filter()
self._set_status(f"Sorted by '{col}' ({'descending' if direction=='desc' else 'ascending'})")
def _heading_label(self, col: str) -> str:
label = col
if self.sorted_by == col:
d = self.sort_state.get(col)
if d == "asc": label += " ▲"
elif d == "desc": label += " ▼"
cfg = self.column_filters.get(col)
if cfg and (("values" in cfg and cfg["values"] is not None) or ("num_op" in cfg and cfg["num_op"] is not None)):
label += " [F]"
return label
def _refresh_heading_labels(self):
for h in self.data_columns:
self.tree_right.heading(h, text=self._heading_label(h), command=lambda col=h: self._on_sort_by(col))
# ---------- Sidebar toggle ----------
def toggle_sidebar_visible(self):
self._sidebar_visible.set(not self._sidebar_visible.get())
self.toggle_sidebar()
def toggle_sidebar(self):
panes_children = self.root_panes.panes()
if self._sidebar_visible.get():
if str(self.sidebar) not in panes_children:
self.root_panes.add(self.sidebar, weight=0)
self._set_status_rows_count()
try: self.btn_sidebar.configure(text="Hide Columns ▶")
except Exception: pass
else:
try: self.root_panes.forget(self.sidebar)
except Exception: pass
self._set_status_rows_count()
try: self.btn_sidebar.configure(text="Show Columns ◀")
except Exception: pass
# ---------- Sidebar (right) checkboxes ----------
def _build_right_sidebar_contents(self):
head = ttk.Frame(self.sidebar, padding=(8, 8))
head.pack(side=tk.TOP, fill=tk.X)
ttk.Label(head, text="列の表示/非表示", font=("TkDefaultFont", 11, "bold")).pack(side=tk.LEFT)
btns = ttk.Frame(self.sidebar, padding=(8, 0))
btns.pack(side=tk.TOP, fill=tk.X)
ttk.Button(btns, text="全選択", command=self._check_all_cols).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(btns, text="全解除", command=self._uncheck_all_cols).pack(side=tk.LEFT)
wrap = ttk.Frame(self.sidebar)
wrap.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=(6, 8))
self.sidebar_canvas = tk.Canvas(wrap, borderwidth=0, highlightthickness=0)
self.sidebar_scroll = ttk.Scrollbar(wrap, orient="vertical", command=self.sidebar_canvas.yview)
self.sidebar_canvas.configure(yscrollcommand=self.sidebar_scroll.set)
self.inner_cols_frame = ttk.Frame(self.sidebar_canvas)
self.inner_window = self.sidebar_canvas.create_window((0, 0), window=self.inner_cols_frame, anchor="nw")
self.sidebar_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.sidebar_scroll.pack(side=tk.RIGHT, fill=tk.Y)
def _on_frame_config(event):
self.sidebar_canvas.configure(scrollregion=self.sidebar_canvas.bbox("all"))
self.inner_cols_frame.bind("<Configure>", _on_frame_config)
def _on_canvas_config(event):
self.sidebar_canvas.itemconfigure(self.inner_window, width=event.width)
self.sidebar_canvas.bind("<Configure>", _on_canvas_config)
self._sidebar_note = ttk.Label(self.inner_cols_frame, text="CSVを開くと列一覧が表示されます。", padding=(4, 4))
self._sidebar_note.pack(side=tk.TOP, anchor="w")
def _populate_sidebar_checkboxes(self, headers: list[str]):
for w in self.inner_cols_frame.winfo_children():
w.destroy()
self.col_vars.clear()
ttk.Label(self.inner_cols_frame, text="チェックで列を表示/非表示", padding=(4, 2)).pack(
side=tk.TOP, anchor="w"
)
ttk.Separator(self.inner_cols_frame, orient="horizontal").pack(fill=tk.X, pady=4)
for h in headers:
var = tk.BooleanVar(value=(h in self.display_columns))
self.col_vars[h] = var
cb = ttk.Checkbutton(self.inner_cols_frame, text=h, variable=var,
command=lambda col=h: self._on_toggle_column(col))
cb.pack(side=tk.TOP, anchor="w", padx=4, pady=2)
ttk.Label(self.inner_cols_frame, text="", padding=(2, 6)).pack()
def _on_toggle_column(self, col: str):
new_display = [h for h in self.data_columns if self.col_vars.get(h, tk.BooleanVar(value=True)).get()]
if not new_display:
self.col_vars[col].set(True)
self._set_status("At least one column must remain visible.")
return
self.display_columns = new_display
self.tree_right["displaycolumns"] = tuple(self.display_columns)
try:
children = self.tree_right.get_children()
sample = [self.tree_right.item(iid, "values") for iid in children[:100]]
self._autosize_right(self.display_columns, sample, use_display=True)
except Exception:
pass
self._align_xview_to_column_boundary()
self._set_status_rows_count()
def _check_all_cols(self):
for h in self.data_columns:
self.col_vars[h].set(True)
if self.data_columns:
self._on_toggle_column(self.data_columns[0])
def _uncheck_all_cols(self):
if not self.data_columns:
return
for h in self.data_columns:
self.col_vars[h].set(False)
self.col_vars[self.data_columns[0]].set(True)
self._on_toggle_column(self.data_columns[0])
# ---------- Vertical scroll sync ----------
def _on_yset_left(self, first, last):
try:
self.vscroll.set(first, last)
except Exception:
pass
def _on_yset_right(self, first, last):
try:
self.tree_left.yview_moveto(first)
self.vscroll.set(first, last)
except Exception:
pass
def _on_yview(self, *args):
try:
self.tree_left.yview(*args)
self.tree_right.yview(*args)
except Exception:
pass
def _sync_vscroll_from_right(self):
try:
first, last = self.tree_right.yview()
self.tree_left.yview_moveto(first)
self.vscroll.set(first, last)
except Exception:
pass
# ---------- Column-wise horizontal navigation ----------
def _on_left_col(self, event):
self._scroll_columns(-1)
def _on_right_col(self, event):
self._scroll_columns(1)
def _on_ctrl_left_key(self, event):
try:
self.tree_right.xview_scroll(-1, "pages")
except Exception:
pass
def _on_ctrl_right_key(self, event):
try:
self.tree_right.xview_scroll(1, "pages")
except Exception:
pass
def _right_display_columns(self):
cols = self.tree_right["displaycolumns"]
return list(cols) if isinstance(cols, (list, tuple)) else list(self.tree_right["columns"])
def _right_column_widths(self):
cols = self._right_display_columns()
return [self.tree_right.column(c, "width") for c in cols]
def _right_total_width(self):
w = sum(self._right_column_widths())
return max(1, w)
def _right_left_offset_px(self):
left_frac = self.tree_right.xview()[0]
return left_frac * self._right_total_width()
def _right_set_left_offset_px(self, px):
total = self._right_total_width()
frac = min(1.0, max(0.0, px / total))
self.tree_right.xview_moveto(frac)
def _right_find_leftmost_display_index(self, offset_px):
widths = self._right_column_widths()
acc = 0
for i, w in enumerate(widths):
if acc + w > offset_px + 1:
return i
acc += w
return max(0, len(widths) - 1)
def _scroll_columns(self, step: int):
cols = self._right_display_columns()
if not cols:
return
widths = self._right_column_widths()
cur_px = self._right_left_offset_px()
cur_idx = self._right_find_leftmost_display_index(cur_px)
target_idx = min(len(widths) - 1, max(0, cur_idx + step))
target_px = sum(widths[:target_idx])
self._right_set_left_offset_px(target_px)
def _align_xview_to_column_boundary(self):
cols = self._right_display_columns()
if not cols:
return
widths = self._right_column_widths()
cur_px = self._right_left_offset_px()
idx = self._right_find_leftmost_display_index(cur_px)
target_px = sum(widths[:idx])
self._right_set_left_offset_px(target_px)
# ---------- Misc ----------
def _set_open_enabled(self, enabled: bool):
state = tk.NORMAL if enabled else tk.DISABLED
self.btn_reload.configure(state=state)
try:
self.file_menu.entryconfig("Reload", state=state)
except Exception:
pass
def _set_status(self, text: str):
self.status.configure(text=text)
def _build_statusbar(self):
self.status = ttk.Label(self, anchor="w", relief=tk.SUNKEN, padding=(8, 2))
self.status.pack(side=tk.BOTTOM, fill=tk.X)
self._set_status("Ready. Right-click a header to filter.")
def _set_status_rows_count(self):
total = len(self.data_rows)
shown = len(self.current_rows) if self.current_rows else total
base = os.path.basename(self.csv_path) if self.csv_path else "(no file)"
self._set_status(f"{base} — rows: {shown} shown / {total} total, columns: {len(self.data_columns)}")
def _show_about(self):
messagebox.showinfo(
"About",
"CSV Viewer (Tkinter)\n"
"Step 11: Header popup only (top filter bar removed)\n\n"
"Tips:\n"
"- Left-click header: Sort asc/desc\n"
"- Right-click header: Open filter popup (Values / Number Filter tabs)\n"
"- Ctrl+O/R/Q: Open/Reload/Quit\n"
"- Ctrl+- / Ctrl+= / Ctrl++: Font smaller/larger\n"
"- Left/Right: Move by 1 visible column (right table)\n"
"- Ctrl+Left/Right: Page scroll (right table)\n"
"- Ctrl+B: Toggle right columns panel"
)
def main():
initial_path = sys.argv[1] if len(sys.argv) > 1 else None
app = CSVViewerApp(initial_path=initial_path)
app.mainloop()
if __name__ == "__main__":
main()
実行例:
python csv_viewer_tk.py path/to/your.csv