#!/usr/bin/env python3

# --- MIT License ---
# Copyright (c) 2026 Carl L. Wuebker and Claude.ai

# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# ---

# Note that Claude.ai Sonnet 4.6, guided by testing & feedback, wrote the code.
# I have minimally tested it -- warning, there may still be bugs.
# Please read the code & test it.
# 16Mar26: Posted to https://wuebker.com

# spSheet8a: Builds on spSheet7a, adding:
#   1) Arithmetic expressions in formulas: parentheses, unary -, +, -, *, /
#      Precedence (high to low): parentheses, unary -, then * and /, then + and -
#   2) File menu with four commands:
#      - Read tab-separated .csv file
#      - Write tab-separated .csv file
#      - Read comma-separated .csv file
#      - Write comma-separated .csv file
#
#   Window-management fix vs spSheet8 (same fix as spSheet6b):
#   - Replaced integer _prog_resize_count with boolean _prog_resize_pending so that
#     multiple widget creations/deletions (append/delete row or col) cannot accumulate
#     the counter above 1 and cause subsequent user-drag Configure events to be silently
#     swallowed, preventing maxW/maxH updates and the snap-back from ever firing.
#   - Replaced the fixed 500ms timer with a ButtonRelease-1 + debounce hybrid.
#
# Architecture: each cell has TWO separate stores:
#   raw[(r,c)]  -- what the user typed (formula or value), never shown in cell
#   cell entry  -- always shows the computed result (or raw text if not formula)
# A formula bar above the grid shows raw[(r,c)] for the selected cell.
# Supports =SUM(A1:C3) over any rectangular range, plus full arithmetic.

import tkinter as tk
from tkinter import filedialog, messagebox
import re
import string
import csv

MIN_WIDTH   = 100
MIN_HEIGHT  = 100
DEBOUNCE_MS = 150      # fallback: fires this long after the last Configure event
INIT_ROWS   = 10
INIT_COLS   = 5
COL_WIDTH   = 10       # characters

maxW = 9999
maxH = 9999
_prog_resize_pending = False   # True while waiting for WM to echo fit_window()
_pending_fit         = False   # True while a user drag needs a fit
_debounce_id         = None    # after() handle for the fallback debounce timer
_last_user_size      = (0, 0)

# --- cell stores ---
raw          = {}   # raw[(r,c)] = string typed by user ("=SUM(...)" or "42")
cell_widgets = {}   # cell_widgets[(r,c)] = tk.Entry showing computed result
result_vars  = {}   # result_vars[(r,c)] = StringVar bound to that Entry

# --- formula bar ---
formula_var     = None   # StringVar for the formula bar Entry
selected_cell   = (1, 1) # currently focused (row, col)


# ------------------------------------------------------------------
# Column helpers
# ------------------------------------------------------------------
def col_to_letter(c):
    s = ''
    c += 1
    while c:
        c, r = divmod(c - 1, 26)
        s = string.ascii_uppercase[r] + s
    return s

def letter_to_col(s):
    s = s.upper().strip()
    if not s or not all(ch in string.ascii_uppercase for ch in s):
        return None
    n = 0
    for ch in s:
        n = n * 26 + (ord(ch) - ord('A') + 1)
    return n - 1

def parse_ref(ref):
    """'B3' -> (row, col) where row/col match internal indices (row 0 = header).
    Display row 1 == internal row 1."""
    m = re.fullmatch(r'([A-Za-z]+)(\d+)', ref.strip())
    if not m:
        return None
    col = letter_to_col(m.group(1))
    if col is None:
        return None
    col += 1   # grid col 0 = row headers, A=col1, B=col2, ...
    row = int(m.group(2))          # no -1: display row N == internal row N
    if col < 1 or row < 1:
        return None
    return (row, col)


# ------------------------------------------------------------------
# Arithmetic expression parser / evaluator
#
# Grammar (recursive descent):
#   expr   := term   (('+' | '-') term)*
#   term   := unary  (('*' | '/') unary)*
#   unary  := '-' unary | '+' unary | primary
#   primary:= NUMBER | CELLREF | '(' expr ')'
#
# Precedence (high -> low): parentheses, unary -, * /, + -
# ------------------------------------------------------------------

