PSPC Viewer

Open, browse, search, and export Autodesk Plant 3D .pspc files — SQLite databases with piping & instrumentation data.

Python 3.10+ tkinter SQLite Windows Open Source
⬇  Download PSPCViewer.exe  (38.1 MB)

Windows  ·  No Python required  ·  Single executable

# Or run from source (Python 3.10+)
pip install pandas openpyxl
python main.py

Features

📂 Open .pspc files

File dialog — supports all SQLite-based formats.

📋 Browse all tables

Left panel lists every table in the database.

🔍 Real-time search

Instant row filtering inside any table.

📊 Export to Excel

All tables → one .xlsx workbook (Ctrl+E).

🔌 Port Connections

Auto-join S1 (EngineeringItems) and S2–S4 (Port table).

👁️ Column management

Hide / show / reorder columns; preferences persist per table.

↕️ Sorting

Click any column header to sort ascending / descending.

💾 Persistent settings

Window size, last file, and column prefs saved across restarts.

Keyboard Shortcuts

Ctrl+OOpen .pspc / SQLite file
Ctrl+EExport all tables to Excel (.xlsx)
F5Reload current table
Double-click cellEdit cell inline
Click headerSort column ascending / descending (▲▼)
Drag header L / RReorder column
Right-click headerHide column  /  open column manager

Project Structure

SQLreader_job/

├── main.py ← entry point; starts the app

├── constants.py ← shared constants (EI_VIEW_COLS, HIDDEN_COLS, …)
├── settings.py ← Settings (JSON) + SettingsDialog
├── dialogs.py ← ColumnDialog (show / hide / reorder columns)
├── widgets.py ← CellEditor (inline cell editing)

├── db_helper.py ← DbHelper — all SQL here, zero tkinter
├── viewer.py ← PSPCViewer — all tkinter here, zero SQL

└── dist/
    └── PSPCViewer.exe ← standalone Windows executable (PyInstaller)

Source Code

"""
main.py — entry point for PSPC Viewer.

Run:
    python main.py
    python main.py PS315.pspc          # open a file directly

Build standalone .exe:
    build_exe.bat                      # uses PyInstaller
"""
import tkinter as tk
import os
import sys

from viewer import PSPCViewer


def main():
    root = tk.Tk()

    # Optional window icon (no crash if missing)
    icon = os.path.join(getattr(sys, "_MEIPASS", os.path.dirname(__file__)), "icon.ico")
    try:
        root.iconbitmap(icon)
    except Exception:
        pass

    app = PSPCViewer(root)

    # Support drag-and-drop / OS file association / CLI argument
    if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
        root.after(300, lambda: app._open(sys.argv[1]))

    root.mainloop()


if __name__ == "__main__":
    main()
"""
constants.py — shared constants for PSPC Viewer
No imports beyond the stdlib; safe to import from anywhere.
"""
import os

APP_VERSION = "1.3"

# ── Settings file location ─────────────────────────────────────────────────
SETTINGS_DIR  = os.path.join(os.path.expanduser("~"), ".pspcviewer")
SETTINGS_FILE = os.path.join(SETTINGS_DIR, "config.json")

# ── Plant Vault Projects root (toolbar quick-open) ─────────────────────────
PLANT_VAULT_ROOT = os.path.join("C:\\", "Documents", "Plant Vault Projects", "Projects")

# ── Default 'Components' simplified table list ─────────────────────────────
DEFAULT_SIMPLIFIED: list[str] = [
    "EngineeringItems", "BlindDisk", "BlindFlange", "BoltSet", "Clamp",
    "Coupling", "Cross", "Elbow", "Fasteners", "Flange", "Gasket",
    "Instrument", "Olet", "Pipe", "PipeRunComponent", "Reducer",
    "SingleBranchFitting", "StubEnd", "Swage", "Tee", "Valve",
    "ValveActuator", "ValveActuatorMap",
]

# ── EngineeringItems smart-view columns ────────────────────────────────────
# Format: (db_column_name, display_label, pixel_width)
EI_VIEW_COLS: list[tuple] = [
    ("PartFamilyLongDesc",  "Description",    280),
    ("PartCategory",         "Category",       100),
    ("NominalDiameter",      "Nom. Ø",          70),
    ("NominalUnit",          "Unit",             50),
    ("EndType",              "End Type",         80),
    ("PressureClass",        "Press.Class",      85),
    ("Schedule",             "Schedule",         70),
    ("WallThickness",        "Wall Thk",         70),
    ("ConnectionPortCount",  "Ports",            55),
    ("Manufacturer",         "Manufacturer",    140),
    ("ItemCode",             "Item Code",       100),
    ("Material",             "Material",        100),
    ("DesignStd",            "Design Std",      110),
    ("Weight",               "Weight",           70),
    ("WeightUnit",           "Wt. Unit",         65),
]

# ── Port detail bottom-panel columns ───────────────────────────────────────
PORT_DETAIL_COLS: list[tuple] = [
    ("PortName",        "Port",          65),
    ("NominalDiameter", "Nom. Ø",        75),
    ("NominalUnit",     "Unit",           50),
    ("EndType",         "End Type",       80),
    ("PressureClass",   "Press.Class",    85),
    ("Schedule",        "Schedule",       70),
    ("WallThickness",   "Wall Thk",       70),
    ("MatchingPipeOd",  "Match. OD",      90),
    ("FlangeStd",       "Flange Std",     90),
    ("GasketStd",       "Gasket Std",     80),
    ("Facing",          "Facing",         70),
    ("FlangeThickness", "Flange Thk",     80),
    ("EngagementLength","Engage Len",     85),
    ("LengthUnit",      "Len Unit",       65),
]

# ── Columns always hidden in raw view (binary GUIDs / internals) ───────────
HIDDEN_COLS: set[str] = {
    "PnPGuid", "PnPTimestamp", "SizeRecordId", "PartFamilyId",
    "CatalogPartFamilyId", "CatalogPartId", "ValveBodyFamilyId",
    "ValveBodyPartSizeId", "ActuatorFamilyId", "ActuatorPartSizeId",
    "CatalogId", "ContentGeometryParamDefinition",
    "ContentIsoSymbolDefinition", "ContentGeometryTemplate",
}

# ── Keywords used to auto-detect port-related tables ──────────────────────
PORT_KW: tuple = (
    "port", "connect", "nozzle", "endpoint", "endtype",
    "end_type", "branch", "open_end",
)
"""
db_helper.py — pure data layer for PSPC Viewer.

DbHelper wraps a sqlite3 connection and exposes clean methods that return
plain Python data (lists, dicts).  No tkinter imports here.
"""
import sqlite3
from collections import defaultdict
from typing import Optional

from constants import (
    EI_VIEW_COLS, PORT_DETAIL_COLS, HIDDEN_COLS, PORT_KW,
)


def safe_str(v) -> str:
    """Convert any SQLite value to a display-safe string."""
    if v is None:                         return ""
    if isinstance(v, (bytes, bytearray)): return v.hex().upper()
    return str(v)


def _is_blob_col(sample: list) -> bool:
    if not sample:
        return False
    return sum(1 for v in sample if isinstance(v, (bytes, bytearray))) > len(sample) * 0.5


# ─────────────────────────────────────────────────────────────────────────────

