Add multi-edit capability to roweditor

This commit is contained in:
2026-01-06 01:04:50 +10:00
parent 6ad7c0362a
commit 9efaac88e6
3 changed files with 250 additions and 163 deletions

View File

@@ -150,18 +150,16 @@ class MainWindow(ThemedTk):
def row_selected(self, event): def row_selected(self, event):
logger.debug(f"Received RowTree selection event {event!r}") logger.debug(f"Received RowTree selection event {event!r}")
row = self.rowtree.get_selected_row() rows = self.rowtree.get_selected_rows()
if row: self.editor.set_rows(rows)
self.editor.set_row(*row)
else:
# TODO: Better managment on this.
self.editor.set_row(None, None)
def row_updated(self, event): def row_updated(self, event):
logger.debug(f"Received RowEditor update event {event!r}") logger.debug(f"Received RowEditor update event {event!r}")
self.custom.add(self.editor.record) fresh = dict(self.editor.rows)
self.rows[self.editor.record] = self.editor.txn self.custom.update(fresh.keys())
self.rowtree.update_rows({self.editor.record: self.editor.txn}) self.rows.update(fresh)
self.rowtree.update_rows(fresh)
# self.rowtree.update_this_row(self.editor.record, self.editor.txn) # self.rowtree.update_this_row(self.editor.record, self.editor.txn)
self.rebuild_account_cache() self.rebuild_account_cache()
@@ -219,6 +217,7 @@ class MainWindow(ThemedTk):
self.ruleset.save_rules() self.ruleset.save_rules()
self.update_status("Rules saved!") self.update_status("Rules saved!")
# TODO: Feedback and confirmation # TODO: Feedback and confirmation
# TODO Raw dump if we can't save for some reason
def do_export_txn(self): def do_export_txn(self):
# TODO: Export options # TODO: Export options

View File