class _Tokenizer:
    """Break a formula string into tokens."""
    NUM    = 'NUM'
    REF    = 'REF'
    FUNC   = 'FUNC'   # word-only identifier, e.g. SUM
    OP     = 'OP'
    LPAREN = 'LPAREN'
    RPAREN = 'RPAREN'
    COLON  = 'COLON'
    END    = 'END'

    _TOK = re.compile(
        r'\s*(?:'
        r'([A-Za-z]+\d+)'       # cell reference  (group 1) -- letters then digits
        r'|([A-Za-z]+)'         # function name   (group 2) -- letters only
        r'|([0-9]*\.?[0-9]+)'   # number          (group 3)
        r'|([+\-*/])'           # operator        (group 4)
        r'|(\()'                # left paren      (group 5)
        r'|(\))'                # right paren     (group 6)
        r'|(:)'                 # colon           (group 7)
        r')\s*'
    )

    def __init__(self, text):
        self._text = text
        self._pos  = 0
        self._peek = None
        self._advance()

    def _advance(self):
        if self._pos >= len(self._text):
            self._peek = (self.END, None)
            return
        m = self._TOK.match(self._text, self._pos)
        if not m:
            raise ValueError(f'Unexpected character at position {self._pos}: '
                             f'{self._text[self._pos]!r}')
        self._pos = m.end()
        if m.group(1):
            self._peek = (self.REF,    m.group(1).upper())
        elif m.group(2):
            self._peek = (self.FUNC,   m.group(2).upper())
        elif m.group(3):
            self._peek = (self.NUM,    float(m.group(3)))
        elif m.group(4):
            self._peek = (self.OP,     m.group(4))
        elif m.group(5):
            self._peek = (self.LPAREN, '(')
        elif m.group(6):
            self._peek = (self.RPAREN, ')')
        else:
            self._peek = (self.COLON,  ':')

    def peek(self):
        return self._peek

    def consume(self):
        tok = self._peek
        self._advance()
        return tok


def _expr(tok, row, col):
    """Parse: term (('+' | '-') term)*"""
    val = _term(tok, row, col)
    while tok.peek()[0] == _Tokenizer.OP and tok.peek()[1] in ('+', '-'):
        op = tok.consume()[1]
        right = _term(tok, row, col)
        if op == '+':
            val += right
        else:
            val -= right
    return val

def _term(tok, row, col):
    """Parse: unary (('*' | '/') unary)*"""
    val = _unary(tok, row, col)
    while tok.peek()[0] == _Tokenizer.OP and tok.peek()[1] in ('*', '/'):
        op = tok.consume()[1]
        right = _unary(tok, row, col)
        if op == '*':
            val *= right
        else:
            if right == 0:
                raise ZeroDivisionError('Division by zero')
            val /= right
    return val

def _unary(tok, row, col):
    """Parse: '-' unary | '+' unary | primary"""
    if tok.peek()[0] == _Tokenizer.OP and tok.peek()[1] == '-':
        tok.consume()
        return -_unary(tok, row, col)
    if tok.peek()[0] == _Tokenizer.OP and tok.peek()[1] == '+':
        tok.consume()
        return _unary(tok, row, col)
    return _primary(tok, row, col)

