From 9efaac88e6a692e68ec0179c7919aa517cf4b940 Mon Sep 17 00:00:00 2001 From: Interitio Date: Tue, 6 Jan 2026 01:04:50 +1000 Subject: [PATCH] Add multi-edit capability to roweditor --- src/beanify/gui/mainwindow.py | 17 +- src/beanify/gui/roweditor.py | 388 +++++++++++++++++++++------------- src/beanify/gui/rowtree.py | 8 +- 3 files changed, 250 insertions(+), 163 deletions(-) diff --git a/src/beanify/gui/mainwindow.py b/src/beanify/gui/mainwindow.py index 097fd80..bac7226 100644 --- a/src/beanify/gui/mainwindow.py +++ b/src/beanify/gui/mainwindow.py @@ -150,18 +150,16 @@ class MainWindow(ThemedTk): def row_selected(self, event): logger.debug(f"Received RowTree selection event {event!r}") - row = self.rowtree.get_selected_row() - if row: - self.editor.set_row(*row) - else: - # TODO: Better managment on this. - self.editor.set_row(None, None) + rows = self.rowtree.get_selected_rows() + self.editor.set_rows(rows) def row_updated(self, event): logger.debug(f"Received RowEditor update event {event!r}") - self.custom.add(self.editor.record) - self.rows[self.editor.record] = self.editor.txn - self.rowtree.update_rows({self.editor.record: self.editor.txn}) + fresh = dict(self.editor.rows) + self.custom.update(fresh.keys()) + self.rows.update(fresh) + self.rowtree.update_rows(fresh) + # self.rowtree.update_this_row(self.editor.record, self.editor.txn) self.rebuild_account_cache() @@ -219,6 +217,7 @@ class MainWindow(ThemedTk): self.ruleset.save_rules() self.update_status("Rules saved!") # TODO: Feedback and confirmation + # TODO Raw dump if we can't save for some reason def do_export_txn(self): # TODO: Export options diff --git a/src/beanify/gui/roweditor.py b/src/beanify/gui/roweditor.py index f6015d0..cdf5c25 100644 --- a/src/beanify/gui/roweditor.py +++ b/src/beanify/gui/roweditor.py @@ -12,12 +12,13 @@ class RowEditor(ttk.Frame): super().__init__(master, **kwargs) # Data state - self.record: Record | None = None - self.txn: PartialTXN | None = None + self.rows: list[tuple[Record, PartialTXN]] = [] self.acmpl_cache: dict[str, list[str]] = acmpl_cache # UI State self.showing_ruleui = False + self.showing_record = False + self.showing_txn = False # UI Elements self.record_vars: dict[str, StringVar] = {} @@ -31,6 +32,10 @@ class RowEditor(ttk.Frame): self.layout() + @property + def showing_multiple(self): + return len(self.rows) > 1 + def layout(self): self.columnconfigure(0, weight=0) self.columnconfigure(1, weight=1) @@ -54,7 +59,7 @@ class RowEditor(ttk.Frame): self.match_var = BooleanVar() self.match_toggle = ttk.Checkbutton( - self, variable=self.match_var, command=self.do_match_toggle + self, variable=self.match_var, command=self._display_matchboxes ) self.match_label = ttk.Label(self, text="Rule creation mode") self.match_toggle.grid(row=0, column=0, padx=11, pady=10, sticky="nw") @@ -74,44 +79,61 @@ class RowEditor(ttk.Frame): self.reset_button = ttk.Button(self.button_frame, text="Reset TXN") self.reset_button.grid(row=0, column=2, padx=20) - self.layout_record(self.record) - self.update_record(self.record) + self._setup_txnbox() + self._display_txns([]) + self._display_records([]) - self.setup_txn() - self.layout_txn(self.txn) - - self.do_match_toggle() + self._display_matchboxes() # Buttons below - def update_record(self, record: Record | None): - """ - Update the saved record, or clear it. - """ - if (self.record is None) != (record is None): - # Changing record states - self.layout_record(record) + def _display_records(self, records: list[Record]): + # Flow and fill recordbox for the given records + if records and not self.showing_record: + # Need to reflow record box + self._layout_recordbox_for(records[0]) + elif self.showing_record and not records: + # Need to display placeholder + self._layout_recordbox_placeholder() - if record is not None: - # Update the record variables - relayout = False - for field in record.display_fields(): - var = self.record_vars.get(field.name) - if var is None: - relayout = True - break - var.set(str(field.value if field.value is not None else "-")) - if relayout: - # Record fields changed, maybe the type of record changed? - # This shouldn't happen, but re-layout should fix it. - self.layout_record(record) - self.update_record(record) + # Fill record box + # First compute fields to show + rawfields = [] + if len(records) > 1: + # Resolve shared field values + # field name -> shared field value + resolved = {} + conflicting = set() + for record in records: + for field in record.display_fields(): + if field.name in conflicting: + pass + elif field.name not in resolved: + resolved[field.name] = field.value + elif resolved[field.name] != field.value: + conflicting.add(field.name) - self.record = record + for name, value in resolved.items(): + if name in conflicting: + value = "- Multiple values found -" + elif value is None: + value = "-" + rawfields.append((name, str(value))) + elif records: + fields = records[0].display_fields() + rawfields = [(field.name, field.value) for field in fields] - def layout_record(self, record: Record | None): + # Then update variables + for name, value in rawfields: + var = self.record_vars.get(name) + if var is None: + raise ValueError(f"Missing UI variable for record field {name}") + + var.set(str(value if value is not None else "-")) + + def _cleanup_recordbox(self): """ - Layout a record into the record frame. + Clear record variables and destroy UI elements inside. """ # Clear the variables and labels self.record_vars.clear() @@ -120,55 +142,72 @@ class RowEditor(ttk.Frame): for label in rowlabels: if label: label.destroy() + if self.record_placeholder is not None: + self.record_placeholder.grid_remove() - # If the record is None, show the placeholder - # If the record is not None, hide the placeholder - # And build the record vars and labels and grid them + def _layout_recordbox_placeholder(self): + """ + Put a placeholder into the recordbox, cleaning up any existing labels. + """ + self._cleanup_recordbox() - if record is None: - # Show placeholder label - if (label := self.record_placeholder) is None: - label = self.record_placeholder = ttk.Label( - self.record_frame, text="Select a row to view record details." + # Show placeholder label + if (label := self.record_placeholder) is None: + label = self.record_placeholder = ttk.Label( + self.record_frame, text="Select a row to view record details." + ) + label.grid(row=0, column=0, columnspan=2, sticky="w") + self.showing_record = False + + def _layout_recordbox_for(self, record: Record): + """ + Fill the recordbox with record labels for the given Record. + """ + self._cleanup_recordbox() + + logger.debug(f"Laying out {record!r}") + + # Build record vars and labels + fields = record.display_fields() + for i, field in enumerate(fields): + var = StringVar() + matchvar = BooleanVar() + name_label = ttk.Label( + self.record_frame, + text=field.display_name, + ) + name_label.grid(row=i, column=1, sticky="w", padx=10) + value_label = ttk.Label( + self.record_frame, + textvariable=var, + ) + value_label.grid(row=i, column=2, sticky="w", padx=5) + + if field.matchable: + matchbox = ttk.Checkbutton( + self.record_frame, + variable=matchvar, ) - label.grid(row=0, column=0, columnspan=2, sticky="w") + self.record_matchvars[field.name] = matchvar + else: + matchbox = None + + self.record_rows.append((matchbox, name_label, value_label)) + self.record_vars[field.name] = var + + self.showing_record = True + + def _display_matchboxes(self): + # Disable match selection in multiset mode + # But remember when we return + if self.showing_multiple: + value = False + self.match_toggle.configure(state="disabled") else: - # Hide placeholder if it exists - logger.debug(f"Laying out {record!r}") - if self.record_placeholder is not None: - self.record_placeholder.grid_remove() + value = self.match_var.get() + self.match_toggle.configure(state="enabled") - # Build record vars and labels - fields = record.display_fields() - for i, field in enumerate(fields): - var = StringVar() - matchvar = BooleanVar() - name_label = ttk.Label( - self.record_frame, - text=field.display_name, - ) - name_label.grid(row=i, column=1, sticky="w", padx=10) - value_label = ttk.Label( - self.record_frame, - textvariable=var, - ) - value_label.grid(row=i, column=2, sticky="w", padx=5) - - if field.matchable: - matchbox = ttk.Checkbutton( - self.record_frame, - variable=matchvar, - ) - self.record_matchvars[field.name] = matchvar - else: - matchbox = None - - self.record_rows.append((matchbox, name_label, value_label)) - self.record_vars[field.name] = var - - def do_match_toggle(self): - value = self.match_var.get() - if value and self.record and self.txn: + if value and self.showing_record and self.showing_txn: # Enable match boxes for i, (box, _, _) in enumerate(self.txn_rows.values()): box.grid(row=i, column=0, sticky="ew", padx=5) @@ -189,22 +228,25 @@ class RowEditor(ttk.Frame): self.rule_button.configure(state="disabled") def do_save_txn(self): - if self.txn is None: - raise ValueError("Cannot save with no transaction") + if not self.rows: + raise ValueError("Cannot save with no rows displayed") - # Convert variables to dict of txn fields -> values - visible = [field.name for field in self.txn.display_fields()] - input_fields = { - name: var.get() for name, var in self.txn_vars.items() if name in visible - } + for _, txn in self.rows: + # Convert variables to dict of txn fields -> values + visible = [field.name for field in txn.display_fields()] + input_fields = { + name: var.get() + for name, var in self.txn_vars.items() + if name in visible and (var.get() or not self.showing_multiple) + } - # Pass to PartialTXN.parse_input - update_fields = self.txn.parse_input(input_fields) + # Pass to PartialTXN.parse_input + update_fields = txn.parse_input(input_fields) + + # Pass this to copy and update + txn.update(**update_fields) + # TODO: Not sure why prev version copied - # Pass this to copy and update - new_txn = self.txn.copy() - new_txn.update(**update_fields) - self.update_txn(new_txn) self.event_generate("<>") def do_create_rule(self): @@ -214,11 +256,15 @@ class RowEditor(ttk.Frame): def make_rule(self): if not self.match_var.get(): raise ValueError("Not in rule creation mode, cannot make rule.") - if self.record is None or self.txn is None: + if self.showing_multiple: + raise ValueError("Cannot create rule with multiple rows selected!") + if not self.rows: raise ValueError("Cannot create rule without record and transaction.") + record, txn = self.rows[0] + rule_record_fields = {} - record_match_values = self.record.match_fields() + record_match_values = record.match_fields() for field, var in self.record_matchvars.items(): if var.get(): rule_record_fields[field] = record_match_values[field] @@ -228,11 +274,59 @@ class RowEditor(ttk.Frame): for name, var in self.txn_vars.items() if self.txn_matchvars[name].get() } - update_fields = self.txn.parse_input(input_fields) + update_fields = txn.parse_input(input_fields) return (rule_record_fields, update_fields) - def setup_txn(self): + def _display_txns(self, txns: list[PartialTXN]): + if txns and not self.showing_txn: + # Layout the txnbox + self._layout_txnbox_for(txns[0]) + elif self.showing_txn and not txns: + # Show the placeholder + self._layout_txnbox_placeholder() + + # Compute the transaction fields to show + rawfields = [] + if len(txns) > 1: + # Resolve shared field values + # field name -> shared field value + resolved = {} + conflicting = set() + for txn in txns: + for field in txn.display_fields(): + if field.name in conflicting: + pass + elif field.name not in resolved: + resolved[field.name] = field.value + elif resolved[field.name] != field.value: + conflicting.add(field.name) + + for name, value in resolved.items(): + if name in conflicting: + value = "" + elif value is None: + value = "" + rawfields.append((name, str(value))) + elif txns: + fields = txns[0].display_fields() + rawfields = [(field.name, field.value) for field in fields] + + for name, value in rawfields: + self.txn_vars[name].set(value) + + def _make_account_entrybox(self, accountname: str, var: StringVar): + box = ttk.Combobox( + self.txn_frame, + textvariable=var, + values=list(self.acmpl_cache[accountname]), + postcommand=lambda: box.configure( + values=list(self.acmpl_cache[accountname]) + ), + ) + return box + + def _setup_txnbox(self): """ Reset and freshly layout the transaction fields. @@ -272,61 +366,59 @@ class RowEditor(ttk.Frame): self.txn_matchvars = txnmatching self.txn_vars = txnvars - def _make_account_entrybox(self, accountname: str, var: StringVar): - box = ttk.Combobox( - self.txn_frame, - textvariable=var, - values=list(self.acmpl_cache[accountname]), - postcommand=lambda: box.configure( - values=list(self.acmpl_cache[accountname]) - ), - ) - return box + def _layout_txnbox_placeholder(self): + """ + Display placeholder in transaction editor box. - def layout_txn(self, txn: PartialTXN | None): - # If the txn is None, hide all the labels and replace with placeholder - # If the txn is not None, re-grid the labels depending on whether the field is in the txn. - if txn is not None: - self.txn_placeholder.grid_remove() - self.save_button.configure(state="enabled") + Hides all the labels and shows placeholder text. + """ + for labels in self.txn_rows.values(): + for label in labels: + label.grid_remove() - txnfields = txn.display_fields() - visible = {field.name for field in txnfields} - for i, (field, (matchbox, keylabel, entrybox)) in enumerate( - self.txn_rows.items() - ): - # matchbox.grid(row=i, column=0, padx=5) - keylabel.grid(row=i, column=1, sticky="e", padx=3) - entrybox.grid(row=i, column=2, sticky="ew", padx=10) - if field not in visible: - # Make sure hidden fields are not included in the match - # Otherwise we will have unexpected behaviour with hidden/garbage data - self.txn_matchvars[field].set(False) - self.txn_vars[field].set("") - matchbox.configure(state="disabled") - # keylabel.grid_remove() - entrybox.configure(state="disabled") - else: - matchbox.configure(state="enabled") - entrybox.configure(state="enabled") - else: - for labels in self.txn_rows.values(): - for label in labels: - label.grid_remove() - self.txn_placeholder.grid(row=0, column=0, rowspan=3, sticky="ew") - self.save_button.configure(state="disabled") + self.txn_placeholder.grid(row=0, column=0, rowspan=3, sticky="ew") + self.save_button.configure(state="disabled") - def update_txn(self, txn: PartialTXN | None): - self.txn = txn - if txn is not None: - txnfields = txn.display_fields() - for field in txnfields: - self.txn_vars[field.name].set(field.value) - # if 'account' in field.name: - # self.txn_rows[field.name][2].set(field.value) - self.layout_txn(txn) + self.showing_txn = False - def set_row(self, record: Record | None, txn: PartialTXN | None): - self.update_record(record) - self.update_txn(txn) - self.do_match_toggle() + def _layout_txnbox_for(self, txn: PartialTXN): + """ + Grid the transaction labels for the given transactions. + """ + self.txn_placeholder.grid_remove() + self.save_button.configure(state="enabled") + + txnfields = txn.display_fields() + visible = {field.name for field in txnfields} + for i, (field, (matchbox, keylabel, entrybox)) in enumerate( + self.txn_rows.items() + ): + # matchbox.grid(row=i, column=0, padx=5) + keylabel.grid(row=i, column=1, sticky="e", padx=3) + entrybox.grid(row=i, column=2, sticky="ew", padx=10) + if field not in visible: + # Make sure hidden fields are not included in the match + # Otherwise we will have unexpected behaviour with hidden/garbage data + self.txn_matchvars[field].set(False) + self.txn_vars[field].set("") + matchbox.configure(state="disabled") + # keylabel.grid_remove() + entrybox.configure(state="disabled") + else: + matchbox.configure(state="enabled") + entrybox.configure(state="enabled") + + self.showing_txn = True + + def set_rows(self, rows: list[tuple[Record, PartialTXN]]): + # If switching to multi-select, turn off rules and disable + # If switching to single, enable rule button + # This is responsible for saving the rows + records, txns = zip(*rows) + self._display_records(records) + self._display_txns(txns) + + self.rows = rows + self._display_matchboxes() + + # def set_row(self, record: Record | None, txn: PartialTXN | None): diff --git a/src/beanify/gui/rowtree.py b/src/beanify/gui/rowtree.py index c563c0a..04aa6e8 100644 --- a/src/beanify/gui/rowtree.py +++ b/src/beanify/gui/rowtree.py @@ -225,12 +225,8 @@ class RowTree(ttk.Frame): command=self.tree.xview, ) - def get_selected_row(self) -> tuple[Record, PartialTXN] | None: - item = self.tree.selection()[0] - if item: - return self.items[item] - else: - return None + def get_selected_rows(self) -> list[tuple[Record, PartialTXN]]: + return [self.items[item] for item in self.tree.selection()] def wipe(self): self.tree.delete(*self.items.keys())