Add GUI interface

This commit is contained in:
2025-12-02 16:17:53 +10:00
parent d1dd81861c
commit 281f0c8c50
5 changed files with 1052 additions and 0 deletions

6
src/gui/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
import logging
logger = logging.getLogger(__name__)
from .mainwindow import MainWindow
from .rowtree import SortingTree, RowTree

297
src/gui/mainwindow.py Normal file
View File

@@ -0,0 +1,297 @@
import tkinter as tk
from tkinter import StringVar, filedialog, messagebox, ttk
from ttkthemes import ThemedTk
from base.converter import Converter
from base.partial import PartialTXN
from base.rules import Rule, RuleSet
from . import logger
from .roweditor import RowEditor
from .rowtree import RowTree
# TODO: Partial detection broken on target account for export?
class MainWindow(ThemedTk):
def __init__(self, beanconfig, converter: Converter, ruleset: RuleSet, initial_files=[], **kwargs):
super().__init__(**kwargs)
self.beanconfig = beanconfig
self.converter = converter
self.sample_record = converter.record_type.sample_record()
self.ruleset = ruleset
# Map of record -> PartialTXN
self.rows = {}
self.custom = set()
self.files = initial_files
self.account_cache = {}
self.sort_by = 0
self.rebuild_account_cache()
self.load_styles()
self.setup_menu()
self.setup_window()
self.initial_ingest()
def load_styles(self):
self.tk.eval("""
set base_theme_dir ../themes/awthemes-10.4.0/
package ifneeded awthemes 10.4.0 \
[list source [file join $base_theme_dir awthemes.tcl]]
package ifneeded colorutils 4.8 \
[list source [file join $base_theme_dir colorutils.tcl]]
package ifneeded awdark 7.12 \
[list source [file join $base_theme_dir awdark.tcl]]
""")
self.tk.call("package", "require", "awdark")
style = ttk.Style(self)
style.theme_use('awdark')
def setup_menu(self):
self.menubar = tk.Menu(self)
self['menu'] = self.menubar
menu_file = tk.Menu(self.menubar, tearoff=0)
menu_file.add_command(label="Ingest File", command=self.do_ingest_file)
menu_file.add_command(label="Export Transactions", command=self.do_export_txn)
menu_file.add_separator()
menu_file.add_command(label="Save Rules", command=self.do_save_rules)
menu_file.add_command(label="Reload Rules", command=self.do_save_rules)
menu_file.add_separator()
menu_file.add_command(label="Exit", command=lambda: self.destroy())
self.menubar.add_cascade(menu=menu_file, label="File")
menu_edit = tk.Menu(self.menubar, tearoff=0)
menu_edit.add_command(label="Edit Rules", command=self.do_edit_rules)
menu_edit.add_command(label="Edit Preferences", command=self.do_edit_preferences)
self.menubar.add_cascade(menu=menu_edit, label="Edit")
def setup_window(self):
style = ttk.Style(self)
# style.configure(
# 'Custom.TPanedWindow',
# sashrelief='ridge',
# sashthickness=2
# )
self.contentframe = ttk.Frame(self, padding=(3, 3, 6, 6), border=1, relief="ridge")
self.content = ttk.PanedWindow(
self.contentframe,
orient='horizontal',
# style='Custom.TPanedwindow',
)
self.rowtree = RowTree(self, base_record=self.sample_record, padding=(3, 3, 12, 12))
self.content.add(self.rowtree, weight=1)
self.editor = RowEditor(self, acmpl_cache=self.account_cache, padding=(3, 3, 12, 12))
self.content.add(self.editor, weight=1)
self.statusbar = tk.Frame(self, relief=tk.SUNKEN)
self.status_var_left = StringVar()
self.status_var_left.set("Loading...")
self.status_label_left = tk.Label(self.statusbar, textvariable=self.status_var_left)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.contentframe.grid(column=0, row=0, sticky='NSEW')
self.content.grid(column=0, row=0, sticky='NSEW')
self.statusbar.grid(row=1, column=0, sticky='ESW')
self.status_label_left.grid(row=0, column=0, sticky='E')
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
self.contentframe.rowconfigure(0, weight=1)
self.contentframe.columnconfigure(0, weight=1)
self.content.rowconfigure(0, weight=1)
self.content.columnconfigure(0, weight=1)
# self.editor.grid(sticky='NSEW')
# Better to have message passing up the chain?
# i.e. the frame passes up the select
self.rowtree.tree.bind('<<TreeviewSelect>>', self.row_selected)
self.editor.bind('<<RowUpdated>>', self.row_updated)
self.editor.bind('<<RuleCreated>>', self.rule_created)
def update_status(self, message=''):
self.status_var_left.set(message)
# TODO Add number of incomplete txns?
# Add record count?
# Add saved status?
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)
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})
# self.rowtree.update_this_row(self.editor.record, self.editor.txn)
self.rebuild_account_cache()
self.update_status("Custom transaction saved")
def rule_created(self, event):
logger.debug(f"Received RowEditor rulecreated event {event!r}")
conditions, values = self.editor.make_rule()
rule = Rule(conditions, values)
affected = sum(rule.check(record.match_fields()) for record in self.rows)
self.ruleset.add_rule(rule)
# Adding a rule means a full regeneration
for record in self.rows:
if record not in self.custom:
txn = self.converter.convert(record, self.ruleset)
self.rows[record] = txn
# Tell the table to regenerate
self.rowtree.update_rows(self.rows)
self.rebuild_account_cache()
self.update_status(f"Rule created! {affected} transactions affected.")
def initial_ingest(self):
if self.files:
rows = {}
for path in self.files:
rows |= self._ingester(path)
# TODO: Error handling
self.rows = rows
self.rowtree.update_rows(rows)
self.show_ingest_summary(self.files, self.rows)
self.rebuild_account_cache()
def do_ingest_file(self):
# Prompt for file to ingest
files = filedialog.askopenfilenames(
defaultextension=".csv",
filetypes=[("CSV Files", ".csv"), ("All Files", "*.*")]
)
rows = {}
for file in files:
rows |= self._ingester(file)
self.rows |= rows
self.rowtree.update_rows(rows)
self.show_ingest_summary(files, rows)
self.rebuild_account_cache()
def do_save_rules(self):
self.ruleset.save_rules()
self.update_status("Rules saved!")
# TODO: Feedback and confirmation
def do_export_txn(self):
# TODO: Export options
# TODO: Replace fields with defaults
upgraded = []
for partialtxn in self.rows.values():
if partialtxn.partial:
messagebox.showerror(
"Export Error",
"Cannot export while some transactions are still incomplete!",
)
return
txn = partialtxn.upgrade()
upgraded.append(txn)
path = filedialog.asksaveasfilename(
defaultextension=".ledger",
filetypes=[
("Beancount Ledger", ".ledger"),
("All Files", "*.*"),
]
)
if path:
with open(path, 'w') as f:
for txn in upgraded:
f.write(str(txn))
f.write('\n\n')
message = f"Exported {len(upgraded)} transactions to {path}"
else:
message = "Export cancelled, no transactions exported"
self.update_status(message)
def do_edit_rules(self):
# TODO: Rule viewer and editor
...
def do_edit_preferences(self):
# TODO: Separate preferences modal
...
def show_ingest_summary(self, files, rows):
"""
Display a small dialogue with a confirmation of the ingested rows.
"""
if files:
partialn = sum(txn.partial for txn in rows.values())
totaln = len(rows)
message = f"Imported records from {len(files)} files. {partialn}/{totaln} records missing account data"
self.update_status(message)
def _ingester(self, path):
"""
Read the provided path and return the parsed rows
as Record -> PartialTXN associations.
"""
records = self.converter.ingest_file(path)
rows = {record: self.converter.convert(record, self.ruleset) for record in records}
return rows
def rebuild_account_cache(self):
"""
Build the 'account name cache',
i.e. the map of tx.field -> list[options]
used for acmpl on entry.
"""
# Get all the account field names
# Grab the value of each of these for all the rows we have
# Grab the value of each of these for all the rules in the ruleset
# Merge into a map, and update the cached map with it.
# Build the list of account names we want to acmpl
field_names = list(PartialTXN.posting_fields.values())
cache = {name: set() for name in field_names}
# Read the ruleset rules for options
for rule in self.ruleset.rules:
for name, namecache in cache.items():
if name in rule.values:
namecache.add(rule.values[name])
# Read the transaction rows for options
for txn in self.rows.values():
for name, namecache in cache.items():
value = getattr(txn, name)
if value is not None:
namecache.add(value)
self.account_cache.clear()
self.account_cache |= cache
def do_reapply_rules(self):
...