class DbHelper:
    """
    All database I/O for the PSPC Viewer lives here.

    Every public method returns plain Python data so the UI layer (viewer.py)
    stays free of SQL and sqlite3 concerns.
    """

    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn
        self._tables: list[str] = []
        self._refresh_table_list()

    # ── Table introspection ───────────────────────────────────────────────────

    def _refresh_table_list(self):
        cur = self.conn.cursor()
        cur.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name COLLATE NOCASE")
        self._tables = [r[0] for r in cur.fetchall()]

    @property
    def all_tables(self) -> list[str]:
        return self._tables

    def col_exists(self, table: str, col: str) -> bool:
        try:
            cur = self.conn.cursor()
            cur.execute(f'PRAGMA table_info("{table}")')
            return any(c[1] == col for c in cur.fetchall())
        except Exception:
            return False

    def _table_cols(self, table: str) -> list[str]:
        cur = self.conn.cursor()
        cur.execute(f'PRAGMA table_info("{table}")')
        return [c[1] for c in cur.fetchall()]

    # ── Raw table data ────────────────────────────────────────────────────────

    def table_raw(self, name: str, show_hidden: bool = False
                  ) -> tuple[list[str], list[list]]:
        """
        Return (visible_cols, rows) for a raw SQL table.
        Hidden/blob columns are filtered out unless show_hidden is True.
        """
        cur = self.conn.cursor()
        cur.execute(f'SELECT * FROM "{name}"')
        all_cols = [d[0] for d in cur.description]
        all_rows = [list(r) for r in cur.fetchall()]

        if show_hidden:
            vis = list(range(len(all_cols)))
        else:
            vis = [i for i, c in enumerate(all_cols) if c not in HIDDEN_COLS]
            if all_rows:
                vis = [i for i in vis
                       if not _is_blob_col([r[i] for r in all_rows[:20]])]

        cols = [all_cols[i] for i in vis]
        rows = [[r[i] for i in vis] for r in all_rows]
        return cols, rows

    # ── EngineeringItems smart view ───────────────────────────────────────────

    def ei_view(self, show_hidden: bool = False
                ) -> tuple[list[str], list[list], list[Optional[int]]]:
        """
        Return (col_labels, rows, pnpids) for the EngineeringItems smart view.
        Shows all columns (same filtering as table_raw); PnPID is tracked
        separately for the port panel but not included as a visible column.
        """
        cur = self.conn.cursor()
        cur.execute('SELECT * FROM "EngineeringItems"')
        all_cols = [d[0] for d in cur.description]
        all_rows = [list(r) for r in cur.fetchall()]

        pnpid_idx = all_cols.index("PnPID") if "PnPID" in all_cols else None
        pnpids    = [
            (int(r[pnpid_idx]) if r[pnpid_idx] is not None else None)
            for r in all_rows
        ] if pnpid_idx is not None else [None] * len(all_rows)

        if show_hidden:
            vis = [i for i in range(len(all_cols)) if i != pnpid_idx]
        else:
            vis = [i for i, c in enumerate(all_cols)
                   if c not in HIDDEN_COLS and i != pnpid_idx]
            if all_rows:
                vis = [i for i in vis
                       if not _is_blob_col([r[i] for r in all_rows[:20]])]

        cols = [all_cols[i] for i in vis]
        rows = [[r[i] for i in vis] for r in all_rows]
        return cols, rows, pnpids

    # ── Component-specific join view ──────────────────────────────────────────

    def comp_join(self, table: str
                  ) -> tuple[list[str], list[list], list[Optional[int]]]:
        """
        Return (col_labels, rows, pnpids) for a component table joined with
        EngineeringItems.  Handles tables that have only a PnPID column
        (e.g. Fasteners, BlindDisk) without SQL errors.
        """
        cur       = self.conn.cursor()
        all_t     = self._table_cols(table)
        t_vis     = [c for c in all_t if c not in HIDDEN_COLS and c != "PnPID"]

        # Drop blob columns based on a data sample
        if t_vis:
            cur.execute(f'SELECT * FROM "{table}" LIMIT 20')
            sample = cur.fetchall()
            if sample:
                t_vis = [c for c in t_vis
                         if not _is_blob_col([r[all_t.index(c)] for r in sample])]

        has_ei     = "EngineeringItems" in self._tables
        sel_parts  = ["t.PnPID"]
        col_labels = []

        for c in t_vis:
            sel_parts.append(f't."{c}"')
            col_labels.append(c)

        if has_ei:
            used_labels = set(col_labels)

            # Always-pinned EI columns (added before the variable extras)
            pinned = [
                ("PartFamilyLongDesc", "Description"),
                ("ItemCode",           "Item Code"),
            ]
            for db_col, lbl in pinned:
                if db_col not in t_vis and lbl not in used_labels:
                    sel_parts.append(f'ei."{db_col}" AS "{lbl}"')
                    col_labels.append(lbl)
                    used_labels.add(lbl)

            # Additional EI columns (up to 7), skipping already-pinned ones
            pinned_db = {db_col for db_col, _ in pinned}
            ei_extra = [c for c, lbl, _ in EI_VIEW_COLS
                        if c not in t_vis and c not in pinned_db
                        and lbl not in used_labels][:7]
            for c in ei_extra:
                lbl = next((l for dc, l, _ in EI_VIEW_COLS if dc == c), c)
                sel_parts.append(f'ei."{c}" AS "{lbl}"')
                col_labels.append(lbl)
            join = (f'FROM "{table}" t '
                    f'LEFT JOIN "EngineeringItems" ei ON t.PnPID = ei.PnPID')
        else:
            join = f'FROM "{table}" t'

        cur.execute(f'SELECT {", ".join(sel_parts)} {join}')
        raw    = [list(r) for r in cur.fetchall()]
        pnpids = [int(r[0]) if r[0] is not None else None for r in raw]
        rows   = [r[1:] for r in raw]
        return col_labels, rows, pnpids

    # ── Port map ──────────────────────────────────────────────────────────────

    def port_map(self) -> dict[int, list[list]]:
        """
        Return {pnpid: [port_row, …]} for all components that have S2+ ports
        in the Port table (via PartPort).  Returns empty dict if tables are absent.
        """
        result: dict[int, list[list]] = defaultdict(list)
        if "PartPort" not in self._tables or "Port" not in self._tables:
            return result
        try:
            cols_sql = ", ".join(f'p."{c}"' for c, _, _ in PORT_DETAIL_COLS)
            rows     = self.conn.execute(
                f'SELECT pp.Part, {cols_sql} '
                f'FROM PartPort pp JOIN Port p ON p.PnPID = pp.Port '
                f'ORDER BY pp.Part, p.PortName'
            ).fetchall()
            for row in rows:
                result[int(row[0])].append(list(row[1:]))
        except Exception:
            pass
        return result

    def s1_port(self, pnpid: int) -> Optional[list]:
        """
        Return the S1 (primary) port data stored inline in EngineeringItems,
        or None if the component is not found.
        """
        if "EngineeringItems" not in self._tables:
            return None
        try:
            col_sql = ", ".join(f'"{c}"' for c, _, _ in PORT_DETAIL_COLS)
            row     = self.conn.execute(
                f'SELECT {col_sql} FROM "EngineeringItems" WHERE PnPID = ?',
                (pnpid,)
            ).fetchone()
            return list(row) if row else None
        except Exception:
            return None

    # ── All ports union (Port Connections tab) ────────────────────────────────

    def all_ports(self) -> tuple[list[str], list[list]]:
        """
        Return (col_labels, rows) combining S1 ports (from EngineeringItems)
        and S2+ ports (from Port table via PartPort) in one unified view.
        Falls back to a raw union of any port-keyword tables if the Plant 3D
        schema is not present.
        """
        tbl_lc = {t.lower(): t for t in self._tables}

        if all(k in tbl_lc for k in ("engineeringitems", "partport", "port")):
            return self._all_ports_plant3d(tbl_lc)
        return self._all_ports_fallback(tbl_lc)

    def _all_ports_plant3d(self, tbl_lc: dict) -> tuple[list[str], list[list]]:
        ei, pp, pt = (tbl_lc["engineeringitems"],
                      tbl_lc["partport"], tbl_lc["port"])
        q = f"""
        SELECT
            ei.PnPID             AS "Part ID",
            ei.PartFamilyLongDesc AS "Component",
            ei.PortName           AS "Port",
            ei.NominalDiameter    AS "Nom. Ø",
            ei.NominalUnit        AS "Unit",
            ei.EndType            AS "End Type",
            ei.PressureClass      AS "Press.Class",
            ei.Schedule           AS "Schedule",
            ei.WallThickness      AS "Wall Thk",
            ei.MatchingPipeOd     AS "Match. OD",
            ei.FlangeStd          AS "Flange Std",
            'EI (S1)'             AS "Source"
        FROM "{ei}" ei WHERE ei.PortName IS NOT NULL
        UNION ALL
        SELECT
            ei.PnPID,
            ei.PartFamilyLongDesc,
            p.PortName,
            p.NominalDiameter, p.NominalUnit, p.EndType,
            p.PressureClass,   p.Schedule,    p.WallThickness,
            p.MatchingPipeOd,  p.FlangeStd,
            'Port table (S2+)'
        FROM "{pt}" p
        JOIN "{pp}" pp2 ON pp2.Port  = p.PnPID
        JOIN "{ei}" ei  ON ei.PnPID  = pp2.Part
        ORDER BY 2, 1, 3
        """
        cur  = self.conn.cursor()
        cur.execute(q)
        cols = [d[0] for d in cur.description]
        rows = [list(r) for r in cur.fetchall()]
        return cols, rows

    def _all_ports_fallback(self, tbl_lc: dict) -> tuple[list[str], list[list]]:
        port_tables = [orig for lc, orig in tbl_lc.items()
                       if any(kw in lc for kw in PORT_KW)]
        if not port_tables:
            return [], []
        cols: list[str] | None = None
        rows: list[list] = []
        cur = self.conn.cursor()
        for t in port_tables:
            cur.execute(f'SELECT * FROM "{t}"')
            if cols is None:
                cols = ["⊞ Table"] + [d[0] for d in cur.description]
            for row in cur.fetchall():
                rows.append([t] + list(row))
        return (cols or []), rows

    # ── Write changes back to DB ──────────────────────────────────────────────

    def save_changes(self, pending: dict) -> None:
        """
        Write pending edits to the database and commit.

        pending format: {table_name: {row_index: {col_name: new_value}}}
        row_index is 0-based and corresponds to the rowid order at load time.
        """
        cur = self.conn.cursor()
        for tbl, rows in pending.items():
            cur.execute(f'SELECT rowid FROM "{tbl}"')
            rowid_map = [r[0] for r in cur.fetchall()]
            for row_idx, changes in rows.items():
                if row_idx >= len(rowid_map):
                    continue
                rowid = rowid_map[row_idx]
                for col, val in changes.items():
                    cur.execute(
                        f'UPDATE "{tbl}" SET "{col}" = ? WHERE rowid = ?',
                        (val, rowid))
        self.conn.commit()

    # ── Export to Excel ───────────────────────────────────────────────────────

    def export_excel(self, dest_path: str) -> None:
        """Export every table to a sheet in dest_path (.xlsx)."""
        import pandas as pd

        with pd.ExcelWriter(dest_path, engine="openpyxl") as wr:
            for tbl in self._tables:
                df = pd.read_sql_query(f'SELECT * FROM "{tbl}"', self.conn)
                for col in df.columns:
                    if df[col].dtype == object:
                        df[col] = df[col].apply(
                            lambda v: v.hex().upper()
                            if isinstance(v, (bytes, bytearray)) else v)
                df.to_excel(wr, sheet_name=tbl[:31], index=False)
"""
settings.py — persistent user preferences + the Settings configuration dialog.
"""
import json
import os
import tkinter as tk
from tkinter import ttk

from constants import SETTINGS_DIR, SETTINGS_FILE, DEFAULT_SIMPLIFIED