@@ -12,12 +12,13 @@ class RowEditor(ttk.Frame):
super().__init__(master, **kwargs) super().__init__(master, **kwargs)
# Data state # Data state
self.record: Record | None = None self.rows: list[tuple[Record, PartialTXN]] = []
self.txn: PartialTXN | None = None
self.acmpl_cache: dict[str, list[str]] = acmpl_cache self.acmpl_cache: dict[str, list[str]] = acmpl_cache
# UI State # UI State
self.showing_ruleui = False self.showing_ruleui = False
self.showing_record = False
self.showing_txn = False
# UI Elements # UI Elements
self.record_vars: dict[str, StringVar] = {} self.record_vars: dict[str, StringVar] = {}
@@ -31,6 +32,10 @@ class RowEditor(ttk.Frame):
self.layout() self.layout()
@property
def showing_multiple(self):
return len(self.rows) > 1
def layout(self): def layout(self):
self.columnconfigure(0, weight=0) self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1) self.columnconfigure(1, weight=1)
@@ -54,7 +59,7 @@ class RowEditor(ttk.Frame):
self.match_var = BooleanVar() self.match_var = BooleanVar()
self.match_toggle = ttk.Checkbutton( 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_label = ttk.Label(self, text="Rule creation mode")
self.match_toggle.grid(row=0, column=0, padx=11, pady=10, sticky="nw") 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 = ttk.Button(self.button_frame, text="Reset TXN")
self.reset_button.grid(row=0, column=2, padx=20) self.reset_button.grid(row=0, column=2, padx=20)
self.layout_record(self.record) self._setup_txnbox()
self.update_record(self.record) self._display_txns([])
self._display_records([])
self.setup_txn() self._display_matchboxes()
self.layout_txn(self.txn)
self.do_match_toggle()
# Buttons below # Buttons below
def update_record(self, record: Record | None): def _display_records(self, records: list[Record]):
""" # Flow and fill recordbox for the given records
Update the saved record, or clear it. if records and not self.showing_record:
""" # Need to reflow record box
if (self.record is None) != (record is None): self._layout_recordbox_for(records[0])
# Changing record states elif self.showing_record and not records:
self.layout_record(record) # Need to display placeholder
self._layout_recordbox_placeholder()
if record is not None: # Fill record box
# Update the record variables # First compute fields to show
relayout = False 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(): for field in record.display_fields():
var = self.record_vars.get(field.name) 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 = "- 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]
# Then update variables
for name, value in rawfields:
var = self.record_vars.get(name)
if var is None: if var is None:
relayout = True raise ValueError(f"Missing UI variable for record field {name}")
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)
self.record = record var.set(str(value if value is not None else "-"))
def layout_record(self, record: Record | None): def _cleanup_recordbox(self):
""" """
Layout a record into the record frame. Clear record variables and destroy UI elements inside.
""" """
# Clear the variables and labels # Clear the variables and labels
self.record_vars.clear() self.record_vars.clear()
@@ -120,23 +142,30 @@ class RowEditor(ttk.Frame):
for label in rowlabels: for label in rowlabels:
if label: if label:
label.destroy() label.destroy()
if self.record_placeholder is not None:
self.record_placeholder.grid_remove()
# If the record is None, show the placeholder def _layout_recordbox_placeholder(self):
# If the record is not None, hide the placeholder """
# And build the record vars and labels and grid them Put a placeholder into the recordbox, cleaning up any existing labels.
"""
self._cleanup_recordbox()
if record is None:
# Show placeholder label # Show placeholder label
if (label := self.record_placeholder) is None: if (label := self.record_placeholder) is None:
label = self.record_placeholder = ttk.Label( label = self.record_placeholder = ttk.Label(
self.record_frame, text="Select a row to view record details." self.record_frame, text="Select a row to view record details."
) )
label.grid(row=0, column=0, columnspan=2, sticky="w") label.grid(row=0, column=0, columnspan=2, sticky="w")
else: self.showing_record = False
# Hide placeholder if it exists
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}") logger.debug(f"Laying out {record!r}")
if self.record_placeholder is not None:
self.record_placeholder.grid_remove()
# Build record vars and labels # Build record vars and labels
fields = record.display_fields() fields = record.display_fields()
@@ -166,9 +195,19 @@ class RowEditor(ttk.Frame):
self.record_rows.append((matchbox, name_label, value_label)) self.record_rows.append((matchbox, name_label, value_label))
self.record_vars[field.name] = var self.record_vars[field.name] = var
def do_match_toggle(self): 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:
value = self.match_var.get() value = self.match_var.get()
if value and self.record and self.txn: self.match_toggle.configure(state="enabled")
if value and self.showing_record and self.showing_txn:
# Enable match boxes # Enable match boxes
for i, (box, _, _) in enumerate(self.txn_rows.values()): for i, (box, _, _) in enumerate(self.txn_rows.values()):
box.grid(row=i, column=0, sticky="ew", padx=5) box.grid(row=i, column=0, sticky="ew", padx=5)
@@ -189,22 +228,25 @@ class RowEditor(ttk.Frame):
self.rule_button.configure(state="disabled") self.rule_button.configure(state="disabled")
def do_save_txn(self): def do_save_txn(self):
if self.txn is None: if not self.rows:
raise ValueError("Cannot save with no transaction") raise ValueError("Cannot save with no rows displayed")
for _, txn in self.rows:
# Convert variables to dict of txn fields -> values # Convert variables to dict of txn fields -> values
visible = [field.name for field in self.txn.display_fields()] visible = [field.name for field in txn.display_fields()]
input_fields = { input_fields = {
name: var.get() for name, var in self.txn_vars.items() if name in visible 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 # Pass to PartialTXN.parse_input
update_fields = self.txn.parse_input(input_fields) update_fields = txn.parse_input(input_fields)
# Pass this to copy and update # Pass this to copy and update
new_txn = self.txn.copy() txn.update(**update_fields)
new_txn.update(**update_fields) # TODO: Not sure why prev version copied
self.update_txn(new_txn)
self.event_generate("<<RowUpdated>>") self.event_generate("<<RowUpdated>>")
def do_create_rule(self): def do_create_rule(self):
@@ -214,11 +256,15 @@ class RowEditor(ttk.Frame):
def make_rule(self): def make_rule(self):
if not self.match_var.get(): if not self.match_var.get():
raise ValueError("Not in rule creation mode, cannot make rule.") 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.") raise ValueError("Cannot create rule without record and transaction.")
record, txn = self.rows[0]
rule_record_fields = {} rule_record_fields = {}
record_match_values = self.record.match_fields() record_match_values = record.match_fields()
for field, var in self.record_matchvars.items(): for field, var in self.record_matchvars.items():
if var.get(): if var.get():
rule_record_fields[field] = record_match_values[field] rule_record_fields[field] = record_match_values[field]
@@ -228,11 +274,59 @@ class RowEditor(ttk.Frame):
for name, var in self.txn_vars.items() for name, var in self.txn_vars.items()
if self.txn_matchvars[name].get() 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) 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. Reset and freshly layout the transaction fields.
@@ -272,21 +366,25 @@ class RowEditor(ttk.Frame):
self.txn_matchvars = txnmatching self.txn_matchvars = txnmatching
self.txn_vars = txnvars self.txn_vars = txnvars
def _make_account_entrybox(self, accountname: str, var: StringVar): def _layout_txnbox_placeholder(self):
box = ttk.Combobox( """
self.txn_frame, Display placeholder in transaction editor box.
textvariable=var,
values=list(self.acmpl_cache[accountname]),
postcommand=lambda: box.configure(
values=list(self.acmpl_cache[accountname])
),
)
return box
def layout_txn(self, txn: PartialTXN | None): Hides all the labels and shows placeholder text.
# 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. for labels in self.txn_rows.values():
if txn is not None: 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.showing_txn = False
def _layout_txnbox_for(self, txn: PartialTXN):
"""
Grid the transaction labels for the given transactions.
"""
self.txn_placeholder.grid_remove() self.txn_placeholder.grid_remove()
self.save_button.configure(state="enabled") self.save_button.configure(state="enabled")
@@ -309,24 +407,18 @@ class RowEditor(ttk.Frame):
else: else:
matchbox.configure(state="enabled") matchbox.configure(state="enabled")
entrybox.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")
def update_txn(self, txn: PartialTXN | None): self.showing_txn = True
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)
def set_row(self, record: Record | None, txn: PartialTXN | None): def set_rows(self, rows: list[tuple[Record, PartialTXN]]):
self.update_record(record) # If switching to multi-select, turn off rules and disable
self.update_txn(txn) # If switching to single, enable rule button
self.do_match_toggle() # 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):

View File

@@ -225,12 +225,8 @@ class RowTree(ttk.Frame):
command=self.tree.xview, command=self.tree.xview,
) )
def get_selected_row(self) -> tuple[Record, PartialTXN] | None: def get_selected_rows(self) -> list[tuple[Record, PartialTXN]]:
item = self.tree.selection()[0] return [self.items[item] for item in self.tree.selection()]
if item:
return self.items[item]
else:
return None
def wipe(self): def wipe(self):
self.tree.delete(*self.items.keys()) self.tree.delete(*self.items.keys())