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.
@@ -0,0 +1,5 @@
1
+ """Ledger Editor — a terminal-based plain-text ledger editor built with Textual."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m ledgerkit_editor`."""
2
+
3
+ from ledgerkit_editor.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ ]