diff --git a/app_state.py b/app_state.py index 3a2e11f..c61b362 100644 --- a/app_state.py +++ b/app_state.py @@ -19,6 +19,13 @@ DEFAULT_BOX_WIDTH = 100 MIN_ALLOWED_BOX_WIDTH = 60 MAX_ALLOWED_BOX_WIDTH = 400 +# The tree and palette column is held at this width whatever the window does, so a wider +# window only ever gives the wiresheet more room. The sash can still be dragged; this is +# the width the column is set to at startup and whenever the preference changes. +DEFAULT_LEFT_PANE_WIDTH = 230 +MIN_ALLOWED_LEFT_PANE_WIDTH = 120 +MAX_ALLOWED_LEFT_PANE_WIDTH = 600 + # Widget handles, filled in by main once the UI exists window = None canvas = None @@ -70,6 +77,7 @@ def load_preferences(): grid_size = DEFAULT_GRID_SIZE add_timestamp = DEFAULT_ADD_TIMESTAMP box_width = DEFAULT_BOX_WIDTH + left_pane_width = DEFAULT_LEFT_PANE_WIDTH sedona_home = "" if os.path.exists(PROPERTIES_FILE): @@ -78,6 +86,9 @@ def load_preferences(): grid_size = config.getint("Editor", "grid_size", fallback=DEFAULT_GRID_SIZE) add_timestamp = config.getboolean("Editor", "add_timestamp", fallback=DEFAULT_ADD_TIMESTAMP) box_width = config.getint("Editor", "box_width", fallback=DEFAULT_BOX_WIDTH) + left_pane_width = config.getint( + "Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH + ) sedona_home = config.get("Editor", "sedona.home", fallback="").strip() except Exception: pass @@ -86,7 +97,8 @@ def load_preferences(): "sedona.home": "", "grid_size": str(DEFAULT_GRID_SIZE), "add_timestamp": str(DEFAULT_ADD_TIMESTAMP), - "box_width": str(DEFAULT_BOX_WIDTH) + "box_width": str(DEFAULT_BOX_WIDTH), + "left_pane_width": str(DEFAULT_LEFT_PANE_WIDTH) } try: with open(PROPERTIES_FILE, "w") as configfile: @@ -95,7 +107,10 @@ def load_preferences(): pass box_width = max(MIN_ALLOWED_BOX_WIDTH, min(MAX_ALLOWED_BOX_WIDTH, box_width)) - return sedona_home, grid_size, add_timestamp, box_width + left_pane_width = max( + MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width) + ) + return sedona_home, grid_size, add_timestamp, box_width, left_pane_width def save_preferences(): @@ -105,7 +120,8 @@ def save_preferences(): "sedona.home": SEDONA_HOME, "grid_size": str(GRID_SIZE), "add_timestamp": str(ADD_TIMESTAMP), - "box_width": str(BOX_WIDTH) + "box_width": str(BOX_WIDTH), + "left_pane_width": str(LEFT_PANE_WIDTH) } with open(PROPERTIES_FILE, "w") as configfile: config.write(configfile) @@ -148,4 +164,4 @@ def mark_clean(): canvas.config(bg="white") -SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH = load_preferences() +SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences() diff --git a/main.py b/main.py index 3846b09..e8f69f0 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ import os import sys import tkinter as tk -from tkinter import filedialog, messagebox, ttk +from tkinter import filedialog, font as tkfont, messagebox, ttk import app_state import manage_kits @@ -14,6 +14,23 @@ import undo import wiresheet from sax_dom import get_component_path, read_meta_property, update_meta_property +# Tree column measurements, in pixels. The padding covers the expander arrow and the +# cell's own inset, which sit ahead of the text ttk lets us measure. +TREE_INDENT = 20 +TREE_TEXT_PADDING = 28 +TREE_MIN_COLUMN_WIDTH = 40 + +# Rows each list asks for. This is the only real floor ttk offers on the left column's own +# split: a pane keeps its requested height, and a short window takes the shortfall from the +# panes in proportion to their weights. Left at the ten-row default the palette asked for +# so much that the tree, carrying the larger weight, gave up all of its height first. +TREE_REQUESTED_ROWS = 6 +PALETTE_REQUESTED_ROWS = 5 + +# Below this the shortfall is deep enough to shrink the tree past a usable few rows, and +# ttk has no way to hold a pane at a floor once the room is simply not there. +MIN_WINDOW_HEIGHT = 300 + def refresh_all_views(): """Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh.""" @@ -550,6 +567,108 @@ def on_canvas_release(event): wiresheet.draw_links() +def fit_tree_column(tree): + """Sizes a Treeview's tree column to its widest visible row. + + ttk derives the horizontal scroll range from the column width, so a column left to + stretch with the widget can never scroll - long text is simply clipped. Sizing it to + the content is what gives the horizontal scrollbar something to move, and the max() + keeps a short list filling the width it has instead of leaving a bare strip. + """ + if tree is None or not tree.winfo_exists(): + return + + style = ttk.Style() + text_font = tkfont.nametofont(style.lookup("Treeview", "font") or "TkDefaultFont") + try: + indent = int(style.lookup("Treeview", "indent") or TREE_INDENT) + except (ValueError, tk.TclError): + indent = TREE_INDENT + + widest = 0 + + def measure(node, depth): + nonlocal widest + for child in tree.get_children(node): + width = depth * indent + text_font.measure(tree.item(child, "text")) + widest = max(widest, width) + # A closed branch hides its children, so they are not part of the width. + if tree.item(child, "open"): + measure(child, depth + 1) + + measure("", 1) + tree.column("#0", width=max(widest + TREE_TEXT_PADDING, tree.winfo_width() - 2), + minwidth=TREE_MIN_COLUMN_WIDTH, stretch=False) + + +def attach_tree_scrollbars(container, tree): + """Puts a Treeview in its container with scrollbars that show only when they help. + + The tree/palette column is a fixed width now, so a deep path or a long type name has + to be reachable by scrolling sideways rather than by widening the window. A bar that + has nothing to scroll is taken out of the grid so it costs no room. + """ + vertical = ttk.Scrollbar(container, orient=tk.VERTICAL, command=tree.yview) + horizontal = ttk.Scrollbar(container, orient=tk.HORIZONTAL, command=tree.xview) + vertical_cell = {"row": 0, "column": 1, "sticky": "ns"} + horizontal_cell = {"row": 1, "column": 0, "sticky": "ew"} + + def show_when_needed(bar, cell, first, last): + if float(first) <= 0.0 and float(last) >= 1.0: + bar.grid_remove() + else: + bar.grid(**cell) + bar.set(first, last) + + tree.configure( + yscrollcommand=lambda first, last: show_when_needed(vertical, vertical_cell, first, last), + xscrollcommand=lambda first, last: show_when_needed(horizontal, horizontal_cell, first, last), + ) + + container.rowconfigure(0, weight=1) + container.columnconfigure(0, weight=1) + tree.grid(row=0, column=0, sticky="nsew") + + # Resizing, and opening or closing a branch, both change what the widest row is. + tree.bind("", lambda event: fit_tree_column(tree), add="+") + tree.bind("<>", lambda event: tree.after_idle(fit_tree_column, tree), add="+") + tree.bind("<>", lambda event: tree.after_idle(fit_tree_column, tree), add="+") + + +def clamp_left_pane_split(event=None): + """Keeps both halves of the left column visible, whatever the sash or the window does. + + ttk lets a sash drag run to either end, and it remembers that position when the window + is made shorter afterwards, so either list can be left with no height at all. Each + pane's requested height is the line. Once the column is too short to seat both, the + floors are dropped and ttk's own proportional squeeze takes over. + """ + total = left_pane.winfo_height() + tree_floor = frame_tree.winfo_reqheight() + palette_floor = frame_palette.winfo_reqheight() + if total <= tree_floor + palette_floor: + return + + position = left_pane.sashpos(0) + clamped = min(max(position, tree_floor), total - palette_floor) + if clamped != position: + left_pane.sashpos(0, clamped) + + +def apply_left_pane_width(): + """Pins the tree/palette column to the configured width. + + Setting the sash before the paned window has been laid out collapses the column + instead of sizing it, so the startup call retries until there is a width to divide. + This is also how a changed preference reaches an already open window. + """ + app_state.window.update_idletasks() + if paned_window.winfo_width() <= 1: + paned_window.after(50, apply_left_pane_width) + return + paned_window.sashpos(0, app_state.LEFT_PANE_WIDTH) + + # --- Dialogs --- def open_preferences_dialog(): @@ -558,7 +677,7 @@ def open_preferences_dialog(): dialog = tk.Toplevel(window) dialog.title("Preferences") - dialog.geometry("470x250") + dialog.geometry("470x290") dialog.resizable(False, False) dialog.transient(window) dialog.grab_set() @@ -605,8 +724,15 @@ def open_preferences_dialog(): entry_box_width.insert(0, str(app_state.BOX_WIDTH)) entry_box_width.grid(row=2, column=1, sticky="ew", pady=(0, 10)) + lbl_left_pane = ttk.Label(frame_padded, text="Tree/palette width (pixels):", anchor="w") + lbl_left_pane.grid(row=3, column=0, sticky="w", pady=(0, 10), padx=(0, 10)) + + entry_left_pane = ttk.Entry(frame_padded) + entry_left_pane.insert(0, str(app_state.LEFT_PANE_WIDTH)) + entry_left_pane.grid(row=3, column=1, sticky="ew", pady=(0, 10)) + lbl_timestamp = ttk.Label(frame_padded, text="Save with Date-Time stamp?", anchor="w") - lbl_timestamp.grid(row=3, column=0, sticky="w", pady=(0, 15), padx=(0, 10)) + lbl_timestamp.grid(row=4, column=0, sticky="w", pady=(0, 15), padx=(0, 10)) var_timestamp = tk.BooleanVar(value=app_state.ADD_TIMESTAMP) chk_timestamp = ttk.Checkbutton( @@ -614,7 +740,7 @@ def open_preferences_dialog(): text="Yes", variable=var_timestamp ) - chk_timestamp.grid(row=3, column=1, sticky="w", pady=(0, 15)) + chk_timestamp.grid(row=4, column=1, sticky="w", pady=(0, 15)) def apply_preferences(): try: @@ -635,6 +761,22 @@ def open_preferences_dialog(): f"{app_state.MAX_ALLOWED_BOX_WIDTH} pixels." ) + try: + pane_width = int(entry_left_pane.get().strip()) + except ValueError: + raise ValueError( + f"Tree/palette width must be a whole number between " + f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and " + f"{app_state.MAX_ALLOWED_LEFT_PANE_WIDTH}." + ) + if not (app_state.MIN_ALLOWED_LEFT_PANE_WIDTH <= pane_width + <= app_state.MAX_ALLOWED_LEFT_PANE_WIDTH): + raise ValueError( + f"Tree/palette width must be between " + f"{app_state.MIN_ALLOWED_LEFT_PANE_WIDTH} and " + f"{app_state.MAX_ALLOWED_LEFT_PANE_WIDTH} pixels." + ) + # The width cap is applied after each box is fitted to its text, so a cap # that is not a multiple of the grid leaves the widest boxes off-grid. proposed = wiresheet.suggest_box_width_for_grid(width, val) @@ -652,12 +794,14 @@ def open_preferences_dialog(): entry_box_width.insert(0, str(width)) app_state.BOX_WIDTH = width + app_state.LEFT_PANE_WIDTH = pane_width app_state.GRID_SIZE = val app_state.ADD_TIMESTAMP = var_timestamp.get() app_state.SEDONA_HOME = entry_home.get().strip() app_state.save_preferences() + apply_left_pane_width() wiresheet.draw_grid() if app_state.active_components_list: wiresheet.render_components(app_state.active_components_list) @@ -671,7 +815,7 @@ def open_preferences_dialog(): messagebox.showerror("Invalid Input", message, parent=dialog) btn_save_pref = ttk.Button(frame_padded, text="Apply Changes", command=apply_preferences) - btn_save_pref.grid(row=4, column=1, sticky="e") + btn_save_pref.grid(row=5, column=1, sticky="e") if not app_state.SEDONA_HOME: entry_home.focus_set() @@ -775,6 +919,8 @@ window = tk.Tk() app_state.window = window window.title(app_state.base_window_title()) window.geometry("950x600") +# Width is left alone; only the height needs a floor, to keep the tree pane off zero. +window.minsize(1, MIN_WINDOW_HEIGHT) # --- Context Popups Structures --- canvas_context_menu = tk.Menu(window, tearoff=0) @@ -876,18 +1022,15 @@ paned_window.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) left_pane = ttk.PanedWindow(paned_window, orient=tk.VERTICAL) frame_tree = ttk.Frame(left_pane) -tree_scrollbar = ttk.Scrollbar(frame_tree) -tree_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) tree = ttk.Treeview( frame_tree, - yscrollcommand=tree_scrollbar.set, show="tree", selectmode="browse", + height=TREE_REQUESTED_ROWS, ) app_state.tree = tree -tree.pack(fill=tk.BOTH, expand=True) -tree_scrollbar.config(command=tree.yview) +attach_tree_scrollbars(frame_tree, tree) tree.bind("<>", on_tree_select) tree.bind("", on_tree_right_click) @@ -909,17 +1052,13 @@ palette_kit_combo.pack(fill=tk.X, padx=4) frame_palette_list = ttk.Frame(frame_palette) frame_palette_list.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) -palette_scrollbar = ttk.Scrollbar(frame_palette_list) -palette_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - palette_tree = ttk.Treeview( frame_palette_list, - yscrollcommand=palette_scrollbar.set, show="tree", selectmode="browse", + height=PALETTE_REQUESTED_ROWS, ) -palette_tree.pack(fill=tk.BOTH, expand=True) -palette_scrollbar.config(command=palette_tree.yview) +attach_tree_scrollbars(frame_palette_list, palette_tree) palette_status = ttk.Label(frame_palette, text="", foreground="#666666") palette_status.pack(anchor="w", padx=4, pady=(0, 4)) @@ -942,6 +1081,7 @@ def populate_palette_types(event=None): palette_tree.insert("", "end", text=type_name, values=(f"{kit_name}::{type_name}", base)) palette_status.config(text=f"{len(types)} component{'' if len(types) == 1 else 's'}") + fit_tree_column(palette_tree) def refresh_palette_kits(): @@ -971,6 +1111,7 @@ def refresh_palette_kits(): palette_status.config(text=f"{len(palette_kit_names)} kits") palette_kit_var.set("") + fit_tree_column(palette_tree) @@ -1081,7 +1222,10 @@ palette_tree.bind("", on_palette_motion) palette_tree.bind("", on_palette_release) left_pane.add(frame_palette, weight=2) -paned_window.add(left_pane, weight=1) + +# weight=0 keeps the tree/palette column out of the share-out of spare width, so growing +# the window feeds the wiresheet alone and the column stays at its configured width. +paned_window.add(left_pane, weight=0) frame_editor = ttk.Frame(paned_window) @@ -1116,7 +1260,14 @@ canvas.bind("", on_canvas_right_click) paned_window.add(frame_editor, weight=3) undo.set_refresh_callback(refresh_all_views) +sax_file.set_tree_filled_callback(lambda: fit_tree_column(tree)) refresh_palette_kits() +apply_left_pane_width() + +# Moving the sash changes the split directly; resizing the window changes what fits. +left_pane.bind("", clamp_left_pane_split, add="+") +left_pane.bind("", clamp_left_pane_split, add="+") +left_pane.bind("", clamp_left_pane_split, add="+") window.after(100, verify_startup_configurations) window.mainloop() diff --git a/sax_file.py b/sax_file.py index 73b5f28..7749e8f 100644 --- a/sax_file.py +++ b/sax_file.py @@ -19,6 +19,15 @@ from sax_dom import ( ) +_tree_filled = None + + +def set_tree_filled_callback(callback): + """Registers what should run once the navigation tree has been rebuilt.""" + global _tree_filled + _tree_filled = callback + + def parse_xml_to_tree(treeview, parent_node, xml_element): """Recursively traverses XML elements and adds tags to the Treeview.""" for child in xml_element: @@ -47,6 +56,8 @@ def refresh_treeview_from_dom(xml_root): tree.delete(item) if not app_state.current_file_path or xml_root is None: + if _tree_filled is not None: + _tree_filled() return file_name = os.path.basename(app_state.current_file_path) @@ -58,6 +69,9 @@ def refresh_treeview_from_dom(xml_root): else: parse_xml_to_tree(tree, root_node, xml_root) + if _tree_filled is not None: + _tree_filled() + def reset_to_startup_state(): """Returns the app to its just-launched state: no file, empty tree, empty sheet.