Add multi-edit capability to roweditor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user