def _primary(tok, row, col):
    """Parse: NUMBER | CELLREF | FUNC '(' ... ')' | '(' expr ')'"""
    kind, val = tok.peek()
    if kind == _Tokenizer.NUM:
        tok.consume()
        return val
    if kind == _Tokenizer.REF:
        tok.consume()
        ref = parse_ref(val)
        if ref is None:
            raise ValueError(f'Bad cell reference: {val}')
        v = get_numeric(*ref)
        if v is None:
            raise ValueError(f'Cell {val} is not numeric')
        return v
    if kind == _Tokenizer.FUNC:
        tok.consume()
        fname = val
        if tok.peek()[0] != _Tokenizer.LPAREN:
            raise ValueError(f'Expected "(" after function name {fname!r}')
        tok.consume()  # consume '('
        if fname == 'SUM':
            # Expect: CELLREF : CELLREF
            if tok.peek()[0] != _Tokenizer.REF:
                raise ValueError('SUM expects a cell range like A1:B3')
            ref1_str = tok.consume()[1]
            if tok.peek()[0] != _Tokenizer.COLON:
                raise ValueError('SUM expects ":" between cell references')
            tok.consume()  # consume ':'
            if tok.peek()[0] != _Tokenizer.REF:
                raise ValueError('SUM expects a cell reference after ":"')
            ref2_str = tok.consume()[1]
            if tok.peek()[0] != _Tokenizer.RPAREN:
                raise ValueError('SUM expects closing ")"')
            tok.consume()  # consume ')'
            r1 = parse_ref(ref1_str)
            r2 = parse_ref(ref2_str)
            if r1 is None or r2 is None:
                raise ValueError('Bad cell reference in SUM range')
            row1 = min(r1[0], r2[0]);  row2 = max(r1[0], r2[0])
            col1 = min(r1[1], r2[1]);  col2 = max(r1[1], r2[1])
            total = 0.0
            for r in range(row1, row2 + 1):
                for c in range(col1, col2 + 1):
                    v = get_numeric(r, c)
                    if v is not None:
                        total += v
            return total
        else:
            raise ValueError(f'Unknown function: {fname!r}')
    if kind == _Tokenizer.LPAREN:
        tok.consume()
        v = _expr(tok, row, col)
        if tok.peek()[0] != _Tokenizer.RPAREN:
            raise ValueError('Missing closing parenthesis')
        tok.consume()
        return v
    raise ValueError(f'Unexpected token: {val!r}')

def eval_arithmetic(expr_str, row, col):
    """Evaluate an arithmetic expression string; return float or raise."""
    tok = _Tokenizer(expr_str)
    val = _expr(tok, row, col)
    if tok.peek()[0] != _Tokenizer.END:
        raise ValueError(f'Unexpected token after expression: {tok.peek()[1]!r}')
    return val


# ------------------------------------------------------------------
# Evaluation
# ------------------------------------------------------------------
def get_numeric(row, col):
    if (row, col) not in raw:
        return None
    val = raw[(row, col)]
    # If it's a formula, evaluate it and try to get a number
    if val.startswith('='):
        result = evaluate(row, col)
        try:
            return float(result)
        except (ValueError, TypeError):
            return None
    try:
        return float(val)
    except ValueError:
        return None

def _fmt(value):
    """Format a float nicely: drop .0 for whole numbers."""
    if value == int(value):
        return str(int(value))
    return str(value)

def evaluate(row, col):
    """Return the string to display in cell (row, col)."""
    content = raw.get((row, col), '')
    if not content.startswith('='):
        return content
    formula = content[1:].strip()

    # --- Arithmetic expression (handles SUM(...), cell refs, operators, parens) ---
    try:
        result = eval_arithmetic(formula, row, col)
        return _fmt(result)
    except ZeroDivisionError:
        return '#DIV/0'
    except Exception:
        return '#ERR'

def recalc_all():
    """Recompute every cell's display value."""
    for (r, c), var in result_vars.items():
        var.set(evaluate(r, c))


# ------------------------------------------------------------------
# Formula bar
# ------------------------------------------------------------------
def on_formula_bar_return(event):
    """User pressed Enter in the formula bar -- commit to selected cell."""
    r, c = selected_cell
    raw[(r, c)] = formula_var.get()
    recalc_all()
    # Return focus to the cell
    w = cell_widgets.get((r, c))
    if w:
        w.focus_set()
    return 'break'


# ------------------------------------------------------------------
# Cell focus / navigation
# ------------------------------------------------------------------
def select_cell(row, col):
    global selected_cell
    selected_cell = (row, col)
    formula_var.set(raw.get((row, col), ''))

def move_focus(row, col):
    nrows = max(r for r, c in raw) + 1
    ncols = max(c for r, c in raw) + 1
    row = max(1, min(row, nrows - 1))
    col = max(1, min(col, ncols - 1))
    w = cell_widgets.get((row, col))
    if w:
        w.focus_set()

def commit_cell(row, col, value):
    """Store value into raw[], recalc, update formula bar if still selected."""
    raw[(row, col)] = value
    recalc_all()
    if selected_cell == (row, col):
        formula_var.set(value)