145
src/gui/notes.md Normal file
View File

@@ -0,0 +1,145 @@
# Bean-Ingest GUI notes
Planning to write a simple GUI for the ingest script using TK and TTK. This is my first time using the framework for anything non-trivial, so let's see how it works.
## Tasks
- [ ] Root singleton for state
- [ ] Config var paths (configuration variables, arguments, selection menus)
- [ ] Root window
- [ ] Record/Transaction edit window (or attach to root)
- [ ] Import path, incl import stats window
- [ ] Export path, incl export options window
# Scratchpad for views and paths
## Setup/config paths
## Root Window
- Display scrollable list of current Record/TXN pairs
- Requires short/summary string for Record and TXN
- Show annotation string as well, if it exists?
- Should either be custom-sortable, or sorted by (partial, date)
- Or ability to only show partials/filter?
- Short path to select a pair and edit (probably literally selecting, or a button next to them)
- Short path to import record file
- Short path to export
- Path to *edit* config (lower priority)
- Path to rebuild from rules
- Note this does not change manually edited TXNs, which are kept track of in either an override dict, or in a set of records.
- We do *not* automatically rebuild when we create a new rule.
## Record/TXN edit
- Path to create a rule from Record data
- Show record fields
- The Record subclass should be responsible for the format I think
- So a method which returns the dictionary of fields to display? With display names, whether they are includeable in the rule, values, etc? Then leave the actual display up to the UI. Record fields aren't editable from the user side so that's less complexity (needing a validitator, type of field/selection, values etc..). Or make the field display a class-var. Or both. Class-var to describe the fields, and a method with a default but overrideable implementation that uses that classvar. Just need a list of tuples in the classvar, and a namedtuple RecordField to be returned. A list of them.
- Show partialTXN fields
- This is more complex to control the formatting of, since we need to support input as well.
- However, the PartialTXN is not intended to be subclassed, so we don't have to give the class control over the format if we don't want to. That is, we can essentially hard-code the input format and parsing into whatever is handling the UI.
Is it worth having a 'show rule creation' toggle? Which shows the selection toggles for the fields, and other toggles such as rebuild immediately (next to the create rule button probable).
- Two ways we could do this practically (well, at least two). We rebuild/re-render the editor with the toggle, or we just hide or display certain UI elements which have space reserved for them already. Which we choose probably depends on how much the rule UI adds to the RowEditor UI.
RowEditor(record, partial_txn, show_rule: bool)
- record_fields = build_record_fields(record)
- txn_fields = build_txn_fields(txn)
- txn_vars = build_txn_vars(txn)
- These are technically bound to the UI frame I think
- So should maybe be created in render_into
- And destroyed.. wherever this is destroyed.
- rule_selections_record = {field -> var}
- rule_selections_txn = {field -> var}
- visible: bool
- frame
- render_into(window)
- toggle_ruleui()
- create_rule_from_selected() -> Rule
- save_txn() -> PartialTXN
- On the root level, everything is indexed against the hash of the record, since it is hashable.
- This parses the user-entered fields (with validation and error-pathway, e.g. a pop-up or dedicated space for an error message), and creates a new PartialTXN combining them with the initial txn.
- Why not just override? I don't have a solid reason, it just feels more extensible to copy-update-override. I suppose it permits e.g. an undo stack, or a simple reset. Although we could just run the parser on the original record again since we don't change that.
- A bunch of callback methods for the render.
### Idea for rule creation
Selection toggles (e.g. checkboxes or dropdown multi-select or drag-drop etc) on both Record fields and the usable TXN fields (payee, narration, account name, etc).
"Create Rule From Selection" button which creates a rule associating the selected Record fields with the selected TXN fields. Possibly through confirmation.
Possibly persistent binary toggle to enable showing selection/Rule related UI.
Show edited/unsaved fields with distinct marker? E.g. colour?
Field validation?
Rules should be createable from a UUID or hash of the record
## Export Window
- Toggle for whether to include the raw record as a comment
- Default fields to use for partial TXNs?
# Refactor notes
## Tasklist
- [ ] Remove flexibility in RowEditor for changing Record fields (assume record fields remain constant).
- [ ] Documentation!
- [ ] Better handling of default placeholers
### UI components
- [ ] ! Export Window, with auto-fill options, and toggles such as whether to include raw as comment
- [ ] ! Rule edit or at least remove and reorder pathway
- [ ] ! Minimal config editor
- [ ] ! Combobox subclass which pops open and shows matching acmpl options?
- [ ] ! Initial popups to select converter and rules
- [ ] Key shortcuts, with a help listing for them
- [ ] Flag should just be a radio button instead
- [ ] Make TXN save grey until actually edited
- [ ] Make TXN reset grey until custom
- [ ] Tree column configuration should be persistent in config? At least what is shown/hidden, for this converter.
- [ ] Exit warning on unsaved rules or transactions
- [ ] ! Move ingest info to status bar
- [ ] ! Move rule creation and update info to status bar
## Scratchpad
Move Record and TXN editor out to their own Frame classes?
Optimally the UI classes would only display and report
We could move the actual data/state to an essentially singleton class and have the frames manipulate that.
Alternatively we could have the frames emit events which are handled by the root window which manipulates the data from callbacks, and updates children from those events.
It does make sense for, say, a RecordViewer to only know about its record, and be queriable for the match boxes selected, for example.
Similarly, the TxnEditor only needs to store its PartialTXN. It does need the account cache as well, but that can be passed in and shared. It needs to be queried for the updated TXN and the match rules.
RowEditor does what then? It has the row we are editing, if it exists, it holds the Record and TXN editor/viewers, and it has some UI elements such as the rule view toggle, and the buttons to create a rule or save the txn or reset it. These buttons need to send signals which trigger updates to the rowtree. This could be via updating a state object which other UI objects such as rowtree are listening to. Or we message-pass up to our parent who asks us for the updates and sends them to the rowtree itself.
RowTree doesn't have any edits which it would transmit.
On the other hand, Rule editing also emits updates when a rule is edited or rules are re-ordered.
If rules are compact enough they *could* be shown on the left, but probably better to use tabs. We could also pop them up in a separate window.
A note on Record viewing.. the Record viewer could save the raw fields and field data only. That way we can re-use it for rule viewing without creating a dummy Record. TXN editing needs the TXN though, ironically.
Status bar notes:
- When rule is created, can note how many rows are affected
- In response to e.g. TXN saved, can note that
- Can possibly replace the popup for ingest
Rule viewer.. we could use the tree view as it is supposed to be used? Each rule is one top level node? Might be cumbersome though.
I am disinclined to use the same tree layout because it will hide many fields of the rule, and most rules are very simple. The rules can't be sorted in the same way either because they have a definite application order. The rules could be written on single text lines or with two columns and dragging/sorting handles on the edge?
| Direction = OUT and Source Account = Foodworks => source_account = Expenses:Home:Groceries
We could do a multiline view?
| Direction: OUT | source_account: Expenses:Home:Groceries
| Source Account: Foodworks |
The relative shortness of rules works with us there because each line is of a manageable height. Might look clunky in practice though.
NOTE:: Consider https://github.com/ragardner/tksheet as a neat replacement for the rowtree. It supports drag and drop of rows so it may be a solid candidate for the rule editor as well.
Also consider document importing.. if we make some strong assumptions it isn't too hard.

