#!/usr/bin/env python3

# ---
# 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.5 and/or 4.6, guided by my testing & feedback,
# wrote the code.  I've minimally tested the code, so there still may be bugs.
# 16Mar26: Posted to https://wuebker.com
# ---

# spSheet6b: Linux python3/tkinter user-interface test
#   This is *NOT* a real spreadsheet.
#   - At startup, spSheet6b's window initially shrinks to fit content
#   - The user can resize the window; the user's size becomes the new maximum window size
#   - When content is smaller than the max size, the lower right of the window shrinks to fit
#   - When content is larger than the max size, scrollbars appear
#   - Window shrink-to-fit fires on ButtonRelease if the WM delivers it, otherwise
#     falls back to a short debounce timer that restarts on every Configure event
#     (so it only fires once the drag goes idle).
#
#   Bugs fixed vs spSheet6a:
#   1. _prog_resize_count could accumulate above 1 when adding/deleting rows or columns,
#      because each widget creation/destruction fires a separate <Configure> on the frame,
#      each calling fit_window() and incrementing the counter.  Subsequent user-initiated
#      Configure events were then swallowed as "programmatic", so maxW/maxH never updated
#      and the snap-back never triggered.
#      Fix: replaced the integer counter with a simple boolean flag (_prog_resize_pending).
#      fit_window() sets it True; on_root_configure clears it and returns on the very next
#      Configure event, then resets the flag -- so exactly one event is consumed regardless
#      of how many fit_window() calls happened before the WM responds.
#   2. ButtonRelease-1 is not delivered by all Linux WMs for window-edge drags (the WM
#      handles the drag entirely in compositor space on some setups).
#      Fix: hybrid approach -- ButtonRelease fires fit_window immediately when available;
#      a short debounce timer (DEBOUNCE_MS) acts as the fallback.  The two are kept in
#      sync so only one fit_window() call results from each drag.

import tkinter as tk

MIN_WIDTH   = 100
MIN_HEIGHT  = 100
DEBOUNCE_MS = 150          # fallback: fires this long after the last Configure event

maxW = 9999
maxH = 9999

_prog_resize_pending = False   # True while we're waiting for the WM to echo fit_window()
_pending_fit         = False   # True while a user drag is in progress and needs a fit
_debounce_id         = None    # after() handle for the fallback debounce timer
_last_user_size      = (0, 0)


# ------------------------------------------------------------------
# update_scrollbars
# ------------------------------------------------------------------
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=0, column=1, sticky=tk.NS)
    else:
        vscroll.grid_remove()

    if need_h:
        hscroll.grid(row=1, column=0, sticky=tk.EW)
    else:
        hscroll.grid_remove()

    return need_v, need_h, sb


# ------------------------------------------------------------------
# on_root_configure -- fires continuously during a window drag
# ------------------------------------------------------------------
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 we caused this Configure ourselves, absorb exactly one event.
    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

    # Live scrollbar feedback during the drag.
    update_scrollbars(w, h)

    # Mark that a fit is needed once the drag ends.
    _pending_fit = True

    # (Re)start the debounce fallback timer.
    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()


# ------------------------------------------------------------------
# on_button_release -- preferred "drag done" signal on X11.
# Cancels the debounce timer so only one fit_window() fires.
# ------------------------------------------------------------------
def on_button_release(event):
    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()


# ------------------------------------------------------------------
# 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)

    canvas_w = win_w - (sb if need_v else 0)
    canvas_h = win_h - (sb if need_h else 0)
    canvas.config(width=canvas_w, height=canvas_h)

    _prog_resize_pending = True
    root.geometry(f'{win_w}x{win_h}')


# ------------------------------------------------------------------
# on_frame_configure -- inner frame changed (row/col added/removed)
# ------------------------------------------------------------------
def on_frame_configure(event):
    canvas.config(scrollregion=canvas.bbox('all'))
    fit_window()


# ------------------------------------------------------------------
# Row / column helpers
# ------------------------------------------------------------------
def append_row():
    row = max(w.grid_info()['row'] for w in frame.grid_slaves())
    cols = sorted(w.grid_info()['column'] for w in frame.grid_slaves(row=row))
    for col in cols:
        tk.Label(frame, text=f'({row+1},{col})', justify='center',
                 padx=5, pady=5).grid(row=row+1, column=col, sticky=tk.EW)


def delete_row():
    rows = {w.grid_info()['row'] for w in frame.grid_slaves()}
    if len(rows) <= 1:
        return
    for w in frame.grid_slaves(row=max(rows)):
        w.destroy()


def append_col():
    col = max(w.grid_info()['column'] for w in frame.grid_slaves())
    rows = sorted(w.grid_info()['row'] for w in frame.grid_slaves(column=col))
    for row in rows:
        tk.Label(frame, text=f'({row},{col+1})', justify='center',
                 padx=5, pady=5).grid(row=row, column=col+1, sticky=tk.EW)


def delete_col():
    cols = {w.grid_info()['column'] for w in frame.grid_slaves()}
    if len(cols) <= 1:
        return
    for w in frame.grid_slaves(column=max(cols)):
        w.destroy()


# ------------------------------------------------------------------
# Main
# ------------------------------------------------------------------
if __name__ == '__main__':
    INIT_ROWS = 5
    INIT_COLS = 5

    root = tk.Tk()
    root.title('tkinter Spreadsheet')
    root.configure(bg='yellow')

    canvas = tk.Canvas(root, bg='lightblue', cursor='crosshair',
                       highlightthickness=0)
    canvas.grid(row=0, 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)
    canvas.create_window((0, 0), window=frame, anchor='nw')

    menu_bar = tk.Menu(root)
    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)
    root.config(menu=menu_bar)

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

    for r in range(INIT_ROWS):
        for c in range(INIT_COLS):
            tk.Label(frame, text=f'({r},{c})', justify='center',
                     padx=5, pady=5).grid(row=r, column=c, sticky=tk.EW)

    root.after(50, fit_window)
    root.mainloop()
