ledgerkit-editor 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ledgerkit_editor/__init__.py +5 -0
- ledgerkit_editor/__main__.py +6 -0
- ledgerkit_editor/app.py +220 -0
- ledgerkit_editor/commands/__init__.py +84 -0
- ledgerkit_editor/highlighting/__init__.py +17 -0
- ledgerkit_editor/highlighting/highlighter.py +407 -0
- ledgerkit_editor/highlighting/theme_bridge.py +98 -0
- ledgerkit_editor/highlighting/tokens.py +37 -0
- ledgerkit_editor/keybindings/__init__.py +14 -0
- ledgerkit_editor/keybindings/emacs_ledger.py +110 -0
- ledgerkit_editor/keybindings/office.py +84 -0
- ledgerkit_editor/themes/__init__.py +59 -0
- ledgerkit_editor/themes/monokai_pro.py +110 -0
- ledgerkit_editor/utils/__init__.py +7 -0
- ledgerkit_editor/utils/atomic_edit.py +51 -0
- ledgerkit_editor/utils/commodity_format.py +299 -0
- ledgerkit_editor/utils/date_parser.py +115 -0
- ledgerkit_editor/utils/file_resolver.py +51 -0
- ledgerkit_editor/utils/ledger_io.py +269 -0
- ledgerkit_editor/widgets/__init__.py +7 -0
- ledgerkit_editor/widgets/filter_popup.py +83 -0
- ledgerkit_editor/widgets/ledger_textarea.py +272 -0
- ledgerkit_editor/widgets/search_bar.py +270 -0
- ledgerkit_editor/widgets/transaction_table.py +849 -0
- ledgerkit_editor/widgets/view_filter_bar.py +45 -0
- ledgerkit_editor-1.0.0.dist-info/METADATA +139 -0
- ledgerkit_editor-1.0.0.dist-info/RECORD +31 -0
- ledgerkit_editor-1.0.0.dist-info/WHEEL +5 -0
- ledgerkit_editor-1.0.0.dist-info/entry_points.txt +2 -0
- ledgerkit_editor-1.0.0.dist-info/licenses/LICENSE +21 -0
- ledgerkit_editor-1.0.0.dist-info/top_level.txt +1 -0
ledgerkit_editor/app.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Root Textual application for the ledger editor.
|
|
2
|
+
|
|
3
|
+
Composes the main layout: a file-path bar below the Header, then JournalEditor
|
|
4
|
+
(full-width editing surface). FilterPopup overlays on demand via Ctrl+Shift+P.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import ClassVar
|
|
15
|
+
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.css.query import NoMatches
|
|
19
|
+
from textual.widgets import Footer, Header, Static
|
|
20
|
+
|
|
21
|
+
from ledgerkit_editor.themes import VALID_THEMES, register_all
|
|
22
|
+
from ledgerkit_editor.themes.monokai_pro import THEME_NAME
|
|
23
|
+
from ledgerkit_editor.utils.file_resolver import resolve_journal_file
|
|
24
|
+
from ledgerkit_editor.widgets.filter_popup import FilterPopup
|
|
25
|
+
from ledgerkit_editor.widgets.transaction_table import JournalEditor
|
|
26
|
+
|
|
27
|
+
__all__ = ["LedgerApp", "main"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LedgerApp(App[None]):
|
|
31
|
+
"""Top-level Textual application for the plain-text ledger editor."""
|
|
32
|
+
|
|
33
|
+
TITLE = "Ledger Editor"
|
|
34
|
+
COMMAND_PALETTE_DISPLAY: ClassVar[str] = "Ctrl+P"
|
|
35
|
+
CSS_PATH = ["themes/monokai_pro.tcss"]
|
|
36
|
+
BINDINGS = [
|
|
37
|
+
Binding("ctrl+o", "toggle_filter", "Filter transactions",
|
|
38
|
+
priority=True, key_display="Ctrl+O"),
|
|
39
|
+
]
|
|
40
|
+
CSS = """
|
|
41
|
+
Screen {
|
|
42
|
+
layout: horizontal;
|
|
43
|
+
}
|
|
44
|
+
#file-path-bar {
|
|
45
|
+
dock: top;
|
|
46
|
+
height: 1;
|
|
47
|
+
background: $surface;
|
|
48
|
+
color: $text-muted;
|
|
49
|
+
padding: 0 1;
|
|
50
|
+
}
|
|
51
|
+
JournalEditor {
|
|
52
|
+
width: 1fr;
|
|
53
|
+
layout: vertical;
|
|
54
|
+
}
|
|
55
|
+
ViewFilterBar {
|
|
56
|
+
dock: top;
|
|
57
|
+
height: 1;
|
|
58
|
+
background: $surface;
|
|
59
|
+
color: $text-muted;
|
|
60
|
+
padding: 0 1;
|
|
61
|
+
}
|
|
62
|
+
SearchBar {
|
|
63
|
+
dock: bottom;
|
|
64
|
+
height: 3;
|
|
65
|
+
background: $surface;
|
|
66
|
+
border-top: solid $primary;
|
|
67
|
+
display: none;
|
|
68
|
+
layout: horizontal;
|
|
69
|
+
padding: 0 1;
|
|
70
|
+
}
|
|
71
|
+
SearchBar Input {
|
|
72
|
+
width: 1fr;
|
|
73
|
+
height: 1;
|
|
74
|
+
background: $panel;
|
|
75
|
+
color: $text;
|
|
76
|
+
border: none;
|
|
77
|
+
}
|
|
78
|
+
SearchBar #match-counter {
|
|
79
|
+
width: auto;
|
|
80
|
+
padding: 0 1;
|
|
81
|
+
color: $text-muted;
|
|
82
|
+
}
|
|
83
|
+
SearchBar Button {
|
|
84
|
+
width: auto;
|
|
85
|
+
min-width: 3;
|
|
86
|
+
height: 1;
|
|
87
|
+
border: none;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
journal_path: Path,
|
|
95
|
+
start_line: int | None = None,
|
|
96
|
+
theme_name: str | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Initialise the app with a resolved journal file path.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
journal_path: Resolved path to the .journal or .ledger file.
|
|
102
|
+
start_line: 1-indexed line to place the cursor on after open.
|
|
103
|
+
theme_name: Registered theme name to activate; defaults to monokai-pro.
|
|
104
|
+
"""
|
|
105
|
+
super().__init__()
|
|
106
|
+
self.journal_path = journal_path
|
|
107
|
+
self._start_line = start_line
|
|
108
|
+
self._theme_name = theme_name
|
|
109
|
+
|
|
110
|
+
def on_mount(self) -> None:
|
|
111
|
+
"""Register bundled themes, activate Monokai Pro, and populate file-path bar."""
|
|
112
|
+
from ledgerkit_editor.widgets.ledger_textarea import LedgerTextArea # noqa: PLC0415
|
|
113
|
+
|
|
114
|
+
editor = self.query_one(JournalEditor)
|
|
115
|
+
ledger_textarea = editor.query_one("#journal_textarea", LedgerTextArea)
|
|
116
|
+
register_all(self, ledger_textarea)
|
|
117
|
+
if os.environ.get("TEXTUAL_THEME") is None:
|
|
118
|
+
self.theme = self._theme_name or THEME_NAME
|
|
119
|
+
self._update_file_path_bar(modified=False)
|
|
120
|
+
|
|
121
|
+
def compose(self) -> ComposeResult:
|
|
122
|
+
"""Build the initial widget tree."""
|
|
123
|
+
yield Header()
|
|
124
|
+
yield Static("", id="file-path-bar")
|
|
125
|
+
yield JournalEditor(self.journal_path, start_line=self._start_line)
|
|
126
|
+
yield Footer()
|
|
127
|
+
|
|
128
|
+
def _update_file_path_bar(self, modified: bool) -> None:
|
|
129
|
+
"""Refresh the path bar label, appending '· modified' when unsaved."""
|
|
130
|
+
path_str = str(Path(self.journal_path).resolve())
|
|
131
|
+
text = f"{path_str} · modified" if modified else path_str
|
|
132
|
+
try:
|
|
133
|
+
self.query_one("#file-path-bar", Static).update(text)
|
|
134
|
+
except NoMatches:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Message handlers
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def on_journal_editor_file_modified_changed(
|
|
142
|
+
self, event: JournalEditor.FileModifiedChanged
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Update the file-path bar with the modified indicator."""
|
|
145
|
+
self._update_file_path_bar(modified=event.modified)
|
|
146
|
+
|
|
147
|
+
def action_toggle_filter(self) -> None:
|
|
148
|
+
"""Open or close the transaction filter popup (Ctrl+Shift+P)."""
|
|
149
|
+
existing = self.query(FilterPopup)
|
|
150
|
+
if existing:
|
|
151
|
+
existing.first().remove()
|
|
152
|
+
else:
|
|
153
|
+
self.mount(FilterPopup())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
157
|
+
"""Parse CLI arguments for the ledger-editor entry point."""
|
|
158
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
159
|
+
|
|
160
|
+
# Extract +N tokens before argparse; it does not handle the bare + prefix.
|
|
161
|
+
# +N is the traditional $EDITOR convention (vi, emacs) used by shell
|
|
162
|
+
# scripts and tools that invoke editors via: $EDITOR +N file.
|
|
163
|
+
#
|
|
164
|
+
# Purpose: match a bare "+<digits>" token anywhere in argv.
|
|
165
|
+
# Group 1: the decimal line number.
|
|
166
|
+
# Edge cases: "+0" matches (clamped to row 0 at use-site); "+N file"
|
|
167
|
+
# adjacent to a filename leaves the filename intact.
|
|
168
|
+
plus_line: int | None = None
|
|
169
|
+
filtered: list[str] = []
|
|
170
|
+
for token in raw:
|
|
171
|
+
m = re.fullmatch(r"\+(\d+)", token)
|
|
172
|
+
if m:
|
|
173
|
+
plus_line = int(m.group(1))
|
|
174
|
+
else:
|
|
175
|
+
filtered.append(token)
|
|
176
|
+
|
|
177
|
+
parser = argparse.ArgumentParser(
|
|
178
|
+
prog="ledger-editor",
|
|
179
|
+
description="Terminal-based plain-text ledger editor (hledger format).",
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"file",
|
|
183
|
+
nargs="?",
|
|
184
|
+
metavar="FILE",
|
|
185
|
+
help="Path to the .journal or .ledger file to edit.",
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--line",
|
|
189
|
+
type=int,
|
|
190
|
+
metavar="N",
|
|
191
|
+
default=None,
|
|
192
|
+
help="Open with cursor at line N (1-indexed). Also accepts +N positional form.",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--theme",
|
|
196
|
+
metavar="THEME",
|
|
197
|
+
default=None,
|
|
198
|
+
choices=sorted(VALID_THEMES),
|
|
199
|
+
help="Color theme to activate at startup (default: monokai-pro).",
|
|
200
|
+
)
|
|
201
|
+
args = parser.parse_args(filtered)
|
|
202
|
+
# --line takes precedence; +N is the fallback when --line is absent.
|
|
203
|
+
if args.line is None and plus_line is not None:
|
|
204
|
+
args.line = plus_line
|
|
205
|
+
return args
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: list[str] | None = None) -> None:
|
|
209
|
+
"""Entry point wired up in pyproject.toml [project.scripts]."""
|
|
210
|
+
args = _parse_args(argv)
|
|
211
|
+
journal_path = resolve_journal_file(args.file)
|
|
212
|
+
if journal_path is None:
|
|
213
|
+
print(
|
|
214
|
+
"No journal file found. Pass a path as an argument, set $LEDGER_FILE, "
|
|
215
|
+
"or create ~/.hledger.journal.",
|
|
216
|
+
file=sys.stderr,
|
|
217
|
+
)
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
app = LedgerApp(journal_path, start_line=args.line, theme_name=args.theme)
|
|
220
|
+
app.run()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command palette stubs and application-level undo/redo infrastructure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
__all__ = ["Command", "CommandHistory"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Command:
|
|
13
|
+
"""An invertible operation on application or model state.
|
|
14
|
+
|
|
15
|
+
Both ``execute`` and ``undo`` must be synchronous callables that complete
|
|
16
|
+
quickly (< 16 ms). Heavy work (disk I/O, journal re-parse) must be
|
|
17
|
+
dispatched via Textual workers by the caller, not inside these callables.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
execute: Callable[[], None]
|
|
21
|
+
undo: Callable[[], None]
|
|
22
|
+
description: str
|
|
23
|
+
"""Short human-readable label shown in notify() on undo/redo."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CommandHistory:
|
|
28
|
+
"""Application-level undo/redo stack for operations that affect both buffer
|
|
29
|
+
text and in-memory model objects.
|
|
30
|
+
|
|
31
|
+
This is separate from TextArea's native EditHistory (_undo_stack). The two
|
|
32
|
+
stacks are kept in sync by JournalEditor: action_undo / action_redo consult
|
|
33
|
+
CommandHistory first and fall through to TextArea native undo if it has no
|
|
34
|
+
entries.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_max_size: int = 100
|
|
38
|
+
_done: list[Command] = field(default_factory=list)
|
|
39
|
+
_undone: list[Command] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def execute(self, cmd: Command) -> None:
|
|
42
|
+
"""Execute a command and push it onto the undo stack."""
|
|
43
|
+
cmd.execute()
|
|
44
|
+
self._done.append(cmd)
|
|
45
|
+
self._undone.clear()
|
|
46
|
+
if len(self._done) > self._max_size:
|
|
47
|
+
self._done.pop(0)
|
|
48
|
+
|
|
49
|
+
def undo(self) -> Optional[Command]:
|
|
50
|
+
"""Undo the most recent command and move it to the redo stack."""
|
|
51
|
+
if not self._done:
|
|
52
|
+
return None
|
|
53
|
+
cmd = self._done.pop()
|
|
54
|
+
cmd.undo()
|
|
55
|
+
self._undone.append(cmd)
|
|
56
|
+
return cmd
|
|
57
|
+
|
|
58
|
+
def redo(self) -> Optional[Command]:
|
|
59
|
+
"""Redo the most recently undone command."""
|
|
60
|
+
if not self._undone:
|
|
61
|
+
return None
|
|
62
|
+
cmd = self._undone.pop()
|
|
63
|
+
cmd.execute()
|
|
64
|
+
self._done.append(cmd)
|
|
65
|
+
return cmd
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def can_undo(self) -> bool:
|
|
69
|
+
"""True if there is at least one command on the undo stack."""
|
|
70
|
+
return bool(self._done)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def can_redo(self) -> bool:
|
|
74
|
+
"""True if there is at least one command on the redo stack."""
|
|
75
|
+
return bool(self._undone)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# TODO: register command palette providers via Textual's CommandPalette API
|
|
79
|
+
# Examples of planned commands:
|
|
80
|
+
# "Open file" — resolve_journal_file, reload EditorDocument
|
|
81
|
+
# "Save" — trigger action_save
|
|
82
|
+
# "Filter..." — open FilterPopup
|
|
83
|
+
# "Go to account" — jump cursor to first posting for an account
|
|
84
|
+
# "Toggle cleared" — action_toggle_cleared on selection
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Syntax highlighting for hledger journal files.
|
|
2
|
+
|
|
3
|
+
Provides the LedgerHighlighter (pure-Python scanner), build_textarea_theme
|
|
4
|
+
(CSS-variable bridge), and the token name constants in tokens.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ledgerkit_editor.highlighting.highlighter import LedgerHighlighter, LineInfo, LineKind
|
|
8
|
+
from ledgerkit_editor.highlighting.theme_bridge import build_textarea_theme
|
|
9
|
+
from ledgerkit_editor.highlighting import tokens
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"LedgerHighlighter",
|
|
13
|
+
"LineInfo",
|
|
14
|
+
"LineKind",
|
|
15
|
+
"build_textarea_theme",
|
|
16
|
+
"tokens",
|
|
17
|
+
]
|