326
src/gui/roweditor.py Normal file
View File

@@ -0,0 +1,326 @@
from tkinter import BooleanVar, StringVar, ttk
from base.partial import PartialTXN
from base.record import Record
from . import logger
class RowEditor(ttk.Frame):
def __init__(self, master, acmpl_cache={}, **kwargs):
super().__init__(master, **kwargs)
# Data state
self.record: Record | None = None
self.txn: PartialTXN | None = None
self.acmpl_cache: dict[str, list[str]] = acmpl_cache
# UI State
self.showing_ruleui = False
# UI Elements
self.record_vars: dict[str, StringVar] = {}
self.record_placeholder: ttk.Label | None = None
self.record_rows: list[tuple[ttk.Checkbutton | None, ttk.Label, ttk.Label]] = []
self.record_matchvars: dict[str, BooleanVar] = {}
self.txn_matchvars: dict[str, BooleanVar] = {}
self.txn_vars: dict[str, StringVar] = {}
self.txn_rows: dict[str, tuple[ttk.Checkbutton, ttk.Label, ttk.Entry]] = {}
self.layout()
def layout(self):
self.columnconfigure(0, weight=0)
self.columnconfigure(1, weight=1)
# self.rowconfigure(2, weight=1)
self.rowconfigure(3, weight=1)
self.configure(relief='sunken')
self.record_frame = ttk.LabelFrame(self, text="Record")
self.txn_frame = ttk.LabelFrame(self, text="Bean Transaction")
self.record_frame.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky='nsew')
self.record_frame.columnconfigure(2, weight=1)
self.txn_frame.grid(row=2, column=0, columnspan=2, padx=5, pady=5, sticky='nsew')
self.txn_frame.columnconfigure(2, weight=1)
self.match_var = BooleanVar()
self.match_toggle = ttk.Checkbutton(self, variable=self.match_var, command=self.do_match_toggle)
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_label.grid(row=0, column=1, padx=7, pady=10, sticky='nw')
self.button_frame = ttk.Frame(self, padding=(2, 2, 3, 3))
self.button_frame.grid(row=3, column=0, columnspan=2, sticky='s')
self.save_button = ttk.Button(self.button_frame, text="Save Transaction", command=self.do_save_txn)
self.save_button.grid(row=0, column=0, padx=20)
self.rule_button = ttk.Button(self.button_frame, text="Create Rule", command=self.do_create_rule)
self.rule_button.grid(row=0, column=1, padx=20)
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_txn()
self.layout_txn(self.txn)
self.do_match_toggle()
# 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)
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)
self.record = record
def layout_record(self, record: Record | None):
"""
Layout a record into the record frame.
"""
# Clear the variables and labels
self.record_vars.clear()
self.record_matchvars.clear()
for rowlabels in self.record_rows:
for label in rowlabels:
if label:
label.destroy()
# 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
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."
)
label.grid(row=0, column=0, columnspan=2, sticky='w')
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()
# 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:
# Enable match boxes
for i, (box, _, _) in enumerate(self.txn_rows.values()):
box.grid(row=i, column=0, sticky='ew', padx=5)
for i, (box, _, _) in enumerate(self.record_rows):
if box:
box.grid(row=i, column=0, sticky='ew', padx=5)
self.rule_button.configure(state="enabled")
else:
# Disable match boxes
for i, (box, _, _) in enumerate(self.txn_rows.values()):
box.grid_remove()
for i, (box, _, _) in enumerate(self.record_rows):
if box:
box.grid_remove()
# Also disable rule button
self.rule_button.configure(state="disabled")
def do_save_txn(self):
if self.txn is None:
raise ValueError("Cannot save with no transaction")
# 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}
# Pass to PartialTXN.parse_input
update_fields = self.txn.parse_input(input_fields)
# 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):
# Signal parent to create rule and rebuild
self.event_generate('<<RuleCreated>>')
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:
raise ValueError("Cannot create rule without record and transaction.")
rule_record_fields = {}
record_match_values = self.record.match_fields()
for field, var in self.record_matchvars.items():
if var.get():
rule_record_fields[field] = record_match_values[field]
input_fields = {
name: var.get()
for name, var in self.txn_vars.items()
if self.txn_matchvars[name].get()
}
update_fields = self.txn.parse_input(input_fields)
return (rule_record_fields, update_fields)
def setup_txn(self):
"""
Reset and freshly layout the transaction fields.
TODO: Actual transaction preview somewhere? On keystroke even?
TODO: Selected fields for Rule should not be reset between records.
TODO: Warning icon and error? Change background colour on fields which don't validate?
"""
self.txn_placeholder = ttk.Label(
self.txn_frame,
text="Select a row to view txn details."
)
# The txn display fields will always be a subset of this
fieldnames = PartialTXN.fields
# field -> stringvar
txnvars = {name: StringVar() for name in fieldnames}
# field -> boolvar
txnmatching = {name: BooleanVar() for name in fieldnames}
# field -> (match box, key label, value entrybox)
txnrows = {}
for name, dname in (fieldnames.items()):
matchbox = ttk.Checkbutton(self.txn_frame, variable=txnmatching[name])
keylabel = ttk.Label(self.txn_frame, text=dname)
if name in self.acmpl_cache:
entrybox = self._make_account_entrybox(name, txnvars[name])
else:
entrybox = ttk.Entry(
self.txn_frame,
textvariable=txnvars[name]
)
# TODO!: If we have a list of accounts, we could use a ComboBox here instead? For autocompletion.
# TODO!: Key shortcuts for prev/next record, and hints on the buttons
# TODO!: Radio Buttons on the flag
txnrows[name] = (matchbox, keylabel, entrybox)
# TODO: Labels for the preview? The annotation? The errors?
self.txn_rows = txnrows
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_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')
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')
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)
def set_row(self, record: Record | None, txn: PartialTXN | None):
self.update_record(record)
self.update_txn(txn)
self.do_match_toggle()