# ------------------------------------------------------------------
# Cell creation
# ------------------------------------------------------------------
def make_cell(row, col):
    rvar = tk.StringVar()
    result_vars[(row, col)] = rvar
    raw[(row, col)] = ''

    entry = tk.Entry(frame, textvariable=rvar, width=COL_WIDTH,
                     justify='right', relief='sunken', bd=1,
                     state='readonly', readonlybackground='white')
    entry.grid(row=row, column=col, sticky=tk.EW, padx=1, pady=1)
    cell_widgets[(row, col)] = entry

    # Each cell has a hidden editor Entry that pops up over it when active
    editor_var = tk.StringVar()
    editor = tk.Entry(frame, textvariable=editor_var, width=COL_WIDTH,
                      justify='right', relief='sunken', bd=1)

    def start_edit(e, r=row, c=col):
        select_cell(r, c)
        # Show editor over the display entry
        editor_var.set(raw.get((r, c), ''))
        editor.grid(row=r, column=c, sticky=tk.EW, padx=1, pady=1)
        editor.lift()
        editor.focus_set()
        editor.selection_range(0, tk.END)

    def finish_edit(nav=None, r=row, c=col):
        val = editor_var.get()
        editor.grid_remove()
        commit_cell(r, c, val)
        if nav:
            move_focus(*nav)
        else:
            # Return focus to the display entry
            w = cell_widgets.get((r, c))
            if w:
                w.focus_set()

    def on_editor_key(e, r=row, c=col):
        if e.keysym == 'Return':
            finish_edit((r + 1, c))
            return 'break'
        if e.keysym == 'Tab':
            finish_edit((r, c + 1))
            return 'break'
        if e.keysym == 'ISO_Left_Tab':
            finish_edit((r, c - 1))
            return 'break'
        if e.keysym == 'Up':
            finish_edit((r - 1, c))
            return 'break'
        if e.keysym == 'Down':
            finish_edit((r + 1, c))
            return 'break'
        if e.keysym == 'Escape':
            editor_var.set(raw.get((r, c), ''))
            editor.grid_remove()
            w = cell_widgets.get((r, c))
            if w:
                w.focus_set()
            return 'break'

    def on_editor_focus_out(e, r=row, c=col):
        if editor.winfo_ismapped():
            finish_edit(None, r, c)

    editor.bind('<KeyPress>', on_editor_key)
    editor.bind('<FocusOut>', on_editor_focus_out)

    def on_display_focus_in(e, r=row, c=col):
        select_cell(r, c)

    def on_display_key(e, r=row, c=col):
        # Navigation keys move without editing
        if e.keysym in ('Tab', 'Return'):
            move_focus(r + 1, c)
            return 'break'
        if e.keysym == 'ISO_Left_Tab':
            move_focus(r, c - 1)
            return 'break'
        if e.keysym == 'Up':
            move_focus(r - 1, c)
            return 'break'
        if e.keysym == 'Down':
            move_focus(r + 1, c)
            return 'break'
        # Any printable character starts editing
        if e.char and e.char.isprintable():
            raw[(r, c)] = ''   # clear on fresh type
            editor_var.set(e.char)
            editor.grid(row=r, column=c, sticky=tk.EW, padx=1, pady=1)
            editor.lift()
            editor.focus_set()
            editor.icursor(tk.END)
            return 'break'

    entry.bind('<FocusIn>',  on_display_focus_in)
    entry.bind('<KeyPress>',  on_display_key)
    entry.bind('<Double-Button-1>', start_edit)
    entry.bind('<Button-1>', lambda e, r=row, c=col: (
        entry.focus_set(), select_cell(r, c)
    ))

    return entry


# ------------------------------------------------------------------
# Header helpers
# ------------------------------------------------------------------
def make_col_header(col):
    tk.Label(frame, text=col_to_letter(col - 1),
             bg='#b0b8c8', relief='groove', width=COL_WIDTH,
             anchor='center').grid(row=0, column=col,
                                   sticky=tk.EW, padx=1, pady=1)

def make_row_header(row):
    tk.Label(frame, text=str(row),
             bg='#b0b8c8', relief='groove', width=3,
             anchor='center').grid(row=row, column=0,
                                   sticky=tk.EW, padx=1, pady=1)


