beancount-gocardless 0.1.8__py3-none-any.whl → 0.1.10__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,789 @@
1
+ import os
2
+ import sys
3
+ from textual.app import App, ComposeResult
4
+ from textual.containers import Container, Vertical, ScrollableContainer
5
+ from textual.widgets import Header, Footer, Button, Static, Input, Label
6
+ from textual.message import Message
7
+ from textual.binding import Binding
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+ from typing import Optional
11
+ from beancount_gocardless.client import GoCardlessClient
12
+ from requests.exceptions import HTTPError as HttpServiceException
13
+ import logging
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ActionMessage(Message):
19
+ """Custom message to signal actions within the TUI."""
20
+
21
+ def __init__(self, action: str, payload: Optional[dict] = None) -> None:
22
+ super().__init__()
23
+ self.action = action
24
+ self.payload = payload or {}
25
+
26
+
27
+ class MenuView(Static):
28
+ """Main menu view."""
29
+
30
+ def compose(self) -> ComposeResult:
31
+ yield Vertical(
32
+ Label("GoCardLess TUI - Main Menu", id="title"),
33
+ Button("List Banks", id="list_banks", variant="primary"),
34
+ Button("List Linked Accounts", id="list_accounts", variant="primary"),
35
+ Button("Get Account Balance", id="get_balance", variant="primary"),
36
+ Button("Create New Link", id="create_link", variant="success"),
37
+ Button("Delete Existing Link", id="delete_link", variant="error"),
38
+ Button("Quit", id="quit", variant="default"),
39
+ id="menu_view_vertical",
40
+ )
41
+
42
+ def on_button_pressed(self, event: Button.Pressed) -> None:
43
+ self.post_message(ActionMessage(event.button.id))
44
+
45
+
46
+ class BaseSubView(Static):
47
+ """Base class for sub-views with a back button."""
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield from self.compose_content()
51
+
52
+ def compose_content(self) -> ComposeResult:
53
+ # To be overridden by subclasses
54
+ yield Static("Content goes here")
55
+
56
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
57
+ if event.button.id == "back_to_menu":
58
+ self.post_message(ActionMessage("show_menu"))
59
+ else:
60
+ # Allow subclasses to handle other buttons
61
+ pass
62
+
63
+
64
+ class BanksView(BaseSubView):
65
+ """View to display list of banks with country and name filtering."""
66
+
67
+ def __init__(self, client: GoCardlessClient, country: Optional[str] = None):
68
+ super().__init__()
69
+ self.client = client
70
+ self.country = country
71
+ self.all_banks = None # Store all banks
72
+ self.status_message = Static("Loading banks...", id="banks_status_message")
73
+ self.banks_table = Static("", id="banks_table")
74
+
75
+ def compose_content(self) -> ComposeResult:
76
+ yield Label("Banks", classes="view_title")
77
+ yield Input(placeholder="Filter banks by name...", id="bank_filter_input")
78
+ yield Input(
79
+ value=self.country,
80
+ placeholder="Country (e.g. FR)",
81
+ id="country_filter_input",
82
+ max_length=2,
83
+ )
84
+ yield self.status_message
85
+ yield ScrollableContainer(
86
+ self.banks_table,
87
+ id="banks_scrollable_area",
88
+ classes="horizontal-scroll",
89
+ )
90
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
91
+
92
+ # Override the base class compose to avoid duplicate back button
93
+ def compose(self) -> ComposeResult:
94
+ yield Vertical(
95
+ *self.compose_content(),
96
+ id="banks_view_vertical",
97
+ )
98
+
99
+ async def on_mount(self) -> None:
100
+ """Load all banks on mount."""
101
+ await self.filter_banks("", "")
102
+
103
+ async def filter_banks(
104
+ self, name_filter: Optional[str] = "", country_code: Optional[str] = None
105
+ ) -> None:
106
+ """Filters the banks based on current country and name filter."""
107
+ try:
108
+ if not self.all_banks:
109
+ self.status_message.update("Loading all banks...")
110
+ self.all_banks = [
111
+ inst.model_dump()
112
+ for inst in self.client.get_institutions(country=None)
113
+ ]
114
+
115
+ self.banks_table.update(
116
+ f"Loading accounts... {name_filter} {country_code} {len(self.all_banks)}"
117
+ )
118
+
119
+ name_filtered = [
120
+ b
121
+ for b in self.all_banks
122
+ if (name_filter.upper() in b.get("name", "").upper() or not name_filter)
123
+ and (country_code in b.get("countries", []) or not country_code)
124
+ ]
125
+
126
+ if not name_filtered:
127
+ filter_msg = f" matching '{name_filter}'" if name_filter else ""
128
+ self.banks_table.update(
129
+ f"No banks found in {country_code}{filter_msg}. Total: {len(self.all_banks)} available."
130
+ )
131
+ self.status_message.update("")
132
+ return
133
+
134
+ # Create a formatted table for display
135
+ table = Table(title=None, expand=True, min_width=80)
136
+ table.add_column("Name", overflow="ellipsis", min_width=30)
137
+ table.add_column("ID", overflow="ellipsis", min_width=20)
138
+ table.add_column("Countries", overflow="ellipsis", min_width=15)
139
+
140
+ for bank in name_filtered:
141
+ countries = ", ".join(bank.get("countries", []))
142
+ table.add_row(bank.get("name", "N/A"), bank.get("id", "N/A"), countries)
143
+
144
+ # Update the table content
145
+ self.banks_table.update(table)
146
+ self.status_message.update(f"Showing {len(name_filtered)} banks")
147
+
148
+ except Exception as e:
149
+ self.status_message.update(
150
+ Text(f"Error filtering banks: {e}", style="bold red")
151
+ )
152
+
153
+ async def on_input_changed(self, event: Input.Changed) -> None:
154
+ """Event handler for input changes in either filter input."""
155
+ input_id = event.input.id
156
+ if input_id in ["country_filter_input", "bank_filter_input"]:
157
+ name_input = self.query_one("#bank_filter_input", Input)
158
+ country_input = self.query_one("#country_filter_input", Input)
159
+ country_code = country_input.value.upper()
160
+ omit_country = len(country_code) != 2
161
+
162
+ await self.filter_banks(
163
+ name_filter=name_input.value,
164
+ country_code=country_code if not omit_country else None,
165
+ )
166
+
167
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
168
+ """Handle button presses within this view."""
169
+ if event.button.id == "back_to_menu":
170
+ self.post_message(ActionMessage("show_menu"))
171
+
172
+
173
+ class AccountsView(BaseSubView):
174
+ """View to display linked accounts with complete information."""
175
+
176
+ def __init__(self, client: GoCardlessClient):
177
+ super().__init__()
178
+ self.client = client
179
+ self.status_message = Static(
180
+ "Loading accounts...", id="accounts_status_message"
181
+ )
182
+ self.accounts_table = Static("", id="accounts_table")
183
+
184
+ def compose_content(self) -> ComposeResult:
185
+ yield Label("Linked Accounts", classes="view_title")
186
+ yield self.status_message
187
+ yield ScrollableContainer(
188
+ self.accounts_table,
189
+ id="accounts_scrollable_area",
190
+ classes="horizontal-scroll",
191
+ )
192
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
193
+
194
+ # Override the base class compose to avoid duplicate back button
195
+ def compose(self) -> ComposeResult:
196
+ yield Vertical(
197
+ *self.compose_content(),
198
+ id="accounts_view_vertical",
199
+ )
200
+
201
+ async def on_mount(self) -> None:
202
+ await self.load_accounts()
203
+
204
+ async def load_accounts(self) -> None:
205
+ """Loads and displays the linked accounts with detailed information."""
206
+ try:
207
+ self.status_message.update("Loading accounts...")
208
+ accounts = self.client.get_all_accounts()
209
+ if not accounts:
210
+ self.accounts_table.update("No accounts found.")
211
+ self.status_message.update("")
212
+ return
213
+
214
+ # Create a formatted table for display
215
+ table = Table(title=None, expand=True, min_width=100)
216
+ table.add_column("#", style="dim", width=3)
217
+ table.add_column("Bank", overflow="ellipsis", min_width=20)
218
+ table.add_column("Name", overflow="crop", min_width=20)
219
+ table.add_column("ID", overflow="ellipsis", min_width=20)
220
+ table.add_column("IBAN", overflow="ellipsis", min_width=20)
221
+ table.add_column("Reference", overflow="ellipsis", min_width=15)
222
+ table.add_column("Status", width=6)
223
+ table.add_column("Last Accessed", min_width=12)
224
+
225
+ # Format and add accounts to the table
226
+ for idx, account in enumerate(accounts, start=1):
227
+ # import pdb
228
+ # pdb.set_trace()
229
+ # Get values with fallbacks for any missing keys
230
+ institution_id = account.get("institution_id", "N/A")
231
+ name = account.get("name", account.get("owner_name", "N/A"))
232
+ iban = account.get("iban", "N/A")
233
+ account_id = account.get("account_id", "N/A")
234
+ reference = account.get("reference", "N/A")
235
+ status = account.get("status", "N/A")
236
+ last_accessed = account.get("last_accessed", "")
237
+ if last_accessed:
238
+ try:
239
+ last_accessed = last_accessed.split("T")[0]
240
+ except Exception:
241
+ pass
242
+
243
+ table.add_row(
244
+ str(idx),
245
+ institution_id,
246
+ name,
247
+ account_id,
248
+ iban,
249
+ reference,
250
+ status,
251
+ last_accessed,
252
+ )
253
+
254
+ # Update the table content
255
+ self.accounts_table.update(table)
256
+ self.status_message.update("")
257
+
258
+ except HttpServiceException as e:
259
+ self.status_message.update(Text(f"API Error: {e}", style="bold red"))
260
+ except Exception as e:
261
+ self.status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
262
+
263
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
264
+ """Handle button presses within this view."""
265
+ if event.button.id == "back_to_menu":
266
+ self.post_message(ActionMessage("show_menu"))
267
+
268
+
269
+ # ... existing code ...
270
+
271
+
272
+ class BalanceView(BaseSubView):
273
+ """View to get and display account balance."""
274
+
275
+ def __init__(
276
+ self, client: "GoCardlessClient"
277
+ ): # Forward reference GoCardlessClient if not imported yet
278
+ super().__init__()
279
+ self.client = client
280
+ self.accounts_data = []
281
+
282
+ def compose(self) -> ComposeResult:
283
+ """Override compose to yield widgets from compose_content."""
284
+ for widget in self.compose_content():
285
+ yield widget
286
+
287
+ def compose_content(self) -> ComposeResult:
288
+ yield Label("Account Balance", classes="view_title")
289
+ # Moved status message to be a direct child of BalanceView, and sibling to content_area
290
+ yield Static(
291
+ "", id="balance_status_message"
292
+ ) # Initialize empty or with a generic placeholder
293
+ yield Vertical(
294
+ # The initial Static message that was here is removed
295
+ id="balance_content_area",
296
+ )
297
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
298
+
299
+ async def on_mount(self) -> None:
300
+ # Query elements only after they are mounted
301
+ content_area = self.query_one("#balance_content_area", Vertical)
302
+ status_message = self.query_one("#balance_status_message", Static)
303
+ status_message.update(
304
+ "Loading accounts for selection..."
305
+ ) # Set initial operational message
306
+ await self.load_accounts_for_selection(content_area, status_message)
307
+
308
+ async def load_accounts_for_selection(
309
+ self, content_area: Vertical, status_message: Static
310
+ ):
311
+ try:
312
+ # status_message.update("Loading accounts...") # Message set by on_mount or can be refined here
313
+ self.accounts_data = self.client.get_all_accounts()
314
+ await (
315
+ content_area.remove_children()
316
+ ) # Clear "Loading..." or previous content from content_area
317
+
318
+ if not self.accounts_data:
319
+ await content_area.mount(
320
+ Static("No accounts linked. Cannot fetch balance.")
321
+ )
322
+ status_message.update(
323
+ "No accounts available to display."
324
+ ) # Update persistent status
325
+ return
326
+
327
+ table = Table(title="Select Account by Index for Balance", expand=True)
328
+ table.add_column("#", style="dim")
329
+ table.add_column("Name", overflow="fold")
330
+ table.add_column("Institution ID", overflow="fold")
331
+ for idx, a in enumerate(self.accounts_data):
332
+ table.add_row(str(idx), a["name"], a["institution_id"])
333
+
334
+ await content_area.mount(table) # Mount Table widget directly
335
+ idx_input = Input(
336
+ placeholder="Enter account # and press Enter", id="acct_idx_input"
337
+ )
338
+ await content_area.mount(idx_input)
339
+ idx_input.focus()
340
+ status_message.update(
341
+ "Please select an account from the list."
342
+ ) # Clear or update status message
343
+ except HttpServiceException as e: # Make sure HttpServiceException is defined
344
+ status_message.update(
345
+ Text(f"Error loading accounts: {e}", style="bold red")
346
+ )
347
+ await content_area.remove_children()
348
+ await content_area.mount(
349
+ Static(
350
+ Text(
351
+ "Failed to load accounts. Check connection or try again later.",
352
+ style="yellow",
353
+ )
354
+ )
355
+ )
356
+ except Exception as e:
357
+ status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
358
+ await content_area.remove_children()
359
+ await content_area.mount(
360
+ Static(
361
+ Text(
362
+ "An unexpected error occurred while loading accounts.",
363
+ style="yellow",
364
+ )
365
+ )
366
+ )
367
+
368
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
369
+ if event.input.id == "acct_idx_input":
370
+ content_area = self.query_one("#balance_content_area", Vertical)
371
+ # status_message is now persistent and queried once
372
+ status_message = self.query_one("#balance_status_message", Static)
373
+
374
+ await (
375
+ content_area.remove_children()
376
+ ) # Clear account list and input from content_area
377
+
378
+ try:
379
+ idx = int(event.value)
380
+ if not (0 <= idx < len(self.accounts_data)):
381
+ # Display temporary error message in content_area
382
+ await content_area.mount(
383
+ Static(Text("Invalid account index.", style="bold red"))
384
+ )
385
+ status_message.update(
386
+ Text(
387
+ "Selection out of range. Please try again.", style="yellow"
388
+ )
389
+ )
390
+ # Reload account selection UI
391
+ await self.load_accounts_for_selection(content_area, status_message)
392
+ return
393
+
394
+ account_to_check = self.accounts_data[idx]
395
+ acct_id = account_to_check["id"]
396
+
397
+ status_message.update(
398
+ f"Fetching balances for {account_to_check['name']} ({acct_id})..."
399
+ )
400
+ # Optionally, display a "Fetching..." message in content_area as well
401
+ await content_area.mount(Static("Fetching balances..."))
402
+
403
+ balances_data = self.client.get_account_balances(acct_id)
404
+ await content_area.remove_children() # Clear "Fetching..." message
405
+
406
+ table = Table(
407
+ title=f"Balances for {account_to_check['name']}", expand=True
408
+ )
409
+ table.add_column("Type", overflow="fold")
410
+ table.add_column("Amount")
411
+ table.add_column("Currency")
412
+ if balances_data.balances:
413
+ for b in balances_data.balances:
414
+ table.add_row(
415
+ b.balance_type,
416
+ b.balance_amount.amount,
417
+ b.balance_amount.currency,
418
+ )
419
+ else:
420
+ table.add_row("N/A", "No balance data found", "N/A")
421
+
422
+ await content_area.mount(table) # Mount the results table
423
+ status_message.update(
424
+ f"Balances for {account_to_check['name']} displayed."
425
+ )
426
+ # Consider how the user navigates back to account selection (e.g., a button, or re-selecting the view)
427
+
428
+ except ValueError:
429
+ # Display temporary error message in content_area
430
+ await content_area.mount(
431
+ Static(
432
+ Text("Invalid input. Please enter a number.", style="bold red")
433
+ )
434
+ )
435
+ status_message.update(
436
+ Text("Numeric input required for account index.", style="yellow")
437
+ )
438
+ await self.load_accounts_for_selection(
439
+ content_area, status_message
440
+ ) # Reload selection
441
+ except (
442
+ HttpServiceException
443
+ ) as e: # Make sure HttpServiceException is defined
444
+ status_message.update(
445
+ Text(f"API Error fetching balances: {e}", style="bold red")
446
+ )
447
+ await content_area.mount(
448
+ Static(
449
+ Text(
450
+ "Could not retrieve balances due to an API error.",
451
+ style="red",
452
+ )
453
+ )
454
+ )
455
+ # Optionally, reload selection UI so user can try again or select a different account
456
+ # await self.load_accounts_for_selection(content_area, status_message)
457
+ except Exception as e:
458
+ status_message.update(
459
+ Text(f"Error processing balance request: {e}", style="bold red")
460
+ )
461
+ await content_area.mount(
462
+ Static(
463
+ Text(
464
+ "An unexpected error occurred while fetching balances.",
465
+ style="red",
466
+ )
467
+ )
468
+ )
469
+ # Optionally, reload selection UI
470
+ # await self.load_accounts_for_selection(content_area, status_message)
471
+
472
+
473
+ class LinkView(BaseSubView):
474
+ """View to create a new bank link."""
475
+
476
+ def __init__(self, client: GoCardlessClient):
477
+ super().__init__()
478
+ self.client = client
479
+
480
+ def compose(self) -> ComposeResult:
481
+ """Override compose to yield widgets from compose_content."""
482
+ for widget in self.compose_content():
483
+ yield widget
484
+
485
+ def compose_content(self) -> ComposeResult:
486
+ yield Label("Create New Bank Link", classes="view_title")
487
+ yield Vertical(
488
+ Input(
489
+ placeholder="Bank ID (e.g., SANDBOXFINANCE_SFIN0000)",
490
+ id="bank_id_input",
491
+ ),
492
+ Input(
493
+ placeholder="Unique Reference (e.g., mypc-savings-ref)", id="ref_input"
494
+ ),
495
+ Button("Create Link", id="do_create_link_button", variant="success"),
496
+ Static(id="link_result_message", classes="result_message_area"),
497
+ id="link_form_area",
498
+ )
499
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
500
+
501
+ async def on_mount(self) -> None:
502
+ self.query_one("#bank_id_input", Input).focus()
503
+
504
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
505
+ if event.button.id == "back_to_menu":
506
+ self.post_message(ActionMessage("show_menu"))
507
+ elif event.button.id == "do_create_link_button":
508
+ await self.process_create_link()
509
+
510
+ async def process_create_link(self) -> None:
511
+ bank_input = self.query_one("#bank_id_input", Input)
512
+ ref_input = self.query_one("#ref_input", Input)
513
+ result_message_widget = self.query_one("#link_result_message", Static)
514
+ create_button = self.query_one("#do_create_link_button", Button)
515
+
516
+ bank_id = bank_input.value.strip()
517
+ reference = ref_input.value.strip()
518
+
519
+ if not bank_id or not reference:
520
+ result_message_widget.update(
521
+ Text("Bank ID and Reference are required.", style="bold red")
522
+ )
523
+ return
524
+
525
+ try:
526
+ bank_input.disabled = True
527
+ ref_input.disabled = True
528
+ create_button.disabled = True
529
+ result_message_widget.update("Creating link...")
530
+
531
+ link_url = self.client.create_bank_link(reference, bank_id) # API call
532
+
533
+ if link_url:
534
+ link_info = {
535
+ "status": "created",
536
+ "message": "Link created successfully",
537
+ "link": link_url,
538
+ }
539
+ else:
540
+ link_info = {
541
+ "status": "exists",
542
+ "message": "Link already exists for this reference",
543
+ "link": None,
544
+ }
545
+
546
+ msg_parts = [
547
+ Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
548
+ ]
549
+ if link_info.get("message"):
550
+ msg_parts.append(Text(f"Message: {link_info['message']}\n"))
551
+ if link_info.get("link"):
552
+ msg_parts.append(
553
+ Text(
554
+ "Link URL (copy and open in browser to authorize):\n",
555
+ style="bold yellow",
556
+ )
557
+ )
558
+ msg_parts.append(Text(f"{link_info['link']}", style="underline blue"))
559
+
560
+ result_message_widget.update(Text.assemble(*msg_parts))
561
+
562
+ except HttpServiceException as e:
563
+ result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
564
+ except Exception as e:
565
+ result_message_widget.update(
566
+ Text(f"Unexpected Error:\n{e}", style="bold red")
567
+ )
568
+ finally:
569
+ bank_input.disabled = False
570
+ ref_input.disabled = False
571
+ create_button.disabled = False
572
+
573
+
574
+ class DeleteLinkView(BaseSubView):
575
+ """View to delete an existing bank link."""
576
+
577
+ def __init__(self, client: GoCardlessClient):
578
+ super().__init__()
579
+ self.client = client
580
+
581
+ def compose(self) -> ComposeResult:
582
+ """Override compose to yield widgets from compose_content."""
583
+ for widget in self.compose_content():
584
+ yield widget
585
+
586
+ def compose_content(self) -> ComposeResult:
587
+ yield Label("Delete Existing Link", classes="view_title")
588
+ yield Vertical(
589
+ Input(placeholder="Reference of link to delete", id="del_ref_input"),
590
+ Button("Delete Link", id="do_delete_link_button", variant="error"),
591
+ Static(id="delete_link_result_message", classes="result_message_area"),
592
+ id="delete_link_form_area",
593
+ )
594
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
595
+
596
+ async def on_mount(self) -> None:
597
+ self.query_one("#del_ref_input", Input).focus()
598
+
599
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
600
+ if event.button.id == "back_to_menu":
601
+ self.post_message(ActionMessage("show_menu"))
602
+ elif event.button.id == "do_delete_link_button":
603
+ await self.process_delete_link()
604
+
605
+ async def process_delete_link(self) -> None:
606
+ ref_input = self.query_one("#del_ref_input", Input)
607
+ result_message_widget = self.query_one("#delete_link_result_message", Static)
608
+ delete_button = self.query_one("#do_delete_link_button", Button)
609
+
610
+ reference = ref_input.value.strip()
611
+
612
+ if not reference:
613
+ result_message_widget.update(
614
+ Text("Reference is required to delete a link.", style="bold red")
615
+ )
616
+ return
617
+
618
+ try:
619
+ ref_input.disabled = True
620
+ delete_button.disabled = True
621
+ result_message_widget.update(
622
+ f"Deleting link with reference '{reference}'..."
623
+ )
624
+
625
+ req = self.client.find_requisition_by_reference(reference)
626
+ if req:
627
+ self.client.delete_requisition(req.id) # API call
628
+ result = {"status": "deleted", "message": "Link deleted successfully"}
629
+ else:
630
+ result = {"status": "not_found", "message": "Link not found"}
631
+
632
+ style = "bold green" if result["status"] == "deleted" else "bold yellow"
633
+ result_message_widget.update(
634
+ Text(
635
+ f"Status: {result['status']}\nMessage: {result['message']}",
636
+ style=style,
637
+ )
638
+ )
639
+
640
+ except HttpServiceException as e:
641
+ result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
642
+ except Exception as e:
643
+ result_message_widget.update(
644
+ Text(f"Unexpected Error:\n{e}", style="bold red")
645
+ )
646
+ finally:
647
+ ref_input.disabled = False
648
+ delete_button.disabled = False
649
+
650
+
651
+ class GoCardLessApp(App):
652
+ TITLE = "GoCardLess API TUI"
653
+ CSS = """
654
+ Screen {
655
+ align: center middle;
656
+ }
657
+ #app_container {
658
+ width: 100%;
659
+ max-width: 300; /* Max width for the main content container */
660
+ height: auto;
661
+ border: round $primary;
662
+ padding: 1 2;
663
+ }
664
+ #title { /* For MenuView title */
665
+ width: 100%;
666
+ text-align: center;
667
+ padding: 1 0 2 0;
668
+ text-style: bold;
669
+ }
670
+ .view_title { /* For sub-view titles */
671
+ width: 100%;
672
+ text-align: center;
673
+ padding: 0 0 1 0;
674
+ text-style: bold underline;
675
+ }
676
+ #menu_view_vertical > Button {
677
+ width: 100%;
678
+ margin-bottom: 1;
679
+ }
680
+ .back_button {
681
+ width: 100%;
682
+ margin-top: 2;
683
+ }
684
+ Input, Button { /* General spacing for inputs and buttons in forms */
685
+ margin-bottom: 1;
686
+ }
687
+ .result_message_area {
688
+ margin-top: 1;
689
+ padding: 1;
690
+ border: round $primary-background-darken-2;
691
+ min-height: 3;
692
+ width: 100%;
693
+ }
694
+ Vertical { /* Ensure vertical containers take full width by default */
695
+ width: 100%;
696
+ }
697
+ Table {
698
+ margin-top: 1;
699
+ }
700
+ """
701
+ BINDINGS = [
702
+ Binding("q", "quit", "Quit App", show=True, priority=True),
703
+ Binding(
704
+ "escape", "show_menu_escape", "Back to Menu", show=True
705
+ ), # Fixed: Always show, we'll control visibility elsewhere
706
+ ]
707
+
708
+ def __init__(self, secret_id=None, secret_key=None):
709
+ super().__init__()
710
+ sid = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
711
+ sk = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
712
+ if not sid or not sk:
713
+ logger.error(
714
+ "Error: GoCardLess credentials (GOCARDLESS_SECRET_ID, GOCARDLESS_SECRET_KEY) not found.",
715
+ )
716
+ logger.error(
717
+ "Please set them as environment variables or pass via arguments (not implemented in this TUI version).",
718
+ )
719
+ sys.exit(1) # Exit if no creds
720
+ self.client = GoCardlessClient(sid, sk, {})
721
+ self._current_view_is_menu = True
722
+
723
+ def compose(self) -> ComposeResult:
724
+ yield Header()
725
+ yield Container(MenuView(), id="app_container")
726
+ yield Footer()
727
+
728
+ async def on_mount(self) -> None:
729
+ # Fixed: removed the call to update_escape_binding since we're showing escape binding always
730
+ # and managing its behavior in action_show_menu_escape
731
+ pass
732
+
733
+ async def switch_view(self, new_view_widget: Static) -> None:
734
+ container = self.query_one("#app_container", Container)
735
+ await container.remove_children()
736
+ await container.mount(new_view_widget)
737
+ self._current_view_is_menu = isinstance(new_view_widget, MenuView)
738
+
739
+ async def action_show_menu_escape(self) -> None:
740
+ """Handle Escape key press to go back to menu."""
741
+ if not self._current_view_is_menu:
742
+ await self.switch_view(MenuView())
743
+
744
+ async def on_action_message(self, msg: ActionMessage) -> None:
745
+ action_to_view_map = {
746
+ "show_menu": MenuView,
747
+ "list_banks": lambda: BanksView(self.client),
748
+ "list_accounts": lambda: AccountsView(self.client),
749
+ "get_balance": lambda: BalanceView(self.client),
750
+ "create_link": lambda: LinkView(self.client),
751
+ "delete_link": lambda: DeleteLinkView(self.client),
752
+ }
753
+
754
+ if msg.action == "quit":
755
+ self.exit()
756
+ return
757
+
758
+ if msg.action in action_to_view_map:
759
+ view_constructor = action_to_view_map[msg.action]
760
+ new_view = view_constructor()
761
+ await self.switch_view(new_view)
762
+ else:
763
+ # Fallback for unknown action, though should not happen with defined buttons
764
+ container = self.query_one("#app_container", Container)
765
+ await container.remove_children()
766
+ await container.mount(
767
+ Static(Text(f"Unknown action: {msg.action}", style="bold red"))
768
+ )
769
+ self._current_view_is_menu = False # Assuming it's not menu
770
+
771
+
772
+ logger.info(os.getenv("GOCARDLESS_SECRET_ID"), os.getenv("GOCARDLESS_SECRET_KEY"))
773
+
774
+
775
+ def main():
776
+ # For this TUI, credentials must be set as environment variables
777
+ # GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY
778
+ if not os.getenv("GOCARDLESS_SECRET_ID") or not os.getenv("GOCARDLESS_SECRET_KEY"):
779
+ logger.error(
780
+ "Error: GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables must be set.",
781
+ )
782
+ sys.exit(1)
783
+
784
+ app = GoCardLessApp()
785
+ app.run()
786
+
787
+
788
+ if __name__ == "__main__":
789
+ main()