# ─────────────────────────────────────────────────────────────────────────────
#  Settings  (thin JSON wrapper)
# ─────────────────────────────────────────────────────────────────────────────

class Settings:
    """Load / save user preferences from ~/.pspcviewer/config.json."""

    _DEFAULTS: dict = {
        "simplified_tables": DEFAULT_SIMPLIFIED,
        "last_file":         None,
        "show_hidden_cols":  False,
        "geometry":          "1420x900",
        "panel_mode":        "components",   # "all" | "components"
        "column_prefs":      {},             # {table: {hidden: [...], order: [...]}}
    }

    def __init__(self):
        self._d = dict(self._DEFAULTS)
        self._load()

    def _load(self):
        try:
            with open(SETTINGS_FILE) as f:
                self._d.update(json.load(f))
        except (FileNotFoundError, json.JSONDecodeError):
            pass

    def save(self):
        os.makedirs(SETTINGS_DIR, exist_ok=True)
        with open(SETTINGS_FILE, "w") as f:
            json.dump(self._d, f, indent=2)

    def __getitem__(self, k):
        return self._d.get(k, self._DEFAULTS.get(k))

    def __setitem__(self, k, v):
        self._d[k] = v


# ─────────────────────────────────────────────────────────────────────────────
#  SettingsDialog  (configure the Components simplified table list)
# ─────────────────────────────────────────────────────────────────────────────