# ------------------------------------------------------------------
# Append / delete rows and columns
# ------------------------------------------------------------------
def current_dims():
    rows = {r for r, c in raw}
    cols = {c for r, c in raw}
    return max(rows) + 1, max(cols) + 1

def append_row():
    nrows, ncols = current_dims()
    make_row_header(nrows)
    for col in range(1, ncols):
        make_cell(nrows, col)

def delete_row():
    nrows, ncols = current_dims()
    if nrows <= 2:
        return
    row = nrows - 1
    for widget in frame.grid_slaves(row=row):
        widget.destroy()
    for col in range(ncols):
        raw.pop((row, col), None)
        result_vars.pop((row, col), None)
        cell_widgets.pop((row, col), None)

def append_col():
    nrows, ncols = current_dims()
    make_col_header(ncols)
    for row in range(1, nrows):
        make_cell(row, ncols)

def delete_col():
    nrows, ncols = current_dims()
    if ncols <= 2:
        return
    col = ncols - 1
    for widget in frame.grid_slaves(column=col):
        widget.destroy()
    for row in range(nrows):
        raw.pop((row, col), None)
        result_vars.pop((row, col), None)
        cell_widgets.pop((row, col), None)


# ------------------------------------------------------------------
# File I/O
# ------------------------------------------------------------------
def _clear_grid():
    """Destroy all cell widgets and reset stores, then rebuild a fresh grid."""
    # Destroy all widgets in frame
    for w in frame.winfo_children():
        w.destroy()
    raw.clear()
    result_vars.clear()
    cell_widgets.clear()

def _build_grid(data):
    """Given data as list-of-lists of strings, rebuild the grid to fit."""
    nrows = max(len(data), 1)
    ncols = max((len(row) for row in data), default=1)
    nrows = max(nrows, INIT_ROWS)
    ncols = max(ncols, INIT_COLS)

    # Corner
    tk.Label(frame, text='', bg='#b0b8c8', relief='groove',
             width=3).grid(row=0, column=0, sticky=tk.EW, padx=1, pady=1)
    for col in range(1, ncols + 1):
        make_col_header(col)
    for row in range(1, nrows + 1):
        make_row_header(row)
        for col in range(1, ncols + 1):
            make_cell(row, col)

    # Fill in values from data
    for r_idx, row_data in enumerate(data):
        for c_idx, cell_val in enumerate(row_data):
            r = r_idx + 1
            c = c_idx + 1
            if (r, c) in raw:
                raw[(r, c)] = cell_val

    recalc_all()
    root.after(50, fit_window)

def _read_csv(delimiter):
    path = filedialog.askopenfilename(
        title='Open CSV file',
        filetypes=[('CSV files', '*.csv'), ('All files', '*.*')]
    )
    if not path:
        return
    try:
        with open(path, newline='', encoding='utf-8') as f:
            reader = csv.reader(f, delimiter=delimiter)
            data = list(reader)
    except Exception as e:
        messagebox.showerror('Read Error', str(e))
        return
    _clear_grid()
    _build_grid(data)

def _write_csv(delimiter):
    path = filedialog.asksaveasfilename(
        title='Save CSV file',
        defaultextension='.csv',
        filetypes=[('CSV files', '*.csv'), ('All files', '*.*')]
    )
    if not path:
        return
    nrows, ncols = current_dims()
    try:
        with open(path, 'w', newline='', encoding='utf-8') as f:
            writer = csv.writer(f, delimiter=delimiter)
            for r in range(1, nrows):
                row_data = [raw.get((r, c), '') for c in range(1, ncols)]
                writer.writerow(row_data)
    except Exception as e:
        messagebox.showerror('Write Error', str(e))
        return
    messagebox.showinfo('Saved', f'Saved to {path}')

def read_tab_csv():
    _read_csv('\t')

def write_tab_csv():
    _write_csv('\t')

def read_comma_csv():
    _read_csv(',')

def write_comma_csv():
    _write_csv(',')