278
src/gui/rowtree.py Normal file
View File

@@ -0,0 +1,278 @@
import tkinter as tk
from datetime import datetime, date
from functools import partial
from tkinter import BooleanVar, ttk
from base.partial import PartialTXN
from base.record import Record
from base.transaction import Amount
from . import logger
def datetime_sort_key(datestr: datetime | str):
value = datetime.fromtimestamp(0)
if isinstance(datestr, (datetime, date)):
value = datestr
elif isinstance(datestr, str):
if datestr:
value = datetime.strptime(datestr, "%Y-%m-%d %H:%M:%S")
return value
def date_sort_key(datestr: date | str):
value = datetime.fromtimestamp(0).date()
if isinstance(datestr, date):
value = datestr
elif isinstance(datestr, str):
if datestr:
value = datetime.strptime(datestr, "%Y-%m-%d").date()
return value
def amount_sort_key(amount: Amount | str):
if isinstance(amount, Amount):
value = (amount.currency, amount.value)
elif amount:
amountstr, currency = amount.strip().split()
value = (currency, float(amountstr))
else:
value = ('', 0)
return value
class SortingTree(ttk.Treeview):
"""
Treeview with column sorting and column selection
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.bind('<Button-3>', self.handle_rightclick)
self.showvars = {col: BooleanVar() for col in self['columns']}
for var in self.showvars.values():
# Default columns to on
var.set(True)
for col in self['columns']:
self.column(col, stretch=True)
# self.set_columns(self['columns'])
@property
def heading_columns(self):
heading_map = {}
for col in self["columns"]:
heading_map[self.heading(col)['text']] = col
return heading_map
def reset_column_sizes(self):
displayed = self['displaycolumns']
max_widths = [0] * len(displayed)
fontname = ttk.Style(self).lookup('TLabel', "font")
# treefont = font.nametofont(fontname)
# treefont = font.Font(family="TkDefaultFont")
magic = 7
for child in self.get_children(''):
for (i, col) in enumerate(displayed):
data = self.set(child, col)
# length = treefont.measure(str(data))
length = len(str(data)) * magic
max_widths[i] = max(max_widths[i], length)
for i, col in enumerate(displayed):
width = max(max_widths[i], 50) + 50
self.column(col, width=width)
def heading(self, column, option=None, *, sort_key=None, **kwargs):
if sort_key and not hasattr(kwargs, 'command'):
command = partial(self._sort, column, False, sort_key)
kwargs['command'] = command
return super().heading(column, option, **kwargs)
def set_columns(self, columns: list[str]):
self['displaycolumns'] = columns
for col, var in self.showvars.items():
var.set(col in columns)
def _sort(self, column, reverse, key):
l = [(self.set(k, column), k) for k in self.get_children('')]
l.sort(key=lambda t: key(t[0]), reverse=reverse)
for index, (_, k) in enumerate(l):
self.move(k, '', index)
self.heading(column, command=partial(self._sort, column, not reverse, key))
def handle_rightclick(self, event):
logger.debug(f"Received right click on SortingTree: {event!r}")
region = self.identify_region(event.x, event.y)
logger.debug(f"REGION: {region}")
if region == 'heading':
self.do_column_select(event)
def do_column_select(self, event):
# Popup a right click menu at the event location with a list of selectable columns
logger.debug("Creating column selection menu for SortingTree")
menu = tk.Menu(self, tearoff=1)
for heading, column in self.heading_columns.items():
menu.add_checkbutton(variable=self.showvars[column], label=heading, command=self._show_columns)
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _show_columns(self):
columns = [col for col, var in self.showvars.items() if var.get()]
self.set_columns(columns)
self.reset_column_sizes()
class RowTree(ttk.Frame):
def __init__(self, master, base_record: Record, rows={}, **kwargs):
super().__init__(master, **kwargs)
# The base record is used as a template for the column display
self.base_record = base_record
self.make_tree()
self.items: dict[str, tuple[Record, PartialTXN]] = {}
self.record_items: dict[Record, str] = {}
self.update_rows(rows)
self.sort_by = 0
self.layout()
def generate_columns(self, record: Record,):
record_fields = record.display_fields()
columns = {
'record.date': ("Date", date_sort_key),
'record.from_source': ("Amount (from source)", amount_sort_key),
}
for field in record_fields:
name = f"record.{field.name}"
value = getattr(record, field.name)
if isinstance(value, Amount):
sorter = amount_sort_key
elif isinstance(value, datetime):
sorter = datetime_sort_key
else:
sorter = str
columns[name] = (field.display_name, sorter)
columns |= {
'txn.flag': ("Bean Status", str),
'txn.payee': ("Bean Payee", str),
'txn.narration': ("Bean Narration", str),
'txn.comment': ("Bean Comment", str),
'txn.document': ("Bean Document", str),
'txn.tags': ("Bean Tags", str),
'txn.links': ("Bean Links", str),
'txn.source_account': ("Bean Source", str),
'txn.source_fee_asset_account': ("Bean Source Fee Asset Acc", str),
'txn.source_fee_expense_account': ("Bean Source Fee Expense Acc", str),
'txn.target_account': ("Bean Target", str),
'txn.target_fee_expense_account': ("Bean Target Fee Acc", str),
}
return columns
def make_tree(self):
self.columns = self.generate_columns(self.base_record)
initially_enabled = [
'record.date',
'record.source_account',
'record.target_account',
'record.from_source',
'txn.source_account',
'txn.target_account',
]
self.tree = SortingTree(
self,
columns=tuple(self.columns.keys()),
show='headings',
)
for col, (dname, sort_key) in self.columns.items():
self.tree.heading(col, text=dname, sort_key=sort_key)
self.tree.set_columns(initially_enabled)
def layout(self):
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
self.tree.grid(row=0, column=0, sticky='NSEW')
self.tree.rowconfigure(0, weight=1)
self.tree.columnconfigure(0, weight=1)
self.scrollyframe = ttk.Frame(self, relief='groove')
self.scrolly = ttk.Scrollbar(
self,
orient='vertical',
command=self.tree.yview,
)
# self.scrollyframe.grid(row=0, column=1, sticky='NS')
self.scrolly.grid(row=0, column=1, sticky='NS')
self.tree.configure(yscrollcommand=self.scrolly.set)
self.scrollx = ttk.Scrollbar(
self,
orient='horizontal',
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 wipe(self):
self.tree.delete(*self.items.keys())
self.items.clear()
logger.debug("Wiped the row tree.")
def update_rows(self, rows: dict[Record, PartialTXN]):
# self.wipe()
added = 0
updated = 0
for record, txn in rows.items():
if record in self.record_items:
itemid = self.record_items[record]
self.tree.item(
itemid,
values=self.row_values(record, txn)
)
updated += 1
else:
itemid = self.tree.insert(
parent='',
index='end',
values=self.row_values(record, txn)
)
self.record_items[record] = itemid
added += 1
self.items[itemid] = (record, txn)
logger.debug(f"Added {added} and updated {updated} rows in the RowTree.")
def update_this_row(self, record, txn):
item = self.tree.selection()[0]
self.tree.item(item, values=self.row_values(record, txn))
def row_values(self, record, txn):
values = []
for col in self.columns:
match col.split('.', maxsplit=1):
case ["txn", "flag"]:
value = txn.flag.value
case ["txn", field]:
value = getattr(txn, field)
case ["record", "date"]:
value = record.date.strftime('%Y-%m-%d')
case ["record", field]:
value = getattr(record, field)
# print(f"{col=} {value=} {type(value)=}")
case _:
raise ValueError(f"Unexpected column {col}")
value = value if value is not None else ''
values.append(value)
return values