Add GUI interface
This commit is contained in:
6
src/gui/__init__.py
Normal file
6
src/gui/__init__.py
Normal 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
297
src/gui/mainwindow.py
Normal 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
145
src/gui/notes.md
Normal 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
326
src/gui/roweditor.py
Normal 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
278
src/gui/rowtree.py
Normal 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user