# ------------------------------------------------------------------
# Window management (same logic as spSheet6b)
# ------------------------------------------------------------------
def update_scrollbars(win_w, win_h):
    cw = frame.winfo_reqwidth()
    ch = frame.winfo_reqheight()
    sb = vscroll.winfo_reqwidth()
    need_v = ch > win_h
    need_h = cw > win_w
    if need_v and not need_h:
        need_h = cw > (win_w - sb)
    if need_h and not need_v:
        need_v = ch > (win_h - sb)
    if need_v:
        vscroll.grid(row=1, column=1, sticky=tk.NS)
    else:
        vscroll.grid_remove()
    if need_h:
        hscroll.grid(row=2, column=0, sticky=tk.EW)
    else:
        hscroll.grid_remove()
    return need_v, need_h, sb

def on_root_configure(event):
    global maxW, maxH, _prog_resize_pending, _pending_fit
    global _debounce_id, _last_user_size
    if event.widget is not root:
        return
    if _prog_resize_pending:
        _prog_resize_pending = False
        return
    w, h = root.winfo_width(), root.winfo_height()
    if w <= 1 or h <= 1:
        return
    if (w, h) == _last_user_size:
        return
    _last_user_size = (w, h)
    maxW, maxH = w, h
    update_scrollbars(w, h)
    _pending_fit = True
    if _debounce_id is not None:
        root.after_cancel(_debounce_id)
    _debounce_id = root.after(DEBOUNCE_MS, _debounce_fit)

def _debounce_fit():
    """Fallback: called DEBOUNCE_MS after the last Configure event."""
    global _debounce_id, _pending_fit
    _debounce_id = None
    if _pending_fit:
        _pending_fit = False
        fit_window()

def on_button_release(event):
    """Preferred 'drag done' signal on X11; cancels debounce so only one fit fires."""
    global _pending_fit, _debounce_id
    if _pending_fit:
        _pending_fit = False
        if _debounce_id is not None:
            root.after_cancel(_debounce_id)
            _debounce_id = None
        fit_window()

def fit_window():
    global _prog_resize_pending
    root.update_idletasks()
    cw = frame.winfo_reqwidth()
    ch = frame.winfo_reqheight()
    need_v, need_h, sb = update_scrollbars(maxW, maxH)
    if need_v and need_h:
        win_w, win_h = maxW, maxH
    elif need_v:
        win_w = min(cw + sb, maxW)
        win_h = maxH
    elif need_h:
        win_w = maxW
        win_h = min(ch + sb, maxH)
    else:
        win_w = max(MIN_WIDTH,  cw)
        win_h = max(MIN_HEIGHT, ch)
    # Account for formula bar height
    canvas_w = win_w - (sb if need_v else 0)
    canvas_h = win_h - fbar_frame.winfo_reqheight() - (sb if need_h else 0)
    canvas.config(width=canvas_w, height=max(20, canvas_h))
    _prog_resize_pending = True
    root.geometry(f'{win_w}x{win_h}')

def on_frame_configure(event):
    canvas.config(scrollregion=canvas.bbox('all'))
    fit_window()


# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def show_help():
    win = tk.Toplevel(root)
    win.title('Formula Help')
    win.resizable(False, False)
    msg = (
        "Cell addresses:  column letter + row number\n"
        "  e.g.  A1  B3  C10\n\n"
        "Entering values:\n"
        "  Click a cell and type -- or double-click to edit in place.\n"
        "  Press Enter (move down) or Tab (move right) to confirm.\n"
        "  Press Escape to cancel an edit.\n\n"
        "Formulas  (must start with '='):\n"
        "  =SUM(A1:A5)      sum column A, rows 1-5\n"
        "  =SUM(A1:E1)      sum row 1, columns A-E\n"
        "  =SUM(A1:C3)      sum all numeric cells in rectangle\n\n"
        "Arithmetic expressions (new in spSheet8):\n"
        "  =2+3             -> 5\n"
        "  =A1*B1           multiply two cells\n"
        "  =(A1+B1)*C1      parentheses control precedence\n"
        "  =-A1             unary minus\n"
        "  =A1/B1-C1*2      full expression with precedence:\n"
        "                   unary - > * / > + -\n\n"
        "Error codes:\n"
        "  #ERR    formula syntax error\n"
        "  #REF    bad cell reference\n"
        "  #DIV/0  division by zero\n\n"
        "Formula bar:\n"
        "  Shows the raw formula/value for the selected cell.\n"
        "  You can also edit directly in the bar and press Enter.\n\n"
        "File menu:\n"
        "  Read/Write tab-separated or comma-separated .csv files.\n"
        "  Reading a file replaces the current spreadsheet contents.\n"
        "  Writing saves raw cell values (formulas, not results).\n"
    )
    tk.Label(win, text=msg, justify='left', font='TkFixedFont',
             padx=12, pady=10).pack()
    tk.Button(win, text='OK', command=win.destroy, width=8).pack(pady=(0,10))


