beancount-gocardless 0.1.7__py3-none-any.whl → 0.1.9__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,771 @@
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: 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 Vertical(
51
+ self.compose_content(),
52
+ Button("Back to Menu", id="back_to_menu", classes="back_button"),
53
+ )
54
+
55
+ def compose_content(self) -> ComposeResult:
56
+ # To be overridden by subclasses
57
+ yield Static("Content goes here")
58
+
59
+ def on_button_pressed(self, event: Button.Pressed) -> None:
60
+ if event.button.id == "back_to_menu":
61
+ self.post_message(ActionMessage("show_menu"))
62
+ else:
63
+ # Allow subclasses to handle other buttons
64
+ pass
65
+
66
+
67
+ class BanksView(BaseSubView):
68
+ """View to display list of banks with country and name filtering."""
69
+
70
+ def __init__(self, client: GoCardlessClient, country: Optional[str] = None):
71
+ super().__init__()
72
+ self.client = client
73
+ self.country = country
74
+ self.all_banks = None # Store all banks
75
+ self.status_message = Static("Loading banks...", id="banks_status_message")
76
+ self.banks_table = Static("", id="banks_table")
77
+
78
+ def compose_content(self) -> ComposeResult:
79
+ yield Label("Banks", classes="view_title")
80
+ yield Input(placeholder="Filter banks by name...", id="bank_filter_input")
81
+ yield Input(
82
+ value=self.country,
83
+ placeholder="Country (e.g. FR)",
84
+ id="country_filter_input",
85
+ max_length=2,
86
+ )
87
+ yield self.status_message
88
+ yield ScrollableContainer(
89
+ self.banks_table,
90
+ id="banks_scrollable_area",
91
+ classes="horizontal-scroll",
92
+ )
93
+ yield Button("Back to Menu", id="back_to_menu", classes="back_button")
94
+
95
+ # Override the base class compose to avoid duplicate back button
96
+ def compose(self) -> ComposeResult:
97
+ yield Vertical(
98
+ *self.compose_content(),
99
+ id="banks_view_vertical",
100
+ )
101
+
102
+ async def on_mount(self) -> None:
103
+ """Load all banks on mount."""
104
+ await self.filter_banks("", "")
105
+
106
+ async def filter_banks(
107
+ self, name_filter: Optional[str] = "", country_code: Optional[str] = None
108
+ ) -> None:
109
+ """Filters the banks based on current country and name filter."""
110
+ try:
111
+ if not self.all_banks:
112
+ self.status_message.update("Loading all banks...")
113
+ self.all_banks = self.client.list_banks(country=None)
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
+ 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.list_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
+ 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
+
298
+ async def on_mount(self) -> None:
299
+ # Query elements only after they are mounted
300
+ content_area = self.query_one("#balance_content_area", Vertical)
301
+ status_message = self.query_one("#balance_status_message", Static)
302
+ status_message.update(
303
+ "Loading accounts for selection..."
304
+ ) # Set initial operational message
305
+ await self.load_accounts_for_selection(content_area, status_message)
306
+
307
+ async def load_accounts_for_selection(
308
+ self, content_area: Vertical, status_message: Static
309
+ ):
310
+ try:
311
+ # status_message.update("Loading accounts...") # Message set by on_mount or can be refined here
312
+ self.accounts_data = self.client.list_accounts()
313
+ await (
314
+ content_area.remove_children()
315
+ ) # Clear "Loading..." or previous content from content_area
316
+
317
+ if not self.accounts_data:
318
+ await content_area.mount(
319
+ Static("No accounts linked. Cannot fetch balance.")
320
+ )
321
+ status_message.update(
322
+ "No accounts available to display."
323
+ ) # Update persistent status
324
+ return
325
+
326
+ table = Table(title="Select Account by Index for Balance", expand=True)
327
+ table.add_column("#", style="dim")
328
+ table.add_column("Name", overflow="fold")
329
+ table.add_column("Institution ID", overflow="fold")
330
+ for idx, a in enumerate(self.accounts_data):
331
+ table.add_row(str(idx), a["name"], a["institution_id"])
332
+
333
+ await content_area.mount(table) # Mount Table widget directly
334
+ idx_input = Input(
335
+ placeholder="Enter account # and press Enter", id="acct_idx_input"
336
+ )
337
+ await content_area.mount(idx_input)
338
+ idx_input.focus()
339
+ status_message.update(
340
+ "Please select an account from the list."
341
+ ) # Clear or update status message
342
+ except HttpServiceException as e: # Make sure HttpServiceException is defined
343
+ status_message.update(
344
+ Text(f"Error loading accounts: {e}", style="bold red")
345
+ )
346
+ await content_area.remove_children()
347
+ await content_area.mount(
348
+ Static(
349
+ Text(
350
+ "Failed to load accounts. Check connection or try again later.",
351
+ style="yellow",
352
+ )
353
+ )
354
+ )
355
+ except Exception as e:
356
+ status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
357
+ await content_area.remove_children()
358
+ await content_area.mount(
359
+ Static(
360
+ Text(
361
+ "An unexpected error occurred while loading accounts.",
362
+ style="yellow",
363
+ )
364
+ )
365
+ )
366
+
367
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
368
+ if event.input.id == "acct_idx_input":
369
+ content_area = self.query_one("#balance_content_area", Vertical)
370
+ # status_message is now persistent and queried once
371
+ status_message = self.query_one("#balance_status_message", Static)
372
+
373
+ await (
374
+ content_area.remove_children()
375
+ ) # Clear account list and input from content_area
376
+
377
+ try:
378
+ idx = int(event.value)
379
+ if not (0 <= idx < len(self.accounts_data)):
380
+ # Display temporary error message in content_area
381
+ await content_area.mount(
382
+ Static(Text("Invalid account index.", style="bold red"))
383
+ )
384
+ status_message.update(
385
+ Text(
386
+ "Selection out of range. Please try again.", style="yellow"
387
+ )
388
+ )
389
+ # Reload account selection UI
390
+ await self.load_accounts_for_selection(content_area, status_message)
391
+ return
392
+
393
+ account_to_check = self.accounts_data[idx]
394
+ acct_id = account_to_check["account_id"]
395
+
396
+ status_message.update(
397
+ f"Fetching balances for {account_to_check['name']} ({acct_id})..."
398
+ )
399
+ # Optionally, display a "Fetching..." message in content_area as well
400
+ await content_area.mount(Static("Fetching balances..."))
401
+
402
+ balances_data = self.client.get_balances(account_id=acct_id)
403
+ await content_area.remove_children() # Clear "Fetching..." message
404
+
405
+ table = Table(
406
+ title=f"Balances for {account_to_check['name']}", expand=True
407
+ )
408
+ table.add_column("Type", overflow="fold")
409
+ table.add_column("Amount")
410
+ table.add_column("Currency")
411
+ if "balances" in balances_data and balances_data["balances"]:
412
+ for b in balances_data["balances"]:
413
+ balance_amount_info = b.get("balanceAmount", {})
414
+ table.add_row(
415
+ b.get("balanceType", "N/A"),
416
+ str(balance_amount_info.get("amount", "N/A")),
417
+ balance_amount_info.get("currency", "N/A"),
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
+
500
+ async def on_mount(self) -> None:
501
+ await self.query_one("#bank_id_input", Input).focus()
502
+
503
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
504
+ if event.button.id == "back_to_menu":
505
+ self.post_message(ActionMessage("show_menu"))
506
+ elif event.button.id == "do_create_link_button":
507
+ await self.process_create_link()
508
+
509
+ async def process_create_link(self) -> None:
510
+ bank_input = self.query_one("#bank_id_input", Input)
511
+ ref_input = self.query_one("#ref_input", Input)
512
+ result_message_widget = self.query_one("#link_result_message", Static)
513
+ create_button = self.query_one("#do_create_link_button", Button)
514
+
515
+ bank_id = bank_input.value.strip()
516
+ reference = ref_input.value.strip()
517
+
518
+ if not bank_id or not reference:
519
+ result_message_widget.update(
520
+ Text("Bank ID and Reference are required.", style="bold red")
521
+ )
522
+ return
523
+
524
+ try:
525
+ bank_input.disabled = True
526
+ ref_input.disabled = True
527
+ create_button.disabled = True
528
+ result_message_widget.update("Creating link...")
529
+
530
+ link_info = self.client.create_link(reference, bank_id) # API call
531
+
532
+ msg_parts = [
533
+ Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
534
+ ]
535
+ if link_info.get("message"):
536
+ msg_parts.append(Text(f"Message: {link_info['message']}\n"))
537
+ if link_info.get("link"):
538
+ msg_parts.append(
539
+ Text(
540
+ "Link URL (copy and open in browser to authorize):\n",
541
+ style="bold yellow",
542
+ )
543
+ )
544
+ msg_parts.append(Text(f"{link_info['link']}", style="underline blue"))
545
+
546
+ result_message_widget.update(Text.assemble(*msg_parts))
547
+
548
+ except HttpServiceException as e:
549
+ result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
550
+ except Exception as e:
551
+ result_message_widget.update(
552
+ Text(f"Unexpected Error:\n{e}", style="bold red")
553
+ )
554
+ finally:
555
+ bank_input.disabled = False
556
+ ref_input.disabled = False
557
+ create_button.disabled = False
558
+
559
+
560
+ class DeleteLinkView(BaseSubView):
561
+ """View to delete an existing bank link."""
562
+
563
+ def __init__(self, client: GoCardlessClient):
564
+ super().__init__()
565
+ self.client = client
566
+
567
+ def compose(self) -> ComposeResult:
568
+ """Override compose to yield widgets from compose_content."""
569
+ for widget in self.compose_content():
570
+ yield widget
571
+
572
+ def compose_content(self) -> ComposeResult:
573
+ yield Label("Delete Existing Link", classes="view_title")
574
+ yield Vertical(
575
+ Input(placeholder="Reference of link to delete", id="del_ref_input"),
576
+ Button("Delete Link", id="do_delete_link_button", variant="error"),
577
+ Static(id="delete_link_result_message", classes="result_message_area"),
578
+ id="delete_link_form_area",
579
+ )
580
+
581
+ async def on_mount(self) -> None:
582
+ await self.query_one("#del_ref_input", Input).focus()
583
+
584
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
585
+ if event.button.id == "back_to_menu":
586
+ self.post_message(ActionMessage("show_menu"))
587
+ elif event.button.id == "do_delete_link_button":
588
+ await self.process_delete_link()
589
+
590
+ async def process_delete_link(self) -> None:
591
+ ref_input = self.query_one("#del_ref_input", Input)
592
+ result_message_widget = self.query_one("#delete_link_result_message", Static)
593
+ delete_button = self.query_one("#do_delete_link_button", Button)
594
+
595
+ reference = ref_input.value.strip()
596
+
597
+ if not reference:
598
+ result_message_widget.update(
599
+ Text("Reference is required to delete a link.", style="bold red")
600
+ )
601
+ return
602
+
603
+ try:
604
+ ref_input.disabled = True
605
+ delete_button.disabled = True
606
+ result_message_widget.update(
607
+ f"Deleting link with reference '{reference}'..."
608
+ )
609
+
610
+ response = self.client.delete_link(reference) # API call
611
+
612
+ style = (
613
+ "bold green" if response.get("status") == "deleted" else "bold yellow"
614
+ )
615
+ result_message_widget.update(
616
+ Text(
617
+ f"Status: {response.get('status', 'N/A')}\nMessage: {response.get('message', 'N/A')}",
618
+ style=style,
619
+ )
620
+ )
621
+
622
+ except HttpServiceException as e:
623
+ result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
624
+ except Exception as e:
625
+ result_message_widget.update(
626
+ Text(f"Unexpected Error:\n{e}", style="bold red")
627
+ )
628
+ finally:
629
+ ref_input.disabled = False
630
+ delete_button.disabled = False
631
+
632
+
633
+ class GoCardLessApp(App):
634
+ TITLE = "GoCardLess API TUI"
635
+ CSS = """
636
+ Screen {
637
+ align: center middle;
638
+ }
639
+ #app_container {
640
+ width: 100%;
641
+ max-width: 300; /* Max width for the main content container */
642
+ height: auto;
643
+ border: round $primary;
644
+ padding: 1 2;
645
+ }
646
+ #title { /* For MenuView title */
647
+ width: 100%;
648
+ text-align: center;
649
+ padding: 1 0 2 0;
650
+ text-style: bold;
651
+ }
652
+ .view_title { /* For sub-view titles */
653
+ width: 100%;
654
+ text-align: center;
655
+ padding: 0 0 1 0;
656
+ text-style: bold underline;
657
+ }
658
+ #menu_view_vertical > Button {
659
+ width: 100%;
660
+ margin-bottom: 1;
661
+ }
662
+ .back_button {
663
+ width: 100%;
664
+ margin-top: 2;
665
+ }
666
+ Input, Button { /* General spacing for inputs and buttons in forms */
667
+ margin-bottom: 1;
668
+ }
669
+ .result_message_area {
670
+ margin-top: 1;
671
+ padding: 1;
672
+ border: round $primary-background-darken-2;
673
+ min-height: 3;
674
+ width: 100%;
675
+ }
676
+ Vertical { /* Ensure vertical containers take full width by default */
677
+ width: 100%;
678
+ }
679
+ Table {
680
+ margin-top: 1;
681
+ }
682
+ """
683
+ BINDINGS = [
684
+ Binding("q", "quit", "Quit App", show=True, priority=True),
685
+ Binding(
686
+ "escape", "show_menu_escape", "Back to Menu", show=True
687
+ ), # Fixed: Always show, we'll control visibility elsewhere
688
+ ]
689
+
690
+ def __init__(self, secret_id=None, secret_key=None):
691
+ super().__init__()
692
+ sid = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
693
+ sk = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
694
+ if not sid or not sk:
695
+ logger.error(
696
+ "Error: GoCardLess credentials (GOCARDLESS_SECRET_ID, GOCARDLESS_SECRET_KEY) not found.",
697
+ )
698
+ logger.error(
699
+ "Please set them as environment variables or pass via arguments (not implemented in this TUI version).",
700
+ )
701
+ sys.exit(1) # Exit if no creds
702
+ self.client = GoCardlessClient(sid, sk, {})
703
+ self._current_view_is_menu = True
704
+
705
+ def compose(self) -> ComposeResult:
706
+ yield Header()
707
+ yield Container(MenuView(), id="app_container")
708
+ yield Footer()
709
+
710
+ async def on_mount(self) -> None:
711
+ # Fixed: removed the call to update_escape_binding since we're showing escape binding always
712
+ # and managing its behavior in action_show_menu_escape
713
+ pass
714
+
715
+ async def switch_view(self, new_view_widget: Static) -> None:
716
+ container = self.query_one("#app_container", Container)
717
+ await container.remove_children()
718
+ await container.mount(new_view_widget)
719
+ self._current_view_is_menu = isinstance(new_view_widget, MenuView)
720
+
721
+ async def action_show_menu_escape(self) -> None:
722
+ """Handle Escape key press to go back to menu."""
723
+ if not self._current_view_is_menu:
724
+ await self.switch_view(MenuView())
725
+
726
+ async def on_action_message(self, msg: ActionMessage) -> None:
727
+ action_to_view_map = {
728
+ "show_menu": MenuView,
729
+ "list_banks": lambda: BanksView(self.client),
730
+ "list_accounts": lambda: AccountsView(self.client),
731
+ "get_balance": lambda: BalanceView(self.client),
732
+ "create_link": lambda: LinkView(self.client),
733
+ "delete_link": lambda: DeleteLinkView(self.client),
734
+ }
735
+
736
+ if msg.action == "quit":
737
+ self.exit()
738
+ return
739
+
740
+ if msg.action in action_to_view_map:
741
+ view_constructor = action_to_view_map[msg.action]
742
+ new_view = view_constructor()
743
+ await self.switch_view(new_view)
744
+ else:
745
+ # Fallback for unknown action, though should not happen with defined buttons
746
+ container = self.query_one("#app_container", Container)
747
+ await container.remove_children()
748
+ await container.mount(
749
+ Static(Text(f"Unknown action: {msg.action}", style="bold red"))
750
+ )
751
+ self._current_view_is_menu = False # Assuming it's not menu
752
+
753
+
754
+ logger.info(os.getenv("GOCARDLESS_SECRET_ID"), os.getenv("GOCARDLESS_SECRET_KEY"))
755
+
756
+
757
+ def main():
758
+ # For this TUI, credentials must be set as environment variables
759
+ # GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY
760
+ if not os.getenv("GOCARDLESS_SECRET_ID") or not os.getenv("GOCARDLESS_SECRET_KEY"):
761
+ logger.error(
762
+ "Error: GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables must be set.",
763
+ )
764
+ sys.exit(1)
765
+
766
+ app = GoCardLessApp()
767
+ app.run()
768
+
769
+
770
+ # if __name__ == "__main__":
771
+ # main()