#!/usr/bin/env python3 """ protocol_editor.py -- spreadsheet-style editor for protocols-N.txt files. Purpose: replace editing protocols-N.txt in LibreOffice Calc/Excel. Calc has no awareness of the file's real structure (ragged rows -- 9-field frequency records plus one 11-field control record) and no per-field validation, so it silently pads/reformats things in ways the device can't read. This editor shows the same data as a spreadsheet-like grid but knows the real record structure, enforces the device's own field limits per cell, and always keeps Rec # numbering consecutive. protocol_editor.py -- start empty protocol_editor.py --file protocols-3.txt -- open a file directly """ import argparse import os import tkinter as tk from tkinter import ttk, filedialog, messagebox import protocol_tool as pt MAX_RECORDS = 275 # --------------------------------------------------------------------- # Field bounds -- pulled directly from rotaryLimits() in rotary_Decode.ino # so this editor enforces exactly what the device itself enforces. # --------------------------------------------------------------------- F_MIN, F_MAX = 0.04, 65535.00 # F1-F4 (Hz). The floor is hardware: the MCPWM # cannot go below 160MHz/(256*256*65536) = 0.0373 Hz, # and the firmware clamps anything lower to 0.04. D_MIN, D_MAX = 0.00, 1.00 # D1-D4 (duty cycle fraction) MODE_MIN, MODE_MAX = 1, 3 # modeID (4/"Adjust" disabled in current firmware) ENDTIME_MIN, ENDTIME_MAX = 0, 9999 # endTime1 (sec) / endTime2 (min), CSV convention SWEEP_INC_MIN, SWEEP_INC_MAX = 0.00, 9999.99 FILE_NO_MIN, FILE_NO_MAX = 0, 99 FREQ_COLS = ["f1", "d1", "f2", "d2", "f3", "d3", "f4", "d4"] FREQ_HEADERS = ["Rec #", "Freq 1", "Duty 1", "Freq 2", "Duty 2", "Freq 3", "Duty 3", "Freq 4", "Duty 4"] # Control record columns; "zero" is the fixed literal first field (always 0). CTRL_COLS = ["zero", "memgrp", "mode", "freqtime", "progtime", "startfreqgrp", "stopfreqgrp", "startsweepgrp", "stopsweepgrp", "sweepinc", "fileno"] CTRL_HEADERS = ["0", "Mem Grp", "Mode", "Freq Time", "Prog Time", "Start Freq Grp", "Stop Freq Grp", "Start Sweep Grp", "Stop Sweep Grp", "Sweep Inc", "File #"] DEFAULT_FREQ_ROW = {"f1": "1.00", "d1": "0.50", "f2": "1.00", "d2": "0.50", "f3": "1.00", "d3": "0.50", "f4": "1.00", "d4": "0.50"} DEFAULT_CTRL_ROW = {"zero": "0", "memgrp": "1", "mode": "1", "freqtime": "1", "progtime": "1", "startfreqgrp": "1", "stopfreqgrp": "1", "startsweepgrp": "1", "stopsweepgrp": "1", "sweepinc": "1.00", "fileno": "0"} BAD_BG = "#ffcccc" OK_BG = "white" SELECTED_BG = "#d6e4ff" UNSELECTED_BG = "#e8e8e8" # --------------------------------------------------------------------- # Fixed pixel widths, shared between each section's header row and its # data row(s), so columns line up exactly regardless of font/widget-type # differences between a Label (header) and an Entry (data cell). # --------------------------------------------------------------------- FREQ_COL_WIDTH = 78 # one width for all 9 frequency-grid columns CTRL_COL_WIDTHS = [45, 75, 55, 90, 90, 130, 120, 140, 130, 90, 65] # one per CTRL_COLS entry CELL_HEIGHT = 26 HEADER_HEIGHT = 24 def validate_field(col, raw): """Return (ok, canonical_string_or_error). Numeric-only is the main check; range checks match the device's own rotaryLimits() bounds.""" raw = raw.strip() if col == "zero": return True, "0" try: value = float(raw) except ValueError: return False, "must be a number" if col in ("f1", "f2", "f3", "f4"): if not (F_MIN <= value <= F_MAX): return False, f"must be {F_MIN}-{F_MAX}" return True, f"{value:.2f}" if col in ("d1", "d2", "d3", "d4"): if not (D_MIN <= value <= D_MAX): return False, f"must be {D_MIN}-{D_MAX}" return True, f"{value:.2f}" if col == "mode": ivalue = int(value) if float(ivalue) != value or not (MODE_MIN <= ivalue <= MODE_MAX): return False, f"must be a whole number {MODE_MIN}-{MODE_MAX}" return True, str(ivalue) if col in ("freqtime", "progtime"): ivalue = int(value) if float(ivalue) != value or not (ENDTIME_MIN <= ivalue <= ENDTIME_MAX): return False, f"must be a whole number {ENDTIME_MIN}-{ENDTIME_MAX}" return True, str(ivalue) if col == "sweepinc": if not (SWEEP_INC_MIN <= value <= SWEEP_INC_MAX): return False, f"must be {SWEEP_INC_MIN}-{SWEEP_INC_MAX}" return True, f"{value:.2f}" if col == "fileno": ivalue = int(value) if float(ivalue) != value or not (FILE_NO_MIN <= ivalue <= FILE_NO_MAX): return False, f"must be a whole number {FILE_NO_MIN}-{FILE_NO_MAX}" return True, str(ivalue) # memgrp / startfreqgrp / stopfreqgrp / startsweepgrp / stopsweepgrp: # numeric-only here, range depends on how many records currently exist # in the file -- checked separately as a cross-record pass at save time. ivalue = int(value) if float(ivalue) != value or ivalue < 1: return False, "must be a whole number, 1 or higher" return True, str(ivalue) GROUP_ID_COLS = ("memgrp", "startfreqgrp", "stopfreqgrp", "startsweepgrp", "stopsweepgrp") class ProtocolEditor(tk.Tk): def __init__(self, initial_path=None): super().__init__() self.title("Protocol File Editor") self.geometry("1100x650") self.current_path = None self.freq_rows = [] # list of dicts, one per frequency record self.ctrl_row = dict(DEFAULT_CTRL_ROW) self.freq_entries = [] # list of dicts of Entry widgets, mirrors freq_rows self.ctrl_entries = {} self.selected_index = None # currently selected frequency row, for insert/delete self._build_widgets() if initial_path: self._load_path(initial_path) else: self._new_file() # ----------------------------------------------------------- # Layout # ----------------------------------------------------------- def _build_widgets(self): toolbar = ttk.Frame(self, padding=4) toolbar.pack(side=tk.TOP, fill=tk.X) ttk.Button(toolbar, text="New", command=self._new_file).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Open...", command=self._open_file).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Save", command=self._save_file).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Save As...", command=self._save_file_as).pack(side=tk.LEFT, padx=2) ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=6) ttk.Button(toolbar, text="Insert Row Above", command=lambda: self._insert_row(above=True)).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Insert Row Below", command=lambda: self._insert_row(above=False)).pack(side=tk.LEFT, padx=2) ttk.Button(toolbar, text="Delete Row", command=self._delete_row).pack(side=tk.LEFT, padx=2) # Scrollable body for frequency records. Built before the header # (though packed/placed below it -- pack() call order controls # top-to-bottom stacking, not Python construction order) so the # scrollbar's width is known in time to reserve matching space in # the header below. body_frame = ttk.Frame(self) self.canvas = tk.Canvas(body_frame, borderwidth=0, highlightthickness=0) vsb = ttk.Scrollbar(body_frame, orient=tk.VERTICAL, command=self.canvas.yview) self.canvas.configure(yscrollcommand=vsb.set) scrollbar_width = vsb.winfo_reqwidth() # Frequency record header (fixed, does not scroll). Every header cell # and every data cell below it is wrapped in a Frame of the exact # same pixel width (see _fixed_cell) -- a Label (header) and an # Entry (data) never occupy identical pixel widths for the same # character "width=" count, so matching them via a fixed-size # wrapper is what actually keeps columns aligned. # header_row is packed into header WITHOUT fill, so pack's default # center anchor centers it horizontally within header's width -- the # same centering the data rows get for free below (a lone non-fill # child of grid_frame, which itself is forced to the canvas's # width). A plain header would center against the *full* header # width, while the data rows center against the *canvas* width, # which is narrower by the scrollbar -- so a same-width spacer is # reserved here first to shrink the header's cavity to match before # header_row gets centered in what's left, keeping both aligned at # any window width. header = ttk.Frame(self) header.pack(side=tk.TOP, fill=tk.X, padx=4) tk.Frame(header, width=scrollbar_width, height=1).pack(side=tk.RIGHT) header_row = ttk.Frame(header) header_row.pack(side=tk.TOP) for text in FREQ_HEADERS: cell = self._fixed_cell(header_row, FREQ_COL_WIDTH, HEADER_HEIGHT) tk.Label(cell, text=text, anchor="center", font=("TkDefaultFont", 9, "bold")).pack(fill=tk.BOTH, expand=True) # Now place the body frame itself (built above). body_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=4, pady=(0, 4)) vsb.pack(side=tk.RIGHT, fill=tk.Y) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) self.grid_frame = ttk.Frame(self.canvas) self.canvas_window = self.canvas.create_window((0, 0), window=self.grid_frame, anchor="nw") self.grid_frame.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) self.canvas.bind("", lambda e: self.canvas.itemconfig(self.canvas_window, width=e.width)) self.canvas.bind_all("", self._on_mousewheel) self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(-1, "units")) self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(1, "units")) # Control record section (fixed at bottom, single row) ttk.Separator(self, orient=tk.HORIZONTAL).pack(side=tk.TOP, fill=tk.X, padx=4, pady=(0, 4)) ttk.Label(self, text="Control Record (Rec 0)", font=("TkDefaultFont", 9, "bold")).pack(side=tk.TOP, anchor="w", padx=6) ctrl_header = ttk.Frame(self) ctrl_header.pack(side=tk.TOP, fill=tk.X, padx=4) for text, width in zip(CTRL_HEADERS, CTRL_COL_WIDTHS): cell = self._fixed_cell(ctrl_header, width, HEADER_HEIGHT) tk.Label(cell, text=text, anchor="center", font=("TkDefaultFont", 9, "bold")).pack(fill=tk.BOTH, expand=True) ctrl_row_frame = ttk.Frame(self) ctrl_row_frame.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(0, 4)) for col, width in zip(CTRL_COLS, CTRL_COL_WIDTHS): cell = self._fixed_cell(ctrl_row_frame, width, CELL_HEIGHT) if col == "zero": tk.Label(cell, text="0", anchor="center", relief=tk.SUNKEN).pack(fill=tk.BOTH, expand=True) else: var = tk.StringVar() entry = tk.Entry(cell, textvariable=var, justify="center", borderwidth=1, highlightthickness=0) entry.pack(fill=tk.BOTH, expand=True) entry.bind("", lambda e, c=col: self._on_ctrl_edit(c)) entry.bind("", lambda e, c=col: self._on_ctrl_return(c)) self.ctrl_entries[col] = (var, entry) # Status bar self.status_var = tk.StringVar() ttk.Label(self, textvariable=self.status_var, anchor="w", relief=tk.SUNKEN).pack(side=tk.BOTTOM, fill=tk.X) def _on_mousewheel(self, event): self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") @staticmethod def _fixed_cell(parent, width, height): """A Frame locked to an exact pixel size (pack_propagate off) so a header Label and a body Entry placed in same-sized cells always line up, regardless of their own font/border-driven natural size.""" cell = tk.Frame(parent, width=width, height=height) cell.pack_propagate(False) cell.pack(side=tk.LEFT, padx=1, pady=1) return cell # ----------------------------------------------------------- # File operations # ----------------------------------------------------------- def _new_file(self): self.current_path = None self.freq_rows = [dict(DEFAULT_FREQ_ROW)] self.ctrl_row = dict(DEFAULT_CTRL_ROW) self.selected_index = None self._rebuild_freq_grid() self._refresh_ctrl_entries() self._update_status() def _open_file(self): path = filedialog.askopenfilename( title="Open protocol file", filetypes=[("Protocol files", "protocols-*.txt"), ("All files", "*.*")], ) if path: self._load_path(path) def _load_path(self, path): try: pt.parse_file_no(os.path.basename(path)) except ValueError as e: messagebox.showerror("Cannot open file", str(e)) return try: with open(path, "rb") as f: raw = f.read() except OSError as e: messagebox.showerror("Cannot open file", str(e)) return if raw.startswith(b"\xef\xbb\xbf"): raw = raw[3:] text = raw.decode("utf-8", errors="replace") freq_rows = [] ctrl_row = None errors = [] for i, raw_line in enumerate(text.splitlines(), start=1): line = raw_line.strip() if not line: continue # Tolerate trailing empty cells left over from a previous # spreadsheet save (Calc pads ragged rows to a rectangle). fields = [pt.clean_field(f) for f in line.split(",")] while fields and fields[-1] == "": fields.pop() if len(fields) <= 1: continue # header line like "Protocols-3 #" count = len(fields) if count == 9: try: values = [float(f) for f in fields[1:]] except ValueError as e: errors.append(f"line {i}: bad number in frequency record: {line!r} ({e})") continue freq_rows.append({ "f1": f"{values[0]:.2f}", "d1": f"{values[1]:.2f}", "f2": f"{values[2]:.2f}", "d2": f"{values[3]:.2f}", "f3": f"{values[4]:.2f}", "d3": f"{values[5]:.2f}", "f4": f"{values[6]:.2f}", "d4": f"{values[7]:.2f}", }) elif count == 11: if ctrl_row is not None: errors.append(f"line {i}: a second control record was found (only one allowed)") continue try: ints = [int(float(f)) for f in fields[0:9]] sweep_inc = float(fields[9]) file_no = int(float(fields[10])) except ValueError as e: errors.append(f"line {i}: bad number in control record: {line!r} ({e})") continue ctrl_row = { "zero": "0", "memgrp": str(ints[1]), "mode": str(ints[2]), "freqtime": str(ints[3]), "progtime": str(ints[4]), "startfreqgrp": str(ints[5]), "stopfreqgrp": str(ints[6]), "startsweepgrp": str(ints[7]), "stopsweepgrp": str(ints[8]), "sweepinc": f"{sweep_inc:.2f}", "fileno": str(file_no), } else: errors.append(f"line {i}: expected 9 or 11 fields (got {count} after removing " f"trailing blanks), line {i}: {line!r}") if errors: messagebox.showerror("File has problems", "Could not fully load this file:\n\n" + "\n".join(errors)) return if not freq_rows: messagebox.showerror("File has problems", "No frequency records found in this file.") return if ctrl_row is None: messagebox.showerror("File has problems", "No control record (last line, 11 fields) found.") return self.current_path = path self.freq_rows = freq_rows self.ctrl_row = ctrl_row self.selected_index = None self._rebuild_freq_grid() self._refresh_ctrl_entries() self._update_status() def _save_file(self): if self.current_path is None: self._save_file_as() return self._write_to(self.current_path) def _save_file_as(self): path = filedialog.asksaveasfilename( title="Save protocol file as", defaultextension=".txt", filetypes=[("Protocol files", "protocols-*.txt"), ("All files", "*.*")], ) if not path: return try: file_no = pt.parse_file_no(os.path.basename(path)) except ValueError as e: messagebox.showerror("Cannot save", str(e)) return self.current_path = path self.ctrl_row["fileno"] = str(file_no) self._refresh_ctrl_entries() self._write_to(path) def _write_to(self, path): problems = self._validate_all() if problems: messagebox.showerror( "Cannot save -- fix these first", "Format problems found:\n\n" + "\n".join(problems), ) return lines = [f"Protocols-{pt.parse_file_no(os.path.basename(path))} #"] for row in self.freq_rows: lines.append(",".join([ row["f1"], row["d1"], row["f2"], row["d2"], row["f3"], row["d3"], row["f4"], row["d4"], ])) # Rebuild with a leading protoID per row (1-based, consecutive) for idx in range(len(self.freq_rows)): row = self.freq_rows[idx] lines[idx + 1] = ",".join([str(idx + 1), row["f1"], row["d1"], row["f2"], row["d2"], row["f3"], row["d3"], row["f4"], row["d4"]]) c = self.ctrl_row lines.append(",".join([ "0", c["memgrp"], c["mode"], c["freqtime"], c["progtime"], c["startfreqgrp"], c["stopfreqgrp"], c["startsweepgrp"], c["stopsweepgrp"], c["sweepinc"], c["fileno"], ])) with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") self.current_path = path self._update_status(f"Saved {len(self.freq_rows)} records to {path}") # ----------------------------------------------------------- # Cross-record validation (group IDs must reference a real Rec #) # ----------------------------------------------------------- def _validate_all(self): problems = [] n = len(self.freq_rows) if n == 0: problems.append("File needs at least one frequency record.") if n > MAX_RECORDS: problems.append(f"Too many frequency records ({n}); maximum is {MAX_RECORDS}.") for idx, row in enumerate(self.freq_rows, start=1): for col in FREQ_COLS: ok, msg = validate_field(col, row[col]) if not ok: problems.append(f"Rec {idx}, {col}: {msg}") for col in CTRL_COLS: if col == "zero": continue ok, msg = validate_field(col, self.ctrl_row[col]) if not ok: problems.append(f"Control record, {col}: {msg}") continue if col in GROUP_ID_COLS and n > 0: v = int(float(self.ctrl_row[col])) if not (1 <= v <= n): problems.append(f"Control record, {col}: must reference an existing Rec # (1-{n}), got {v}") return problems # ----------------------------------------------------------- # Grid rendering # ----------------------------------------------------------- def _rebuild_freq_grid(self): for child in self.grid_frame.winfo_children(): child.destroy() self.freq_entries = [] for idx, row in enumerate(self.freq_rows): row_frame = ttk.Frame(self.grid_frame) row_frame.pack(side=tk.TOP) rec_cell = self._fixed_cell(row_frame, FREQ_COL_WIDTH, CELL_HEIGHT) rec_label = tk.Label(rec_cell, text=str(idx + 1), anchor="center", relief=tk.SUNKEN) rec_label.pack(fill=tk.BOTH, expand=True) rec_label.bind("", lambda e, i=idx: self._select_row(i)) entries = {} for col in FREQ_COLS: cell = self._fixed_cell(row_frame, FREQ_COL_WIDTH, CELL_HEIGHT) var = tk.StringVar(value=row[col]) entry = tk.Entry(cell, textvariable=var, justify="center", borderwidth=1, highlightthickness=0) entry.pack(fill=tk.BOTH, expand=True) entry.bind("", lambda e, i=idx: self._select_row(i)) entry.bind("", lambda e, i=idx, c=col: self._on_freq_edit(i, c)) entry.bind("", lambda e, i=idx, c=col: self._on_freq_return(i, c)) entry.bind("", lambda e, i=idx, c=col: self._on_freq_vertical_nav(i, c, -1)) entry.bind("", lambda e, i=idx, c=col: self._on_freq_vertical_nav(i, c, 1)) entries[col] = (var, entry) self.freq_entries.append({"label": rec_label, "entries": entries}) self._refresh_row_highlight() self._recolor_all() def _refresh_ctrl_entries(self): for col, (var, entry) in self.ctrl_entries.items(): var.set(self.ctrl_row[col]) self._recolor_ctrl() # ----------------------------------------------------------- # Editing / validation callbacks # ----------------------------------------------------------- def _on_freq_edit(self, idx, col): """Validate/store one frequency cell and return True if it's valid -- callers use this to decide whether keyboard navigation is allowed to leave the cell (see the _on_freq_return/_vertical_nav below: an invalid cell keeps focus and stays shaded red instead of letting the cursor move on).""" if idx >= len(self.freq_rows): return False # row was deleted out from under a pending event var, entry = self.freq_entries[idx]["entries"][col] ok, result = validate_field(col, var.get()) if ok: self.freq_rows[idx][col] = result var.set(result) entry.configure(bg=OK_BG) else: entry.configure(bg=BAD_BG) self._update_status() return ok def _on_ctrl_edit(self, col): """Same contract as _on_freq_edit, but for a control-record cell.""" var, entry = self.ctrl_entries[col] ok, result = validate_field(col, var.get()) if ok: self.ctrl_row[col] = result var.set(result) self._recolor_ctrl() self._update_status() return self._ctrl_field_ok(col, var.get()) def _ctrl_field_ok(self, col, value): """Full validity of a control-record cell, including the cross-record group-ID-must-reference-an-existing-Rec# check that plain validate_field() can't do on its own (used by both the red- shading logic and the Enter-key navigation gate, so they always agree).""" ok, _ = validate_field(col, value) if ok and col in GROUP_ID_COLS: n = len(self.freq_rows) if n > 0: ok = 1 <= int(float(value)) <= n return ok def _recolor_all(self): for idx, row in enumerate(self.freq_rows): for col in FREQ_COLS: var, entry = self.freq_entries[idx]["entries"][col] ok, _ = validate_field(col, var.get()) entry.configure(bg=OK_BG if ok else BAD_BG) def _recolor_ctrl(self): for col, (var, entry) in self.ctrl_entries.items(): entry.configure(bg=OK_BG if self._ctrl_field_ok(col, var.get()) else BAD_BG) # ----------------------------------------------------------- # Keyboard navigation: Up/Down move to the same column in the row # above/below, Enter moves to the next cell in reading order -- both # only when the cell being left is valid (an invalid cell keeps focus, # shaded red, so the user fixes it before moving on). # ----------------------------------------------------------- def _focus_freq_cell(self, idx, col): var, entry = self.freq_entries[idx]["entries"][col] entry.focus_set() entry.selection_range(0, tk.END) def _next_freq_cell(self, idx, col): col_i = FREQ_COLS.index(col) if col_i + 1 < len(FREQ_COLS): return idx, FREQ_COLS[col_i + 1] if idx + 1 < len(self.freq_rows): return idx + 1, FREQ_COLS[0] return None, None def _on_freq_vertical_nav(self, idx, col, delta): if self._on_freq_edit(idx, col): target = idx + delta if 0 <= target < len(self.freq_rows): self._focus_freq_cell(target, col) return "break" def _on_freq_return(self, idx, col): if self._on_freq_edit(idx, col): next_idx, next_col = self._next_freq_cell(idx, col) if next_idx is not None: self._focus_freq_cell(next_idx, next_col) return "break" def _next_ctrl_col(self, col): editable = [c for c in CTRL_COLS if c != "zero"] i = editable.index(col) return editable[i + 1] if i + 1 < len(editable) else None def _on_ctrl_return(self, col): if self._on_ctrl_edit(col): next_col = self._next_ctrl_col(col) if next_col: var, entry = self.ctrl_entries[next_col] entry.focus_set() entry.selection_range(0, tk.END) return "break" # ----------------------------------------------------------- # Row selection / insert / delete # ----------------------------------------------------------- def _select_row(self, idx): self.selected_index = idx self._refresh_row_highlight() def _refresh_row_highlight(self): for idx, item in enumerate(self.freq_entries): item["label"].configure(bg=SELECTED_BG if idx == self.selected_index else UNSELECTED_BG) def _insert_row(self, above): if len(self.freq_rows) >= MAX_RECORDS: messagebox.showerror("Cannot insert", f"Maximum of {MAX_RECORDS} records reached.") return if self.selected_index is None: target = len(self.freq_rows) # nothing selected -> append at end else: target = self.selected_index if above else self.selected_index + 1 self.freq_rows.insert(target, dict(DEFAULT_FREQ_ROW)) self.selected_index = target self._rebuild_freq_grid() self._recolor_ctrl() self._update_status() def _delete_row(self): if self.selected_index is None: messagebox.showinfo("Delete Row", "Click a Rec # first to select the row to delete.") return if len(self.freq_rows) <= 1: messagebox.showerror("Cannot delete", "A file needs at least one frequency record.") return del self.freq_rows[self.selected_index] self.selected_index = None self._rebuild_freq_grid() self._recolor_ctrl() self._update_status() # ----------------------------------------------------------- def _update_status(self, message=None): n = len(self.freq_rows) problems = self._validate_all() name = self.current_path if self.current_path else "(untitled)" base = f"{name} -- {n} record(s)" if problems: base += f" -- {len(problems)} problem(s), see cells shaded red" if message: base = message + " | " + base self.status_var.set(base) def main(): parser = argparse.ArgumentParser(description="Spreadsheet-style editor for protocols-N.txt files.") parser.add_argument("--file", help="Protocol file to open on startup (e.g. protocols-3.txt)") parser.add_argument("--dir", default=".", help="Directory the file lives in (used with --file)") args = parser.parse_args() initial_path = os.path.join(args.dir, args.file) if args.file else None app = ProtocolEditor(initial_path) app.mainloop() if __name__ == "__main__": main()