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