第8章:仕上げと完成版(コード全文)

python

ここまでの内容を統合した完成版です。
ポイント:

  • 上部フィルタバーは廃止し、ヘッダー右クリックポップアップのみでフィルタ
  • 数値列では「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

前へ → 第7章
トップへ → サマリへ戻る

タイトルとURLをコピーしました