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

View File

@@ -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("<<RowUpdated>>")
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):

View File

@@ -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())