# ------------------------------------------------------------------
# Main
# ------------------------------------------------------------------
if __name__ == '__main__':
    root = tk.Tk()
    root.title('tkinter Spreadsheet 8a')
    root.configure(bg='#d0d0d0')

    # --- Formula bar (row 0 of root grid) ---
    fbar_frame = tk.Frame(root, bg='#d0d0d0', pady=2)
    fbar_frame.grid(row=0, column=0, columnspan=2, sticky=tk.EW)
    tk.Label(fbar_frame, text='Formula:', bg='#d0d0d0').pack(side=tk.LEFT, padx=(4,2))
    formula_var = tk.StringVar()
    fbar_entry = tk.Entry(fbar_frame, textvariable=formula_var, relief='sunken')
    fbar_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,4))
    fbar_entry.bind('<Return>', on_formula_bar_return)

    # --- Canvas + scrollbars (rows 1-2 of root grid) ---
    canvas = tk.Canvas(root, bg='#e8e8e8', highlightthickness=0)
    canvas.grid(row=1, column=0)

    vscroll = tk.Scrollbar(root, orient=tk.VERTICAL,   command=canvas.yview)
    hscroll = tk.Scrollbar(root, orient=tk.HORIZONTAL, command=canvas.xview)
    canvas.config(yscrollcommand=vscroll.set, xscrollcommand=hscroll.set)

    frame = tk.Frame(canvas, bg='#e8e8e8')
    canvas.create_window((0, 0), window=frame, anchor='nw')

    # --- Menus ---
    menu_bar = tk.Menu(root)

    # File menu (new in spSheet8)
    file_menu = tk.Menu(menu_bar, tearoff=0)
    file_menu.add_command(label='Read tab-separated .csv',   command=read_tab_csv)
    file_menu.add_command(label='Write tab-separated .csv',  command=write_tab_csv)
    file_menu.add_separator()
    file_menu.add_command(label='Read comma-separated .csv', command=read_comma_csv)
    file_menu.add_command(label='Write comma-separated .csv',command=write_comma_csv)
    menu_bar.add_cascade(label='File', menu=file_menu)

    row_menu = tk.Menu(menu_bar, tearoff=0)
    row_menu.add_command(label='Append', command=append_row)
    row_menu.add_command(label='Delete', command=delete_row)
    row_menu.add_separator()
    row_menu.add_command(label='Exit', command=root.quit)
    menu_bar.add_cascade(label='Row', menu=row_menu)

    col_menu = tk.Menu(menu_bar, tearoff=0)
    col_menu.add_command(label='Append', command=append_col)
    col_menu.add_command(label='Delete', command=delete_col)
    menu_bar.add_cascade(label='Column', menu=col_menu)

    help_menu = tk.Menu(menu_bar, tearoff=0)
    help_menu.add_command(label='About formulas', command=show_help)
    menu_bar.add_cascade(label='Help', menu=help_menu)

    root.config(menu=menu_bar)

    # --- Build initial grid ---
    # Corner
    tk.Label(frame, text='', bg='#b0b8c8', relief='groove',
             width=3).grid(row=0, column=0, sticky=tk.EW, padx=1, pady=1)
    for col in range(1, INIT_COLS + 1):
        make_col_header(col)
    for row in range(1, INIT_ROWS + 1):
        make_row_header(row)
        for col in range(1, INIT_COLS + 1):
            make_cell(row, col)

    # Select top-left cell
    root.after(60, lambda: cell_widgets[(1,1)].focus_set())

    frame.bind('<Configure>', on_frame_configure)
    root.bind('<Configure>', on_root_configure)
    root.bind('<ButtonRelease-1>', on_button_release)
    root.after(50, fit_window)
    root.mainloop()