class SettingsDialog(tk.Toplevel):
    """Lets the user choose which tables appear in the 'Components' view."""

    def __init__(self, parent: tk.Tk, settings: Settings, db_tables: list[str]):
        super().__init__(parent)
        self.title("⚙  Configure 'Components' view")
        self.geometry("440x580")
        self.resizable(True, True)
        self.transient(parent)
        self.grab_set()

        self.settings = settings
        self.result: list[str] | None = None   # set to new list on Save

        current = set(settings["simplified_tables"])
        known   = set(db_tables)
        # Tables saved from before that are no longer in this DB
        extra   = [t for t in current if t not in known]
        display = db_tables + extra

        # ── Header ───────────────────────────────────────────
        ttk.Label(self,
                  text="Tables visible in 'Components' view",
                  font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=12, pady=(10, 2))
        ttk.Label(self,
                  text="Grey = not in current database (remembered from before).",
                  foreground="#888").pack(anchor="w", padx=12, pady=(0, 6))

        # ── Scrollable checklist ──────────────────────────────
        outer  = ttk.Frame(self)
        outer.pack(fill=tk.BOTH, expand=True, padx=12, pady=4)
        canvas = tk.Canvas(outer, bg="white", highlightthickness=0)
        vsb    = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
        inner  = ttk.Frame(canvas)
        inner.bind("<Configure>",
                   lambda _: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.create_window((0, 0), window=inner, anchor="nw")
        canvas.configure(yscrollcommand=vsb.set)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        canvas.pack(fill=tk.BOTH, expand=True)
        canvas.bind("<MouseWheel>",
                    lambda e: canvas.yview_scroll(int(-1*(e.delta/120)), "units"))

        self._vars: dict[str, tk.BooleanVar] = {}
        for t in display:
            var = tk.BooleanVar(value=(t in current))
            self._vars[t] = var
            fg = "#000" if t in known else "#aaa"
            tk.Checkbutton(inner, text=t, variable=var, foreground=fg,
                           bg="white", font=("Consolas", 9)).pack(anchor="w", padx=8, pady=1)

        # ── Buttons ───────────────────────────────────────────
        bf = ttk.Frame(self)
        bf.pack(fill=tk.X, padx=12, pady=8)
        ttk.Button(bf, text="All",
                   command=lambda: [v.set(True)  for v in self._vars.values()]).pack(side=tk.LEFT, padx=2)
        ttk.Button(bf, text="None",
                   command=lambda: [v.set(False) for v in self._vars.values()]).pack(side=tk.LEFT, padx=2)
        ttk.Button(bf, text="Default",
                   command=self._reset).pack(side=tk.LEFT, padx=2)
        ttk.Button(bf, text="Cancel",
                   command=self.destroy).pack(side=tk.RIGHT, padx=2)
        ttk.Button(bf, text="Save",
                   command=self._save).pack(side=tk.RIGHT, padx=2)

    def _reset(self):
        ds = set(DEFAULT_SIMPLIFIED)
        for t, v in self._vars.items():
            v.set(t in ds)

    def _save(self):
        self.result = [t for t, v in self._vars.items() if v.get()]
        self.destroy()
"""
dialogs.py — application-level dialogs (not settings-related).

Currently contains:
  ColumnDialog  — show / hide / reorder columns for the active table view
"""
import tkinter as tk
from tkinter import ttk, messagebox


class ColumnDialog(tk.Toplevel):
    """
    Two-panel dialog: Visible (left) ↔ Hidden (right).
    Move items with the arrow buttons; reorder Visible with ▲ / ▼.
    """

    def __init__(self, parent: tk.Tk, tree: ttk.Treeview):
        super().__init__(parent)
        self.title("⊞  Show / Hide Columns")
        self.geometry("540x400")
        self.minsize(420, 300)
        self.resizable(True, True)
        self.transient(parent)
        self.grab_set()

        self.tree   = tree
        self.result: list[str] | None = None

        all_cols = list(tree["columns"])
        cur_disp = self._get_disp(tree)
        cur_set  = set(cur_disp)

        shown  = list(cur_disp)
        hidden = [c for c in all_cols if c not in cur_set]

        ttk.Label(self,
                  text="Visible columns (left) will be shown in that order.",
                  foreground="#555").pack(anchor="w", padx=12, pady=(8, 4))

        # ── Main three-column layout ──────────────────────────────────────────
        mid = ttk.Frame(self)
        mid.pack(fill=tk.BOTH, expand=True, padx=12, pady=2)
        mid.columnconfigure(0, weight=1)
        mid.columnconfigure(2, weight=1)
        mid.rowconfigure(1, weight=1)

        # Left panel — Visible
        ttk.Label(mid, text="Visible", font=("Segoe UI", 9, "bold")).grid(
            row=0, column=0, pady=(0, 2))
        lf   = ttk.Frame(mid)
        lf.grid(row=1, column=0, sticky="nsew")
        lvsb = ttk.Scrollbar(lf)
        lvsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.shown_lb = tk.Listbox(
            lf, yscrollcommand=lvsb.set, selectmode=tk.EXTENDED,
            font=("Consolas", 9), activestyle="dotbox")
        self.shown_lb.pack(fill=tk.BOTH, expand=True)
        lvsb.config(command=self.shown_lb.yview)
        self.shown_lb.bind("<MouseWheel>",
            lambda e: self.shown_lb.yview_scroll(int(-e.delta / 120), "units"))
        for c in shown:
            self.shown_lb.insert(tk.END, c)

        # Centre — arrow buttons
        cf = ttk.Frame(mid, width=60)
        cf.grid(row=1, column=1, padx=6)
        ttk.Button(cf, text="◀ Show",  width=7, command=self._show_sel).pack(pady=3)
        ttk.Button(cf, text="Hide ▶",  width=7, command=self._hide_sel).pack(pady=3)
        ttk.Separator(cf, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=8)
        ttk.Button(cf, text="▲ Up",    width=7, command=self._move_up).pack(pady=3)
        ttk.Button(cf, text="▼ Down",  width=7, command=self._move_down).pack(pady=3)

        # Right panel — Hidden
        ttk.Label(mid, text="Hidden", font=("Segoe UI", 9, "bold")).grid(
            row=0, column=2, pady=(0, 2))
        rf   = ttk.Frame(mid)
        rf.grid(row=1, column=2, sticky="nsew")
        rvsb = ttk.Scrollbar(rf)
        rvsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hidden_lb = tk.Listbox(
            rf, yscrollcommand=rvsb.set, selectmode=tk.EXTENDED,
            font=("Consolas", 9), activestyle="dotbox")
        self.hidden_lb.pack(fill=tk.BOTH, expand=True)
        rvsb.config(command=self.hidden_lb.yview)
        self.hidden_lb.bind("<MouseWheel>",
            lambda e: self.hidden_lb.yview_scroll(int(-e.delta / 120), "units"))
        for c in hidden:
            self.hidden_lb.insert(tk.END, c)

        # ── Bottom buttons ────────────────────────────────────────────────────
        bf = ttk.Frame(self)
        bf.pack(fill=tk.X, padx=12, pady=8)
        ttk.Button(bf, text="Show All",
                   command=self._show_all).pack(side=tk.LEFT, padx=2)
        ttk.Button(bf, text="Hide All",
                   command=self._hide_all).pack(side=tk.LEFT, padx=2)
        ttk.Button(bf, text="Cancel",
                   command=self.destroy).pack(side=tk.RIGHT, padx=2)
        ttk.Button(bf, text="Apply",
                   command=self._apply).pack(side=tk.RIGHT, padx=2)

    # ── Actions ───────────────────────────────────────────────────────────────

    def _show_sel(self):
        sel = self.hidden_lb.curselection()
        items = [self.hidden_lb.get(i) for i in sel]
        for i in reversed(sel):
            self.hidden_lb.delete(i)
        for item in items:
            self.shown_lb.insert(tk.END, item)

    def _hide_sel(self):
        sel = self.shown_lb.curselection()
        items = [self.shown_lb.get(i) for i in sel]
        for i in reversed(sel):
            self.shown_lb.delete(i)
        for item in items:
            self.hidden_lb.insert(tk.END, item)

    def _move_up(self):
        sel = self.shown_lb.curselection()
        if not sel or sel[0] == 0:
            return
        for i in sel:
            val = self.shown_lb.get(i)
            self.shown_lb.delete(i)
            self.shown_lb.insert(i - 1, val)
            self.shown_lb.selection_set(i - 1)

    def _move_down(self):
        sel = self.shown_lb.curselection()
        if not sel or sel[-1] == self.shown_lb.size() - 1:
            return
        for i in reversed(sel):
            val = self.shown_lb.get(i)
            self.shown_lb.delete(i)
            self.shown_lb.insert(i + 1, val)
            self.shown_lb.selection_set(i + 1)

    def _show_all(self):
        items = list(self.hidden_lb.get(0, tk.END))
        self.hidden_lb.delete(0, tk.END)
        for item in items:
            self.shown_lb.insert(tk.END, item)

    def _hide_all(self):
        items = list(self.shown_lb.get(0, tk.END))
        self.shown_lb.delete(0, tk.END)
        for item in items:
            self.hidden_lb.insert(tk.END, item)

    def _apply(self):
        new_disp = list(self.shown_lb.get(0, tk.END))
        if not new_disp:
            messagebox.showwarning("Column visibility",
                                   "At least one column must remain visible.",
                                   parent=self)
            return
        self.result = new_disp
        self.destroy()

    # ── Helpers ───────────────────────────────────────────────────────────────

    @staticmethod
    def _get_disp(tree: ttk.Treeview) -> list[str]:
        d = tree["displaycolumns"]
        if not d or d in ("#all", ("#all",)):
            return list(tree["columns"])
        if isinstance(d, str):
            return list(tree["columns"])
        return list(d)
"""
widgets.py — small reusable tkinter widgets.

Currently contains:
  CellEditor — places an Entry widget directly over a Treeview cell for inline editing.
"""
import tkinter as tk
from tkinter import ttk


class CellEditor:
    """
    Overlays a small Entry widget on top of a Treeview cell so the user can
    edit the value in-place.

    Usage
    -----
    editor = CellEditor(tree, on_save_callback)
    editor.open(item_iid, "#2", current_value)   # open on column 2
    editor.close()                               # close without saving
    """

    def __init__(self, tree: ttk.Treeview, on_save):
        """
        Parameters
        ----------
        tree     : the Treeview to attach to
        on_save  : callback(item_iid: str, col_index: int, new_value: str)
                   called when the user commits the edit
        """
        self.tree    = tree
        self.on_save = on_save
        self._w: tk.Entry | None = None

    # ── Public API ────────────────────────────────────────────────────────────

    def open(self, item: str, col_id: str, current: str):
        """Open the editor over (item, col_id) pre-filled with current."""
        self.close()
        bbox = self.tree.bbox(item, col_id)
        if not bbox:
            return
        x, y, w, h = bbox
        col_index   = int(col_id.replace("#", "")) - 1

        var = tk.StringVar(value=current)
        e   = tk.Entry(self.tree, textvariable=var,
                       relief="solid", bd=1,
                       font=("Consolas", 9), bg="#fffde7")
        e.place(x=x, y=y, width=max(w, 120), height=h)
        e.focus_set()
        e.select_range(0, tk.END)
        self._w = e

        commit = lambda _=None: (self.on_save(item, col_index, var.get()), self.close())
        e.bind("<Return>",    commit)
        e.bind("<Tab>",       commit)
        e.bind("<Escape>",    lambda _: self.close())
        e.bind("<FocusOut>",  lambda _: self.close())

    def close(self):
        """Destroy the editor widget without saving."""
        if self._w:
            self._w.destroy()
            self._w = None
"""
viewer.py — PSPCViewer: the main application window.

All tkinter UI lives here.  Database work is delegated to DbHelper.
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import sqlite3
import os
import sys

from constants import (
    EI_VIEW_COLS, PORT_DETAIL_COLS, DEFAULT_SIMPLIFIED, APP_VERSION,
    PLANT_VAULT_ROOT,
)
from settings  import Settings, SettingsDialog
from dialogs   import ColumnDialog
from widgets   import CellEditor
from db_helper import DbHelper, safe_str


def resource_path(rel: str) -> str:
    base = getattr(sys, "_MEIPASS", os.path.dirname(__file__))
    return os.path.join(base, rel)


# ─────────────────────────────────────────────────────────────────────────────

class PSPCViewer:
    APP_TITLE = "PSPC Viewer — Autodesk Plant 3D"

    def __init__(self, root: tk.Tk):
        self.root = root
        self.cfg  = Settings()
        self.db:  DbHelper | None = None
        self.conn: sqlite3.Connection | None = None

        # State
        self.cur_file:  str | None   = None
        self.cur_table: str | None   = None
        self.cur_cols:  list[str]    = []
        self.cur_rows:  list[list]   = []
        self.pending:   dict         = {}  # {tbl: {row_idx: {col: val}}}

        # Port detail
        self._port_map:    dict[int, list[list]] = {}
        self._comp_pnpids: list[int | None]      = []
        self._port_panel_visible = False

        # Port connections tab
        self.all_port_rows: list[list] = []
        self.port_tab_cols: list[str]  = []

        # Plant Vault quick-open state
        self._proj_paths: dict[str, str] = {}
        self._spec_paths: dict[str, str] = {}

        # Header drag state
        self._drag_from:    str | None = None
        self._drag_start_x: int        = 0
        self._drag_moved:   bool       = False

        # Sort state  {col_name: currently_reversed}
        self._sort_rev: dict[str, bool] = {}

        # UI mode
        self._mode        = tk.StringVar(value=self.cfg["panel_mode"])
        self._show_hidden = tk.BooleanVar(value=self.cfg["show_hidden_cols"])

        root.title(self.APP_TITLE)
        root.geometry(self.cfg["geometry"])
        root.minsize(960, 640)
        root.protocol("WM_DELETE_WINDOW", self._on_close)
        root.bind("<Configure>", self._on_resize)

        self._build_ui()
        self._bind_keys()
        self._apply_mode_style()

        last = self.cfg["last_file"]
        if last and os.path.isfile(last):
            root.after(200, lambda: self._open(last))

    # ══════════════════════════════════════════════════════════════════════════
    #  UI construction
    # ══════════════════════════════════════════════════════════════════════════

    def _build_ui(self):
        self._build_menu()
        self._build_toolbar()
        self._build_body()
        self._build_status()

    # ── Menu ──────────────────────────────────────────────────────────────────

    def _build_menu(self):
        mb = tk.Menu(self.root)

        fm = tk.Menu(mb, tearoff=0)
        fm.add_command(label="Open…",            command=self.cmd_open,   accelerator="Ctrl+O")
        fm.add_separator()
        fm.add_command(label="Export to Excel…", command=self.cmd_excel,  accelerator="Ctrl+E")
        fm.add_separator()
        fm.add_command(label="Exit",             command=self._on_close)
        mb.add_cascade(label="File", menu=fm)

        vm = tk.Menu(mb, tearoff=0)
        vm.add_command(label="Port Connections tab", command=self.cmd_ports_tab)
        vm.add_command(label="Reload table",         command=self.cmd_reload, accelerator="F5")
        vm.add_separator()
        vm.add_checkbutton(label="Show hidden columns (GUIDs / blobs)",
                           variable=self._show_hidden, command=self.cmd_reload)
        mb.add_cascade(label="View", menu=vm)

        hm = tk.Menu(mb, tearoff=0)
        hm.add_command(label="About", command=self._about)
        mb.add_cascade(label="Help", menu=hm)
        self.root.config(menu=mb)

    # ── Toolbar ───────────────────────────────────────────────────────────────

    def _build_toolbar(self):
        bar = ttk.Frame(self.root, relief="raised")
        bar.pack(side=tk.TOP, fill=tk.X)

        # Left: standard action buttons
        for lbl, cmd in [
            ("📂 Open",             self.cmd_open),
            ("📊 Export Excel",     self.cmd_excel),
            ("|", None),
            ("🔌 Port Connections", self.cmd_ports_tab),
            ("🔄 Reload",           self.cmd_reload),
            ("|", None),
            ("❓ How To",           self.cmd_howto),
            ("|", None),
        ]:
            if lbl == "|":
                ttk.Separator(bar, orient=tk.VERTICAL).pack(
                    side=tk.LEFT, fill=tk.Y, padx=5, pady=3)
            else:
                ttk.Button(bar, text=lbl, command=cmd).pack(
                    side=tk.LEFT, padx=2, pady=2)

        # Middle: Plant Vault project selector
        ttk.Label(bar, text="Project:").pack(side=tk.LEFT, padx=(0, 2), pady=2)
        self._proj_var   = tk.StringVar()
        self._proj_combo = ttk.Combobox(bar, textvariable=self._proj_var,
                                        width=30, state="readonly")
        self._proj_combo.pack(side=tk.LEFT, padx=2, pady=2)
        self._proj_combo.bind("<<ComboboxSelected>>", self._on_proj_select)
        ttk.Button(bar, text="⟳", width=2,
                   command=self._refresh_projects).pack(side=tk.LEFT, padx=(0, 4), pady=2)

        ttk.Separator(bar, orient=tk.VERTICAL).pack(
            side=tk.LEFT, fill=tk.Y, padx=5, pady=3)

        # Right: spec sheet selector
        ttk.Label(bar, text="Spec Sheet:").pack(side=tk.LEFT, padx=(0, 2), pady=2)
        self._spec_var   = tk.StringVar()
        self._spec_combo = ttk.Combobox(bar, textvariable=self._spec_var,
                                        width=30, state="readonly")
        self._spec_combo.pack(side=tk.LEFT, padx=2, pady=2)
        self._spec_combo.bind("<<ComboboxSelected>>", self._on_spec_select)

        self._refresh_projects()

    # ── Body (left panel + notebook) ──────────────────────────────────────────

    def _build_body(self):
        pw = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        pw.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        lf = ttk.Frame(pw, width=180)
        pw.add(lf, weight=1)
        self._build_left_panel(lf)

        rf = ttk.Frame(pw)
        pw.add(rf, weight=7)
        self.nb = ttk.Notebook(rf)
        self.nb.pack(fill=tk.BOTH, expand=True)
        self._build_data_tab()
        self._build_ports_tab()

    # ── Left panel ────────────────────────────────────────────────────────────

    def _build_left_panel(self, parent):
        hdr = ttk.Frame(parent)
        hdr.pack(fill=tk.X, padx=4, pady=(4, 0))
        ttk.Label(hdr, text="Tables",
                  font=("Segoe UI", 10, "bold")).pack(side=tk.LEFT)
        self._btn_gear = ttk.Button(hdr, text="⚙", width=2,
                                     command=self._open_settings)
        self._btn_gear.pack(side=tk.RIGHT)

        tog = ttk.Frame(parent)
        tog.pack(fill=tk.X, padx=4, pady=(2, 0))
        self._btn_all  = tk.Button(tog, text="All",
                                    relief="flat", padx=8, pady=2,
                                    font=("Segoe UI", 9),
                                    command=lambda: self._set_mode("all"))
        self._btn_comp = tk.Button(tog, text="Components",
                                    relief="flat", padx=8, pady=2,
                                    font=("Segoe UI", 9),
                                    command=lambda: self._set_mode("components"))
        self._btn_all .pack(side=tk.LEFT,  fill=tk.X, expand=True)
        self._btn_comp.pack(side=tk.RIGHT, fill=tk.X, expand=True)

        self._tbl_q = tk.StringVar()
        self._tbl_q.trace_add("write", self._refresh_tbl_list)
        ttk.Entry(parent, textvariable=self._tbl_q).pack(
            fill=tk.X, padx=4, pady=(3, 1))

        frm = ttk.Frame(parent)
        frm.pack(fill=tk.BOTH, expand=True, padx=2, pady=(0, 2))
        vsb = ttk.Scrollbar(frm)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tbl_lb = tk.Listbox(frm, yscrollcommand=vsb.set,
                                  activestyle="dotbox",
                                  font=("Consolas", 9))
        self.tbl_lb.pack(fill=tk.BOTH, expand=True)
        vsb.config(command=self.tbl_lb.yview)
        self.tbl_lb.bind("<<ListboxSelect>>", self._on_tbl_sel)

    # ── Data tab ─────────────────────────────────────────────────────────────

    def _build_data_tab(self):
        self._data_tab = ttk.Frame(self.nb)
        self.nb.add(self._data_tab, text="  📋 Data  ")

        # Top bar
        top = ttk.Frame(self._data_tab)
        top.pack(fill=tk.X, padx=4, pady=(4, 2))
        ttk.Label(top, text="Table:").pack(side=tk.LEFT)
        self._lbl_tbl = ttk.Label(top, text="—",
                                   font=("Segoe UI", 9, "bold"),
                                   foreground="#005a9e")
        self._lbl_tbl.pack(side=tk.LEFT, padx=(4, 12))
        ttk.Label(top, text="🔍").pack(side=tk.LEFT)
        self._srch = tk.StringVar()
        self._srch.trace_add("write", self._apply_search)
        ttk.Entry(top, textvariable=self._srch, width=26).pack(
            side=tk.LEFT, padx=2)
        ttk.Button(top, text="✕", width=2,
                   command=lambda: self._srch.set("")).pack(
            side=tk.LEFT, padx=(0, 8))
        ttk.Button(top, text="⊞ Columns",
                   command=self._open_col_dialog).pack(side=tk.LEFT, padx=2)

        # Main treeview
        tf = ttk.Frame(self._data_tab)
        tf.pack(fill=tk.BOTH, expand=True, padx=4, pady=(0, 2))
        vsb = ttk.Scrollbar(tf, orient=tk.VERTICAL)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        hsb = ttk.Scrollbar(tf, orient=tk.HORIZONTAL)
        hsb.pack(side=tk.BOTTOM, fill=tk.X)

        sty = ttk.Style()
        sty.configure("Treeview",         rowheight=22, font=("Consolas", 9))
        sty.configure("Treeview.Heading", font=("Segoe UI", 9, "bold"))
        sty.map("Treeview", background=[("selected", "#cce5ff")])

        self.dtree = ttk.Treeview(tf, yscrollcommand=vsb.set,
                                   xscrollcommand=hsb.set,
                                   selectmode="extended")
        self.dtree.pack(fill=tk.BOTH, expand=True)
        vsb.config(command=self.dtree.yview)
        hsb.config(command=self.dtree.xview)
        self.dtree.tag_configure("mod",  background="#fff3cd")
        self.dtree.tag_configure("odd",  background="#f8f9fa")
        self.dtree.tag_configure("even", background="#ffffff")

        self.dtree.bind("<Double-1>",         self._dbl_click)
        self.dtree.bind("<Return>",           self._enter_key)
        self.dtree.bind("<<TreeviewSelect>>", self._on_row_select)
        self.dtree.bind("<Button-3>",         self._on_header_rclick)
        self.dtree.bind("<ButtonPress-1>",    self._hdr_press)
        self.dtree.bind("<B1-Motion>",        self._hdr_motion)
        self.dtree.bind("<ButtonRelease-1>",  self._hdr_release)

        self.editor = CellEditor(self.dtree, self._on_cell_save)

        self._lbl_rows = ttk.Label(self._data_tab, text="", foreground="#666")
        self._lbl_rows.pack(anchor="e", padx=8, pady=1)

        # Port detail panel (hidden until Components mode + component table)
        self._port_sep = ttk.Separator(self._data_tab, orient=tk.HORIZONTAL)
        self._port_frm = ttk.LabelFrame(self._data_tab,
                                         text="Ports of selected component",
                                         height=200)
        self._port_frm.pack_propagate(False)
        pf   = ttk.Frame(self._port_frm)
        pf.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
        pvsb = ttk.Scrollbar(pf, orient=tk.VERTICAL)
        pvsb.pack(side=tk.RIGHT, fill=tk.Y)
        phsb = ttk.Scrollbar(pf, orient=tk.HORIZONTAL)
        phsb.pack(side=tk.BOTTOM, fill=tk.X)
        self.ptree_detail = ttk.Treeview(pf, yscrollcommand=pvsb.set,
                                          xscrollcommand=phsb.set)
        self.ptree_detail.pack(fill=tk.BOTH, expand=True)
        pvsb.config(command=self.ptree_detail.yview)
        phsb.config(command=self.ptree_detail.xview)
        self.ptree_detail.tag_configure("s1",   background="#d4edda")
        self.ptree_detail.tag_configure("odd",  background="#e8f4f8")
        self.ptree_detail.tag_configure("even", background="#ffffff")

    def _show_port_panel(self):
        if not self._port_panel_visible:
            self._port_sep.pack(fill=tk.X, padx=4, pady=(2, 0))
            self._port_frm.pack(fill=tk.BOTH, padx=4, pady=(2, 4))
            self._port_panel_visible = True

    def _hide_port_panel(self):
        if self._port_panel_visible:
            self._port_sep.pack_forget()
            self._port_frm.pack_forget()
            self._port_panel_visible = False

    # ── Port connections tab ──────────────────────────────────────────────────

    def _build_ports_tab(self):
        tab = ttk.Frame(self.nb)
        self.nb.add(tab, text="  🔌 Port Connections  ")

        top = ttk.Frame(tab)
        top.pack(fill=tk.X, padx=4, pady=(4, 2))
        ttk.Label(top, text="🔍").pack(side=tk.LEFT)
        self._port_q = tk.StringVar()
        self._port_q.trace_add("write", self._filter_port_tab)
        ttk.Entry(top, textvariable=self._port_q, width=32).pack(
            side=tk.LEFT, padx=2)
        ttk.Button(top, text="✕", width=2,
                   command=lambda: self._port_q.set("")).pack(side=tk.LEFT)
        ttk.Separator(top, orient=tk.VERTICAL).pack(
            side=tk.LEFT, fill=tk.Y, padx=8, pady=2)
        ttk.Button(top, text="🔄 Reload",
                   command=self._load_all_ports).pack(side=tk.LEFT)

        pf  = ttk.Frame(tab)
        pf.pack(fill=tk.BOTH, expand=True, padx=4, pady=(0, 2))
        vsb = ttk.Scrollbar(pf, orient=tk.VERTICAL)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        hsb = ttk.Scrollbar(pf, orient=tk.HORIZONTAL)
        hsb.pack(side=tk.BOTTOM, fill=tk.X)
        self.ptree = ttk.Treeview(pf, yscrollcommand=vsb.set,
                                   xscrollcommand=hsb.set)
        self.ptree.pack(fill=tk.BOTH, expand=True)
        vsb.config(command=self.ptree.yview)
        hsb.config(command=self.ptree.xview)
        self.ptree.tag_configure("odd",  background="#f8f9fa")
        self.ptree.tag_configure("even", background="#ffffff")
        self._lbl_ports = ttk.Label(
            tab, text="Open a .pspc file to see port connections.",
            foreground="#666")
        self._lbl_ports.pack(anchor="e", padx=8, pady=1)

    def _build_status(self):
        self._status = tk.StringVar(value="Ready — open a .pspc file to begin.")
        ttk.Label(self.root, textvariable=self._status,
                  relief=tk.SUNKEN, anchor=tk.W, padding=(4, 1)).pack(
            side=tk.BOTTOM, fill=tk.X)

    def _bind_keys(self):
        self.root.bind("<Control-o>", lambda _: self.cmd_open())
        self.root.bind("<Control-e>", lambda _: self.cmd_excel())
        self.root.bind("<F5>",        lambda _: self.cmd_reload())

    # ══════════════════════════════════════════════════════════════════════════
    #  Commands
    # ══════════════════════════════════════════════════════════════════════════

    # ── Plant Vault quick-open ─────────────────────────────────────────────────

    def _refresh_projects(self):
        """Scan PLANT_VAULT_ROOT and populate the Project combobox."""
        self._proj_paths = {}
        dirs = []
        if os.path.isdir(PLANT_VAULT_ROOT):
            try:
                for entry in sorted(os.scandir(PLANT_VAULT_ROOT),
                                    key=lambda e: e.name.lower()):
                    if entry.is_dir():
                        dirs.append(entry.name)
                        self._proj_paths[entry.name] = entry.path
            except PermissionError:
                pass
        self._proj_combo["values"] = dirs
        self._spec_combo["values"] = []
        self._spec_var.set("")
        if not dirs:
            self._proj_var.set("")

    def _on_proj_select(self, _=None):
        """Project chosen — scan its Spec Sheets folder for .pspc files."""
        proj = self._proj_var.get()
        if not proj or proj not in self._proj_paths:
            return
        self._spec_paths = {}
        spec_dir = os.path.join(self._proj_paths[proj], "Spec Sheets")
        files = []
        if os.path.isdir(spec_dir):
            try:
                for entry in sorted(os.scandir(spec_dir),
                                    key=lambda e: e.name.lower()):
                    if entry.is_file() and entry.name.lower().endswith(".pspc"):
                        files.append(entry.name)
                        self._spec_paths[entry.name] = entry.path
            except PermissionError:
                pass
        self._spec_combo["values"] = files
        self._spec_var.set("")
        if not files:
            self._status.set(
                f"No .pspc files in '{proj}\\Spec Sheets'  —  check folder path")

    def _on_spec_select(self, _=None):
        """Spec sheet chosen — open it."""
        name = self._spec_var.get()
        if name and name in self._spec_paths:
            self._open(self._spec_paths[name])

    def cmd_open(self):
        path = filedialog.askopenfilename(
            title="Open PSPC / SQLite file",
            filetypes=[
                ("Plant 3D Spec", "*.pspc"),
                ("SQLite DB",     "*.db *.sqlite *.sqlite3"),
                ("All files",     "*.*"),
            ])
        if path:
            self._open(path)

    def cmd_save(self):
        if not self.db:
            messagebox.showwarning("No file", "No file is open.")
            return
        if not self.pending:
            messagebox.showinfo("Nothing to save", "No unsaved changes.")
            return
        total = sum(len(r) for rows in self.pending.values() for r in rows.values())
        if not messagebox.askyesno("Save changes",
                f"Write {total} cell change(s) to:\n{self.cur_file}"):
            return
        try:
            self.db.save_changes(self.pending)
            self.pending = {}
            self._status.set("✓ Changes saved.")
            messagebox.showinfo("Saved", "All changes written to the database.")
            self.cmd_reload()
        except Exception as e:
            messagebox.showerror("Save failed", str(e))

    def cmd_excel(self):
        if not self.db:
            messagebox.showwarning("No file", "No file is open.")
            return
        menu = tk.Menu(self.root, tearoff=0)
        menu.add_command(label="📋  Export Screen  (vidni stolpci / filtrirane vrstice)",
                         command=self._excel_screen)
        menu.add_command(label="📦  Export All  (vse tabele v ločene sheete)",
                         command=self._excel_all)
        try:
            menu.post(self.root.winfo_pointerx(), self.root.winfo_pointery())
        finally:
            menu.grab_release()

    def _excel_screen(self):
        if not self.cur_table:
            messagebox.showwarning("No table", "Najprej izberi tabelo.")
            return
        try:
            import pandas as pd
            import openpyxl  # noqa
        except ImportError as e:
            messagebox.showerror("Missing library",
                f"Install:  pip install pandas openpyxl\n\n({e})")
            return
        dest = filedialog.asksaveasfilename(
            title="Export Screen to Excel", defaultextension=".xlsx",
            filetypes=[("Excel workbook", "*.xlsx"), ("All files", "*.*")])
        if not dest:
            return
        try:
            disp_cols = self._get_displaycols()
            all_cols  = list(self.dtree["columns"])
            indices   = [all_cols.index(c) for c in disp_cols if c in all_cols]
            rows = [
                [self.dtree.item(iid, "values")[i] for i in indices]
                for iid in self.dtree.get_children()
            ]
            df = pd.DataFrame(rows, columns=disp_cols)
            with pd.ExcelWriter(dest, engine="openpyxl") as wr:
                df.to_excel(wr, sheet_name=self.cur_table[:31], index=False)
            self._status.set(f"✓ Exported (screen) → {dest}")
            messagebox.showinfo("Done", f"Saved:\n{dest}")
        except Exception as e:
            messagebox.showerror("Export failed", str(e))

    def _excel_all(self):
        try:
            import pandas   # noqa
            import openpyxl  # noqa
        except ImportError as e:
            messagebox.showerror("Missing library",
                f"Install:  pip install pandas openpyxl\n\n({e})")
            return
        dest = filedialog.asksaveasfilename(
            title="Export All to Excel", defaultextension=".xlsx",
            filetypes=[("Excel workbook", "*.xlsx"), ("All files", "*.*")])
        if not dest:
            return
        try:
            self.db.export_excel(dest)
            self._status.set(f"✓ Exported (all) → {dest}")
            messagebox.showinfo("Done", f"Saved:\n{dest}")
        except Exception as e:
            messagebox.showerror("Export failed", str(e))

    def cmd_ports_tab(self):
        self.nb.select(1)
        if self.db:
            self._load_all_ports()

    def cmd_reload(self):
        if self.cur_table:
            self._load_table(self.cur_table)

    # ══════════════════════════════════════════════════════════════════════════
    #  File open
    # ══════════════════════════════════════════════════════════════════════════

    def _open(self, path: str):
        try:
            if self.conn:
                self.conn.close()
            self.conn      = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
            self.db        = DbHelper(self.conn)
            self.cur_file  = path
            self.pending   = {}
            self.root.title(f"{self.APP_TITLE}  —  {os.path.basename(path)}  [read-only]")
            self._status.set(f"Opened: {path}")
            self.cfg["last_file"] = path
            self.cfg.save()
            self._populate_tables()
            self._load_all_ports()
        except Exception as e:
            messagebox.showerror("Open failed", str(e))

    # ══════════════════════════════════════════════════════════════════════════
    #  Left-panel table list
    # ══════════════════════════════════════════════════════════════════════════

    def _populate_tables(self):
        self._refresh_tbl_list()
        start = ("EngineeringItems"
                 if "EngineeringItems" in self.db.all_tables
                 else (self.db.all_tables[0] if self.db.all_tables else None))
        if start:
            self._select_lb(start)
            self._load_table(start)

    def _refresh_tbl_list(self, *_):
        if not self.db:
            return
        q     = self._tbl_q.get().lower()
        mode  = self._mode.get()
        simp  = set(self.cfg["simplified_tables"])
        names = self.db.all_tables
        if mode == "components":
            names = [t for t in names if t in simp]
        if q:
            names = [t for t in names if q in t.lower()]
        self.tbl_lb.delete(0, tk.END)
        for t in names:
            self.tbl_lb.insert(tk.END, t)

    def _select_lb(self, name: str):
        items = list(self.tbl_lb.get(0, tk.END))
        if name in items:
            idx = items.index(name)
            self.tbl_lb.selection_clear(0, tk.END)
            self.tbl_lb.selection_set(idx)
            self.tbl_lb.see(idx)

    def _on_tbl_sel(self, _):
        sel = self.tbl_lb.curselection()
        if sel:
            self._load_table(self.tbl_lb.get(sel[0]))

    def _set_mode(self, mode: str):
        self._mode.set(mode)
        self.cfg["panel_mode"] = mode
        self._apply_mode_style()
        self._refresh_tbl_list()
        if self.cur_table:
            self._load_table(self.cur_table)

    def _apply_mode_style(self):
        mode = self._mode.get()
        if mode == "all":
            self._btn_all .config(bg="#005a9e", fg="white")
            self._btn_comp.config(bg="#e0e0e0", fg="#333")
            self._btn_gear.pack_forget()
            self._hide_port_panel()
        else:
            self._btn_all .config(bg="#e0e0e0", fg="#333")
            self._btn_comp.config(bg="#005a9e", fg="white")
            self._btn_gear.pack(side=tk.RIGHT)

    # ══════════════════════════════════════════════════════════════════════════
    #  Table loading  (delegates data work to DbHelper)
    # ══════════════════════════════════════════════════════════════════════════

    def _load_table(self, name: str):
        if not self.db:
            return
        self.cur_table = name
        self._lbl_tbl.config(text=name)
        self._srch.set("")
        self.editor.close()
        self._sort_rev.clear()

        mode      = self._mode.get()
        has_pnpid = self.db.col_exists(name, "PnPID")

        if mode == "components" and has_pnpid:
            self._show_port_panel()
            if name == "EngineeringItems":
                self._load_ei()
            else:
                self._load_comp(name)
        else:
            self._hide_port_panel()
            self._load_raw(name)

    def _load_raw(self, name: str):
        try:
            cols, rows = self.db.table_raw(name, self._show_hidden.get())
            self.cur_cols     = cols
            self.cur_rows     = rows
            self._comp_pnpids = []
            self._render(cols, rows)
            self._lbl_rows.config(text=f"{len(rows)} rows · {len(cols)} columns")
            self._status.set(f"Table: {name}  —  {len(rows)} rows")
        except Exception as e:
            messagebox.showerror("Load error", str(e))

    def _load_ei(self):
        try:
            labels, rows, pnpids = self.db.ei_view(self._show_hidden.get())
            self.cur_cols     = labels
            self.cur_rows     = rows
            self._comp_pnpids = pnpids
            self._port_map    = self.db.port_map()
            self._render(labels, rows)
            self._lbl_rows.config(
                text=f"{len(rows)} components · {len(labels)} columns  "
                     f"— click a row to see its ports below")
            self._status.set(f"Table: EngineeringItems  —  {len(rows)} components")
        except Exception as e:
            messagebox.showerror("Load error (EI view)", str(e))

    def _load_comp(self, name: str):
        try:
            labels, rows, pnpids = self.db.comp_join(name)
            self.cur_cols     = labels
            self.cur_rows     = rows
            self._comp_pnpids = pnpids
            self._port_map    = self.db.port_map()
            self._render(labels, rows)
            self._lbl_rows.config(
                text=f"{len(rows)} rows · {len(labels)} columns  "
                     f"— click a row to see its ports below")
            self._status.set(f"Table: {name}  —  {len(rows)} rows")
        except Exception as e:
            messagebox.showerror("Load error (join view)", str(e))

    # ══════════════════════════════════════════════════════════════════════════
    #  Render  (tkinter only, no SQL)
    # ══════════════════════════════════════════════════════════════════════════

    def _render(self, cols: list[str], rows: list[list]):
        tree = self.dtree
        tree.delete(*tree.get_children())
        tree["displaycolumns"] = "#all"
        tree["columns"] = cols
        tree["show"]    = "headings"
        for col in cols:
            w = min(max(len(col) * 9, 70), 300)
            tree.heading(col, text=col)          # sort handled by _hdr_release
            tree.column(col, width=w, minwidth=40, stretch=False)
        # Auto-hide columns where every row is blank
        if rows:
            empty = {i for i, _ in enumerate(cols)
                     if all(not safe_str(r[i]).strip() for r in rows)}
            vis = [c for i, c in enumerate(cols) if i not in empty]
        else:
            vis = list(cols)
        tree["displaycolumns"] = vis or cols

        for i, row in enumerate(rows):
            tag = "odd" if i % 2 else "even"
            tree.insert("", tk.END, iid=str(i),
                        values=[safe_str(v) for v in row], tags=(tag,))
        self._apply_col_prefs()

    # ══════════════════════════════════════════════════════════════════════════
    #  Port detail panel
    # ══════════════════════════════════════════════════════════════════════════

    def _on_row_select(self, _):
        if not self._port_panel_visible:
            return
        sel = self.dtree.selection()
        if not sel:
            return
        idx = int(sel[0])
        if idx < len(self._comp_pnpids) and self._comp_pnpids[idx] is not None:
            self._refresh_port_detail(self._comp_pnpids[idx])

    def _refresh_port_detail(self, pnpid: int):
        tree = self.ptree_detail
        tree.delete(*tree.get_children())
        labels = [lbl for _, lbl, _ in PORT_DETAIL_COLS]
        widths = [w   for _, _,   w in PORT_DETAIL_COLS]
        tree["columns"] = labels
        tree["show"]    = "headings"
        for lbl, w in zip(labels, widths):
            tree.heading(lbl, text=lbl)
            tree.column(lbl, width=w, minwidth=40, stretch=False)

        s1 = self.db.s1_port(pnpid)
        if s1:
            s1[0] = s1[0] or "S1"
            tree.insert("", tk.END, values=[safe_str(v) for v in s1], tags=("s1",))

        extra = self._port_map.get(pnpid, [])
        for i, p in enumerate(extra):
            tree.insert("", tk.END,
                        values=[safe_str(v) for v in p],
                        tags=("odd" if i % 2 else "even",))

        total = (1 if s1 else 0) + len(extra)
        self._port_frm.config(
            text=f"Ports of selected component  "
                 f"({total} port{'s' if total != 1 else ''})  "
                 f"— S1 (green) from EngineeringItems  ·  S2+ from Port table")

    # ══════════════════════════════════════════════════════════════════════════
    #  Port connections tab
    # ══════════════════════════════════════════════════════════════════════════

    def _load_all_ports(self):
        if not self.db:
            return
        try:
            cols, rows = self.db.all_ports()
            if not cols:
                self._lbl_ports.config(text="No port tables detected.")
                return
            self.all_port_rows = rows
            self.port_tab_cols = cols
            self._render_port_tab(cols, rows)
            self._lbl_ports.config(
                text=f"{len(rows)} port entries  "
                     f"(S1 from EngineeringItems · S2+ from Port table)")
        except Exception as e:
            self._lbl_ports.config(text=f"Error: {e}")

    def _render_port_tab(self, cols: list[str], rows: list[list]):
        tree = self.ptree
        tree.delete(*tree.get_children())
        tree["columns"] = cols
        tree["show"]    = "headings"
        for col in cols:
            w = min(max(len(col) * 9, 60), 280)
            tree.heading(col, text=col,
                         command=lambda c=col: self._sort_col(tree, c, False))
            tree.column(col, width=w, minwidth=40, stretch=False)
        for i, row in enumerate(rows):
            tree.insert("", tk.END, iid=str(i),
                        values=[safe_str(v) for v in row],
                        tags=("odd" if i % 2 else "even",))

    def _filter_port_tab(self, *_):
        q    = self._port_q.get().lower()
        tree = self.ptree
        tree.delete(*tree.get_children())
        for i, row in enumerate(self.all_port_rows):
            if not q or any(q in safe_str(v).lower() for v in row):
                tree.insert("", tk.END, iid=str(i),
                            values=[safe_str(v) for v in row],
                            tags=("odd" if i % 2 else "even",))

    # ══════════════════════════════════════════════════════════════════════════
    #  Cell editing
    # ══════════════════════════════════════════════════════════════════════════

    def _dbl_click(self, event):
        return  # read-only mode
        if self.dtree.identify_region(event.x, event.y) == "heading":
            return
        item = self.dtree.identify_row(event.y)
        col  = self.dtree.identify_column(event.x)
        if not item or not col:
            return
        disp     = self._get_displaycols()
        disp_idx = int(col.replace("#", "")) - 1
        if disp_idx < 0 or disp_idx >= len(disp):
            return
        col_name = disp[disp_idx]
        all_idx  = list(self.dtree["columns"]).index(col_name)
        val      = self.dtree.item(item, "values")[all_idx]
        self.editor.open(item, f"#{all_idx + 1}", val)

    def _enter_key(self, _):
        return  # read-only mode

    def _on_cell_save(self, item: str, col_index: int, new_value: str):
        vals = list(self.dtree.item(item, "values"))
        if col_index >= len(vals) or vals[col_index] == new_value:
            return
        vals[col_index] = new_value
        self.dtree.item(item, values=vals, tags=("mod",))
        row_idx  = int(item)
        col_name = (self.cur_cols[col_index]
                    if col_index < len(self.cur_cols) else f"col_{col_index}")
        self.pending.setdefault(self.cur_table, {})\
                    .setdefault(row_idx, {})[col_name] = new_value
        self._status.set(
            f"⚠ Unsaved — {self.cur_table}.{col_name}  row {row_idx + 1}")

    # ══════════════════════════════════════════════════════════════════════════
    #  Search & sort
    # ══════════════════════════════════════════════════════════════════════════

    def _apply_search(self, *_):
        q    = self._srch.get().lower()
        tree = self.dtree
        tree.delete(*tree.get_children())
        for i, row in enumerate(self.cur_rows):
            if not q or any(q in safe_str(v).lower() for v in row):
                tree.insert("", tk.END, iid=str(i),
                            values=[safe_str(v) for v in row],
                            tags=("odd" if i % 2 else "even",))

    def _sort_col(self, tree: ttk.Treeview, col: str, reverse: bool):
        data = [(tree.set(c, col), c) for c in tree.get_children("")]
        try:
            data.sort(key=lambda x: (x[0] == "", float(x[0])), reverse=reverse)
        except (ValueError, TypeError):
            data.sort(key=lambda x: (x[0] == "", x[0].lower()), reverse=reverse)
        for idx, (_, child) in enumerate(data):
            tree.move(child, "", idx)

    # ══════════════════════════════════════════════════════════════════════════
    #  Header drag-to-reorder
    # ══════════════════════════════════════════════════════════════════════════

    def _hdr_press(self, event):
        if self.dtree.identify_region(event.x, event.y) == "heading":
            self._drag_from    = self._col_id_at_x(event.x)
            self._drag_start_x = event.x
            self._drag_moved   = False
        else:
            self._drag_from = None

    def _hdr_motion(self, event):
        if self._drag_from and abs(event.x - self._drag_start_x) > 8:
            self._drag_moved = True
            self.dtree.config(cursor="sb_h_double_arrow")

    def _hdr_release(self, event):
        self.dtree.config(cursor="")
        if not self._drag_from:
            return
        if self._drag_moved:
            if self.dtree.identify_region(event.x, event.y) == "heading":
                to = self._col_id_at_x(event.x)
                if to and to != self._drag_from:
                    self._move_col(self._drag_from, to)
        else:
            if self.dtree.identify_region(event.x, event.y) == "heading":
                col = self._drag_from
                if col:
                    rev = self._sort_rev.get(col, False)
                    self._sort_col(self.dtree, col, rev)
                    self._sort_rev[col] = not rev
                    arrow = " ▲" if not rev else " ▼"
                    for c in self._get_displaycols():
                        self.dtree.heading(c, text=c.rstrip(" ▲▼"))
                    self.dtree.heading(col, text=col.rstrip(" ▲▼") + arrow)
        self._drag_from  = None
        self._drag_moved = False

    def _col_id_at_x(self, x: int) -> str | None:
        col_n = self.dtree.identify_column(x)
        if not col_n or col_n == "#0":
            return None
        idx  = int(col_n.replace("#", "")) - 1
        cols = self._get_displaycols()
        return cols[idx] if 0 <= idx < len(cols) else None

    def _move_col(self, from_id: str, to_id: str):
        disp = self._get_displaycols()
        if from_id not in disp or to_id not in disp:
            return
        fi, ti = disp.index(from_id), disp.index(to_id)
        disp.insert(ti, disp.pop(fi))
        self.dtree["displaycolumns"] = disp
        self._save_col_prefs()

    def _get_displaycols(self) -> list[str]:
        d = self.dtree["displaycolumns"]
        if not d or d in ("#all", ("#all",)):
            return list(self.dtree["columns"])
        if isinstance(d, str):
            return list(self.dtree["columns"])
        return list(d)

    # ══════════════════════════════════════════════════════════════════════════
    #  Column visibility
    # ══════════════════════════════════════════════════════════════════════════

    def _save_col_prefs(self):
        if not self.cur_table:
            return
        all_cols = list(self.dtree["columns"])
        disp     = self._get_displaycols()
        hidden   = [c for c in all_cols if c not in set(disp)]
        prefs    = self.cfg["column_prefs"]
        prefs[self.cur_table] = {"hidden": hidden, "order": disp}
        self.cfg["column_prefs"] = prefs
        self.cfg.save()

    def _apply_col_prefs(self):
        if not self.cur_table:
            return
        saved = self.cfg["column_prefs"].get(self.cur_table)
        if not saved:
            return
        all_cols = list(self.dtree["columns"])
        col_set  = set(all_cols)
        hidden   = set(saved.get("hidden", []))
        order    = [c for c in saved.get("order", []) if c in col_set]
        order   += [c for c in all_cols if c not in set(order)]
        disp     = [c for c in order if c not in hidden]
        if disp:
            self.dtree["displaycolumns"] = disp

    def _on_header_rclick(self, event):
        if self.dtree.identify_region(event.x, event.y) != "heading":
            return
        col = self._col_id_at_x(event.x)
        if not col:
            return
        menu = tk.Menu(self.root, tearoff=0)
        menu.add_command(label=f'Hide  "{col}"',
                         command=lambda: self._hide_col(col))
        menu.add_separator()
        menu.add_command(label="⊞  Show / Hide Columns…",
                         command=self._open_col_dialog)
        try:
            menu.post(event.x_root, event.y_root)
        finally:
            menu.grab_release()

    def _hide_col(self, col: str):
        disp = [c for c in self._get_displaycols() if c != col]
        if not disp:
            messagebox.showwarning("Column visibility",
                                   "Can't hide the last visible column.")
            return
        self.dtree["displaycolumns"] = disp
        self._status.set(f"Column hidden: {col}  — use '⊞ Columns' to restore")
        self._save_col_prefs()

    def _open_col_dialog(self):
        if not self.dtree["columns"]:
            messagebox.showinfo("No table", "Load a table first.")
            return
        dlg = ColumnDialog(self.root, self.dtree)
        self.root.wait_window(dlg)
        if dlg.result is not None:
            self.dtree["displaycolumns"] = dlg.result
            hidden_n = len(self.dtree["columns"]) - len(dlg.result)
            self._status.set(
                f"Columns updated — {len(dlg.result)} visible"
                + (f", {hidden_n} hidden" if hidden_n else ""))
            self._save_col_prefs()

    # ══════════════════════════════════════════════════════════════════════════
    #  Settings dialog
    # ══════════════════════════════════════════════════════════════════════════

    def _open_settings(self):
        tables = self.db.all_tables if self.db else []
        dlg    = SettingsDialog(self.root, self.cfg, tables)
        self.root.wait_window(dlg)
        if dlg.result is not None:
            self.cfg["simplified_tables"] = dlg.result
            self.cfg.save()
            self._refresh_tbl_list()
            self._status.set(
                f"✓ Settings saved  ({len(dlg.result)} tables in Components view)")

    # ══════════════════════════════════════════════════════════════════════════
    #  Misc / lifecycle
    # ══════════════════════════════════════════════════════════════════════════

    def _about(self):
        messagebox.showinfo("About PSPC Viewer",
            f"PSPC Viewer v{APP_VERSION}\n\n"
            "Pregledovalnik Autodesk Plant 3D .pspc datotek.\n"
            ".pspc je SQLite baza z informacijami o inštrumentaciji (cevovodi,\n"
            "komponente, priključki). Datoteka se odpre samo za branje (read-only)\n"
            "— originalni podatki se nikoli ne spremenijo.\n\n"
            "Kaj lahko naredite:\n"
            "  • Pregledate vse tabele in vsebino baze\n"
            "  • Iščete in sortirate po stolpcih\n"
            "  • V Components načinu vidite komponente z vsemi priključki (S1–S4)\n"
            "  • Izvozite katerokoli tabelo v Excel (.xlsx)\n\n"
            "Stolpci:\n"
            "  Right-click header  → skrij ta stolpec\n"
            "  ⊞ Columns button   → pokaži / skrij stolpce\n"
            "  Drag header L/R    → preuredi stolpce\n"
            "  Click header       → razvrsti (▲ ▼)\n\n"
            "Bližnjice:  Ctrl+O  Odpri  ·  Ctrl+E  Izvozi v Excel  ·  F5  Osveži\n\n"
            "Config:  ~/.pspcviewer/config.json")

    def cmd_howto(self):
        win = tk.Toplevel(self.root)
        win.title("How To Use — PSPC Viewer")
        win.resizable(False, False)
        win.grab_set()

        text = (
            "═══════════════════════════════════════\n"
            "  WHAT IS THIS PROGRAM?\n"
            "═══════════════════════════════════════\n"
            "PSPC Viewer lets you inspect .pspc files from\n"
            "Autodesk Plant 3D projects. A .pspc file is a\n"
            "SQLite database containing piping and\n"
            "instrumentation data: components, ports,\n"
            "engineering items, and their connections.\n"
            "\n"
            "The file is always opened READ-ONLY —\n"
            "your original data is never modified.\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  GETTING STARTED\n"
            "═══════════════════════════════════════\n"
            "1. Click  📂 Open  (or Ctrl+O) and select\n"
            "   a .pspc file from your Plant 3D project.\n"
            "2. The left panel lists all tables in the file.\n"
            "3. Click a table name to view its contents.\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  BROWSING DATA\n"
            "═══════════════════════════════════════\n"
            "• Click a column header   → sort ascending / descending\n"
            "• Right-click a header    → hide that column\n"
            "• ⊞ Columns button        → show or hide any column\n"
            "• Drag a column header    → reorder columns\n"
            "• Search box (top-left)   → filter table list by name\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  COMPONENTS VIEW\n"
            "═══════════════════════════════════════\n"
            "Switch to  Components  mode in the top-left\n"
            "dropdown to see only the main instrument/\n"
            "equipment tables.\n"
            "\n"
            "Click any component row to see all its ports\n"
            "listed below (S1–S4 connections):\n"
            "  S1 (green)  = defined directly in EngineeringItems\n"
            "  S2 / S3 / S4 = looked up via the PartPort table\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  PORT CONNECTIONS TAB\n"
            "═══════════════════════════════════════\n"
            "Click  🔌 Port Connections  to open a full\n"
            "view of all port-to-port connections in the file.\n"
            "Useful for tracing how components are linked.\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  EXPORTING TO EXCEL\n"
            "═══════════════════════════════════════\n"
            "Click  📊 Export Excel  (or Ctrl+E) to save\n"
            "every table as a separate sheet in an .xlsx file.\n"
            "\n"
            "═══════════════════════════════════════\n"
            "  KEYBOARD SHORTCUTS\n"
            "═══════════════════════════════════════\n"
            "  Ctrl+O   Open file\n"
            "  Ctrl+E   Export to Excel\n"
            "  F5       Reload current table\n"
        )

        frm = ttk.Frame(win, padding=10)
        frm.pack(fill=tk.BOTH, expand=True)

        sb = ttk.Scrollbar(frm, orient=tk.VERTICAL)
        sb.pack(side=tk.RIGHT, fill=tk.Y)

        box = tk.Text(frm, width=56, height=28, wrap=tk.NONE,
                      font=("Courier", 10), relief=tk.FLAT,
                      yscrollcommand=sb.set, state=tk.NORMAL)
        box.insert(tk.END, text)
        box.config(state=tk.DISABLED)
        box.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        sb.config(command=box.yview)

        ttk.Button(win, text="Close", command=win.destroy).pack(pady=(0, 8))

    def _on_resize(self, _):
        if hasattr(self, "_resize_job"):
            self.root.after_cancel(self._resize_job)
        self._resize_job = self.root.after(
            500, lambda: self.cfg.__setitem__("geometry", self.root.geometry()))

    def _on_close(self):
        if self.pending:
            if messagebox.askyesno("Unsaved changes",
                    "You have unsaved changes. Save before closing?"):
                self.cmd_save()
        self.cfg.save()
        if self.conn:
            self.conn.close()
        self.root.destroy()